diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae40ddf33..a8eee20e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -49,6 +49,14 @@ repos: pass_filenames: false verbose: true + - id: rendered-literals + name: rendered literals + entry: uv run scripts/checks/rendered_literals.py + language: system + files: ^tests/.*\.py$ + pass_filenames: false + verbose: true + - id: tag-names name: tag names entry: uv run scripts/checks/tag_names.py --all diff --git a/Makefile b/Makefile index 18874d3e0..6b75d682a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test benchmarks \ - ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags \ + ftm-samples nsf-samples nsf-render compression-report icons player check-import-boundary check-tag-names check-unused-tags check-rendered-literals \ check-language-keys check-palette-colors check-shortcut-actions calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -113,7 +113,7 @@ test: $(call script,dev/tests) benchmarks: - uv run python -m pytest tests/benchmarks --no-cov + uv run python -m pytest tests/benchmarks --no-cov -s ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: @@ -145,6 +145,9 @@ check-tag-names: check-unused-tags: uv run scripts/checks/unused_tags.py +check-rendered-literals: + uv run scripts/checks/rendered_literals.py + check-language-keys: uv run scripts/checks/language_keys.py diff --git a/docs/api/index.md b/docs/api/index.md index 87a9b0cc5..a2752db75 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -30,7 +30,7 @@ from sampletones import ( | `Config` | generation configuration; build it with `Config.load(path)` or `Config.default()` | | `Window` | analysis window derived from a config (`Window.from_config(config)`) | | `InstructionLibrary` | the library of candidate instructions a reconstruction searches | -| `Reconstructor` | runs a reconstruction: `Reconstructor(config)("sample.wav")` | +| `Reconstructor` | runs a reconstruction: `Reconstructor(config, channels)("sample.wav")` | | `Reconstruction` | the result of a reconstruction — its approximation audio, per-channel instructions, and the config used | | `ChannelName` | enum naming the four channels: `pulse1`, `pulse2`, `triangle`, `noise` | | `Generator` | shared base class of the oscillator generators | @@ -92,12 +92,13 @@ With a library in place for the configuration: ```python from sampletones import Config, Reconstructor from sampletones_core.audio.io import write_wave +from sampletones_core.constants.enums import DEFAULT_CHANNELS # Load configuration config = Config.load("config.json") -# Prepare the reconstructor -reconstructor = Reconstructor(config) +# Prepare the reconstructor for the channels the run may use +reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS)) # Reconstruct an audio file and save the reconstruction reconstruction = reconstructor("sample.wav") diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 52945c0dc..35c522f40 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -274,7 +274,9 @@ below that is room the matching leaves unused, and material that was never in A= temperament — most recordings of most instruments — sits somewhere inside it. `sampletones_core.reconstructions.reconstructor.refinement` spends that room, after the decoder has -settled which note each frame plays and before the frames are rendered. +settled which note each frame plays and before the frames are rendered. It spends it where the run +asks: a stem entry names the channels it carries toward its own recording, so one recording's bass +line can land on its exact tuning while another's lead keeps the grid. ### 6.1 Reading rather than searching @@ -304,14 +306,14 @@ frames with no pitch to read. ### 6.2 Landing the note, and holding it -A reading becomes a bend through the generator, which owns the divider geometry: `bend_towards` +A reading becomes a bend through the generator, which owns the divider geometry: `bend_toward` answers with the divider steps that land the note nearest the frequency read, bounded by `bend_range` — **half the gap to each neighboring note**. That bound is what leaves the refined pitches gapless: note *n* covers `[(tₙ + tₙ₊₁) / 2, (tₙ + tₙ₋₁) / 2]`, and those windows tile the divider range exactly, so every divider the notes span is reachable and none is claimed twice. A bend that followed every reading exactly would jitter, and jitter is more audible than the tuning -it chases. So the per-frame proposals are settled by a change-penalised walk, the same shape the +it chases. So the per-frame proposals are settled by a change-penalized walk, the same shape the Viterbi decoder settles a note contour with: the cost of a bend is how far it stands from that frame's reading, plus a toll on changing at all. The states a frame may take are the bends its neighborhood proposed together with no bend, which keeps the walk to a handful of states even where @@ -329,14 +331,19 @@ a tenth or more, since the reading needs a handful of bins per frame and the tra every bin the spectrum covers. Restricting it to the bins the chosen notes actually name is the work `docs/development/bugs-and-todos.md` records under **Features**. -A frame makes no proposal where it rests, where its channel is not pitched — the noise channel's -sixteen periods have no finer grid — or where its reading falls below the confidence threshold. A -conversion that bent no note records both bend dimensions as ones the channel governs, so it writes -the same instrument it wrote before the feature existed. +A frame makes no proposal where it rests, where the stem holding it leaves that channel out, where +its channel is not pitched — the noise channel's sixteen periods have no finer grid — or where its +reading falls below the confidence threshold. A conversion that bent no note records both bend +dimensions as ones the channel governs, so it writes the instrument an unrefined run writes. + +Which recordings are carried, and on which channels, each stem entry states for itself in +`bends` — a subset of the channels it occupies, and of the three that load a divider. A channel a +stem leaves out keeps the note the matching chose, and a stem carrying nothing at all is never +read, so the transform is spent only where a bend comes of it. The settings below shape a bend +once it is asked for, and hold for a whole run. | parameter | default | notes | |---|---|---| -| `generation.refinement.enabled` | on | acts only where the run renders the chosen instructions | | `generation.refinement.confidence` | 0.15 | the share of a frame's energy its harmonics must hold | | `generation.refinement.change_weight` | 2.0 | divider steps of reading error worth avoiding one change | | `generation.refinement.window` | 4 | the frames on either side whose readings a frame may settle on | @@ -383,7 +390,7 @@ noise): | spectral / temporal weight | 0.8 / 0.2 | criterion blend | | spectral distance | β-divergence | also `squared`, `absolute` | | selector | Viterbi | `greedy` / `viterbi` | -| pitch refinement | on | bends each note onto the divider the source sounds | +| pitch refinement | per stem | bends each note onto the divider the source sounds | | normalize / quantize | on / off | input preprocessing | Package map: diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index e5e2fc86b..f6d377f93 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -134,13 +134,20 @@ single-sample pipeline always did. A request becomes jobs through `reconstructions.converter`: a `ConversionPlan` answers with the `ConversionJob`s it divides into, resolved against the configuration the run uses. `GroupConversion` mixes the recordings it is given -into one job, and `DirectoryConversion` scans a folder into one single-source job -per audio file. `ReconstructionConverter` runs those jobs across its worker pool +into one job; `BatchConversion` gives each gathered recording a job of its own, +carrying the setup that recording's own row holds and the folder whose tree its +reconstruction mirrors; and `DirectoryConversion` scans a folder into one +single-source job per audio file, which is what the command line converts a +directory as. `ReconstructionConverter` runs those jobs across its worker pool and reports the reconstructions written. -`StemsConfig` (`reconstructor/stems/configs/`) is the setup: the entries with -their ids and channels, the precedence hierarchy and its mode, and the channel -cap. It validates its own consistency — unique ids, a hierarchy naming every +`StemsConfig` (`reconstructor/stems/configs/`) is the setup: the entries, the +precedence hierarchy and its mode, and the channel cap. An entry is an id and the +`StemSettings` its recording is converted with — the channels it may occupy, and +which of those it carries toward the divider it really sounds. A further +per-recording choice is a field on those settings, which is what lets the list a +reader sets a run up in, the entry the run records, and a later reader of that +record all state the same thing. It validates its own consistency — unique ids, a hierarchy naming every entry exactly once, a cap of at least one — so an inconsistent setup can be neither built nor stored, and it derives the views the run reads (`entries_by_id`, `covered_channels`, `frame_budget`). diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 39fca4369..79d215183 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 honor, 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`, the reconstruction browser has `docs/development/browser.md`, the YAML configuration package has `docs/development/config-organization.md`, how a long operation says how far it has come has `docs/development/progress.md`, and the packages the repository divides into have `docs/development/packages.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`, the YAML configuration package has `docs/development/config-organization.md`, how a long operation says how far it has come has `docs/development/progress.md`, the keyboard and the actions it reaches have `docs/development/keyboard.md`, the identifier vocabularies have `docs/development/vocabularies.md`, colors and palettes have `docs/development/palette.md`, and the packages the repository divides into have `docs/development/packages.md`. --- @@ -59,17 +59,15 @@ This means the UI layer can never be in an inconsistent state: it always reflect ### 5. Panels communicate via optional callback hooks -A panel never calls coordinator or logic methods directly. Instead it exposes public optional callback attributes (`on_x: Optional[Callback] = None`) that coordinators set during wiring. The panel fires them through `CallbackMixin.call()`, which logs a warning and yields `None` for a hook left unset. +A panel never calls coordinator or logic methods directly. Instead it exposes public optional callback attributes (`on_x: Optional[Callback] = None`) that coordinators set during wiring. The panel fires them through `CallbackMixin.call()`, which notes a hook left unset at debug and yields `None`, so partial wiring during construction reads as the expected condition it is. A hook the panel consults for state rather than notifies of an event is read through `CallbackMixin.query()`, which preserves the hook's declared return type and takes the answer to assume while the hook is unset. A widget parameter or branch fed by such a hook therefore receives a value of its expected type at every moment, including the window before wiring completes. This decouples widget construction (which happens during `create_panel()`) from the moment wiring takes place (which happens in the coordinator's constructor), and lets panels be instantiated without any coordinator present. -### 6. Background threads deliver results through `CallbackQueue` +### 6. DearPyGui's context belongs to the render thread -Services execute long-running work on background threads. Their results are posted to `CallbackQueue` with a priority, and the main-thread render loop drains the due results each frame within a per-frame time budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across frames while rendering continues. Draining on the render thread keeps every callback's DPG work on the thread that owns the context. This is the only mechanism for crossing the thread boundary; applying a background result to UI state directly from the worker thread is forbidden. - -**The drain runs between frames, so a callback waits for none.** The render thread is inside the drain rather than inside a frame, which makes the next frame the drain's own to reach: `dpg.split_frame` there waits for what the wait itself prevents, and the application stops for good. Work that needs a drawn frame — reading a laid-out size, letting a configuration take effect — is scheduled through `FrameCallbackManager` and picked up when that frame arrives. +The thread that created the DearPyGui context is the only one that may build, configure, or destroy an item, and an item freed from another thread is freed with no Python thread state — a crash rather than a glitch. So work reaching the interface from anywhere else arrives on that thread first, through a crossing named for what it carries: a background result is queued for the render loop to drain, a worker's touch of a widget goes through `on_render_thread`, and a gesture DearPyGui gathered is run at the top of a frame. A crossing that would hold the frames up puts its waiting on a thread of its own, and work that needs a drawn frame names the frame it is picked up on. The four crossings, the helpers that make them, and the hazard each one answers are in [`render-thread.md`](render-thread.md). ### 7. Construction flows from the composition root @@ -79,39 +77,11 @@ Services execute long-running work on background threads. Their results are post ### 8. All display text comes from `LanguageManager` -Every user-visible string is looked up on `LanguageManager` by the key the language file spells: - -``` -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 an element enum, which is any enum deriving from `AbstractElement`. An element enum is found by what it derives from, so one naming a panel's own widgets lives with the other panel vocabularies under `categories/elements/`, while one naming a domain's gestures — `HistoryAction` — lives beside that domain and serves as both the value the domain records and the element its label is looked up by. `en.yaml` is a flat map keyed exactly this way, so the dotted string is the lookup form — `language_manager["global.dialog.label.ok"]` — and a reader holds a key against the language file by eye. `categories/key/` owns the grammar: `validate_text_key` checks every key the file holds at load time, and a lookup that misses raises `MissingTextError` naming the key and the file. This makes the text system the single source of truth and enables future localisation. Log messages are developer-facing and exempt. - -Text resolves where it is displayed. A class that reads text holds the manager as `self._language_manager`, assigned in its own `__init__`, and looks each string up at the point of use, so a language change takes effect on the next read. Where the same text is read at more than one site in a class, one named binding serves them all and the reads stay in step. - -A key assembled at runtime passes its four members instead — `language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element]` — with the variable part annotated as the concrete element enum it carries (`SequencerOrderElements`, `DialogElements`). That annotation is what keeps the key checkable: the `language-keys` hook expands it to the enum's members and holds every key it reaches against the language file. A lookup therefore states its key as literals, as annotated members, or as a conditional between two literal keys — the three forms the hook reads values from: - -```python -language_manager[ - "global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name" -] -``` +Every user-visible string is looked up on `LanguageManager` by the key the language file spells — `page.panel.text_type.element` — and resolves at the point of use, so a language change takes effect on the next read. `en.yaml` is a flat map keyed exactly this way, which makes the text system the single source of truth, lets a reader hold a key against the language file by eye, and enables future localization. A lookup states its key in a form the `language-keys` hook can read, so every key the code spells names an entry and every entry the file holds is reached. The grammar, the forms a lookup takes, and where each element enum lives are in [`vocabularies.md`](vocabularies.md). Log messages are developer-facing and exempt. ### 9. `tags/` holds only DPG identifiers -The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colors, timings, and display strings live in YAML configuration loaded at startup (`layout/`). - -**`compose_tag` is the one composer.** `tags/compose.py` owns `TAG_SEPARATOR` and the joiner; every tag reaches its final spelling through it. Each part is lowercased and its whitespace runs become single underscores, so a tag built from a runtime name — a sample title, a layer label — reads the same however that name arrives cased or spaced, and a part already holding a composed tag contributes its own segments, which is how a child tag extends its parent. Fragments hold bare segments (`SUF_GRAPH_PLOT = "plot"`) and gain separators only from the joiner, so a fragment reads as the segment it names and either end composes onto it. - -**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 -``` - -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. +The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole tags, and `SUF_*`/`PRE_*` fragments that compose into them. Dimensions, colors, timings, and display strings live in YAML configuration loaded at startup (`layout/`). Every tag reaches its final spelling through one composer, and a constant's name states the tag it composes, which the `tag-names` hook holds it to. The composer, the `TagName` spelling, and the rules a fragment follows are in [`vocabularies.md`](vocabularies.md). ### 10. Exclusive operations expose a lifecycle-accurate active state @@ -128,70 +98,27 @@ A new exclusive operation joins by contributing its `is_active` to the authority Where behavior depends on the operating system, the desktop environment, or an external command-line tool, that variation is expressed as a `Protocol` with one implementation per target, chosen by a runtime factory — never as platform branches scattered through the callers. The factory probes availability (`locate_program`) and environment (`System.current()`, `XDG_CURRENT_DESKTOP`) and returns the implementation that fits; callers depend only on the Protocol and read identically on every platform. -`utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. Each tool's quirks stay sealed inside its own implementation — the portal lists every offered type in its selector, reports the one the user picked, and is told which window a dialog belongs to, since the desktop draws it in another process, `kdialog` activates a single filter, `zenity` lists the filter but leaves the selector on its "(None)" default because its command line offers no way to pre-select one — and the guarantee callers depend on, that a saved file carries one of the offered extensions, is enforced once in the API layer above every backend. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. +Each tool's quirks stay sealed inside its own implementation and are named in that class's docstring, where a reader meets them beside the code they explain; the guarantee callers depend on — that a saved file carries one of the offered extensions — is enforced once in the API layer above every backend. `utils/file_dialogs/` applies this to native file dialogs: a `FileDialogBackend` Protocol in `protocol.py`, with desktop-portal, `kdialog`, `zenity`, and `tkinter` implementations under `backends/`, selected by `select_file_dialog_backend()`. `sampletones_core/calibration/referee/` follows the same shape with its `build_referees()` factory. Ordering the implementations is part of the factory's job: where several are available, the one that expresses the most wins. A save offering several file types is answered by the portal because it alone reports which type was chosen, so an export names its format in the type selector; a backend answering with a name alone leaves the extension to be read from the name, and the API layer settles it either way. ### 12. One dispatcher owns the keyboard -DearPyGui gives every key handler the same global reach and no way for one to stop another — or ImGui itself — from also seeing a press. Priority and consume semantics therefore exist only where the application builds them. A single `KeyRouter` (`utils/gui/keyboard/`) owns the one `add_key_press_handler` for the whole application, snapshots the modifier state once into a frozen `KeyEvent`, and offers that event to registered **scopes** from highest priority to lowest. The first active scope whose handler returns `True` claims the press and ends the walk; this software walk is the sole consume mechanism the framework leaves available. - -Each keyboard consumer registers one scope through `register(handle, *, priority, active)`, where `active()` reports whether the scope wants keys at this moment and `handle(event) -> bool` acts on the press and reports whether it claimed it. Three priorities order the whole application: - -| Priority | Scope | Active when | Behavior | -|----------|-------|-------------|-----------| -| `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) | 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` | +DearPyGui gives every key handler the same global reach, so priority and consume semantics exist where the application builds them. A single `KeyRouter` (`utils/gui/keyboard/`) owns the one `add_key_press_handler` for the whole application and offers each press to registered **scopes** from highest priority to lowest; the first active scope that claims the press ends the walk. Three priorities order the application — a modal dialog above a sequencer sub-panel above the application shortcuts — and each consumer registers one scope stating when it wants keys and which presses it claims. -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 binding is declared once and read by everyone who prints or fires it: `ShortcutId` names the action together with the category that answers it, and the scheme under `sampletones_config/keybindings/` decides the combination, so a printed key and the handler behind it stay in step by construction. -**A panel scope answers on its own tab.** A cursor and a selection outlive a move to another tab, so a panel is given the predicate that reports whether its tab is the one in front and reads it at the moment of the press, the way focus is read. The composition root resolves the tab and the scope composes the answer into its `active`, which keeps the fact in one place and leaves the router's contract — the scope decides whether it wants the key — as it stands. - -**Focus is pulled, not pushed.** Whether a text or value field keeps a plain key for itself is one router query, `is_field_focused`, that reads the focused item from DearPyGui at the moment of the press and counts it only while that item is actively being edited. Every input is covered by construction, and the router alone holds the rule. - -The query resolves the focused item to the field behind it. A `dpg.group` reports the state of the widget inside it, and DearPyGui names the outermost such group as the focused item — the instruments panel's sequence input, laid out beside its copy button inside a card body group, reaches the keyboard as that group. An active group therefore answers with the field being edited below it, found by following the one branch that reports focus, so a panel-spanning group costs a key press only the path down to its field. - -**Modal suppression lives in one place.** The router holds a LIFO stack of modal handlers; `push_modal` / `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. Since `MODAL` outranks the panel and shortcut scopes, the scopes beneath it carry no "a dialog is open" check of their own. - -**One vocabulary, one declaration.** The keyboard has one key table (`utils/gui/keyboard/keys.py`), which reads a key both ways — the name a file writes and the code a press carries — and one combination type, `KeyCombination`, which parses that spelling, displays it, and answers whether a press matches it. Above them a binding is declared exactly once: `ShortcutId` names every action a key reaches together with the category that answers it, and the scheme under `sampletones_config/keybindings/` is where the combination is decided. The menu printing an accelerator, the panel acting on a press, and the dispatcher firing the callback all read that one entry, so a printed key and the handler behind it stay in step by construction. - -The split is that **the combination is data and the category is code**: which keys reach an action is the reader's to choose, while which scope answers them follows from where the action is handled. A scheme is validated as it loads — every `ShortcutId` is answered, every key name resolves, and one combination reaches one action within a category — and a collision is a `SystemError` at startup, beside the layout and palette failures. - -A preference layers over the shipped scheme. `ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it: `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping, and an override naming an action this build has none of, a key the table has none of, or a combination its category already gives away is reported and left out, so one stale entry costs only itself. A change reaches the running application through `ShortcutSource.on_bindings_changed` — the keyboard's analogue of the palette switch (principle 13) — and the dispatcher re-reads the keys while the menus re-print their accelerators. Each registration names the action it fires, which is what leaves a rebind that little to catch up. - -**A scheme is edited through a draft.** `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched — the combination each was given, or nothing where it was left unbound — so what reaches the preference is those actions alone while every other key follows the scheme beneath. An assignment displaces: giving an action a combination its category already answers takes the key from the holder in the same step, which is what makes every scheme a draft produces a valid one, and the dialog names the holder and asks before that step is taken. The draft is what the dialog edits, and a commit is what activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done. - -**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labeled 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. +The router is constructed at the composition root and injected into every consumer (principle 7). The scopes, the focus query, the modal stack, the key vocabulary, and how a scheme is chosen, layered, and edited are in [`keyboard.md`](keyboard.md). ### 13. A color is a token, resolved where it is drawn -A color 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 color 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 color reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a color 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 color, 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). +A color 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 color follows a palette swap. Every annotation names `BaseColor`; the read happens where the value is handed to a widget, and what a consumer keeps is the token. What DearPyGui has already taken a copy of is registered with `PaletteBindings` rather than remembered by whoever set it, so a palette change is one switch. The `palette-colors` hook holds all three rules (see Enforcement); the color forms and the switch itself are in [`palette.md`](palette.md). ### 14. An action is declared once; whoever shows it prints it -An **action** is one `ShortcutId` — the name a key press, a menu item and a context item all reach one behavior by. Declaring one is a chain of four links, and the `shortcut-actions` check holds every one of them (see Enforcement): - -| Link | Where | What it states | -|------|-------|----------------| -| The action | `utils/gui/shortcuts/ids.py` | its name, and the category that answers it | -| Its keys | every scheme under `sampletones_config/keybindings/` | the combination that fires it, `~` where it ships unbound | -| Its call | `shell.py` — a `ShortcutBindings` field and the entry naming it in the binding map, or membership of `FAMILY_SHORTCUT_IDS` | the one call the action makes | -| Its label | a `KeybindingActionElements` member and its `en.yaml` entry | how the keybindings editor lists it | - -Two kinds of action state their call differently, and the check knows both. One that a whole enum parameterises — an export item per format, an item per channel — is a **family**: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. A family is *declared*, not recognized: `FAMILY_SHORTCUT_IDS` names the mappings that are ones, so what excuses an action from stating a call of its own is written down rather than inferred from the shape of a dictionary — `SHORTCUT_IDS_BY_NAME` answers with every action and is deliberately not among them. A **panel-scope** action states no call at all, because its key scope (principle 12) acts on the press itself. A `DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category holds. - -**A menu item is a view of an action, never a second declaration of it.** `ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names one: it takes both the accelerator and the call from the action, and keeps the item under it, so a rebind re-prints the key already on screen. An item passes a `callback` of its own only where it carries a state to show, and then that call is the one switching the state it shows. - -**A set of actions several menus show is declared by whoever owns them, once.** The owner states one builder — `GUISequencerVoicesPanel.add_action_items` for a voice, a grid's edit surface for a cell — and each door decides where to print it: the panel's own row menu, the menu bar's **Edit** group through `EditSurfaceProtocol` and `EditRouter`, the **Voice** group through the panel. Adding an action to the builder reaches every door, and the dividers around it belong to the door rather than to the set. +An **action** is one `ShortcutId` — the name a key press, a menu item, and a context item all reach one behavior by. Declaring one is a chain of four links: the action and the category that answers it, its keys in every shipped scheme, the one call it makes, and the label the keybindings editor lists it by. The `shortcut-actions` check holds every link (see Enforcement). -**A menu whose contents follow a selection states them when it is opened.** A menu bar is built once, while what an item should say follows the cursor at the moment a reader opens the menu. `ui/elements/menu_section.py::MenuSection` is that mechanism: a marker leads the menu, the framework reports it drawn once a frame while the menu stands open, and a gap in those reports marks a fresh opening and restates the section. The marker leads rather than trails because a container standing below a menu item takes the width those items span as its own, which the popup would then grow to fit on every frame. +A menu item is a view of an action: `ShortcutManager.add_menu_item(shortcut_id, ...)` takes both the accelerator and the call from the action and keeps the item under it, so a rebind re-prints the key already on screen. A set of actions several menus show is stated by one builder belonging to whoever owns them, and each door decides where to print it. A menu whose contents follow a selection states them when it is opened. The four links, the kinds of action that state their call differently, and the mechanism behind a restated menu are in [`keyboard.md`](keyboard.md). --- @@ -201,7 +128,7 @@ Two mechanisms keep the codebase aligned with this document. **Import-expressible contracts are enforced by a check.** `sampletones_config/boundaries/rules.yaml` states 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 configuration is itself a defect. The same domain holds the order the repository's packages import each other in, and the layering inside `sampletones_player`, both declared as layer tables in `docs/development/packages.md`. `sampletones_config/boundaries/` declares what the boundaries are, `sampletones_shared/meta/import_boundary/` holds how they are read and reported, and `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) runs them over the source tree. A rule names the prefixes it reaches through the groups `boundaries/general.yaml` declares, so the interface several layers stay clear of is written once and each rule names it. 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 names the contracts group that stays in reach. 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, and the declarations that complete them, 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: +**The identifier vocabularies, the declarations that complete them, and the shapes a case may not take 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 | |------|--------|---------------| @@ -210,10 +137,11 @@ Two mechanisms keep the codebase aligned with this document. | `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 color as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme color filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) | | `shortcut-actions` | `shortcut_actions.py` | Every action against the links it needs: a combination in every shipped scheme, a name the keybindings editor lists it by, and — for an application-scope action — the call it makes, whether its own binding or a family (principle 14) | +| `rendered-literals` | `rendered_literals.py` | A case against the text it renders: an equality holding `str(...)` or an f-string against a written-out string pins whatever the platform or the build decided (`guidelines.md` § Tests) | 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 and shortcut checks read 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. +**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. A change that alters one of them lands with the edit stating the new contract, and one that knowingly leaves a distance behind lands with an entry in `docs/development/bugs-and-todos.md § Architecture` — `guidelines.md` § Documents holds that rule. The ledger, not the codebase, is the memory of what is currently out of line. --- @@ -224,13 +152,13 @@ They read the source as an AST through the shared layer in `sampletones_shared/m **Purpose:** Constructs and updates the DearPyGui widget tree. Panels own their DPG tags and the widget subtree rooted at `self.tag`. **Contracts:** -- A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterwards only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself. +- A panel creates its entire widget tree in one call to `create_panel(parent)`, rooting its subtree at `self.tag` inside the coordinator-injected `parent`, and calls DPG afterward only in `update_view()`, `update_*` methods, and event callbacks wired by DPG itself. - Panels hold only visual state: their tag, their child widget references, and layout dimensions. Domain objects stay in logic; panels receive projections of them. - A panel never encodes its own placement: it does not compose a column tag (`SUF_PANEL_*`) as its parent, and it never hosts a sibling panel. Tab layout is the coordinator's (see the Coordinators reference). Where a section is a card, one card is one panel is one module; the coordinator declares which cards a tab contains and how they are arranged. - Structural depth themes are bound only by the layout primitives, never by a panel or coordinator. The `TabColumns` scaffold binds each column its declared depth theme — recessed GROUND for a column hosting a stack of floating cards, raised SURFACE for a full-height column that is itself a single docked surface (a file tree, an instrument list) — the `card()` context manager binds SURFACE to a card, and `well()` binds recessed GROUND to a padded region sunk inside one, so a list reads as one body rather than as content loose on its card. Panels and coordinators bind only semantic/content themes (a per-channel checkbox tint, the player toolbar), never GROUND or SURFACE. - Every mutation from outside goes through `update_view(view_model)` or through a direct DPG call (`dpg_configure_item`, `dpg_set_value`) triggered by an `update_*` method. - Callback wiring from coordinators sets public `on_x` attributes *after* construction; panels must therefore tolerate `None` hooks until wiring is complete. -- A widget whose rendering needs synchronous per-item queries declares a consumer-owned `Protocol` of exactly that surface (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. Hooks and view models remain the default — the Protocol is the exception for query-heavy widgets where projecting a whole tree per repaint would be disproportionate. +- Hooks and view models are how a panel reaches state. A widget that queries per-item state *while it draws*, where projecting the whole collection per repaint would be disproportionate, declares one consumer-owned `Protocol` of exactly the queries that draw makes (e.g. `TreeLogicProtocol`, through which the file trees query per-node favorite and playability state); the owning coordinator constructs the real logic object and injects it, and the panel types against the Protocol. One panel holds one such Protocol: a second is the sign that the panel holds two jobs, and the panel divides. - Dialog presentation belongs to coordinators: a panel fires an intent hook, and the owning coordinator renders the dialog via `DialogsRenderer` with text resolved there. Reusable modal *editing* windows subclass `GUIWindow` and follow the ordinary panel contracts. **Sub-structure:** @@ -321,13 +249,13 @@ There are two coordinator kinds: *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. -`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterwards from the coordinator's post-build initialization, invoked once the whole tree exists, rather than inside `create_tab()`. +`create_tab()` is the sole authority for the tab's layout: it declares the column and card arrangement through the shared `ui/elements/layout` primitives (`TabColumns`, `card()`) and injects each panel's parent container via `create_panel(parent)`. It builds widgets only — initial view population (pushing the first view models, refreshing trees) runs afterward from the coordinator's post-build initialization, invoked once the whole tree exists, rather than inside `create_tab()`. **Contracts:** - A coordinator touches DPG only on a narrow, closed surface: inside `create_tab()`, and when building dialog content inside a closure passed to `DialogsRenderer.show_modal`. A dialog that must wait for the next frame is deferred through `FrameCallbackManager`. All other presentation goes through `DialogsRenderer`. - File selection runs through OS-native dialogs, which live outside DPG. A coordinator opens one via `utils/file_dialogs` — a synchronous call that blocks until the user picks a path or cancels — resolves the dialog title and filter name from `LanguageManager`, and routes the returned path through a handler decorated with `@ignore_none_path`, so a canceled dialog is a silent no-op and each handler body runs with a real path. The backend is chosen at runtime; a coordinator never branches on platform. - A coordinator holds no domain state. It delegates reads and writes to the managers and controllers it was given; what it caches is presentation wiring — resolved language strings, panels, logic objects, callbacks. -- Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. The one sanctioned wrapper is an intent-level guard that a contract requires — e.g. a busy-authority start-time guard (principle 10) wrapping an operation's entry point. +- Callbacks received from `Application` as constructor parameters are stored and forwarded as-is. A wrapper is sanctioned where a contract requires an intent-level guard — a busy-authority start-time guard (principle 10) wrapping an operation's entry point — and that guard is the whole of what the wrapper holds. A wrapper that renames a call, reorders its arguments, or adds a step of its own is the coordinator taking on work that belongs to the logic object the call reaches. - Error dialogs, confirmations, and notices are presented here, with text resolved from `LanguageManager` here (see the Error Handling Policy). **May import:** `ui/`, `view_model/`, `logic/`, `services/`, `utils/`, `categories/`, `layout/`, `config/`. diff --git a/docs/development/browser.md b/docs/development/browser.md index ce82c113e..a8dd63cd8 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -1,6 +1,6 @@ # The Reconstruction Browser -This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs +This document governs the tree of reconstructions the **Reconstruction** 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 @@ -175,7 +175,7 @@ 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. +upward. **What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion @@ -251,7 +251,7 @@ 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 +anchors the preference follows are read out of that one answer. What it materializes 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 @@ -276,4 +276,4 @@ 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. +and drops the folders it had read afterward, so opening one lists it as it stands on disk. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 04a3613e5..04ae1542b 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,6 +4,13 @@ * Interface scale * Tree navigation using keys +* Moving through the converter's list of gathered recordings with the keyboard. The list holds one + row picked out, which is a selection rather than a position: `ConverterState.selected` names it, + a click sets it, and `Del` reaches it through the `SOURCES` key scope + (`ui/panels/main/converter/listing.py`). What is missing is a cursor the arrow keys move, `Home` + and `End`, and a folder opened and closed from the keyboard — the last of which the list answers + for on its own, since which folders stand open is `OpenFolders` in `ui/elements/stems/` rather + than anything the model records. * Waveform LOD for zooming * Alt for scrolling graphs * Drag and drop @@ -40,6 +47,13 @@ starts carrying. ### Workflow * Waveform construction preview for single-file conversion +* Picking several rows of the converter's list at once, so a group leaves or settles in one gesture + rather than a row at a time. The widget family already draws a multi-pick reading — + `StemsListOffer.picking` with `picked_keys` and `picking_room` in `view_model/shared/stems.py` — + built for the mix chooser and switched off for `GATHERED_SOURCES`. What a converter pick needs + beyond it is a pick with no ceiling, since the chooser's is the room a mix has, and the gestures + the list already offers one row reaching every picked row: removal, a channel box, and the + settings card, which names a single row today. * Selection operations on a reconstruction * Reconstruction trimming @@ -60,6 +74,13 @@ starts carrying. tenth or more of a short conversion, and a CI runner has measured it at a third. The kernel is a matrix of one row per bin, so restricting it to the rows the chosen notes name is a slice — what needs care is that the union of harmonic bins over a whole stream is wider than any one frame's. +* Keeping what a stopped folder scan found. `_walk` in `logic/main/sources/scan.py` reports + `on_stopped` and returns where the reader presses **Stop**, so the recordings met so far go + nowhere, while `_gather` already answers with them. Handing that list to `answer` instead would + let a reader stop a long walk and keep the count they watched climb. What it needs beside it is + `_gather_read`'s empty branch (`coordinators/tabs/main.py`) reworked: a stop before the first + recording turns up is a different answer from a folder that holds none, which is what that branch + says today. * Calibrating the pitch refinement. `generation.refinement`'s confidence threshold, change weight and window are chosen by hand; `docs/concepts/calibration.md`'s experiment measures the criterion blend and could measure these beside it. The change weight is the one with an audible trade-off: @@ -72,7 +93,7 @@ starts carrying. * Code documentation * A backward-compatibility corpus of files older builds actually wrote. Every upgrade step is exercised against a payload the test builds itself — hand-written mappings for the step, and, - for projects, a current document rewritten backwards into the older shape — so a step is held + for projects, a current document rewritten backward into the older shape — so a step is held only to the fields it names. One archived `.stn`, `.ins` and `.stp` per shipped version, each written by that version and exercising every feature it could store, would hold the whole document to the chain and would catch a field that changed shape while no step named it. @@ -116,6 +137,63 @@ again. the size at which the sequencer panels and the sequencer tab coordinator were divided into subpackages. Each divides the same way: a module per concern, with the class that stays holding the collaborators and the public surface. +* The Main tab's public surface renames several calls on its way to a panel — + `refresh_converter_view`, `is_converter_panel_visible`, `refresh_browser`. These are the tab's + own face rather than the inbound callbacks the Coordinators contract governs, which now travel as + one `MainTabHooks` value and are forwarded as they stand. What is worth settling is whether the + face wants those names at all, or whether the application should ask for the thing rather than + for the refresh of it. +* `utils/gui/dpg.py::dpg_get_item_parent` catches `Exception` where the Error Handling Policy leaves + the broad catch to a service's top-level task wrapper. It stays: DearPyGui raises `Exception` + itself for an absent item rather than a type of its own, so the catch is as narrow as what it + answers. A test pins that contract, and the day the library raises something of its own is the + day the catch narrows. +* Every fixture in `tests/unit/sampletones_application/coordinators/tabs/test_main.py` builds + `MainTabCoordinator` through `__new__` and populates its privates by hand, so what those cases + describe is a method rather than the wired object. The wiring itself is exercised — + `test_startup.py` builds the real application and drives gestures through it end to end — so the + gap is that a case reading the coordinator's own behavior cannot see a hook left unset. Building + the object in that file is what closes it. +* Principle 6 was rewritten once on the premise that a widget's callback arrives on the render + thread, reasoned from `manual_callback_management` never having been enabled. A probe reads the + opposite: a global mouse handler reports one thread identifier and the render loop another, so + DearPyGui answers a gesture on a thread of its own and every callback that rebuilt widgets was + racing the renderer. Manual callback management is now on and the frame runs what DearPyGui + gathered, which makes the principle true rather than merely stated. What a gesture costs is now + paid between frames, so a callback heavy enough to be felt is one to spread across frames itself. +* `state.last_paths.library` is written and never read. `SessionManager.set_library_path` records + the directory a library was chosen from, and `get_library_path` is reached by no caller: the + dialog that would open there takes its starting directory from the advanced settings panel + instead. Either the dialog reads the remembered path or the field and its pair of accessors go. +* Which recordings a mix is built from is decided in `ui/` until **Add** is pressed. The chooser + holds the pick as its own `_picked` set and asks `StemsListViewModel` how a gesture moves it — + `picking_of`, `reaches` and `picking_settled` compute the transitions in `view_model/shared/`, + which is a projection answering a question about state rather than describing one. The logic + layer hears the answer and nothing before it, so a pick abandoned by closing the window was + never state anyone else could read. Principles 3 and 4 put that machine in `logic/`, with the + chooser drawing what a view model says and reporting the gesture; moving it is a phase rather + than a patch, because the dialog is what drives the pick today. +* `ConverterMessages` reads the strings it puts to a reader once, at construction, where principle + 8 has text resolve at the point of use so a language change takes effect on the next read. The + stage names and the status lines are cached as fields; the templates the run fills are read live. + This predates the converter's rebuild — the class it replaced cached the same way — and the fix + is the same either way: read each key where it is used, and let the manager answer. +* `FolderScan` (`logic/main/sources/scan.py`) runs a long directory read on a worker and reports + back, which is what `services/` is for, while standing in `logic/`. It reports through optional + hooks rather than the result union, and its reports arrive on the worker's own thread, so the + coordinator crosses to the render thread on its behalf rather than the walk posting to + `CallbackQueue`. It stays there because it is short and the tab is its only caller; what a move + would buy is the exhaustive `match` every other long operation reports through. +* Every gesture re-derives the whole setup. `ConverterLogic._settle` reads the gathered sources + into rows and follows the state to its destination, which builds one batch entry per recording + still holding a channel. `tests/benchmarks/test_converter_load.py` holds both to the length of + the list, and reads about 40 ms and 50 ms on a folder of ten thousand with the collector held + off. What a reader pays is more: a gesture hands `_settle` a state whose recordings are new + objects, so the readings are taken cold and the collector's own share falls inside them — + measured together at roughly a quarter of a second per gesture at that size, before a widget is + touched. All of it is repeated work, since what changed was one recording. Answering it means + holding the rows against the gathering that produced them and deriving entries for the + recordings a gesture actually moved. * Several directories under `ui/` carry modules without an `__init__.py`, which leaves each one a namespace package. A tool reading the tree treats such a directory as a root it can import from, so a module inside one answers for a standard-library name of the same word: `ui/elements/trace.py` @@ -125,6 +203,52 @@ again. ## Bugs +* A reconstruction written by an earlier 0.3.2 build cannot be opened, and the pending upgrade step + is not where that is answered. The record stamped data version 2.2 while a stem entry still stated + its channels on the entry itself; the entry now carries a `StemSettings`, and a step from 2.1 + never runs on a file already stamped 2.2. Data version 2.2 therefore means the current shape, and + a file stamped 2.2 in the earlier one is a mid-development artifact rather than a release the + format owes compatibility to — the files that existed were removed. What a release shipped is + unaffected: a v0.3.1 file carries data version 2.1 and no stems record, so the step synthesizes + one in the current shape. The lesson holds for the rest of 0.3.2: a shape that moves between + releases moves inside the pending step, and a build writing the pending version writes the shape + that step produces. + +* Every right-click leaves a popup window behind. `ui/elements/context_menu.py` opens an untagged + `dpg.window(popup=True)` that nothing deletes, so the item tree grows by a menu's worth of widgets + and their captured closures per gesture, for the life of the run. It is shared by the converter's + list, the file browsers and the samples panel. The popup needs a tag of its own per panel and a + deletion before it is built again. + +* A mix of one recording offers five moves that can never be taken. `update_view` in + `ui/panels/main/converter/panel.py` reads `self._banded = view_model.mixes`, while the list bands + on `collapse_levels = not mixes_several` (`view_model/main/converter.py`). With **Mixed** named + and one recording gathered, the menu is built banded over a list drawn collapsed, so + `_moves(row, banded=True)` answers with every level item and each of them disabled. Reading + `mixes_several` there is the whole of it: the menu and the list would then answer one question. + +* A channel key pressed during a conversion rewrites the setup with nothing on screen. + `toggle_slot` in `logic/main/converter/logic.py` settles the choice whatever the run is doing, + and `_settle` emits a view while `not self.is_active`, so `1`–`4` mid-run move the gathering and + the reader meets it once the run closes. The boxes beside a row already draw with `enabled=live`, + which is what holds the pointer to the same rule; the guard `toggle_slot` wants is that rule, + read from `live`. + +* The removal key reaches a row a collapsed converter card is hiding. `GUIStemsList.picked_key` + answers with the row the reading holds picked out, which a collapsed card keeps, so `Del` takes + the picked recording off a list the reader has put away. The Source settings card names that row + throughout and collapsing the card is the reader's own gesture, so the pick is right to survive + it; what is missing is that the card standing away is a reason for the key to rest. Whether a + card is shown is read from a drawn frame, so the answer belongs with the collapse controller + rather than with the list. + +* A windowed list keeps a settle pass alive for as long as it holds rows back, including while its + card is collapsed and the list is hidden. `GUIStemsList._settle_soon` re-arms while `_following` + is true, and `WindowedRegion.standing` asks only that the body item exists, so a gathering of a + few hundred recordings reconfigures two widgets every frame for the rest of the run. DearPyGui + offers no scroll callback, so the pass itself is the design; what it wants is the same drawn + visibility reading the removal key above needs, which is why the two are recorded together. + * No refreshing after library generation * Misaligned dialog boxes sizes at initialization * Audible noise instructions when matching near-silent samples for FFT γ0 diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 4d58745b5..3ca1c3ccc 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -35,7 +35,7 @@ These rules govern the Python in this repository. They complement 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 behavior 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 behavior differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behavior is warranted there. +1. Wrap a third-party library or OS tool whose behavior differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. That implementation's class docstring is where its quirks are named, so a reader meets them beside the code they explain. ## Type Hints @@ -82,7 +82,15 @@ These rules govern the Python in this repository. They complement 1. Write for a reader who never saw the history. A document is not a changelog or a devlog: do not argue against past states, resolved problems, or rejected alternatives the reader never knew existed. The design as it stands carries its own justification; history belongs in commit messages and release notes. 1. Reach for a negative example only when the contrast teaches something the positive statement cannot, and use it sparingly. One well-placed "what to avoid" illuminates; a document written mostly in negatives is noise. 1. State each fact once, in the document that owns it, and cross-reference sibling documents rather than repeating them. -1. Use American English. +1. A document change is part of the change that motivates it. Code that alters a contract a document states lands together with the edit stating the new contract, and a deviation the change knowingly leaves behind lands with an entry in the ledger that document names. What a branch leaves behind is therefore the current contract, the recorded distance from it, or both. +1. Use American English, in prose and identifiers alike. A name someone else owns keeps the spelling they gave it: `MatchRule.serialise()` is jeepney's, `CancelledError` is the standard library's. + +## Guide + +1. `docs/guide/` is written for someone using the application, not changing it. A page says what a reader can do and how, in the order they would do it; a page organized by control catalogs the application instead of explaining it. +1. A few sentences per feature. Mechanism, file formats and per-widget behavior belong to `docs/development/`, and a `###` inside a guide section is the sign a passage grew into a reference. +1. Write for a reader with no picture of the screen. Name a control by the label the application ships, read from the language file, rather than by where it sits. +1. Write in plain, direct English. Short sentences carrying one fact each, the noun repeated rather than replaced by a pronoun, and a bulleted list wherever the page states several things of one kind. Use everyday verbs — *shows*, *changes*, *opens*, *removes*, *click* — in place of this repository's own vocabulary (*settles*, *holds*, *answers*, *stands for*, *reaches*), which names concepts a reader of the guide has never met. ## Tests @@ -93,9 +101,8 @@ 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. **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 behavior 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 color, 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. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes and layouts are tuned freely, so a case that restates one turns every adjustment into a test edit. Read the value where it is configured — or from the constant that defines it — and assert the behavior around it: the bound it lies within, the round-trip it survives, the action it answers. Spell a value out only where the value itself is the contract, a file format's constant say, and name that reason in the case. +1. **A case assumes no one platform.** The separators in a path, the ending of a line, the formatting of a number, the order a directory arrives in — these belong to where the suite runs, not to the case. Compare a path with a `Path` rather than with the string POSIX renders it as; the suite runs on Windows too. 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 behavior, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side. diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md new file mode 100644 index 000000000..5572905cc --- /dev/null +++ b/docs/development/keyboard.md @@ -0,0 +1,180 @@ +# The Keyboard and the Actions It Reaches + +This document describes how a key press reaches behavior in `sampletones_application`, and how an +**action** — the one name a press, a menu item and a context item all reach one behavior by — is +declared and shown. It governs `utils/gui/keyboard/`, `utils/gui/shortcuts/`, the schemes under +`sampletones_config/keybindings/`, and the menu surfaces that print an action. Consult it when +giving a panel keys of its own, adding a shortcut, or putting an action on a menu. + +The design truths it realizes are principles 12 and 14 of [`architecture.md`](architecture.md): +one dispatcher owns the keyboard, and an action is declared once. This document holds the +mechanism behind both. + +--- + +## The dispatcher + +DearPyGui delivers a press to every registered key handler with the same global reach, and gives +none of them a way to stop another — or ImGui itself — from also seeing it, so priority and consume +semantics exist only where the application builds them. A single `KeyRouter` +(`utils/gui/keyboard/`) owns the one `add_key_press_handler` for the whole application, snapshots +the modifier state once into a frozen `KeyEvent`, and offers that event to registered **scopes** +from highest priority to lowest. The first active scope whose handler returns `True` claims the +press and ends the walk; this software walk is the sole consume mechanism the framework +leaves available. + +Each keyboard consumer registers one scope through `register(handle, *, priority, active)`, where +`active()` reports whether the scope wants keys at this moment and `handle(event) -> bool` acts on +the press and reports whether it claimed it. + +### Priorities + +Three priorities order the whole application: + +| Priority | Scope | Active when | Behavior | +|----------|-------|-------------|-----------| +| `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 sub-panel the keys are meant for — the sequencer's tracker grid, the order list, the voices, the converter's list of gathered recordings, the instruments panel while an audition is open | its tab is in front and the sub-panel holds what the keys act on: a cursor, a row picked out, or an open audition | handles the keys its own category names 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` | + +The router offers a panel the key ahead of the shortcut scope, so a panel returns `False` on any +combination it does not own — the grid yields every `Ctrl`-modified press — which is what lets +field-transparent shortcuts such as `Ctrl+PgDn` / `Ctrl+PgUp` tab-switching reach the shortcut +scope while a grid cursor is set. + +### A panel scope answers on its own tab + +A cursor, a picked row and an open audition all 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 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. + +### The modal stack + +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, every scope beneath it reads the keyboard as though the +application held no dialogs at all. + +Its one global handler is bound in `shell.py` once the DPG context exists, on the router the +composition root built and injected into every consumer (architecture principle 7). + +--- + +## The vocabulary + +One key table (`utils/gui/keyboard/keys.py`) reads a key both ways — the name a file writes and the +code a press carries — and one combination type, `KeyCombination`, parses that spelling, displays +it, and answers whether a press matches it. + +Above them stands the one declared binding (architecture principle 12). The menu printing an +accelerator, the panel acting on a press, and the dispatcher firing the callback all read that one +entry, so each of the three shows or fires whatever the scheme currently says. + +**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. + +--- + +## Schemes + +### 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 analog of the palette switch ([`palette.md`](palette.md)) — 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 labeled 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. + +--- + +## Actions + +Declaring an action is a chain of four links, and the `shortcut-actions` check holds every one of +them (see [`architecture.md`](architecture.md) § Enforcement): + +| Link | Where | What it states | +|------|-------|----------------| +| The action | `utils/gui/shortcuts/ids.py` | its name, and the category that answers it | +| Its keys | every scheme under `sampletones_config/keybindings/` | the combination that fires it, `~` where it ships unbound | +| Its call | `shell.py` — a `ShortcutBindings` field and the entry naming it in the binding map, or membership of `FAMILY_SHORTCUT_IDS` | the one call the action makes | +| Its label | a `KeybindingActionElements` member and its `en.yaml` entry | how the keybindings editor lists it | + +Two kinds of action state their call differently, and the check knows both. + +A **family** is an action a whole enum parameterizes — an export item per format, an item per +channel: a `Dict[Enum, ShortcutId]` in `ids.py` whose reader dispatches on the enum member. +`FAMILY_SHORTCUT_IDS` names the mappings that are families, so what excuses an action from stating +a call of its own is written down. `SHORTCUT_IDS_BY_NAME` answers with every action and stands +outside that list. + +A **panel-scope** action states no call at all, because its key scope acts on the press itself. A +`DIALOG` action is named nowhere in the editor, since a dialog is operated by the keys its category +holds. + +### A menu item is a view of an action + +`ShortcutManager.add_menu_item(shortcut_id, ...)` is how a menu names an action: it takes both the +accelerator and the call from the action, and keeps the item under it, so a rebind re-prints the key +already on screen. An item passes a `callback` of its own only where it carries a state to show, and +then that call is the one switching the state it shows. + +### A set of actions several menus show is declared by whoever owns them + +The owner states one builder — `GUISequencerVoicesPanel.add_action_items` for a voice, a grid's edit +surface for a cell — and each door decides where to print it: the panel's own row menu, the menu +bar's **Edit** group through `EditSurfaceProtocol` and `EditRouter`, the **Voice** group through the +panel. Adding an action to the builder reaches every door, and the dividers around it belong to the +door rather than to the set. + +### A menu whose contents follow a selection states them when it is opened + +A menu bar is built once, while what an item should say follows the cursor at the moment a reader +opens the menu. `ui/elements/menu_section.py::MenuSection` is that mechanism: a marker leads the +menu, the framework reports it drawn once a frame while the menu stands open, and a gap in those +reports marks a fresh opening and restates the section. `MenuSection` states why the marker leads. diff --git a/docs/development/palette.md b/docs/development/palette.md new file mode 100644 index 000000000..854cf7785 --- /dev/null +++ b/docs/development/palette.md @@ -0,0 +1,40 @@ +# Colors and Palettes + +This document describes how a color is written, composed, and handed to DearPyGui, and what a +palette change costs. It governs `utils/palette/` and `utils/gui/palette/`. Consult it when adding a +color the interface draws with, or a shade the interface derives from one. + +The design truth it realizes is principle 13 of [`architecture.md`](architecture.md): a color is a +token, resolved where it is drawn. This document holds the mechanism. + +--- + +## A color is a token + +Every annotation names `BaseColor` (`utils/palette/colors/`) — a dataclass field, a signature, a +dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry, +which is the one place a written token is read out of the configuration. The `rgba` read happens +where the value is handed to a widget, and what a consumer keeps is the token, so whoever holds a +color follows a palette swap. + +## 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. + +## A palette change is one switch + +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 color reached, and +`dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a color gets there. + +`PaletteSource.activate` then fires the composition root's listener, which re-applies the bindings, +refreshes the viewport clear color, and repaints the sequencer for the row and cell highlights +DearPyGui holds as table state. + +The `palette-colors` hook holds all three rules — an attribute assigned a resolved `rgba`, a theme +color filled outside the palette bindings, and a hex literal in the shipped configuration outside +`palettes/` (see [`architecture.md`](architecture.md) § Enforcement). diff --git a/docs/development/playback.md b/docs/development/playback.md index b4a3886a8..c7c5b9f4e 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -3,8 +3,8 @@ This document governs sound across the application: what may be heard, who decides, and what each transport command means. Consult it when adding audio a user can start, a surface that starts it, or a control over what is heard. The contracts here bind every tab and every player. It complements -`docs/development/architecture.md` (which owns the keyboard-routing layer, §12) and -`docs/development/guidelines.md`. +`docs/development/architecture.md`, `docs/development/keyboard.md` (which owns the keyboard-routing +layer) and `docs/development/guidelines.md`. --- diff --git a/docs/development/progress.md b/docs/development/progress.md index 6d43652ce..f3ba93b1f 100644 --- a/docs/development/progress.md +++ b/docs/development/progress.md @@ -132,7 +132,8 @@ file in between. | Opening the channel, reading it, and reaping it | `TaskProcessor` (`parallelization/processor.py`) | | How long a run has left, from what it has covered | `ETAEstimator` (`parallelization/progress.py`) | | Turning a run's account into a result the application reads | `ConversionService` (`services/conversion/`) | -| The bar, the status line, and the stage's name | `ConverterLogic` (`logic/main/converter.py`) | +| The bar, and the taskbar the run also reports to | `ConversionRun` (`logic/main/converter/run.py`) | +| The status line and the stage's name | `ConverterMessages` (`logic/main/converter/messages.py`) | ### A finished task stays finished diff --git a/docs/development/render-thread.md b/docs/development/render-thread.md new file mode 100644 index 000000000..9049d3eb2 --- /dev/null +++ b/docs/development/render-thread.md @@ -0,0 +1,63 @@ +# The Render Thread and What Crosses to It + +This document describes how work reaches DearPyGui from somewhere other than the thread that owns +its context, and what each crossing costs. It governs `utils/gui/render_thread.py`, +`utils/gui/callbacks.py`, `utils/gui/frame.py`, and the queue in `utils/callbacks/`. Consult it when +a worker thread has something to show, when a gesture rebuilds widgets, or when work needs a frame +to have been drawn first. + +The design truth it realizes is principle 6 of [`architecture.md`](architecture.md): DearPyGui's +context belongs to the render thread. This document holds the mechanism. + +--- + +## A background result crosses through `CallbackQueue` + +Services execute long-running work on background threads and post each result to `CallbackQueue` +with a priority; the main-thread render loop drains the due results each frame within a per-frame +time budget (`scheduling.queue_budget_seconds`), so a large backlog spreads across frames while +rendering continues. Every background result reaches UI state this way, and applying one to UI state +directly from the worker thread is forbidden. + +## Work arriving from a worker crosses through `on_render_thread` + +A thread of our own — a directory being read, a subtree being rebuilt — reaches the interface while +the render thread is walking the very items it would create and drop, and an item freed there is +freed with no Python thread state: a crash rather than a glitch. +`utils/gui/render_thread.py::on_render_thread` is that crossing: work already on the render thread +runs where it stands, and work arriving from any other thread joins the queue. A worker that reads a +value or sets one on a standing widget still goes through it, since the hazard is the thread rather +than the gesture. + +A run claims the drawing thread when its loop starts and lets it go when the loop stops. An +unclaimed context runs the work in place, which is what an interface being built stands in. + +## A widget's own gesture is held for the frame + +DearPyGui answers a gesture on a thread of its own, so a callback that rebuilds widgets there runs +while the render loop walks the very items it drops. `utils/gui/callbacks.py::hold_callbacks` turns +on manual callback management when the context is created, and `run_held_callbacks` runs what +DearPyGui gathered at the top of each frame's drain. So a gesture reaches the interface from the +thread that drew it, and `on_render_thread` is a direct call inside a callback because the callback +already stands there. + +## A gesture that waits keeps the frames going + +A callback standing on the render thread holds the frames up for as long as it runs, and a native +dialog runs for as long as the reader takes to answer it. +`utils/gui/render_thread.py::answered_while_drawing` puts that waiting on a thread of its own and +draws frames until it reports back, which is how `utils/file_dialogs/api.py` opens one. The gestures +those frames gather wait for the drain that follows, so the interface stays painted while it stands +inert. + +## Work that needs a drawn frame names the frame it waits for + +Reading a laid-out size or letting a configuration take effect needs a frame to have been drawn with +it, while the drain runs between frames rather than inside one. +`FrameCallbackManager.set_frame_callback` (`utils/gui/frame.py`) names the frame the work is picked +up on, and is how a callback waits for one. + +The drain is what makes the wait a scheduled one. The render thread inside a drain is between frames +rather than inside one, which makes the next frame the drain's own to reach, so `dpg.split_frame` +there waits for what the wait itself prevents and the application stops for good. Naming a frame +count asks for the same thing and lets the loop keep running. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 48abe7fee..f8b779cfe 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -150,7 +150,7 @@ row accepts, so text typed by hand lands the values the grid would. 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 +else the desktop picks up afterward. `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. diff --git a/docs/development/vocabularies.md b/docs/development/vocabularies.md new file mode 100644 index 000000000..8b8f4690c --- /dev/null +++ b/docs/development/vocabularies.md @@ -0,0 +1,102 @@ +# Identifier Vocabularies + +Two vocabularies name things across `sampletones_application`: the keys every user-visible string is +looked up by, and the identifiers DearPyGui knows a widget by. Both are spelled by a grammar, both +are held to the source whole-tree by a pre-commit hook, and both keep in one place a fact that would +otherwise be restated at every use. Consult this document when adding a string the reader sees, or a +widget another module reaches. + +The design truths it realizes are principles 8 and 9 of [`architecture.md`](architecture.md): all +display text comes from `LanguageManager`, and `tags/` holds only DPG identifiers. This document +holds the grammar behind both. + +--- + +## Display text + +### The grammar + +Every user-visible string is looked up on `LanguageManager` by the key the language file spells: + +``` +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 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 localization. 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. + +### The forms a lookup takes + +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 in one of the three forms the hook reads values from: as literals, +as annotated members, or as a conditional between two literal keys. + +```python +language_manager[ + "global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name" +] +``` + +--- + +## Widget tags + +### `compose_tag` is the one composer + +`tags/compose.py` owns `TAG_SEPARATOR` and the joiner; every tag reaches its final spelling through +it. Each part is lowercased and its whitespace runs become single underscores, so a tag built from a +runtime name — a sample title, a layer label — reads the same however that name arrives cased or +spaced, and a part already holding a composed tag contributes its own segments, which is how a child +tag extends its parent. Fragments hold bare segments (`SUF_GRAPH_PLOT = "plot"`) and gain separators +only from the joiner, so a fragment reads as the segment it names and either end composes onto it. + +### A runtime name carries its identity beside it + +Because the composer reads two names that differ only in case or spacing as one segment, a tag built +from a name a user gave — a file path, a project title — carries `identity_part(*parts)` beside the +name. The part is a short digest of the name exactly as it arrived, so `Kick.wav` and `kick.wav` +name widgets of their own, and the readable name stays in the tag so a DearPyGui error still says +which row it is about. `tags/compose.py` states the rule once over +`sampletones_shared.utils.hashing.identity_digest`, which joins the parts on a separator no name +carries; `ui/elements/stems/tags.py` and `ui/elements/tree/tag.py` both read it. A widget keyed by +anything a user names needs it — DearPyGui refuses a duplicate alias, so two names arriving at one +tag break the draw rather than crossing quietly. + +### A whole tag is a `TagName` + +`TagName` is the `str` subclass in `categories/key/tag.py` that names a tag's four parts and +composes them: + +```python +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. diff --git a/docs/formats/configuration.md b/docs/formats/configuration.md index 674da04ac..1c5b30216 100644 --- a/docs/formats/configuration.md +++ b/docs/formats/configuration.md @@ -43,13 +43,15 @@ change any of these and a different library is selected or generated. ## `generation` -Which channels are used, and how candidates are scored. It carries a few -top-level keys and groups the scoring controls into `calculation`, `weights`, -`metric`, and `decoder`. +How candidates are scored. It carries a few top-level keys and groups the +scoring controls into `calculation`, `weights`, `metric`, and `decoder`. + +Which channels a conversion uses is stated by the conversion itself — per +recording, in the converter — so it is a setting of the interface rather than a +configuration key. | Key | Meaning | Values | | --- | --- | --- | -| `channels` | channels used | list of `pulse1`, `pulse2`, `triangle`, `noise` (legacy key `generators` loads too) | | `drive` | how hard the channels are pushed (alias: `mixer`) | 0 < value ≤ 5 | | `reset_phase` | reset oscillator phase within each instruction | `true` / `false` | | `final_regeneration` | re-render the chosen instructions at the end to keep oscillators continuous | `true` / `false` | diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index c0d463ad7..489991cee 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -78,12 +78,13 @@ current shape before deserialization (see is stored alongside the data version, for reference. The current data version is 2.2. Version 2.2 renamed the per-channel stream and -approximation keys from `generator_name` to `channel_name` and the channel -selection under the embedded config from `generators` to `channels`; the enum -values stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. It -also records the source audio as one path per stem and carries the `stems_data` -record on every reconstruction; a file written before either existed is read with -its single path listed and a one-stem record synthesized from what it plays. +approximation keys from `generator_name` to `channel_name`; the enum values +stored inside (`pulse1`, `pulse2`, `triangle`, `noise`) never changed. It also +records the source audio as one path per stem and carries the `stems_data` record +on every reconstruction; a file written before either existed is read with its +single path listed and a one-stem record synthesized from what it plays. The +channel selection lives on that record — each entry states the settings its stem +was converted with — so the embedded configuration carries none. ## Storage and export diff --git a/docs/glossary.md b/docs/glossary.md index 3c7217875..8d755038c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -141,7 +141,7 @@ applies a loudness curve. The strategy that reads a channel's per-frame candidates into the stream it plays, named by `generation.decoder.selector`. The **greedy** decoder plays each frame's -best candidate; the **Viterbi** decoder (the default) favours continuity, changing a +best candidate; the **Viterbi** decoder (the default) favors continuity, changing a channel only when the gain in match quality outweighs the cost of the change. ### Calibration diff --git a/docs/guide/command-line.md b/docs/guide/command-line.md index c4b495b0b..76a2d5912 100644 --- a/docs/guide/command-line.md +++ b/docs/guide/command-line.md @@ -13,6 +13,8 @@ executable you made (`./bin/sampletones` on Linux, `bin\sampletones.exe` on Wind * **Reconstruct a file** — `sampletones input.wav -o output.stn` * **Reconstruct a folder** — `sampletones path/to/folder` reconstructs every audio file inside it. +* **Choose the channels** — add `--channels pulse1,pulse2` to reconstruct onto those two + alone; without it a run uses pulse 1, triangle, and noise. * **Open a file in the app** — `sampletones song.stp` opens the interface preloaded with it; a `.stn` reconstruction or `.ins` library works the same way. * **Use a specific configuration** — add `--config my-config.json`; otherwise your @@ -28,6 +30,7 @@ executable you made (`./bin/sampletones` on Linux, `bin\sampletones.exe` on Wind | --- | --- | | `path` | (positional) an audio file or folder to reconstruct, or a `.stn` / `.ins` / `.stp` file to open in the app. Omit it to launch the interface. | | `--output`, `-o` | output path for a reconstruction | +| `--channels` | channels the reconstruction may use, comma separated (default: `pulse1,triangle,noise`) | | `--config`, `-c` | path to a configuration `.json` (default: your saved `config.json`) | | `--generate`, `-g` | build the instruction library for the configuration, then exit | | `--version`, `-v` | print the version and exit | diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 95ece9029..4f5cf0edc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,43 +1,51 @@ # Configuration -_SampleToNES_ reconstructs according to a **generation configuration** — the -sample rate, the NES frequency, which channels are used, how the audio is -analyzed, and how candidates are scored. The settings you reach for most often are -on the **Main** tab; the rest live in the configuration file, for when you want to -go deeper. +_SampleToNES_ reconstructs audio according to a **generation configuration**: the +sample rate, the NES frequency, how the audio is analyzed, and how candidates are +scored. The settings you change most often are on the **Main** tab. The rest are +in the configuration file, for when you want to go deeper. + +Channels are not part of the configuration. The NES has four sound channels — +**Pulse 1**, **Pulse 2**, **Triangle**, and **Noise** — and you choose which of +them each recording uses in the converter's list, every time you set up a +conversion. See [choosing which channels a recording +uses](converting.md#choosing-which-channels-a-recording-uses). ## From the interface -The **Main** tab exposes the everyday settings (grouped under **General -settings**, **Reconstructor settings**, and **Advanced settings**): +Three cards on the **Main** tab hold the everyday settings: -- which **Channels** take part, and the **Drive** applied to them; -- **Normalize audio** and **Quantize audio** preprocessing; -- the **Sample rate** and **NES frequency**; -- the **Generation method** and **Feature scaling**, which set how the audio's - frequency content is measured and weighted (see - [Reconstruction algorithms](../concepts/reconstruction.md)); -- the **Workers** count and the library and output folders. +- **General settings** — **Normalize audio** and **Quantize audio**, and the + **Sample rate** and **NES frequency** that a library is built for. +- **Source settings** — **Drive**, which sets how hard the channels are pushed, + and the channels and bends of the recording you selected in the converter's + list. +- **Advanced settings** — **Method** and **Feature scaling**, which set how the + audio's frequency content is measured and weighted (see [Reconstruction + algorithms](../concepts/reconstruction.md)); the **Workers** count; and the + instruction library and output folders. Choose **View ▸ Show advanced + settings** to display this card. Changing any of these updates your configuration, which is saved to `config.json` (see [Where your files live](files.md)). ## In the configuration file -The configuration holds more than the interface shows. The finer controls — the +The configuration holds more than the interface shows. You can edit the finer +controls directly in `config.json`: the [selector](../concepts/reconstruction.md) (greedy or Viterbi), the phase aligner, the scoring weights and distance metric, the number of candidates kept per frame, -and so on — can be edited directly in `config.json`. The -[configuration file reference](../formats/configuration.md) lists every section -and key, and [Reconstruction algorithms](../concepts/reconstruction.md) explains -what they do and lists the defaults. +and so on. The [configuration file +reference](../formats/configuration.md) lists every section and key, and +[Reconstruction algorithms](../concepts/reconstruction.md) explains what they do +and lists the defaults. You can also load and save whole configurations from the **Reconstruction** menu -(**Load generation settings...** and **Save generation settings...**), or point the -app at one on the [command line](command-line.md) with `--config`. +(**Load generation settings...** and **Save generation settings...**), or point +the app at one on the [command line](command-line.md) with `--config`. ## Deployment settings -A couple of settings — the log level and strict history checking — are decided when -the application is packaged, not by you, so they are not part of your -configuration. They exist for development and support. +Two settings — the log level and strict history checking — are decided when the +application is packaged, so they are not part of your configuration. They exist +for development and support. diff --git a/docs/guide/converting.md b/docs/guide/converting.md new file mode 100644 index 000000000..ce11aacb6 --- /dev/null +++ b/docs/guide/converting.md @@ -0,0 +1,80 @@ +# Converting audio + +The **Main** tab (`F1`) turns audio files into +[reconstructions](../concepts/reconstruction.md). You gather the recordings you +want on the **Converter** card, choose which NES channels each one may use, +decide whether every recording becomes its own reconstruction or they all mix +into one, and start the conversion. + +## Choosing what to convert + +The **Converter** card lists the recordings a conversion uses. Add them from the **Filesystem** browser: + +- Double-click an audio file, or Ctrl-click it, to add it. You can also right-click it and choose **Add as stem**. +- Ctrl-click a folder, or right-click it and choose **Add folder**, to add every recording inside it, at any depth in the folder tree. + +Turn on **Playback ▸ Autoplay** (`Ctrl+P`) to play a recording with a single click. This lets you listen through a folder before adding anything from it. With Autoplay off, right-click a recording and choose **Play**. + +Adding a folder opens a small window while the folder is read. **Stop** ends the search and leaves the list as it was. A folder with no recordings inside it says so and adds nothing. + +**x** removes a row from the list. Removing a folder removes every recording in it. + +Click a row to select it. The **Source settings** card then shows that recording. Right-clicking a row selects it as well. Press `Del` to remove the selected row. Closing a folder that holds the selected recording clears the selection. + +## Choosing which channels a recording uses + +The NES has four sound channels: **Pulse 1**, **Pulse 2**, **Triangle**, and **Noise**. Every recording in the list has a checkbox for each channel. Check the channels that the recording may use. Press `1` to `4` to switch a channel on or off for the selected row. + +A folder represents all the recordings inside it. Its checkbox shows their channel assignments: + +- checked if all recordings use the channel, +- filled with the channel's color if only some recordings use it, +- empty if none of them use it. + +Click the checkbox to change the channel for all recordings in the folder. To change the channel for one recording, open the folder first: click the marker next to the folder name, or double-click the folder name. Each recording inside then has its own checkboxes. + +The **Source settings** card has two more settings. **Drive** sets how hard the channels are pushed. It applies to the whole conversion. + +Below it, the card shows the name of the recording you selected in the list and a checkbox for each of its channels. **Pulse 1**, **Pulse 2**, and **Triangle** also have a **bend** checkbox. This tunes each note to the recording's exact pitch. Noise has no bend. + +**Channels per source** limits how many channels one recording can use at the same time, from 1 to 4. Set it to 1 to make each recording use one channel. + +## One reconstruction each, or one mix from all + +**Output**, at the top of the **Converter** card, decides what the conversion produces: + +- **One per recording** — each recording in the list becomes its own reconstruction. Recordings added with a folder are saved in a matching folder structure. +- **One from all** — all recordings are mixed into a single reconstruction. Folders are replaced by the recordings inside them. + +A mix can hold up to eight recordings. If you switch to **One from all** with more than eight recordings in the list, a dialog asks which ones to mix. The same dialog opens when you add a folder with more recordings than the mix has room for. + +The dialog lists the same rows as the card and shows how many recordings you have selected. Double-click a row to hear the recording. **Add** becomes available when your selection fits. A full mix cannot accept more recordings, so uncheck one before checking another. + +Once a mix has two or more recordings, the rows are grouped into **levels**. A level decides which recordings choose their channels first. Everything on level 1 is given channels before anything on level 2. This lets a lead melody take the channels it needs before a background part does. + +Drag a row onto another row to put them on the same level. Drag it into the gap between levels to give it a level of its own. You can also right-click a row to use the same commands. + +**Order** decides how the levels take turns: + +- **Round robin** — every level gets a turn in each round. +- **Strict** — one level is filled before the next one chooses. + +## Running a conversion + +Click the button under **Output** to start the conversion. The button's label tells you what it is about to do. Only one conversion can run at a time. While a conversion is running, the button reads **Cancel**. + +**Destination:** shows where the result is saved: a single file for one conversion, or a folder for a longer run. Click the path to open it in your file manager. If the conversion would replace an existing reconstruction, the app asks you first. + +When the conversion finishes, click **Load** to open the result on the **Reconstruction** tab, where you can [listen to it and export it](reconstruction.md). After a conversion of several recordings, the button reads **Open** instead. + +The first conversion with a given set of settings builds the [instruction library](../concepts/instruction-library.md) it needs. This takes a while. Later conversions with the same settings reuse the library. + +## The instruction library + +The **Instructions** tab (`F4`) builds and browses the [instruction library](../concepts/instruction-library.md), the catalog of NES tones that a conversion searches. + +A conversion builds the library it needs on its own, so you rarely need to go there. The tab is useful for building a library before a long session and for exploring what your settings can produce. + +Select an instruction to see its **Waveform** and **Spectrum**. This lets you see and hear a single NES tone on its own. + +**Generate library** builds a library for your current settings. Once a library is loaded, the button reads **Regenerate instructions**. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b9b23eeb6..04a9530bf 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,39 +7,41 @@ instruments, and building a whole song. Both assume it is already ## Reconstruct a sound into FamiTracker instruments 1. Launch the app and open the **Main** tab. -2. In the **Filesystem** browser on the left, click an audio file (WAV, MP3, - FLAC, OGG, AIFF, or AU) — or a folder, to reconstruct every audio file inside it. -3. Optionally choose which channels to use under **Reconstructor settings** and - adjust **General settings**. At least one channel must be enabled. -4. Click **Convert sample** (or **Convert directory** for a folder). The first - time you use a given set of settings, the - [instruction library](../concepts/instruction-library.md) is built - automatically ("Generating instructions library..."), then the reconstruction - runs. -5. When it finishes, click **Load** to open the result on the **Reconstructions** +2. In the **Filesystem** browser, double-click an audio file (WAV, MP3, FLAC, + OGG, AIFF, or AU) — or Ctrl-click a folder, to reconstruct every audio file + inside it. +3. Optionally click the recording in the list and choose which channels it uses + under **Source settings**, and adjust **General settings**. Each recording + needs at least one channel. +4. Click the button under **Output** to start the conversion. The first time you + convert with a given set of settings, the [instruction + library](../concepts/instruction-library.md) it needs is built first + ("Generating instructions library..."), which takes a while. +5. When it finishes, click **Load** to open the result on the **Reconstruction** tab. 6. Choose **Reconstruction ▸ Export instruments ▸ FamiTracker instruments...** and - name the export. One `.fti` file is generated per instrument: `Kick (pulse1).fti`, - `Kick (triangle).fti`, and so on. + name the export. The app writes one `.fti` file per instrument: `Kick + (pulse1).fti`, `Kick (triangle).fti`, and so on. That is the shortest path from a sound to instruments you can load in FamiTracker. -The [interface guide](interface.md) covers the **Main** and **Reconstructions** -tabs in full. +[Converting audio](converting.md) and [working with a +reconstruction](reconstruction.md) cover the **Main** and **Reconstruction** tabs +in full. ## Build a song and export a module 1. Choose **File ▸ New project**. The app switches to the **Sequencer** tab. 2. Have one or more reconstructions ready — make them as above, or open existing ones. -3. Add each as a sample: in the Sequencer's **Reconstructions** browser on the - left, right-click a reconstruction and choose **Add to Sequencer**. If its NES - frequency differs from the project's, confirm with **Add anyway**. -4. In the **Tracker** grid, click a cell and type notes on your keyboard; assign a - sample to a channel with the cell's right-click **Set voice**. +3. Add each as a sample: in the Sequencer's **Browser**, right-click a + reconstruction and choose **Add to Sequencer**. If its NES frequency differs + from the project's, confirm with **Add anyway**. +4. In the **Tracker** grid, click a cell and type notes on your keyboard. To + assign a sample to a channel, right-click a cell and choose **Set voice**. 5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, and **NES frequency** under **Module options**. 6. Choose **File ▸ Export ▸ FamiTracker module...** and pick a path for the `.ftm` - file. **Bitphase project...** beside it writes the same song as a `.btp`. + file. **Bitphase project...** next to it writes the same song as a `.btp`. The [sequencer guide](sequencer.md) covers the tracker grid, the order, voices, and undo history in full. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index e01a5d67d..1458e8dfd 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -1,234 +1,39 @@ # The interface -_SampleToNES_ is one window: a menu bar at the top, four tabs, and a status bar -at the bottom. Each tab works left to right — pick something on the left, set it -up in the center, refine it on the right. +_SampleToNES_ has four tabs. Use `F1` to `F4` to switch between them: -This page covers the **Main**, **Instructions**, and **Reconstructions** tabs -and the menus around them. The **Sequencer** has [its own page](sequencer.md). +- [**Main**](converting.md) (`F1`) — turn audio files into [reconstructions](../concepts/reconstruction.md). +- [**Reconstruction**](reconstruction.md) (`F2`) — listen to a reconstruction, edit its instruments, and export it. +- [**Sequencer**](sequencer.md) (`F3`) — arrange reconstructions into a song. +- [**Instructions**](converting.md#the-instruction-library) (`F4`) — build and browse the [instruction library](../concepts/instruction-library.md) a conversion uses. -## Main +You usually work through the tabs in this order. Convert your files on **Main**. When the conversion finishes, click **Load** to open the result on **Reconstruction**. From there, **Add to Sequencer** adds the reconstruction to a song. -The **Main** tab turns an audio file into a -[reconstruction](../concepts/reconstruction.md). Most sessions start here. +The **Instructions** tab is optional. It lets you explore individual _instructions_ — the unit blocks produced by the NES sound processor. -Pick an audio file — or a whole folder — in the **Filesystem** browser on the -left, set up the conversion in the center, and click **Convert sample** (or -**Convert directory** for a folder). The browser reopens the folders you were -last working in, and **Collapse all** folds them away again. The [instruction -library](../concepts/instruction-library.md) your settings need is built the -first time you convert, so you can start straight away. +## The menus -While a run goes on, the panel shows the file going in and the file coming out; -click either path to open it in your file manager. Afterwards, **Load** opens -the new reconstruction on the **Reconstructions** tab — after a folder run the -button reads **Open** instead. **Cancel** stops a run, and only one runs at a -time. +Each menu covers one kind of work: -Converting one file or one stems mix always writes to the same filename. If a -reconstruction of that name is already there, the app asks first; click -**Convert anyway** to replace it. Converting a folder starts straight away: it -converts the recordings that still need a reconstruction and leaves the ones -already made, so you can rerun it to carry on where you stopped. +- **File** — projects, exporting a song as a module or a program, and rendering a song to audio. +- **Edit** — undo and redo, followed by commands for whatever you have selected. Its lower half changes with what you are working on. +- **Reconstruction** — creating, opening, and saving reconstructions, and exporting them. +- **Voice** — adding voices to the sequencer, and commands for the voice you selected. +- **Playback** — playing, autoplay, following the song, and muting channels. +- **View** — advanced settings, favorites, the display, and the shortcuts. +- **Help** — **About**. -### Stems mode +Two items are easy to miss: -**Stems mode** turns the card into a list of recordings to mix into one -reconstruction. Tick it, then click each recording in the browser. You can also -start from a classic conversion in one step: right-click a recording and choose -**Add as stem**, or Ctrl-click it. Ctrl-clicking a folder offers everything -inside it, as **Add folder as stems** does; if the folder holds more recordings -than the list has room for, you pick which ones. +- **View ▸ Show advanced settings** shows the **Advanced settings** card on the **Main** tab. It contains the generation method, feature scaling, worker count, and library and output folders. [Configuration](configuration.md) explains each one. +- **Playback ▸ Audio settings...** chooses the device, sample rate, and buffer size you listen through. These are separate from **Sample rate** and **NES frequency** on the **Main** tab. Those settings decide how the audio is reconstructed. -Each row shows one recording and a checkbox per channel it may use. Untick them -all and the row grays out: that recording sits out of the conversion, and stays -in the list so you can bring it back. +Project properties belong to a project and are covered in the [sequencer guide](sequencer.md). -Rows sit in **level** bands. A level is a turn to choose: every recording on -level 1 picks its channels before any on level 2, so a lead can take what it -needs before a pad does. Drag a row onto another row to join that row's level, -or into the gap between two levels to give it a level of its own. Right-clicking a -row lists the same moves as menu items, alongside the recording's own actions — -copy its name or path, or show the file in your file manager. +## Keyboard shortcuts -**Order** sets how the levels take turns: round by round, or one level filled -before the next picks. **x** takes a row out. Untick **Stems mode** and the -first recording stays as your single selection. +**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the keyboard and lets you change any shortcut. Click an action's shortcut and press the keys you want. If another action already uses those keys, the app names that action and asks whether to reassign them. -**Channels per source** caps how many channels one recording may hold in a -single frame, and it applies to every conversion — one file, a whole folder, or -a stems mix. Set to 1, each recording gets a single voice. +**Reset to defaults** restores the original shortcuts. Your changes take effect when you click **OK**. They are saved with your settings and are still there the next time you start. -Reconstructing a file or a folder from the browser converts that one thing, so -while you are gathering stems it asks before dropping the list. - -### Settings - -A few settings are worth knowing before you convert. Under **Reconstructor -settings**, the **Channels** toggles choose which channels take part — at least -one must be on — and **Drive** sets how hard they are pushed. **General -settings** holds the analysis options: sample rate, NES frequency, generation -method, and feature scaling. The rest, including the worker count and the output -and library folders, sit under **Advanced settings**, which **View ▸ Show -advanced settings** reveals. [Configuration](configuration.md) explains each -one. - -## Reconstructions - -The **Reconstructions** tab is where you compare a reconstruction with the -original, fine-tune it, and export it. - -Open a saved reconstruction from the **Browser** on the left. It shows 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 reconstruction you have open has unsaved edits, you are asked whether to -save it first. Play it back and switch **Play audio source:** between -**Reconstruction** and **Original audio** to compare the two. **Locate original -audio** re-links the source files if they have moved. - -### Finding your way around the browser - -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. - -Narrowing keeps whatever folders you had open, so ticking the box on and off -leaves the tree as you left it. To have the browser open its way down to each -favorite instead, turn on **View ▸ Auto-expand favorites**, which you can set -for reconstructions and for folders separately. It expands each time you tick -**Favorites only**, and unticking folds those rows back. - -**Collapse all**, beside the refresh button, folds the whole tree in one click. -Whatever you leave open is remembered for the next time you start the app. - -### The Stems card - -A reconstruction mixed from several recordings has a **Stems** card. It lists -each recording under the level it was picked on — the same list the converter -showed you while you were gathering. - -Each row has a colored box for every channel that recording actually took, and -a box at the front that moves all of them at once. Untick one and those frames -go silent everywhere: in the waveform, in playback, in the original audio, and -in a WAV export. That is how you hear what each recording contributed, channel -by channel. A channel you have switched off under the waveform shows its column -grayed, and your ticks stay where you put them. - -Click a row to show its recording in your file browser, and tick **Collapse -levels** to read the whole list as one table. These ticks last for the session: -saving records which recording owns which frame, not what you were listening to. - -**x** at the end of a row removes the recording from the reconstruction for -good, so the app asks first. Its frames go silent and its row disappears, and -the rest play as they did. One recording always stays, so the last row's **x** -is grayed out. - -### Exporting - -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`, **NSF program...** writes a single -`.nsf` that plays the whole reconstruction on a NES, 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)). - -### Editing instruments - -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. Typing `|` before an item marks where -that sequence repeats from while a note is held, so `15 14 | 12 10` attacks and then -circles the last two values. Each sequence keeps its own point, so a short duty cycle -can circle beside a longer volume envelope. Clearing a sequence hands that dimension -back to the channel, so an instrument with its volume sequence cleared plays at -whatever volume the channel is set to. - -Beside each channel is the room its instrument takes on the NES, with the whole -sample's above them, so you can see what an edit costs. The figures are in bytes -and count what a FamiTracker export saves, so clearing a sequence brings them -down. **Export instrument...** writes the channel you are looking at, in -whichever tracker format you pick in the save dialog — see [where your files -live](files.md#exported-files). - -An **instrument** — a voice you wrote by hand rather than converted, see the -[sequencer guide](sequencer.md#voices-samples-and-instruments) — opens here too, from -the **Voices** list's right-click ▸ **Edit**. It stands on no recording, so the tab -shows its envelopes alone: one set every channel reads, under the instrument's own -name. A row of the tracker states the note it sounds at, so the pitch steppers stand -down and **Audition** takes their place: pick **Pulse**, **Triangle** or **Noise**, and -the note keys — `Z` to `M` for one octave and `Q` to `U` for the one above it, at the -octave the tracker types in — play the instrument on that generator. The waveform card -draws what you would hear, in that generator's own color, and redraws as you edit. -Editing an instrument puts away whatever reconstruction the tab held. - -## Instructions - -The **Instructions** tab builds and browses the [instruction -library](../concepts/instruction-library.md) for your current settings, and lets -you inspect single instructions. - -You will rarely come here just to build a library — converting on the **Main** -or **Reconstructions** tab builds the matching one for you. It is useful for -building one ahead of time, or for exploring what a configuration can produce: -pick an instruction and its waveform and spectrum appear with a player, so you -can hear a single NES tone on its own. - -**Generate library** builds the library for the current settings; if one already -exists, _SampleToNES_ asks **Regenerate library?** first. **Cancel generation** -stops it, **Refresh instructions data** re-reads the catalog, and selecting an -entry in the **Libraries** tree loads it. - -## Around the app - -The menu bar and status bar sit outside the tabs. - -Each menu covers one kind of work: **File** for projects, **Edit** for undo, -redo, and whatever your cursor is on, **Reconstruction** for the current -reconstruction and its exports, **Voice** for the ways a voice comes into the -sequencer and what the one you picked offers, **Playback** for playing and for -muting the sequencer's channels, **View** for settings and the window, and **Help** -for **About**. What **Edit** offers below undo and redo follows your cursor: the -block actions of the sequencer grid you are in, or the actions of the voice you -have picked in the **Voices** list. - -**Voice** answers "how do I get a voice in" on its own: **New instrument**, **Add -sample from file...**, **Import instrument...**, and **Add to Sequencer** stand -together at the top, and the actions of the voice you picked follow — the same set the -list's own right-click menu prints. See [voices](sequencer.md#voices-samples-and-instruments). - -Two items write audio you can play anywhere: **Reconstruction ▸ Export to -WAV...** for the reconstruction you have open, 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 its options. - -Two other items are easy to miss. **View ▸ Show advanced settings** reveals the -extra options on the **Main** tab. **Playback ▸ Audio settings...** picks the -playback device, sample rate, and buffer size; these change what you hear, while -the **Sample rate** and **NES frequency** on the **Main** tab change how audio -is reconstructed. - -`F1` to `F4` switch tabs in order — **Main**, **Reconstructions**, -**Sequencer**, and **Instructions**. They 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 channels -that take part on **Main**, the channels drawn on **Reconstructions**, and the -song's mix anywhere else. In the sequencer's grids the digits type values into -the cell you are on, so mute there with the channel names or the **Playback ▸ -Channels** menu. - -### 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 tells you which one 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). +`Space` plays and pauses, and `Esc` stops. On macOS, the shortcuts use Command where other platforms use Control. diff --git a/docs/guide/reconstruction.md b/docs/guide/reconstruction.md new file mode 100644 index 000000000..1b597ea1c --- /dev/null +++ b/docs/guide/reconstruction.md @@ -0,0 +1,62 @@ +# Working with a reconstruction + +The **Reconstruction** tab (`F2`) is where you listen to a reconstruction, +compare it against the audio it was made from, edit the instruments it plays, +and export it. Open one from the **Browser**, or click **Load** after +[a conversion](converting.md) on the **Main** tab. + +## Listening to a reconstruction + +Open a saved reconstruction from the **Browser** on the **Reconstruction** tab. + +The browser groups reconstructions in two ways: + +- **By configuration** groups them by the settings they were made with. +- **By sample** groups every version of the same source audio together. + +If the reconstruction you have open has unsaved changes, the app asks whether to save it first. + +To keep frequently used reconstructions within reach, right-click a reconstruction or a folder and choose **Mark as favorite**. Check **Favorites only** to show only those items. + +The **Source** card switches playback between **Reconstruction** and **Original audio**, so you can compare the two. The **Waveform** card shows each channel and has a checkbox for each one. Keys `1` to `4` toggle the same checkboxes. + +## Hearing what each recording contributed + +The **Stems** card lists the recordings used to build a reconstruction. It groups them by the level each one was given. Every row has a checkbox for each channel that the recording used, and the checkbox at the front toggles all of them. + +Uncheck a channel to silence it in the waveform, playback, original audio, and WAV export. This lets you hear what one recording contributed, channel by channel. These checkboxes only change what you hear. They do not change anything that is saved. + +**Collapse levels** shows the whole list as one table. + +**x** at the end of a row removes that recording from the reconstruction after asking you to confirm. The recording becomes silent and its row disappears, and the change is written when you save the reconstruction. A reconstruction keeps at least one recording, so the last row's **x** is disabled. + +## Editing instruments + +The **Instruments** panel shows what each channel plays. You edit the sequences by dragging the bars or typing values. + +Each channel has its own set of sequences: + +- **Pulse 1** and **Pulse 2** — volume, arpeggio, pitch, hi-pitch, and duty cycle. +- **Triangle** — volume, arpeggio, pitch, and hi-pitch. +- **Noise** — volume, arpeggio, and duty cycle. + +Type `|` before a value to mark where the sequence repeats while a note is held. `15 14 | 12 10` plays the attack once and then loops the last two values. + +Clear a sequence to leave that setting to the channel. For example, an instrument with an empty volume sequence plays at whatever volume the channel is set to. Each channel shows how many bytes its instrument takes on the NES, so you can see what an edit costs. + +You can also edit a hand-written **instrument** here — a voice with no recording behind it, described in the [sequencer guide](sequencer.md#voices-samples-and-instruments). Right-click it in the **Voices** list and choose **Edit**. + +Since an instrument has no recording, the panel shows **Audition** instead of the pitch steppers. Choose **Pulse**, **Triangle**, or **Noise**, and the note keys then play the instrument on that sound generator. + +## Exporting + +You export a reconstruction from the **Reconstruction** menu: + +- **Export instruments ▸ FamiTracker instruments...** writes one `.fti` file per channel. +- **Export instruments ▸ Bitphase presets...** writes the same instruments as `.json`. +- **Export instruments ▸ NSF program...** writes a single `.nsf` file that plays the whole reconstruction on a NES. +- **Export to WAV...** renders the audio using the channel and stem checkboxes you have set. + +**Export instrument...** in the **Instruments** panel writes only the channel you are looking at, in whichever format you choose in the save dialog. See [where your files live](files.md#exported-files). + +To use a reconstruction in a song, right-click it and choose **Add to Sequencer**. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 601abd5d6..728c8f69b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -26,18 +26,18 @@ Four ways bring a voice in, and the **Voice** menu holds all four: | **New instrument** | An instrument holding a note at full volume, ready to place and hear | | **Add sample from file...** | A reconstruction saved anywhere on disk, as a sample | | **Import instrument...** | A FamiTracker instrument file (`.fti`), as an instrument | -| **Add to Sequencer** | The reconstruction the **Reconstructions** tab holds, as a sample | +| **Add to Sequencer** | The reconstruction the **Reconstruction** tab holds, as a sample | The first three also sit at the top of the **Voices** list, and on the list's own menu — right-click below the rows to reach it. **Add to Sequencer** is on the -**Reconstructions** browser to the left (right-click a reconstruction) and on the -**Reconstructions** tab. If a reconstruction was made at a different NES frequency +**Browser** on the Sequencer tab (right-click a reconstruction) and on the +**Reconstruction** tab. If a reconstruction was made at a different NES frequency than the project and the project already has voices, _SampleToNES_ warns with **Different NES frequency**; **Add anyway** adds it regardless. A new instrument starts out holding a note at full volume, so you can place it and -hear it straight away; give it the sound you want on the **Reconstructions** tab -(right-click ▸ **Edit**). See [editing instruments](interface.md#editing-instruments). +hear it straight away; give it the sound you want on the **Reconstruction** tab +(right-click ▸ **Edit**). See [editing instruments](reconstruction.md#editing-instruments). An imported `.fti` arrives with the volume, arpeggio, and duty-cycle envelopes the file states, and **Instrument imported** names anything the file held on a tracker's own terms that the voice leaves behind — see [reading an instrument diff --git a/docs/index.md b/docs/index.md index 0052d4d1a..58b11407e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,7 +18,9 @@ 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 interface](guide/interface.md) — the four tabs, the menus, and the keyboard shortcuts. +- [Converting audio](guide/converting.md) — the Main tab: gathering recordings, choosing channels, and running a conversion. +- [Working with a reconstruction](guide/reconstruction.md) — the Reconstruction tab: listening, editing instruments, and exporting. - [The sequencer](guide/sequencer.md) — the tracker: arranging samples and hand-written instruments 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. @@ -61,6 +63,10 @@ The [**development**](development/) section is for contributors. - [Package layers](development/packages.md) — the packages the repository divides into, and the order they import each other in. - [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. +- [Keyboard and actions](development/keyboard.md) — how a press reaches behavior, and how an action is declared and shown. +- [Identifier vocabularies](development/vocabularies.md) — the keys display text is looked up by, and the tags DearPyGui knows a widget by. +- [Colors and palettes](development/palette.md) — how a color is written, composed, and handed to DearPyGui. +- [The render thread](development/render-thread.md) — how work reaches DearPyGui from another thread, and what each crossing costs. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Progress](development/progress.md) — how a long operation says how far it has come, in one process and across the pool's workers. - [Console player](development/player.md) — the 6502 driver an `.nsf` carries, the codec that fits a song beside it, and how both are verified. diff --git a/scripts/checks/rendered_literals.py b/scripts/checks/rendered_literals.py new file mode 100755 index 000000000..954acc18b --- /dev/null +++ b/scripts/checks/rendered_literals.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 + +""" +Checks that no case holds text it rendered itself against a literal it spelled out. + +Rendering a value and comparing the result with a written-out string pins whatever decided that +rendering. `str(path)` reads one way on Windows and another elsewhere, and a formatted setting reads +whatever the build ships, so a case written that way passes where it was authored and fails where it +is run next. Comparing the values instead — a `Path` against a `Path`, a setting against the +configuration it comes from — holds on every platform and survives every tuning. + +Equality is what pins a rendering entire, so that is what the check reads; a case holding a +fragment picks one clear of anything a platform decides. Every hit therefore reads under one of two +rules in `guidelines.md`: a case assumes no one platform, or a shipped value is a choice rather than +a contract. + +Usage: + python scripts/checks/rendered_literals.py +""" + +import argparse +import ast +import sys +from pathlib import Path +from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple, Type + +from sampletones_shared.meta.source.modules import SourceModule, discover_modules +from sampletones_shared.paths.source import REPOSITORY_ROOT + +TEST_ROOTS: Final[Tuple[Path, ...]] = (REPOSITORY_ROOT / "tests",) + +EQUALITY_OPERATORS: Final[Tuple[Type[ast.cmpop], ...]] = (ast.Eq, ast.NotEq) +RENDERING_CALL: Final[str] = "str" +CALL_RENDERING: Final[str] = "str()" +TEMPLATE_RENDERING: Final[str] = "an f-string" + + +class Finding(NamedTuple): + """One comparison holding rendered text against a spelled-out literal.""" + + location: str + rendering: str + + +def rendering_of(node: ast.expr) -> str: + """How an expression turns a value into text, where it does. + + Args: + node: Expression to read. + + Returns: + str: The rendering's name, or an empty string where the expression renders nothing. + """ + match node: + case ast.Call(func=ast.Name(id=called)) if called == RENDERING_CALL: + return CALL_RENDERING + case ast.JoinedStr(values=values) if any(isinstance(value, ast.FormattedValue) for value in values): + return TEMPLATE_RENDERING + case _: + return "" + + +def spells_text(node: ast.expr) -> bool: + """Whether an expression is a string written out in the case itself.""" + return isinstance(node, ast.Constant) and isinstance(node.value, str) + + +def states_equality(node: ast.Compare) -> bool: + """Whether a comparison asks for the whole of one side to read as the other. + + Equality is what pins a rendering entire. Containment holds a fragment the case picked, which + it can pick clear of anything a platform decides, so those are left to the case. + """ + return all(isinstance(operator, EQUALITY_OPERATORS) for operator in node.ops) + + +def comparison_finding(module: SourceModule, node: ast.Compare) -> List[Finding]: + """The finding a comparison earns, where one side renders text and another spells it out. + + Args: + module: Module the comparison sits in. + node: Comparison to read. + + Returns: + List[Finding]: The finding, or an empty list where the comparison holds values. + """ + if not states_equality(node): + return [] + + sides = [node.left, *node.comparators] + renderings = tuple(filter(None, (rendering_of(side) for side in sides))) + if not renderings or not any(spells_text(side) for side in sides): + return [] + + return [Finding(location=module.location(node), rendering=renderings[0])] + + +def module_findings(module: SourceModule) -> Iterator[Finding]: + """Every comparison in a module that holds rendered text against a spelled-out literal.""" + for node in ast.walk(module.tree): + if isinstance(node, ast.Compare): + yield from comparison_finding(module, node) + + +def findings(modules: Sequence[SourceModule]) -> List[Finding]: + """Every such comparison across the given modules, in the order they were read.""" + return [finding for module in modules for finding in module_findings(module)] + + +def main(argv: Sequence[str]) -> int: + """Report every case comparing text it rendered with a literal it spelled out.""" + parser = argparse.ArgumentParser( + description="Check that no case holds rendered text against a spelled-out literal.", + ) + parser.add_argument( + "--tests", + type=Path, + action="append", + dest="roots", + help="directory of cases to read, repeatable", + ) + arguments = parser.parse_args(list(argv)) + + roots: Tuple[Path, ...] = tuple(arguments.roots or TEST_ROOTS) + found = findings(discover_modules(roots)) + if not found: + return 0 + + print("Case(s) holding rendered text against a spelled-out literal:", file=sys.stderr) + for location, rendering in found: + print(f" {location}: {rendering} compared with a written-out string", file=sys.stderr) + + print( + f"\nFound {len(found)} such comparison(s). Compare the values rather than their text: " + "a Path against a Path, a configured value against the configuration it comes from.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py index dc3dcb875..234e5e91d 100644 --- a/src/sampletones/__main__.py +++ b/src/sampletones/__main__.py @@ -8,7 +8,10 @@ from sampletones_shared.paths.extensions import EXT_FILES_AUDIO if TYPE_CHECKING: + from typing import List + from sampletones_core.configs import Config + from sampletones_core.constants.enums import ChannelName HELP_PATH = """Path to either: * audio file path/directory to reconstruct @@ -17,6 +20,9 @@ HELP_OUTPUT = """Output path for reconstruction.""" +HELP_CHANNELS = """Channels the reconstruction may use, comma separated + (pulse1, pulse2, triangle, noise; default: pulse1,triangle,noise)""" + HELP_CONFIG = """Path to a configuration .json file (if not provided, default configuration will be used)""" @@ -36,6 +42,7 @@ class ProgramArguments: path: Optional[Path] = None output: Optional[Path] = None config: Optional[Path] = None + channels: Optional[str] = None help: bool = False version: bool = False @@ -49,6 +56,24 @@ def _load_config(config_path: Optional[Path]) -> "Config": return Config.load(config_path) if config_path else Config.default() +def _channels(stated: Optional[str]) -> "List[ChannelName]": + """The channels a run hands out: the ones named on the command line, or the usual three. + + Raises: + SystemExit: If a name is not one of the channels the hardware has. + """ + from sampletones_core.constants.enums import DEFAULT_CHANNELS, ChannelName + + if stated is None: + return list(DEFAULT_CHANNELS) + + names = [name.strip() for name in stated.split(",") if name.strip()] + try: + return [ChannelName(name) for name in names] + except ValueError as exception: + raise SystemExit(f"Unknown channel in --channels: {exception}") from exception + + def main() -> None: parser = argparse.ArgumentParser( prog="SampleToNES", @@ -68,6 +93,12 @@ def main() -> None: default=None, help=HELP_OUTPUT, ) + parser.add_argument( + "--channels", + type=str, + default=None, + help=HELP_CHANNELS, + ) parser.add_argument( "--config", "-c", @@ -158,7 +189,7 @@ def main() -> None: ) config = _load_config(config_path) - return reconstruct_file(path, config, output_path) + return reconstruct_file(path, config, _channels(args.channels), output_path) else: raise RuntimeError( @@ -173,7 +204,7 @@ def main() -> None: ) config = _load_config(config_path) - return reconstruct_directory(path, config) + return reconstruct_directory(path, config, _channels(args.channels)) else: raise RuntimeError("Unsupported path type or file extension.") diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 1dbd25f28..c2a924bd6 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -29,6 +29,7 @@ ReconstructionCoordinator, ) from sampletones_application.coordinators.render import SongRenderCoordinator +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) @@ -132,9 +133,14 @@ from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.fps import FPSTimer from sampletones_application.utils.frame_limiter import FrameLimiter +from sampletones_application.utils.gui.callbacks import run_held_callbacks 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.render_thread import ( + claim_render_thread, + release_render_thread, +) 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 @@ -307,6 +313,10 @@ def __init__( ) self.stem_selection_window: GUIStemSelectionWindow = GUIStemSelectionWindow( layout=self.layout.tabs.main.converter, + stems_layout=self.layout.general.stems, + glyphs=self.layout.glyphs.common, + language_manager=self.language_manager, + status_bar=self.status_bar, title=self.language_manager["main.converter.title.stem_selection_dialog"], message=self.language_manager["main.converter.message.stem_selection_prompt"], limit_template=self.language_manager["main.converter.template.stem_selection_limit"], @@ -477,22 +487,27 @@ def __init__( audio_device_manager=self.audio_device_manager, library_manager=self.library_manager, conversion_service=self.conversion_service, - on_reconstruct_file=self._reconstruct_file, - on_reconstruct_directory=self._reconstruct_directory, - on_load_reconstruction=self._reconstruction_coordinator.load_with_confirmation, - on_load_library=self._load_library, - is_operation_active=self._is_operation_active, - on_busy_state_changed=self._refresh_busy_state, + hooks=MainTabHooks( + is_operation_active=self._is_operation_active, + on_busy_state_changed=self._refresh_busy_state, + on_reconstruct_file=self._reconstruct_file, + on_reconstruct_directory=self._reconstruct_directory, + on_load_reconstruction=self._reconstruction_coordinator.load_with_confirmation, + on_load_library=self._load_library, + on_load_file=self._on_converted_reconstruction_loaded, + on_load_directory=self._navigate_to_reconstructions, + on_canceled=self._refresh_browsers, + on_refresh_trees=self._refresh_browsers, + on_generate_library=self._instructions_tab.ensure_library_loaded, + ), layout=MainTabParameters.from_config(self.layout), language_manager=self.language_manager, dialogs=self.dialogs, status_bar=self.status_bar, - on_load_file=self._on_converted_reconstruction_loaded, - on_load_directory=self._navigate_to_reconstructions, - on_canceled=self._refresh_reconstruction_trees, - on_refresh_trees=self._refresh_reconstruction_trees, - on_generate_library=self._instructions_tab.ensure_library_loaded, stem_selection_window=self.stem_selection_window, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + tab_active=self._is_main_tab_current, ) self._sequencer_tab = SequencerTabCoordinator( @@ -564,7 +579,7 @@ def __init__( ExportStage.WRITING: self.language_manager["settings.export.label.stage_writing"], }, size_template=self.language_manager["settings.export.template.size"], - cancelling_label=self.language_manager["settings.export.message.status_cancelling"], + canceling_label=self.language_manager["settings.export.message.status_canceling"], ) self._export_coordinator = SongExportCoordinator( @@ -796,6 +811,14 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) + def _is_main_tab_current(self) -> bool: + """Whether the Main tab is in front, which is what puts the converter's list on the keyboard. + + The list keeps the row a reader picked out while another tab is worked on, so this is what + tells a press meant for that row from one meant for whatever is now in front. + """ + return self._shell.get_current_tab() == Tab.MAIN + def _is_reconstructions_tab_current(self) -> bool: """Whether the Reconstructions tab is in front, which is what puts its panels on the keyboard. @@ -1005,7 +1028,7 @@ def _export_reconstruction_instruments_dialog(self, export_format: ExportFormat) self._reconstructions_tab.request_export_instruments_dialog(export_format) def _reconstruct_file(self, filepath: Path) -> None: - self._main_tab.set_input_path(filepath, convert=True) + self._main_tab.convert_path(filepath) self.session_manager.set_audio_input_path(filepath.parent) self._set_current_tab(Tab.MAIN) self._update_menu() @@ -1021,7 +1044,7 @@ def _handle_reconstruct_file(self, filepath: Path) -> None: self._reconstruct_file(filepath) def _reconstruct_directory(self, directory_path: Path) -> None: - self._main_tab.set_input_path(directory_path, convert=True) + self._main_tab.convert_path(directory_path) self.session_manager.set_audio_input_path(directory_path) self._set_current_tab(Tab.MAIN) self._update_menu() @@ -1040,7 +1063,13 @@ def _on_playback_error(self, exception: Exception) -> None: def _on_converted_reconstruction_loaded(self, filepath: Path) -> None: self._reconstruction_coordinator.load_with_confirmation(filepath) - def _refresh_reconstruction_trees(self) -> None: + def _refresh_browsers(self) -> None: + """Reads the disk afresh in every browser, so a reconstruction just written stands in each. + + The Main tab browses the whole filesystem and offers to load a reconstruction from it, so + it reads a finished run as much as the two tabs the run was started from. + """ + self._main_tab.refresh_browser() self._reconstructions_tab.refresh_browser() self._sequencer_tab.refresh_browser() @@ -1537,10 +1566,10 @@ def _stop(self) -> None: def _toggle_channel(self, generator: ChannelName) -> 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 + A channel is switched by a control of its own on three tabs: the channels the row picked + out of the converter may take 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. + them is on screen, so a reader stays where they are while they move it. """ match self._shell.get_current_tab(): case Tab.MAIN: @@ -1632,6 +1661,13 @@ def frame(self) -> None: dpg.render_dearpygui_frame() def _post_frame(self) -> None: + """Answer the gestures the frame gathered, then the work waiting on the render thread. + + DearPyGui holds a widget's callback rather than running it where the gesture landed, so + the gestures run here — on the thread that drew the items they reach — and whatever they + ask of the queue is due from the same thread on the next pass. + """ + run_held_callbacks() CaretOverlay.redraw() CallbackQueue.notify_frame() CallbackQueue.add( @@ -1649,6 +1685,7 @@ def _save_config(self) -> bool: return True def run(self) -> None: + claim_render_thread() try: while dpg.is_dearpygui_running(): self.frame() @@ -1657,6 +1694,7 @@ def run(self) -> None: except KeyboardInterrupt: return finally: + release_render_thread() self._render_coordinator.cleanup() self._export_coordinator.cleanup() stop_background_workers() diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index ed0fdf399..f5cd82d40 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -10,3 +10,11 @@ class ConverterStemMoveElements(AbstractElement): CONTEXT_JOIN_BELOW = "context_join_below" CONTEXT_ISOLATE = "context_isolate" CONTEXT_REMOVE_STEM = "context_remove_stem" + + +class ConverterFolderElements(AbstractElement): + """What a gathered folder offers, as the folder's own menu names it.""" + + CONTEXT_OPEN_FOLDER = "context_open_folder" + CONTEXT_CLOSE_FOLDER = "context_close_folder" + CONTEXT_REMOVE_FOLDER = "context_remove_folder" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 53fe0d432..3fb69de2c 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -80,6 +80,8 @@ class KeybindingActionElements(AbstractElement): SELECT_TAB_SEQUENCER = "select_tab_sequencer" SELECT_TAB_INSTRUCTIONS = "select_tab_instructions" + SOURCES_REMOVE_SOURCE = "sources_remove_source" + ORDER_PREVIOUS_POSITION = "order_previous_position" ORDER_NEXT_POSITION = "order_next_position" ORDER_PREVIOUS_CHANNEL = "order_previous_channel" @@ -159,6 +161,7 @@ class KeybindingCategoryElements(AbstractElement): """The name a reader finds each editable scope under, one member per :class:`ShortcutCategory`.""" APPLICATION = "application" + SOURCES = "sources" ORDER = "order" TRACKER = "tracker" VOICES = "voices" diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index a0386ac4b..20d4643c8 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -74,7 +74,7 @@ class Panel(StrEnum): # Main tab CONFIG = auto() - RECONSTRUCTOR = auto() + SOURCE = auto() CONVERTER = auto() ADVANCED = auto() diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 2e911a106..999559875 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -2,10 +2,13 @@ from typing import Dict, Optional, Set from sampletones_application.config.session.application.config import ApplicationConfig +from sampletones_application.constants.output import OutputKind from sampletones_application.constants.playback import FollowMode from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.constants.enums import HierarchyMode from sampletones_core.data.metadata import Metadata +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings 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 @@ -153,6 +156,38 @@ def follow_mode(self) -> FollowMode: def set_follow_mode(self, value: FollowMode) -> None: self.config.playback.follow_mode = value + @property + def converter_settings(self) -> StemSettings: + """The settings a recording is given when it joins the converter's list.""" + return self.config.converter.settings + + def set_converter_settings(self, settings: StemSettings) -> None: + self.config.converter.settings = settings + + @property + def converter_output(self) -> OutputKind: + """What a run writes, as the reader last left the output switch.""" + return self.config.converter.output + + def set_converter_output(self, output: OutputKind) -> None: + self.config.converter.output = output + + @property + def converter_channel_cap(self) -> int: + """How many channels one recording may hold in a frame, as the reader last set it.""" + return self.config.converter.channel_cap + + def set_converter_channel_cap(self, channel_cap: int) -> None: + self.config.converter.channel_cap = channel_cap + + @property + def converter_hierarchy_mode(self) -> HierarchyMode: + """How the levels of a mix take turns, as the reader last set it.""" + return self.config.converter.hierarchy_mode + + def set_converter_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + self.config.converter.hierarchy_mode = hierarchy_mode + @property def octave(self) -> int: return self.config.tracker.octave diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index 0400f2bc2..466b192b2 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -114,12 +114,7 @@ def apply_generation_settings( self, update: GenerationSettingsUpdate, ) -> None: - new_generation = self.config.generation.model_copy( - update={ - "drive": update.drive, - "channels": update.channels, - } - ) + new_generation = self.config.generation.model_copy(update={"drive": update.drive}) self.config = self.config.model_copy( update={ "generation": new_generation, diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index d19ab68b1..c33e712b2 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -7,9 +7,12 @@ 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.output import OutputKind from sampletones_application.constants.playback import FollowMode from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.constants.enums import HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings class SessionManager: @@ -84,6 +87,38 @@ def set_auto_expand_favorite_directories(self, value: bool) -> None: def set_follow_mode(self, value: FollowMode) -> None: self._config_manager.set_follow_mode(value) + @property + def converter_settings(self) -> StemSettings: + """The settings a recording is given when it joins the converter's list.""" + return self._config_manager.converter_settings + + def set_converter_settings(self, settings: StemSettings) -> None: + self._config_manager.set_converter_settings(settings) + + @property + def converter_output(self) -> OutputKind: + """What a run writes, as the reader last left the output switch.""" + return self._config_manager.converter_output + + def set_converter_output(self, output: OutputKind) -> None: + self._config_manager.set_converter_output(output) + + @property + def converter_channel_cap(self) -> int: + """How many channels one recording may hold in a frame, as the reader last set it.""" + return self._config_manager.converter_channel_cap + + def set_converter_channel_cap(self, channel_cap: int) -> None: + self._config_manager.set_converter_channel_cap(channel_cap) + + @property + def converter_hierarchy_mode(self) -> HierarchyMode: + """How the levels of a mix take turns, as the reader last set it.""" + return self._config_manager.converter_hierarchy_mode + + def set_converter_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + self._config_manager.set_converter_hierarchy_mode(hierarchy_mode) + def set_loop_song(self, value: bool) -> None: self._config_manager.set_loop_song(value) diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 97412fd99..203701669 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -2,6 +2,7 @@ from sampletones_application.config.session.application.audio import AudioConfig from sampletones_application.config.session.application.browser import BrowserConfig +from sampletones_application.config.session.application.converter import ConverterConfig 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 @@ -26,6 +27,10 @@ class ApplicationConfig(BaseModel): default_factory=BrowserConfig, description="How the browsers of reconstructions read what they narrow to.", ) + converter: ConverterConfig = Field( + default_factory=ConverterConfig, + description="What a recording is converted with when it joins the converter's list.", + ) display: DisplayConfig = Field( default_factory=DisplayConfig, description="The palette and frame pacing preferences.", diff --git a/src/sampletones_application/config/session/application/converter.py b/src/sampletones_application/config/session/application/converter.py new file mode 100644 index 000000000..5873ff725 --- /dev/null +++ b/src/sampletones_application/config/session/application/converter.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, ConfigDict, Field, field_serializer + +from sampletones_application.constants.output import OutputKind +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + HierarchyMode, + bending_channels, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.types.data import SerializedData + + +def _starting_settings() -> StemSettings: + return StemSettings( + channels=list(DEFAULT_CHANNELS), + bends=bending_channels(list(DEFAULT_CHANNELS)), + ) + + +class ConverterConfig(BaseModel): + """How the converter opens, carried between runs. + + A recording joins the conversion holding ``settings``, and the reader then says otherwise for + it alone. Keeping those as one value is what lets a further per-recording choice reach the + settings file, the list and the reconstruction record in the same step. + + The rest name the shape of the run itself, so a launch opens on the run the reader last set up + rather than on the shipped one. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + settings: StemSettings = Field( + default_factory=_starting_settings, + description="The settings a recording is given when it joins the conversion.", + ) + + output: OutputKind = Field( + default=OutputKind.PER_RECORDING, + description="Whether a run writes one reconstruction per recording or one from them all.", + ) + channel_cap: int = Field( + default=len(ChannelName), + description="How many channels one recording may hold in a single frame.", + ) + hierarchy_mode: HierarchyMode = Field( + default=DEFAULT_STEMS_HIERARCHY_MODE, + description="How the levels of a mix take turns choosing.", + ) + + @field_serializer("settings") + def serialize_settings(self, settings: StemSettings) -> SerializedData: + """Writes each channel as the plain word it names, which is what the settings file carries.""" + return settings.model_dump(mode="json") + + @field_serializer("output") + def serialize_output(self, output: OutputKind) -> str: + """Writes the kind as the plain word it names, which is what the settings file carries.""" + return output.value + + @field_serializer("hierarchy_mode") + def serialize_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> str: + """Writes the mode as the plain word it names, which is what the settings file carries.""" + return hierarchy_mode.value diff --git a/src/sampletones_application/constants/output.py b/src/sampletones_application/constants/output.py new file mode 100644 index 000000000..69a157ace --- /dev/null +++ b/src/sampletones_application/constants/output.py @@ -0,0 +1,19 @@ +from enum import StrEnum + + +class OutputKind(StrEnum): + """What a run writes from the recordings a reader gathered. + + The two kinds read the same list and answer differently for it: a per-recording run converts + every recording the list holds, writing each into a tree that mirrors the folder it came from; + a mixed run converts the recordings into one reconstruction, which is why it holds a fixed + number of them and the levels say which picks first. + """ + + PER_RECORDING = "per_recording" + MIXED = "mixed" + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self is OutputKind.MIXED diff --git a/src/sampletones_application/constants/sources.py b/src/sampletones_application/constants/sources.py new file mode 100644 index 000000000..c0ef4a985 --- /dev/null +++ b/src/sampletones_application/constants/sources.py @@ -0,0 +1,23 @@ +from enum import StrEnum + + +class SourceKind(StrEnum): + """The two kinds of row a converter's list holds. + + A recording stands for itself and a folder for what was found below it, which is what decides + how a row is drawn, what one gesture on it settles, and what taking it out takes with it. + """ + + RECORDING = "recording" + FOLDER = "folder" + + +class SettingsField(StrEnum): + """The per-recording choices a reader makes, by the name the settings hold each under. + + A further choice is one more member here, one more slot beside it, and one more line in the + card that names them — which is what keeps a new field from reaching every layer by hand. + """ + + CHANNELS = "channels" + BENDS = "bends" diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py index 41894d9d9..e0c9b42a3 100644 --- a/src/sampletones_application/coordinators/keybindings.py +++ b/src/sampletones_application/coordinators/keybindings.py @@ -41,7 +41,7 @@ class KeybindingsCoordinator: 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. + and the rebound actions to the session; canceling 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. diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index e3e003fe7..bd2df4454 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -50,7 +50,7 @@ class ReconstructionCoordinator: - It must be saved before replacement. - Its dirty/saved state drives the window title. - Menu bar instrument regeneration flows through it so that all - reconstruction mutations remain centralised. + reconstruction mutations remain centralized. The reconstructions tab is wired in after construction through ``set_reconstructions_tab``; ``_tab`` asserts it is present before first use. diff --git a/src/sampletones_application/coordinators/tabs/hooks.py b/src/sampletones_application/coordinators/tabs/hooks.py new file mode 100644 index 000000000..37b1bd90e --- /dev/null +++ b/src/sampletones_application/coordinators/tabs/hooks.py @@ -0,0 +1,28 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from sampletones_shared.types.callback import PathCallback, VoidCallback + + +@dataclass(frozen=True) +class MainTabHooks: + """What the Main tab reports to the application around it, and what it asks of it. + + The tab answers for its own panels and logic; everything that reaches past them — loading a + reconstruction, refreshing the browsers of other tabs, asking whether another exclusive + operation is running — travels here, so the tab's own wiring reads as one collaborator rather + than as a dozen loose arguments. + """ + + is_operation_active: Callable[[], bool] + on_busy_state_changed: VoidCallback + on_reconstruct_file: PathCallback + on_reconstruct_directory: PathCallback + on_load_reconstruction: Callable[[Optional[Path]], None] + on_load_library: PathCallback + on_load_file: PathCallback + on_load_directory: VoidCallback + on_canceled: VoidCallback + on_refresh_trees: VoidCallback + on_generate_library: VoidCallback diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index d544ff6b4..e70c0a6c2 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -15,6 +15,7 @@ from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.instructions import InstructionsTabParameters @@ -126,9 +127,10 @@ def __init__( language_manager=language_manager, is_operation_active=is_operation_active, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._library_tree_logic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._library_panel = GUIInstructionsLibraryPanel( diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 66125638a..aba2ead11 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -1,19 +1,21 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Optional, Tuple import dearpygui.dearpygui as dpg 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.output import OutputKind +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) -from sampletones_application.logic.main.converter import ( - ConversionSuccess, - ConverterLogic, -) -from sampletones_application.logic.main.explorer import ExplorerLogic +from sampletones_application.logic.main.converter.logic import ConverterLogic +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.logic.main.explorer_manager import ExplorerManager +from sampletones_application.logic.main.sources.scan import FolderScan +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters from sampletones_application.services.conversion.service import ConversionService @@ -28,6 +30,7 @@ ) from sampletones_application.tags.main import ( TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, TAG_MAIN_CONFIG_PANEL, TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, @@ -37,38 +40,41 @@ TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, TAG_MAIN_EXPLORER_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, + TAG_MAIN_SOURCE_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.dialogs.scanning import GUIScanWindow from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow from sampletones_application.ui.panels.main.advanced import GUIAdvancedSettingsPanel from sampletones_application.ui.panels.main.config import GUIConfigPanel -from sampletones_application.ui.panels.main.converter import GUIConverterPanel +from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel -from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel +from sampletones_application.ui.panels.main.source.panel import GUISourceSettingsPanel from sampletones_application.utils.file_dialogs.api import select_directory_dialog from sampletones_application.utils.file_dialogs.result import ignore_none_path 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 ActivePredicate, KeyRouter +from sampletones_application.utils.gui.render_thread import on_render_thread +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.view_model.main.advanced import ( AdvancedSettingsPanelViewModel, ) from sampletones_application.view_model.main.config import ConfigPanelViewModel from sampletones_application.view_model.main.converter import ConverterViewModel -from sampletones_application.view_model.main.reconstructor import ( - ReconstructorPanelViewModel, +from sampletones_application.view_model.main.source import ( + SourceSettingsPanelViewModel, ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.converter import top_level_audio_files from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger -from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.types.callback import VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_MAIN, SUF_PANEL_LEFT) _CENTER_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_MAIN, SUF_PANEL_CENTER) @@ -94,53 +100,81 @@ def __init__( audio_device_manager: AudioDeviceManager, library_manager: InstructionsLibraryManager, conversion_service: ConversionService, - on_reconstruct_file: PathCallback, - on_reconstruct_directory: PathCallback, - on_load_reconstruction: Callable[[Optional[Path]], None], - on_load_library: PathCallback, - is_operation_active: Callable[[], bool], - on_busy_state_changed: VoidCallback, + hooks: MainTabHooks, *, layout: MainTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, status_bar: GUIStatusBar, - on_load_file: PathCallback, - on_load_directory: VoidCallback, - on_canceled: VoidCallback, - on_refresh_trees: VoidCallback, - on_generate_library: VoidCallback, stem_selection_window: GUIStemSelectionWindow, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, ) -> None: self._language_manager = language_manager self._config_manager = config_manager self._session_manager = session_manager self._library_manager = library_manager - self._on_reconstruct_file = on_reconstruct_file - self._on_reconstruct_directory = on_reconstruct_directory - self._on_load_reconstruction = on_load_reconstruction - self._is_operation_active = is_operation_active - self._on_busy_state_changed = on_busy_state_changed - self._on_refresh_trees = on_refresh_trees + self._hooks = hooks self._dialogs = dialogs self._stem_selection_window = stem_selection_window self._geometry = layout.geometry self._side_panel_count: int self._config_height = layout.config_height - _msg_converter_error = language_manager["main.converter.message.status_error"] - _msg_no_files = language_manager["main.converter.message.status_no_files"] - _msg_no_generators = language_manager["main.converter.message.status_no_channels"] self._ttl_progress = language_manager["main.converter.title.progress_dialog"] + self._repaint_priority = layout.scheduling.priorities.gui_action + + self._build_explorer( + config_manager, + session_manager, + audio_device_manager, + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + ) + self._build_cards( + config_manager, + session_manager, + conversion_service, + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, + ) + self._wire_settings(config_manager) + self._wire_explorer() + self._wire_converter( + config_manager, + library_manager, + conversion_service, + dialogs, + language_manager, + ) - self._explorer_logic: ExplorerLogic = ExplorerLogic( + def _build_explorer( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + audio_device_manager: AudioDeviceManager, + *, + layout: MainTabParameters, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + """The file browser: what reads the disk, what plays from it, and what draws both.""" + self._explorer_logic: ExplorerManager = ExplorerManager( config_manager, language_manager=language_manager, open_directories=session_manager.expanded_directories, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) + self._folder_scan: FolderScan = FolderScan() self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._explorer_panel: GUIExplorerPanel = GUIExplorerPanel( @@ -152,11 +186,28 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL), ) + self._folder_scan.on_started = self._on_scan_started + self._folder_scan.on_progress = self._on_scan_progress + self._folder_scan.on_stopped = self._on_scan_stopped self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled 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 + self._file_playback.on_error = self._on_explorer_autoplay_error + def _build_cards( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + conversion_service: ConversionService, + *, + layout: MainTabParameters, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + ) -> None: + """The tab's cards and the converter behind them, each opening on what it last stood at.""" _config = config_manager.config self._config_panel: GUIConfigPanel = GUIConfigPanel( ConfigPanelViewModel( @@ -171,14 +222,25 @@ def __init__( language_manager=language_manager, status_bar=status_bar, ) - self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( - ReconstructorPanelViewModel( - channels=frozenset(_config.generation.channels), + self._converter_logic: ConverterLogic = ConverterLogic( + config_manager, + session_manager, + conversion_service, + scheduling=layout.scheduling, + language_manager=language_manager, + is_operation_active=self._hooks.is_operation_active, + ) + self._source_panel: GUISourceSettingsPanel = GUISourceSettingsPanel( + SourceSettingsPanelViewModel( + slots=self._converter_logic.settings_slots, + inspected=None, drive=_config.generation.drive, + live=True, ), - layout=layout.main.reconstructor, + layout=layout.main.source, inputs=layout.inputs, - initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_RECONSTRUCTOR_PANEL), + stems_layout=layout.stems, + initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_SOURCE_PANEL), language_manager=language_manager, status_bar=status_bar, ) @@ -197,13 +259,11 @@ def __init__( status_bar=status_bar, path_colors=layout.path_colors, ) - self._converter_logic: ConverterLogic = ConverterLogic( - config_manager, - conversion_service, - scheduling=layout.scheduling, + self._scan_window: GUIScanWindow = GUIScanWindow( + layout=layout.main.converter, language_manager=language_manager, - is_operation_active=is_operation_active, ) + self._scan_window.on_stop = self._folder_scan.stop self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, stems_layout=layout.stems, @@ -212,35 +272,53 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_CONVERTER_PANEL), language_manager=language_manager, status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, ) + def _wire_settings(self, config_manager: ConfigManager) -> None: + """What the settings cards report, and what redraws them when the configuration moves.""" config_manager.add_config_change_callback(self._update_config_panel_view) - config_manager.add_config_change_callback(self._update_reconstructor_panel_view) + config_manager.add_config_change_callback(self._update_source_panel_view) config_manager.add_config_change_callback(self._update_advanced_settings_panel_view) self._config_panel.on_audio_settings_changed = config_manager.apply_audio_settings self._config_panel.on_library_settings_changed = config_manager.apply_library_settings - self._reconstructor_panel.on_generation_settings_changed = config_manager.apply_generation_settings + self._source_panel.on_generation_settings_changed = config_manager.apply_generation_settings + self._source_panel.on_slot_toggled = self._converter_logic.toggle_slot self._advanced_settings_panel.on_advanced_settings_changed = config_manager.apply_advanced_settings self._advanced_settings_panel.on_select_library_directory = self._select_library_directory self._advanced_settings_panel.on_select_output_directory = self._select_output_directory self._wire_collapse_handlers() + def _wire_explorer(self) -> None: + """What a gesture in the browser reaches: the converter, the tab's own guards, the app.""" self._explorer_panel.set_callbacks( - on_wave_file_clicked=self._on_wave_file_clicked, - on_directory_clicked=self._on_directory_clicked, on_directory_add_requested=self._on_directory_add_requested, on_file_add_requested=self._on_file_add_requested, can_add_stems=self._can_add_stems, on_reconstruct_file=self._request_reconstruct_file, on_reconstruct_directory=self._request_reconstruct_directory, - on_load_reconstruction=on_load_reconstruction, - on_load_library=on_load_library, - on_set_as_library_directory=self._advanced_settings_panel.change_library_directory, + on_load_reconstruction=self._hooks.on_load_reconstruction, + on_load_library=self._hooks.on_load_library, + on_set_as_library_directory=self._handle_select_library_directory, on_set_as_reconstructions_directory=self._advanced_settings_panel.change_reconstructions_directory, ) + def _wire_converter( + self, + config_manager: ConfigManager, + library_manager: InstructionsLibraryManager, + conversion_service: ConversionService, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + ) -> None: + """What the converter reports and what answers it: the panel, the dialogs, the library.""" + _msg_converter_error = language_manager["main.converter.message.status_error"] + _msg_no_files = language_manager["main.converter.message.status_no_files"] + _msg_no_generators = language_manager["main.converter.message.status_no_channels"] self._converter_logic.on_view_changed = self._on_converter_view_changed self._converter_logic.on_success = self._on_conversion_success self._converter_logic.on_error = lambda error: dialogs.show_error(error, _msg_converter_error) @@ -257,16 +335,16 @@ def __init__( self._converter_logic.on_target_exists = self._confirm_overwriting_target self._converter_logic.is_library_available = library_manager.is_library_available_for_config self._converter_logic.cancel_library_generation = library_manager.cancel_generation - self._converter_logic.on_load_file = on_load_file - self._converter_logic.on_load_directory = on_load_directory - self._converter_logic.on_canceled = on_canceled - self._converter_logic.generate_library = on_generate_library + self._converter_logic.on_load_file = self._hooks.on_load_file + self._converter_logic.on_load_directory = self._hooks.on_load_directory + self._converter_logic.on_canceled = self._hooks.on_canceled + self._converter_logic.generate_library = self._hooks.on_generate_library config_manager.add_config_change_callback(self._converter_logic.refresh_view) library_manager.on_generation_progress_extra = conversion_service.forward_library_progress self._converter_panel.on_convert_requested = self._converter_logic.start_conversion self._converter_panel.on_cancel_requested = self._request_cancel_confirmation - self._converter_panel.on_stems_mode_changed = self._request_stems_mode + self._converter_panel.on_output_changed = self._request_output self._converter_panel.on_channel_cap_changed = self._converter_logic.set_channel_cap self._converter_panel.on_hierarchy_mode_changed = self._converter_logic.set_hierarchy_mode self._converter_panel.on_source_channels_changed = self._converter_logic.set_source_channels @@ -276,7 +354,12 @@ def __init__( self._converter_panel.on_source_isolated = self._converter_logic.isolate_source self._converter_panel.on_source_dropped_on_source = self._converter_logic.move_source_onto self._converter_panel.on_source_dropped_on_level = self._converter_logic.move_source_to_new_level - self._stem_selection_window.on_add = self._converter_logic.add_sources + self._converter_panel.on_folder_removed = self._converter_logic.remove_folder + self._converter_panel.on_folder_channel_toggled = self._converter_logic.toggle_folder_channel + self._converter_panel.on_row_selected = self._converter_logic.select_row + self._converter_panel.on_selection_cleared = self._converter_logic.clear_selection + self._converter_panel.on_source_played = self._file_playback.play + self._stem_selection_window.on_source_played = self._file_playback.play 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.""" @@ -286,45 +369,43 @@ def _on_explorer_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: - self._converter_panel.update_view(view_model) - self._on_busy_state_changed() + """The converter's own view, and the settings card that follows what it has picked out. - def _on_wave_file_clicked(self, filepath: Path) -> None: - if not self._is_operation_active(): - self._converter_logic.select_source(filepath) + A gesture on the list rebuilds the list, and DearPyGui calls a widget's callback on a + thread of its own, so the redraw crosses to the render thread rather than tearing widgets + down underneath the frame being walked. + """ + on_render_thread(self._repaint_converter, view_model, priority=self._repaint_priority) - def _on_directory_clicked(self, directory_path: Path) -> None: - if not self._is_operation_active(): - self._converter_logic.set_input_path(directory_path, convert=False) + def _repaint_converter(self, view_model: ConverterViewModel) -> None: + self._converter_panel.update_view(view_model) + self._update_source_panel_view() + self._hooks.on_busy_state_changed() def _request_reconstruct_file(self, filepath: Path) -> None: if self._notify_converter_running(): return - self._leaving_stems_mode(lambda: self._on_reconstruct_file(filepath)) + self._replacing_the_setup(lambda: self._hooks.on_reconstruct_file(filepath)) def _request_reconstruct_directory(self, directory_path: Path) -> None: if self._notify_converter_running(): return - self._leaving_stems_mode(lambda: self._on_reconstruct_directory(directory_path)) + self._replacing_the_setup(lambda: self._hooks.on_reconstruct_directory(directory_path)) - def _leaving_stems_mode(self, reconstruct: VoidCallback) -> None: - """Runs a conversion the browser asked for, asking first where it would drop a stems list. + def _replacing_the_setup(self, reconstruct: VoidCallback) -> None: + """Runs a conversion the browser asked for, asking first where it would drop what was gathered. - A Reconstruct names one file or one folder, which is what a classic conversion converts, so - the gathered recordings are what the reader is being asked about. Declining leaves the - setup as it stands and starts nothing. + A Reconstruct names one file or one folder and converts that alone, so a setup already + holding sources is what the reader is being asked about. Declining leaves the setup as it + stands and starts nothing. """ - if not self._converter_logic.stems_mode: + if not self._converter_logic.gathered_paths: reconstruct() return - self._confirm_discarding_stems(lambda: self._reconstruct_without_stems(reconstruct)) - - def _reconstruct_without_stems(self, reconstruct: VoidCallback) -> None: - self._converter_logic.set_stems_mode(False) - reconstruct() + self._confirm_discarding_stems(reconstruct) def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: self._dialogs.show_confirmation( @@ -337,23 +418,34 @@ def _confirm_discarding_stems(self, on_confirm: VoidCallback) -> None: on_cancel=self._converter_logic.refresh_view, ) - def _confirm_overwriting_target(self, target: Path) -> None: - """Asks before a conversion writes over the reconstruction already standing at its target. + def _confirm_overwriting_target(self, targets: Tuple[Path, ...]) -> None: + """Asks before a conversion writes over the reconstructions already standing at its targets. - A batch keeps what it finds and converts the rest, so this reaches the reader for a - single conversion — the one run whose output would replace a file already made. + Confirming writes over every one of them, so the prompt speaks for all of them: one is + named by the path it stands at, and several are counted. """ + one = len(targets) == 1 + message = ( + self._language_manager["main.converter.message.overwrite_target_prompt"] + if one + else self._language_manager["main.converter.message.overwrite_targets_prompt"] + ) + title = ( + self._language_manager["main.converter.title.overwrite_target_dialog"] + if one + else self._language_manager["main.converter.title.overwrite_targets_dialog"] + ) self._dialogs.show_confirmation( TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, - self._language_manager["main.converter.message.overwrite_target_prompt"], - self._language_manager["main.converter.title.overwrite_target_dialog"], + message if one else message.format(count=len(targets)), + title, lambda: self._converter_logic.start_conversion(confirmed=True), ok_label=self._language_manager["main.converter.label.overwrite_target_button"], - path=target, + path=targets[0] if one else None, ) def _notify_converter_running(self) -> bool: - if not self._is_operation_active(): + if not self._hooks.is_operation_active(): return False logger.warning("Conversion is already running. Wait or cancel the current operation.") @@ -366,7 +458,7 @@ def _notify_converter_running(self) -> bool: return True def _on_conversion_success(self, success: ConversionSuccess) -> None: - self._on_refresh_trees() + self._hooks.on_refresh_trees() if success.is_single: message = self._language_manager["main.converter.message.load_file_prompt"] ok_label = self._language_manager["main.converter.label.load_button"] @@ -387,50 +479,114 @@ def _on_conversion_success(self, success: ConversionSuccess) -> None: on_cancel=self._converter_logic.close, ) - def _request_stems_mode(self, stems_mode: bool) -> None: - """Answers the stems-mode switch, asking first where leaving it would drop recordings. + def _request_output(self, output: OutputKind) -> None: + """Answers the output switch, asking which recordings to mix where the list overflows one. - Turning stems mode off keeps the first recording, so a list of several loses the rest; - that is what the prompt confirms. Every other switch takes effect straight away. + A mix reaches a fixed number of recordings, so a longer list is put to the reader in the + window that shows what fits already ticked. The switch reads the output the setup still + holds while the question stands, since the run is what the reader is being asked about. + Every other switch takes effect straight away. """ - if stems_mode or self._converter_logic.source_count <= 1: - self._converter_logic.set_stems_mode(stems_mode) + if not output.mixes or self._converter_logic.list_fits_a_mix: + self._converter_logic.set_output(output) return - self._confirm_discarding_stems(lambda: self._converter_logic.set_stems_mode(False)) + self._converter_logic.refresh_view() + self._stem_selection_window.open( + self._converter_logic.gathered_rows, + self._converter_logic.mix_ceiling, + self._converter_logic.mix_only, + ) def _can_add_stems(self) -> bool: """The converter is free to gather recordings into a stems conversion.""" - return not self._is_operation_active() + return not self._hooks.is_operation_active() def _on_file_add_requested(self, filepath: Path) -> None: """Gathers one recording into a stems conversion, opening one where none is being built.""" - if self._is_operation_active(): + if self._hooks.is_operation_active(): return - self._converter_logic.set_stems_mode(True) - self._converter_logic.add_sources([filepath]) + self._converter_logic.gather_recordings([filepath]) def _on_directory_add_requested(self, directory_path: Path) -> None: - """Offers a folder's recordings to a stems conversion, asking which ones where they overflow. + """Reads what a folder holds, and gathers it once the reading is done. - Where the folder holds no more than the list has room for, every recording joins at once. - A fuller folder raises the selection window, which shows what fits already ticked. + A tree is read one entry at a time and a large one takes seconds, so the reading runs + beside the interface and says how far it has got. """ - if self._is_operation_active(): + if self._hooks.is_operation_active(): return - candidates = top_level_audio_files(directory_path) - if not candidates: + self._folder_scan.start(directory_path, self._gather_folder_read) + + def _on_scan_started(self, directory_path: Path) -> None: + """Puts the wait on screen, since reading a folder of thousands takes seconds.""" + on_render_thread(self._scan_window.open, directory_path, priority=self._repaint_priority) + + def _on_scan_progress(self, count: int) -> None: + on_render_thread(self._scan_window.report, count, priority=self._repaint_priority) + + def _on_scan_stopped(self) -> None: + on_render_thread(self._scan_window.close, priority=self._repaint_priority) + + def _gather_folder_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Gathers what the walk found, on the thread the widgets it draws belong to.""" + on_render_thread(self._gather_read, directory_path, found, priority=self._repaint_priority) + + def _convert_folder_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + """Converts what the walk found, on the thread the widgets it draws belong to.""" + on_render_thread(self._convert_read, directory_path, found, priority=self._repaint_priority) + + def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + self._scan_window.close() + if not found: + self._nothing_below(directory_path) return - self._converter_logic.set_stems_mode(True) - room = self._converter_logic.room_for_sources - if len(candidates) <= room: - self._converter_logic.add_sources(candidates) + if self._mixing_beyond_room(found): return - self._stem_selection_window.open(candidates, room) + self._converter_logic.gather_folder(directory_path, found) + + def _nothing_below(self, directory_path: Path) -> None: + """Says that a folder holds no recordings, which is the answer the reading came back with. + + The reading is what knows, so the menu offers every folder and the answer arrives once, + rather than every folder being walked to decide whether the item may be clicked. + """ + logger.info(f"No recordings below {directory_path}.") + self._dialogs.show_info( + TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, + self._language_manager["main.converter.message.scan_nothing_below"], + self._language_manager["main.converter.title.scan_dialog"], + ) + + def _convert_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: + self._scan_window.close() + self._converter_logic.convert_folder(directory_path, found) + + def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: + """Whether what was read brings in more than the mix has room for, which is a question. + + A mix already standing on recordings leaves the reader a choice between those and the ones + the folder offers, so the question stands both together and opens with the mix as it is. + The answer names the whole mix, which is how letting one go makes room for another; the + setup stands as it was until the reader gives one. + """ + if not self._converter_logic.mixes: + return False + + offered = self._converter_logic.rows_offered(found) + if sum(len(row.recordings) for row in offered) <= self._converter_logic.room_for_sources: + return False + + self._stem_selection_window.open( + self._converter_logic.gathered_rows + offered, + self._converter_logic.mix_ceiling, + self._converter_logic.mix_only, + ) + return True def _request_cancel_confirmation(self) -> None: self._dialogs.show_confirmation( @@ -453,12 +609,17 @@ def _update_config_panel_view(self) -> None: ) ) - def _update_reconstructor_panel_view(self) -> None: - config = self._config_manager.config - self._reconstructor_panel.update_view( - ReconstructorPanelViewModel( - channels=frozenset(config.generation.channels), - drive=config.generation.drive, + def _update_source_panel_view(self) -> None: + """The settings card reads the choices from the converter and the drive from the config. + + The two owners answer one card, so the composition point is where their readings meet. + """ + self._source_panel.update_view( + SourceSettingsPanelViewModel( + slots=self._converter_logic.settings_slots, + inspected=self._converter_logic.inspected_source, + drive=self._config_manager.config.generation.drive, + live=self._converter_logic.live, ) ) @@ -524,35 +685,45 @@ def create_tab(self) -> None: self._sync_explorer_width() + @property + def _config_columns(self) -> Tuple[ColumnSpec, ...]: + """The settings cards the tab lays side by side, in the order they read.""" + return ( + ColumnSpec( + tag=TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, + build=self._config_panel.create_panel, + ), + ColumnSpec( + tag=TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, + build=self._advanced_settings_panel.create_panel, + ), + ) + def _build_center(self, parent: str) -> None: - """Stacks the config and reconstructor cards side by side, then the advanced and converter cards below.""" + """Stacks the settings cards side by side, then the converter and the card reading its list. + + The reconstruction card names whichever row the converter's list stands on, so it follows + that list and the tab reads in one direction: what a run is set up with, what it gathers, + and what the gathered row takes. + """ TabColumns.row( panel_gap=self._geometry.panel_gap, height=self._config_height, tag=TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, - columns=[ - ColumnSpec( - tag=TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, - build=self._config_panel.create_panel, - ), - ColumnSpec( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, - build=self._reconstructor_panel.create_panel, - ), - ], + columns=self._config_columns, ) - self._sync_config_row_height() - dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) - self._advanced_settings_panel.create_panel(parent) + self._sync_advanced_settings() dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._converter_panel.create_panel(parent) + dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) + self._source_panel.create_panel(parent) def _wire_collapse_handlers(self) -> None: """Routes each Main card's collapse toggle to the handler that persists it and reflows the shared config row.""" self._explorer_panel.set_collapse_handler(self._on_explorer_collapse_changed) self._config_panel.set_collapse_handler(self._on_config_row_collapse_changed) - self._reconstructor_panel.set_collapse_handler(self._on_config_row_collapse_changed) - self._advanced_settings_panel.set_collapse_handler(self._on_card_collapse_changed) + self._advanced_settings_panel.set_collapse_handler(self._on_config_row_collapse_changed) + self._source_panel.set_collapse_handler(self._on_card_collapse_changed) self._converter_panel.set_collapse_handler(self._on_card_collapse_changed) def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: @@ -584,14 +755,15 @@ def _sync_explorer_width(self) -> None: dpg_configure_item(_LEFT_COLUMN_TAG, width=width) def _on_config_row_collapse_changed(self, card_tag: str, collapsed: bool) -> None: - """Persists the config or reconstructor collapse, then reflows the row both cards share.""" + """Persists a settings card's collapse, then reflows the row both of them share.""" self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_config_row_height() def _sync_config_row_height(self) -> None: - """Lets the shared config row size to its collapsed cards once both are collapsed, else keeps it full height.""" - both_collapsed = self._config_panel.collapsed and self._reconstructor_panel.collapsed - height = 0 if both_collapsed else self._config_height + """Lets the settings row size to its collapsed bars once every card standing in it is collapsed.""" + advanced_stands = self._session_manager.advanced_settings and not self._advanced_settings_panel.collapsed + expanded = not self._config_panel.collapsed or advanced_stands + height = self._config_height if expanded else 0 dpg_configure_item(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, height=height) def is_converter_active(self) -> bool: @@ -603,8 +775,17 @@ def is_converter_panel_visible(self) -> bool: def refresh_converter_view(self) -> None: self._converter_logic.refresh_view() - def set_input_path(self, path: Path, convert: bool) -> None: - self._converter_logic.set_input_path(path, convert=convert) + def convert_path(self, path: Path) -> None: + """Converts exactly what a Reconstruct named, replacing whatever the reader gathered. + + A folder is read before it is converted, which is work the reader watches rather than + waits blindly through. + """ + if not path.is_dir(): + self._converter_logic.convert_recording(path) + return + + self._folder_scan.start(path, self._convert_folder_read) def save_browser_shape(self) -> None: """Writes down the folders the explorer stands open, so a later run reads down to them.""" @@ -614,15 +795,32 @@ def refresh_browser(self) -> None: self._explorer_panel.refresh() def toggle_channel(self, channel: ChannelName) -> None: - """Switches one channel in or out of the set a reconstruction is built from.""" - self._reconstructor_panel.toggle_channel(channel) + """Settles one channel on the recording or folder the reader has picked out of the list.""" + self._converter_logic.toggle_channel(channel) def toggle_advanced_settings(self) -> None: - advanced_settings = self._session_manager.toggle_show_advanced_settings() - self._advanced_settings_panel.set_visibility(advanced_settings) + """Puts the advanced card away or stands it back beside the general one.""" + self._session_manager.toggle_show_advanced_settings() + self._sync_advanced_settings() def sync_advanced_settings_visibility(self) -> None: - self._advanced_settings_panel.set_visibility(self._session_manager.advanced_settings) + """Stands the settings row as the session left it, which is what a launch opens on.""" + self._sync_advanced_settings() + + def _sync_advanced_settings(self) -> None: + """Stands the advanced card where the reader asked for it, the general one taking the rest. + + The row divides itself among the cards standing in it, so a card put away leaves the whole + width to the one beside it and the general settings reach as far as the cards below them. + """ + standing = self._session_manager.advanced_settings + cells = {TAG_MAIN_CONFIG_PANEL_CONFIG_CELL} + if standing: + cells.add(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + + self._advanced_settings_panel.set_visibility(standing) + TabColumns.stand_columns(self._config_columns, cells, self._geometry.panel_gap) + self._sync_config_row_height() def emit_initial_view(self) -> None: self._converter_logic.emit_initial_view() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 4e8891363..be6079847 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -36,6 +36,7 @@ from sampletones_application.logic.reconstruction.reconstruction import ( ReconstructionPanelLogic, ) +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.player import PlayerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.reconstruction import ( @@ -185,9 +186,10 @@ def __init__( config_manager, browser_manager, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._browser_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel( @@ -204,7 +206,7 @@ def __init__( 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_preview_error + self._file_playback.on_error = self._on_preview_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( @@ -328,7 +330,7 @@ def __init__( self._reconstruction_instruments_logic.on_display_refreshed = self._instrument_audition_logic.refresh def _on_export_result(self, result: ExportResult) -> None: - """Reports a finished export in the words of the artefact it produced. + """Reports a finished export in the words of the artifact it produced. A run long enough to watch held a window while it ran, and DearPyGui carries one modal at a time, so the report waits for the frame that draws the screen without it. diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 686f46146..1a3ec7413 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -39,6 +39,7 @@ TrackerRegionAdjuster, ) from sampletones_application.logic.sequencer.voices import SequencerVoicesLogic +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters from sampletones_application.services.song_player.service import SongPlayerService @@ -133,9 +134,10 @@ def __init__( browser_manager, project_controller, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._sequencer_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._sequencer_browser_panel: GUISequencerBrowserPanel = GUISequencerBrowserPanel( @@ -572,7 +574,7 @@ def _wire_browser_callbacks(self) -> None: self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled 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 + self._file_playback.on_error = self._on_preview_error def _wire_playback_callbacks(self) -> None: self._song_player_logic.on_position_changed = self._on_player_position_changed @@ -820,7 +822,7 @@ def _request_nes_frequency_change(self, nes_frequency: int) -> None: The rate governs how every sample plays back, so changing it on a project that already 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. + project applies silently. Canceling restores the field to the project's current value. """ if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency: return diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 46fa060ff..f380bd0a8 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -4,7 +4,26 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): master_column_width: int channel_column_width: int + channel_solo_width: int + channel_box_width: int remove_button_width: int level_strip_height: int well_padding: int well_margin: int + well_ceiling: int + twisty_width: int + folder_ceiling: int + folder_indent: int + window_overscan: int + scrollbar_width: int + cell_padding: int + name_height: int + + @property + def folder_reserve(self) -> int: + """The room a folder's region spends at the right of its body. + + A region insets its body by its padding and keeps a scrollbar's width clear beside it, so + the grid outside a folder holds that same strip and the two grids stand as one. + """ + return self.well_padding + self.scrollbar_width diff --git a/src/sampletones_application/layout/tabs/main/__init__.py b/src/sampletones_application/layout/tabs/main/__init__.py index b06ef45d5..b5a3de0d6 100644 --- a/src/sampletones_application/layout/tabs/main/__init__.py +++ b/src/sampletones_application/layout/tabs/main/__init__.py @@ -3,11 +3,11 @@ from sampletones_application.layout.tabs.main.advanced import AdvancedLayout from sampletones_application.layout.tabs.main.config import ConfigLayout from sampletones_application.layout.tabs.main.converter import ConverterLayout -from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout class MainLayout(BaseModel, extra="forbid", frozen=True): config: ConfigLayout converter: ConverterLayout - reconstructor: ReconstructorLayout + source: SourceSettingsLayout advanced: AdvancedLayout diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index 003338189..c2428a231 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -8,3 +8,5 @@ class ConverterLayout(BaseModel, extra="forbid", frozen=True): button_height: int stem_selection: Dimensions stem_selection_footer: int + stem_selection_list: int + scan: Dimensions diff --git a/src/sampletones_application/layout/tabs/main/reconstructor.py b/src/sampletones_application/layout/tabs/main/source.py similarity index 51% rename from src/sampletones_application/layout/tabs/main/reconstructor.py rename to src/sampletones_application/layout/tabs/main/source.py index 93b4b2a27..2ee8c6f47 100644 --- a/src/sampletones_application/layout/tabs/main/reconstructor.py +++ b/src/sampletones_application/layout/tabs/main/source.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -class ReconstructorLayout(BaseModel, extra="forbid", frozen=True): +class SourceSettingsLayout(BaseModel, extra="forbid", frozen=True): drive_format: str height: int diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index 3da132ced..5f8cfcb0f 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -35,7 +35,7 @@ class SongExportLogic(CallbackMixin): at an end, and the bytes against the room there is where it does not. An export holds the application while it writes, so the dialog stands from the first word the - run says until the outcome that ends it, and cancelling is answered at the next point the + run says until the outcome that ends it, and canceling is answered at the next point the format looks up. """ @@ -45,18 +45,18 @@ def __init__( *, stage_labels: Dict[ExportStage, str], size_template: str, - cancelling_label: str, + canceling_label: str, ) -> None: self._service = export_service self._stage_labels = stage_labels self._size_template = size_template - self._cancelling_label = cancelling_label + self._canceling_label = canceling_label self._phase: ExportPhase = ExportPhase.IDLE self._stages: List[ExportStage] = [] self._figure: str = NO_FIGURE self._progress: float = NO_PROGRESS - self._travelling: bool = False + self._traveling: bool = False self._service.subscribe(self._on_service_result) @@ -78,9 +78,9 @@ def cancel(self) -> None: if not self._service.is_running(): return - self._phase = ExportPhase.CANCELLING - self._figure = self._cancelling_label - self._travelling = False + self._phase = ExportPhase.CANCELING + self._figure = self._canceling_label + self._traveling = False self._emit_view() self._service.cancel() @@ -102,13 +102,13 @@ def _on_started(self) -> None: self._stages = [] self._figure = NO_FIGURE self._progress = NO_PROGRESS - self._travelling = True + self._traveling = True self._emit_view() self.call(self.on_started) def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None: """Puts the stage's own reading on screen, holding what a stop was asked under.""" - if self._phase == ExportPhase.CANCELLING: + if self._phase == ExportPhase.CANCELING: return stage = progress.current_item @@ -116,7 +116,7 @@ def _on_progress(self, progress: ServiceProgress[ExportStage]) -> None: return self._reach(stage) - self._travelling = stage in TRAVELING_STAGES + self._traveling = stage in TRAVELING_STAGES self._progress = progress.fraction self._figure = self._figure_text(progress) self._emit_view() @@ -132,7 +132,7 @@ def _figure_text(self, progress: ServiceProgress[ExportStage]) -> str: figure is spelled out for the one that does not: what the song takes so far against what the console has room for, which is the answer the reader is waiting on. """ - if self._travelling or progress.total <= NOTHING_MEASURED: + if self._traveling or progress.total <= NOTHING_MEASURED: return NO_FIGURE return self._size_template.format( @@ -154,5 +154,5 @@ def _view_model(self) -> SongExportViewModel: stages=tuple(self._stages), figure=self._figure, progress=self._progress, - traveling=self._travelling, + traveling=self._traveling, ) diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index c196adab6..03edc02a7 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -1,11 +1,13 @@ -import hashlib -from typing import Callable, Dict, Iterable, List, Tuple +from typing import Callable, Dict, Final, Iterable, List, Tuple from sampletones_core.project import Project from sampletones_core.project.voices.instrument import Instrument from sampletones_core.project.voices.sample import Sample from sampletones_core.project.voices.voice import samples from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.utils.hashing import identity_digest + +FINGERPRINT_LENGTH: Final[int] = 64 ReconstructionHash = Callable[[Reconstruction], str] @@ -17,10 +19,11 @@ def fingerprint_project( ) -> str: """Returns a content hash used to verify that a restore reproduces a snapshot. - The hash covers the full project state; each sample's reconstruction content - enters through ``reconstruction_hash``, so the caller decides between a - memoized digest (capture, where copy-on-write keeps it valid) and a fresh one - (verification, where recomputing from scratch catches any divergence). + The hash covers the full project state, spelled out part by part so that two projects + differing anywhere in it differ here. Each sample's reconstruction content enters through + ``reconstruction_hash``, so the caller decides between a memoized digest (capture, where + copy-on-write keeps it valid) and a fresh one (verification, where recomputing from scratch + catches any divergence). """ parts: List[str] = [ project.metadata.model_dump_json(), @@ -37,8 +40,7 @@ def fingerprint_project( case Instrument(): parts.append(voice.model_dump_json()) - combined = "|".join(parts) - return hashlib.sha256(combined.encode("utf-8")).hexdigest() + return identity_digest(*parts, length=FINGERPRINT_LENGTH) class ReconstructionHashCache: diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index ef58c7183..f253a943a 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -7,7 +7,7 @@ from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 40df1830a..b15b098c1 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -214,7 +214,7 @@ def _complete_generation( def is_generating(self) -> bool: """A generation is in progress from the moment a creator is started until it is cleaned up. - Creator presence is the source of truth: it spans the saving and finalising step that runs + Creator presence is the source of truth: it spans the saving and finalizing step that runs after the worker thread clears its own ``is_running`` flag, so the generation reads as in progress right up to cleanup. """ diff --git a/src/sampletones_application/logic/instruction/table.py b/src/sampletones_application/logic/instruction/table.py index 18808eea0..22ee8a5d9 100644 --- a/src/sampletones_application/logic/instruction/table.py +++ b/src/sampletones_application/logic/instruction/table.py @@ -8,7 +8,7 @@ ) 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 +from sampletones_shared.utils.hashing import hash_model class InstructionTableLogic: diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py deleted file mode 100644 index f061adac4..000000000 --- a/src/sampletones_application/logic/main/converter.py +++ /dev/null @@ -1,656 +0,0 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Callable, Dict, Final, FrozenSet, Optional, Protocol, Sequence, Tuple - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior -from sampletones_application.logic.main.stems import ( - ConversionSetup, - StemLevels, - StemSource, - derive_conversion_setup, - effective_channels, -) -from sampletones_application.services.conversion.result import ConversionItem, ConversionResult -from sampletones_application.services.result import ( - ServiceCanceled, - ServiceError, - ServiceIntermediate, - ServiceProgress, - ServiceStarted, - ServiceSuccess, -) -from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_application.utils.progress import SystemProgress -from sampletones_application.view_model.main.converter import ( - ACTIVE_PHASES, - ConversionPhase, - ConverterViewModel, -) -from sampletones_application.view_model.shared.stems import StemRowViewModel -from sampletones_core.configs import Config -from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE -from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.parallelization import ETAEstimator, TaskProgress -from sampletones_core.reconstructions.converter import ( - ConversionPlan, - DirectoryConversion, - GroupConversion, - get_output_path, - group_output_path, -) -from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.stage import ReconstructionStage -from sampletones_shared.exceptions import NoFilesToProcessError -from sampletones_shared.logger import logger -from sampletones_shared.types.callback import PathCallback, VoidCallback -from sampletones_shared.utils.callbacks import CallbackMixin - -SINGLE_JOB: Final[int] = 1 -SYSTEM_PROGRESS_STEPS: Final[int] = 1000 - - -@dataclass(frozen=True) -class ConversionSuccess: - """The outcome a completed conversion hands to its listener: the reconstructions it wrote. - - One written reconstruction is one the reader can open straight away; several are a batch, - which the reader reaches as a folder.""" - - written: Tuple[Path, ...] - - @property - def is_single(self) -> bool: - """One reconstruction was written, so it is the one a follow-up offer would load.""" - return len(self.written) == 1 - - -class ConversionServiceProtocol(Protocol): - """The slice of the conversion service the converter 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. - """ - - def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ... - - def start(self, config: Config, plan: ConversionPlan) -> None: ... - - def cancel(self) -> None: ... - - def cleanup(self) -> None: ... - - def shutdown(self) -> None: ... - - def is_running(self) -> bool: ... - - -class ConverterLogic(CallbackMixin): - def __init__( - self, - config_manager: ConfigManager, - conversion_service: ConversionServiceProtocol, - *, - scheduling: SchedulingBehavior, - language_manager: LanguageManager, - is_operation_active: Callable[[], bool], - ) -> None: - self._language_manager = language_manager - self._config_manager = config_manager - self._service = conversion_service - self._scheduling = scheduling - self._is_operation_active = is_operation_active - self._msg_idle = language_manager["main.converter.message.status_idle"] - self._msg_cancelling = language_manager["main.converter.message.status_cancelling"] - self._stage_messages: Dict[ReconstructionStage, str] = { - ReconstructionStage.LOADING: language_manager["main.converter.message.stage_loading"], - ReconstructionStage.MATCHING: language_manager["main.converter.message.stage_matching"], - ReconstructionStage.DECODING: language_manager["main.converter.message.stage_decoding"], - ReconstructionStage.RENDERING: language_manager["main.converter.message.stage_rendering"], - } - - self._phase: ConversionPhase = ConversionPhase.IDLE - self._input_path: Optional[Path] = None - self._output_path: Optional[Path] = None - self._written: Tuple[Path, ...] = () - self._is_file: bool = True - self._stems_mode: bool = False - self._levels: StemLevels = StemLevels() - self._channel_cap: int = len(ChannelName) - self._hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE - self._system_progress = SystemProgress() - - self._service.subscribe(self._on_service_result) - - self.on_view_changed: Optional[Callable[[ConverterViewModel], None]] = None - self.on_success: Optional[Callable[[ConversionSuccess], None]] = None - self.on_error: Optional[Callable[[Exception], None]] = None - self.on_no_files_to_process: Optional[VoidCallback] = None - self.on_no_generators: Optional[VoidCallback] = None - self.on_target_exists: Optional[PathCallback] = None - self.on_load_file: Optional[PathCallback] = None - self.on_load_directory: Optional[VoidCallback] = None - self.on_canceled: Optional[VoidCallback] = None - self.generate_library: Optional[VoidCallback] = None - self.cancel_library_generation: Optional[VoidCallback] = None - self.is_library_available: Optional[Callable[[], bool]] = None - - @property - def stems_mode(self) -> bool: - """Several recordings are being gathered into one reconstruction.""" - return self._stems_mode - - @property - def source_count(self) -> int: - """How many recordings the stems list holds.""" - return self._levels.count - - @property - def room_for_sources(self) -> int: - """How many more recordings the stems list has room for.""" - return MAX_STEM_SOURCES - self.source_count - - @property - def is_active(self) -> bool: - """A conversion is occupying resources from the moment of request (the WAITING phase, during - which the library is prepared and the run is scheduled) until it reaches a terminal phase.""" - return self._phase in ACTIVE_PHASES - - def emit_initial_view(self) -> None: - self._emit_view_model(self._msg_idle, 0.0) - - def refresh_view(self) -> None: - """Re-emits the idle view so the Convert button reflects whether another exclusive operation - is active. Only the idle phase carries the Convert button; the other phases disable it by - phase alone, so re-emitting them would add nothing.""" - if self._phase == ConversionPhase.IDLE: - self._emit_view_model(self._msg_idle, 0.0) - - def set_input_path(self, input_path: Path, convert: bool = False) -> None: - config = self._config_manager.config.model_copy() - if not self._assign_paths(input_path, config): - return - - if not self.is_active: - self._phase = ConversionPhase.IDLE - self._emit_view_model(self._msg_idle, 0.0) - - if convert: - self.start_conversion() - - def select_source(self, path: Path) -> None: - """Answers a recording picked in the explorer: it joins the list, or becomes the input. - - In stems mode a pick adds to the setup being built, so a reader gathers a conversion by - clicking the recordings it mixes. Otherwise it is the single thing to convert. - """ - if self._stems_mode: - self.add_sources([path]) - return - - self.set_input_path(path) - - def add_sources(self, paths: Sequence[Path]) -> None: - """Adds recordings to the stems list, up to the room it has left. - - A path already listed keeps the row it has, so adding it again leaves the setup as it is. - """ - enabled = frozenset(self._config_manager.config.generation.channels) - for path in paths: - if self._levels.count >= MAX_STEM_SOURCES: - break - - self._levels = self._levels.add(StemSource(path=path, channels=enabled)) - - self._refresh_setup() - - def remove_source(self, path: Path) -> None: - """Takes a recording out of the stems list.""" - self._levels = self._levels.remove(path) - self._refresh_setup() - - def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: - """Names the channels one recording may take, among the ones the reader was offered. - - A channel the configuration leaves out reaches no checkbox, so the recording keeps - whatever it was given for it and gets that choice back when the channel returns. - """ - enabled = frozenset(self._config_manager.config.generation.channels) - self._apply( - self._levels.replace_source( - path, - lambda source: source.with_channels((source.channels - enabled) | channels), - ) - ) - - def move_source_within_level(self, path: Path, offset: int) -> None: - """Moves a recording past the neighbor it shares a level with.""" - self._apply(self._levels.move_within_level(path, offset)) - - def join_source_level(self, path: Path, offset: int) -> None: - """Sends a recording to the level above or below the one it picks on.""" - self._apply(self._levels.join_level(path, offset)) - - def isolate_source(self, path: Path) -> None: - """Gives a recording a level of its own, picking after the one it shared.""" - self._apply(self._levels.isolate(path)) - - def move_source_onto(self, path: Path, target_path: Path) -> None: - """Moves a recording to the level and the place another one holds.""" - self._apply(self._levels.move_onto(path, target_path)) - - def move_source_to_new_level(self, path: Path, position: int) -> None: - """Gives a recording a level of its own, in the slot the levels are broken at.""" - self._apply(self._levels.move_to_new_level(path, position)) - - def set_stems_mode(self, stems_mode: bool) -> None: - """Switches between converting one selection and mixing several recordings into one. - - Entering stems mode carries a selected file in as the first row. Leaving it keeps the - first row as the single selection, which is what the reader picked first. - """ - if stems_mode == self._stems_mode: - return - - self._stems_mode = stems_mode - if stems_mode: - self._enter_stems_mode() - else: - self._leave_stems_mode() - - self._refresh_setup() - - def set_channel_cap(self, channel_cap: int) -> None: - """Names how many channels one recording may hold in a frame, for every conversion.""" - self._channel_cap = min(max(channel_cap, MIN_CHANNEL_CAP), self._max_channel_cap()) - self._refresh_setup() - - def set_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: - """Names how the levels take turns: round by round, or one level exhausted before the next.""" - self._hierarchy_mode = hierarchy_mode - self._refresh_setup() - - def start_conversion(self, confirmed: bool = False) -> None: - """Starts the run the current setup describes, asking first where it would write over work. - - ``confirmed`` states that the reader has already answered for the file standing at the - target, which is what lets the prompt's answer come back and run. - """ - if self._is_operation_active(): - logger.warning("A conversion or library generation is already in progress") - return - - if not self._config_manager.config.generation.channels: - self.call(self.on_no_generators) - return - - standing_target = self._standing_target() - if standing_target is not None and not confirmed: - self.call(self.on_target_exists, standing_target) - return - - self._phase = ConversionPhase.WAITING - self._emit_view_model(self._language_manager["main.converter.message.status_waiting"], 0.0) - self.call(self.generate_library) - self._wait_for_library_and_start() - - def cancel(self) -> None: - if self._service.is_running(): - self._phase = ConversionPhase.CANCELLING - self._emit_view_model(self._msg_cancelling, 0.0) - self._system_progress.error() - self._service.cancel() - elif self._phase == ConversionPhase.WAITING: - self.call(self.cancel_library_generation) - self._on_cancellation_complete() - - def close(self) -> None: - try: - self._service.cleanup() - finally: - self._system_progress.clear() - self._written = () - self._phase = ConversionPhase.IDLE - self._emit_view_model(self._msg_idle, 0.0) - - def handle_load_request(self) -> None: - if len(self._written) == 1: - self.call(self.on_load_file, self._written[0]) - else: - self.call(self.on_load_directory) - - self.close() - - def cleanup(self) -> None: - self._service.shutdown() - self._system_progress.clear() - - def _on_service_result(self, result: ConversionResult) -> None: - match result: - case ServiceStarted(total=total): - self._system_progress.start(total) - case ServiceProgress() as progress: - self._handle_progress_result(progress) - case ServiceIntermediate(data=progress): - self._handle_library_progress(progress) - case ServiceSuccess(value=written): - self._on_conversion_complete(written) - case ServiceError(exception=exception): - self._on_conversion_error(exception) - case ServiceCanceled(): - self._on_cancellation_complete() - - def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: - if self._phase == ConversionPhase.CANCELLING: - self._emit_view_model(self._msg_cancelling, progress.fraction) - return - - self._phase = ConversionPhase.RUNNING - self._system_progress.set( - round(progress.fraction * SYSTEM_PROGRESS_STEPS), - SYSTEM_PROGRESS_STEPS, - ) - self._emit_view_model( - self._compose_progress_text(progress), - progress.fraction, - input_path=self._display_input_path(progress), - ) - - def _display_input_path(self, progress: ServiceProgress[ConversionItem]) -> Optional[Path]: - """The recording the run names itself by, or the one the reader chose.""" - if progress.current_item is None: - return self._input_path - - return progress.current_item.source - - def _compose_progress_text(self, progress: ServiceProgress[ConversionItem]) -> str: - """What the run is doing, how far it has come, and how long it has left. - - A batch is many reconstructions and a count says where it stands; a single job counts to - one, so it names the document it is writing instead. Either way the reconstruction under - way says which stage it is in, which is the whole of what a reader watching one job has. - """ - return self._run_text(progress) + self._stage_text(progress) + self._estimate_text(progress) - - def _run_text(self, progress: ServiceProgress[ConversionItem]) -> str: - if progress.total > SINGLE_JOB: - return self._language_manager["main.converter.template.progress_template"].format( - progress.completed, progress.total - ) - - return self._language_manager["main.converter.template.single_progress_template"].format( - self._reconstruction_name() - ) - - def _stage_text(self, progress: ServiceProgress[ConversionItem]) -> str: - step = progress.current_item.step if progress.current_item is not None else None - if step is None: - return "" - - return self._language_manager["main.converter.template.stage_template"].format( - stage=self._stage_messages[step.stage], - completed=step.completed, - total=step.total, - ) - - def _estimate_text(self, progress: ServiceProgress[ConversionItem]) -> str: - eta_string = ETAEstimator.format_duration(progress.eta_seconds) - if not eta_string: - return "" - - return self._language_manager["global.dialog.template.time_estimation"].format(eta_string=eta_string) - - def _reconstruction_name(self) -> str: - """The document a single job writes, which is what a run of one is making.""" - if self._output_path is not None: - return self._output_path.stem - - return self._input_path.stem if self._input_path is not None else "" - - def _handle_library_progress(self, progress: TaskProgress) -> None: - if self._phase != ConversionPhase.WAITING: - return - - total = max(progress.total, 1) - fraction = progress.completed / total - self._emit_view_model(self._language_manager["main.converter.message.status_generating_library"], fraction) - - def _assign_paths(self, input_path: Path, config: Config) -> bool: - try: - self._output_path = get_output_path(config, input_path) - self._input_path = input_path - self._is_file = input_path.is_file() - except FileNotFoundError as exception: - logger.error("Input file does not exist") - self.call(self.on_error, exception) - return False - except OSError as exception: - logger.error("Invalid path") - self.call(self.on_error, exception) - return False - - return True - - def _wait_for_library_and_start(self) -> None: - if self._phase != ConversionPhase.WAITING: - return - - if not self.call(self.is_library_available): - CallbackQueue.add( - self._wait_for_library_and_start, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.schedule, - ) - else: - self._start_conversion() - - def _start_conversion(self) -> None: - assert self._input_path is not None, "Input path is not set" - config = self._config_manager.config.model_copy() - self._system_progress.initialize() - self._service.start(config, self._conversion_plan(config, self._input_path)) - - def _standing_target(self) -> Optional[Path]: - """The reconstruction this run would write over, where one stands. - - A batch converts what is still to be written and keeps the rest, so it puts nothing to - the reader; a single conversion writes one file, and that is the one worth asking about. - """ - if self._input_path is None: - return None - - config = self._config_manager.config - targets = self._conversion_plan(config, self._input_path).existing_targets(config) - return targets[0] if targets else None - - def _conversion_plan(self, config: Config, input_path: Path) -> ConversionPlan: - """What the request amounts to: one reconstruction from the recordings listed or the file - selected, or one per audio file the selected directory holds.""" - setup = self._stems_setup(config) - if self._stems_mode: - return GroupConversion(sources=setup.sources, stems=setup.stems) - - if self._is_file: - return GroupConversion(sources=(input_path,), stems=setup.stems) - - return DirectoryConversion(directory=input_path, stems=setup.stems) - - def _stems_setup(self, config: Config) -> ConversionSetup: - """The recordings and the setup the conversion runs with, carrying the channel cap. - - In stems mode this is what the gathered levels amount to; otherwise it is one stem over - every enabled channel, which is the classic run's shape. - """ - enabled = list(config.generation.channels) - if self._stems_mode: - return derive_conversion_setup( - self._levels, - enabled, - channel_cap=self._effective_channel_cap, - hierarchy_mode=self._hierarchy_mode, - ) - - return ConversionSetup( - sources=(), - stems=StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap), - ) - - @property - def _source_paths(self) -> Tuple[Path, ...]: - """The recordings that take part, in the order the conversion mixes them.""" - return self._stems_setup(self._config_manager.config).sources - - @property - def _effective_channel_cap(self) -> int: - """The cap a run holds to: what the reader asked for, within the channels now enabled.""" - return min(self._channel_cap, self._max_channel_cap()) - - def _max_channel_cap(self) -> int: - return max(len(self._config_manager.config.generation.channels), MIN_CHANNEL_CAP) - - def _apply(self, levels: StemLevels) -> None: - """Takes up a rewritten stems list and follows it wherever the setup changed.""" - self._levels = levels - self._refresh_setup() - - def _enter_stems_mode(self) -> None: - enabled = frozenset(self._config_manager.config.generation.channels) - if self._levels.count == 0 and self._input_path is not None and self._is_file: - self._levels = self._levels.add(StemSource(path=self._input_path, channels=enabled)) - - def _leave_stems_mode(self) -> None: - if self._levels.count: - self._levels = self._levels.keep_first() - self._assign_paths(self._levels.paths[0], self._config_manager.config) - - def _refresh_setup(self) -> None: - """Follows the setup wherever it changed: the destination it now names, and the view.""" - self._update_stems_output_path() - if not self.is_active: - self._phase = ConversionPhase.IDLE - self._emit_view_model(self._msg_idle, 0.0) - - def _update_stems_output_path(self) -> None: - if not self._stems_mode: - return - - sources = self._source_paths - if sources: - self._output_path = group_output_path(self._config_manager.config, sources) - - def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: - """The gathered recordings as the panel reads them, each stating where it stands. - - A gathered recording is named by its path, so the list reports every gesture under the - path it landed on, and it offers a box on every channel the configuration enables. A - recording that has left the disk since it was gathered reports itself as missing. - """ - enabled = list(config.generation.channels) - return tuple( - StemRowViewModel( - key=str(source.path), - path=source.path, - channels=frozenset(effective_channels(source, enabled)), - offered_channels=frozenset(enabled), - available=source.path.is_file(), - level=level_index, - position=position, - level_size=len(level), - level_count=self._levels.level_count, - ) - for level_index, level in enumerate(self._levels.levels) - for position, source in enumerate(level) - ) - - def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None: - self._written = written - if len(written) == 1: - self._output_path = written[0] - - self._phase = ConversionPhase.COMPLETED - self._emit_view_model(self._language_manager["main.converter.message.status_reconstruction_completed"], 1.0) - self.call(self.on_success, ConversionSuccess(written=written)) - - def _on_conversion_error(self, exception: Exception) -> None: - self._system_progress.error() - self._phase = ConversionPhase.FAILED - self._emit_view_model(self._language_manager["main.converter.message.status_error"], 0.0) - self._schedule_return_to_idle() - if isinstance(exception, NoFilesToProcessError): - self.call(self.on_no_files_to_process) - else: - self.call(self.on_error, exception) - - def _on_cancellation_complete(self) -> None: - self._phase = ConversionPhase.CANCELED - self._emit_view_model(self._language_manager["main.converter.message.status_canceled"], 0.0) - self._schedule_return_to_idle() - self.call(self.on_canceled) - - def _schedule_return_to_idle(self) -> None: - CallbackQueue.add( - self.close, - priority=self._scheduling.priorities.schedule, - delay=self._scheduling.delays.cancel, - ) - - def _compose_action_label(self, input_path: Optional[Path]) -> str: - """The label the single action button shows: the cancel label while a conversion holds - resources, otherwise the convert label named after what it would convert.""" - if self._phase in ACTIVE_PHASES: - return self._language_manager["main.converter.label.cancel_button"] - - if self._stems_mode: - return self._compose_stems_action_label() - - base = ( - self._language_manager["main.converter.label.convert_sample_button"] - if self._is_file - else self._language_manager["main.converter.label.convert_directory_button"] - ) - if input_path is None: - return base - - return self._language_manager["main.converter.template.convert_label_template"].format(base, input_path.name) - - def _compose_stems_action_label(self) -> str: - """The stems label, named after how many recordings take part.""" - base = self._language_manager["main.converter.label.convert_stems_button"] - playing = len(self._source_paths) - if not playing: - return base - - return self._language_manager["main.converter.template.convert_label_template"].format(base, playing) - - def _emit_view_model( - self, - status_text: str, - progress: float, - input_path: Optional[Path] = None, - ) -> None: - config = self._config_manager.config - display_output = ( - self._output_path if self._output_path is not None else self._config_manager.get_reconstructions_directory() - ) - display_input = input_path if input_path is not None else self._input_path - view_model = ConverterViewModel( - phase=self._phase, - status_text=status_text, - action_label=self._compose_action_label(display_input), - progress=progress, - input_path=display_input, - output_path=display_output, - is_file=self._is_file, - other_operation_active=self._is_operation_active(), - stems_mode=self._stems_mode, - stem_sources=self._stem_rows(config), - enabled_channels=frozenset(config.generation.channels), - channel_cap=self._effective_channel_cap, - max_channel_cap=self._max_channel_cap(), - hierarchy_mode=self._hierarchy_mode, - max_sources=MAX_STEM_SOURCES, - ) - self.call(self.on_view_changed, view_model) diff --git a/src/sampletones_application/logic/main/converter/__init__.py b/src/sampletones_application/logic/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py new file mode 100644 index 000000000..40a9755ce --- /dev/null +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -0,0 +1,90 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import AbstractSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter import BatchEntry +from sampletones_core.reconstructions.converter.paths import ( + config_directory_path, + group_output_path, +) + + +@dataclass(frozen=True) +class Destination: + """What a run converts and where the reconstruction it writes lands. + + The output path follows the input: a recording names the document beside it, a directory names + the tree its batch mirrors, and a mix names the document the gathered recordings amount to. + Deriving it in one step keeps the path the panel shows and the path the run writes the same + answer. + """ + + input_path: Optional[Path] + output_path: Optional[Path] + is_file: bool + + @classmethod + def unset(cls) -> Self: + """The destination a converter opens with, before a reader has picked anything.""" + return cls(input_path=None, output_path=None, is_file=True) + + @property + def reconstruction_name(self) -> str: + """The document a single job writes, which is what a run of one is making.""" + if self.output_path is not None: + return self.output_path.stem + + return self.input_path.stem if self.input_path is not None else "" + + def aimed_at_mix( + self, + config: Config, + sources: Tuple[Path, ...], + channels: AbstractSet[ChannelName], + ) -> Self: + """The destination the recordings a mix gathers name between them. + + A mix with nobody taking part names nothing of its own, so the destination it last held + stands until a recording joins it. + """ + if not sources: + return self + + return replace(self, output_path=group_output_path(config, sources, channels)) + + def aimed_at_batch(self, config: Config, entries: Tuple[BatchEntry, ...]) -> Self: + """The destination a run writing one reconstruction per recording names. + + One recording names the document it is written to, which is what a reader converting a + single file is looking at; several name the directory the channels they cover between them + are held under, which is the tree the batch writes into. + """ + if not entries: + return self + + if len(entries) == 1: + return replace(self, output_path=entries[0].output_path(config)) + + covered = frozenset().union(*(entry.stems.covered_channels for entry in entries)) + return replace(self, output_path=config_directory_path(config, covered)) + + def named_after(self, sources: SourceList) -> Self: + """What a run names itself by, read from the sources gathered for it. + + A setup holding one row is that row: a recording names the document it makes, a folder + names the tree it mirrors. Several rows name none of them, so the run reads as what its + destination says instead. + """ + rows = sources.rows + if len(rows) != 1: + return replace(self, input_path=None, is_file=True) + + key = rows[0].key + return replace(self, input_path=key.path, is_file=not key.names_folder) + + def writing_to(self, output_path: Path) -> Self: + """The destination a completed run wrote, which is the document a reader would open.""" + return replace(self, output_path=output_path) diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py new file mode 100644 index 000000000..a4eccbf2a --- /dev/null +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -0,0 +1,162 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import FrozenSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import SettingsSlot +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class Gathering: + """The sources a reader gathered, and the order the ones a mix converts pick in. + + The list holds whatever was gathered — recordings named one by one, folders standing for the + recordings below them — and says what each converts under. A run that writes one reconstruction + per recording converts the list as it stands, so the list is unbounded: a folder of thousands is + the case that run exists for. + + The levels are the mix's side. They name the recordings one reconstruction is built from, in + the order those recordings pick in, and there are only ever as many as a mix can hold. A mix + converts loose recordings alone, so turning to one flattens the folders standing in the list. + """ + + sources: SourceList + levels: MixLevels + + @classmethod + def empty(cls) -> Self: + """The setup a converter opens with, which a reader fills by gathering sources.""" + return cls(sources=SourceList(), levels=MixLevels()) + + @property + def count(self) -> int: + """How many recordings the list holds, folders counting for what they stand for.""" + return self.sources.count + + @property + def row_count(self) -> int: + """How many rows the list draws, a folder standing as one.""" + return self.sources.row_count + + @property + def ceiling(self) -> int: + """How many recordings one mix reaches, whatever the list holds.""" + return self.levels.ceiling + + @property + def room(self) -> int: + """How many more recordings the mix has room to take.""" + return self.levels.room + + @property + def fits_a_mix(self) -> bool: + """One mix has room for every recording gathered, so turning to one asks the reader nothing.""" + return self.count <= self.ceiling + + @property + def paths(self) -> Tuple[Path, ...]: + """Where every gathered recording stands, in the order the list holds it.""" + return self.sources.paths + + @property + def recordings(self) -> Tuple[Recording, ...]: + """Every gathered recording, in the order the list holds it.""" + return self.sources.recordings + + def recording(self, path: Path) -> Optional[Recording]: + """The gathered recording at ``path``, where the list holds one.""" + return self.sources.recording(path) + + def folder_root_of(self, path: Path) -> Optional[Path]: + """The folder a gathered recording was found below, where one stands for it.""" + return self.sources.folder_root_of(path) + + def listing(self, recording: Recording) -> Self: + """One more recording in the list, which a per-recording run converts as it stands.""" + return replace(self, sources=self.sources.add_recording(recording)) + + def listing_folder(self, folder: Folder) -> Self: + """One more folder in the list, standing for every recording gathered below it.""" + return replace(self, sources=self.sources.add_folder(folder)) + + def mixing(self, recording: Recording) -> Self: + """One more recording in the list and in the mix, where the mix has room for it. + + A mix reaches a fixed number of recordings, so a setup with no room left stands as it is; + a path already gathered keeps the settings and the place it has. + """ + if not self.room: + return self + + return replace( + self, + sources=self.sources.add_recording(recording), + levels=self.levels.add(recording.path), + ) + + def remove(self, key: SourceKey) -> Self: + """The setup without the row ``key`` names, which leaves the list and the mix alike.""" + sources = self.sources.remove(key) + standing = frozenset(sources.paths) + levels = self.levels + for path in self.levels.paths: + if path not in standing: + levels = levels.remove(path) + + return replace(self, sources=sources, levels=levels) + + def written( + self, + path: Path, + slot: SettingsSlot, + value: FrozenSet[ChannelName], + ) -> Self: + """The setup with one recording's slot settled to ``value``.""" + recording = self.recording(path) + if recording is None: + return self + + return replace(self, sources=self.sources.written(recording.key, slot, value)) + + def settled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Self: + """The setup with ``channel_name`` settled on every recording ``key`` stands for.""" + return replace(self, sources=self.sources.settled(key, slot, channel_name, held)) + + def toggled(self, key: SourceKey, slot: SettingsSlot, channel_name: ChannelName) -> Self: + """The setup one gesture on ``key`` leaves behind, settled the way the row reads.""" + return replace(self, sources=self.sources.toggled(key, slot, channel_name)) + + def with_levels(self, levels: MixLevels) -> Self: + """The setup as rewritten levels leave it, the recordings standing as they were.""" + return replace(self, levels=levels) + + def mixing_only(self, recordings: Tuple[Recording, ...]) -> Self: + """The setup a mix runs from: exactly these recordings, loose, in the order they are named. + + This is what answering for a mix leaves behind — the folders give up the recordings they + stood for, and what the reader did not name goes with them. A recording is handed in whole + rather than by path, so one the list already holds keeps what it was given and one joining + from a folder arrives under the settings a recording joins with. + """ + sources = SourceList() + levels = MixLevels() + for recording in recordings: + sources = sources.add_recording(recording) + levels = levels.add(recording.path) + + return replace(self, sources=sources, levels=levels) + + def unmixed(self) -> Self: + """The setup a per-recording run converts: the list as it stands, the mix let go.""" + return replace(self, levels=MixLevels()) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py new file mode 100644 index 000000000..c0ffd6380 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -0,0 +1,588 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional, Sequence, 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.constants.output import OutputKind +from sampletones_application.constants.sources import SettingsField, SourceKind +from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.messages import ConverterMessages +from sampletones_application.logic.main.converter.run import ( + ConversionRun, + ConversionServiceProtocol, + ConversionSuccess, + RunReport, +) +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import ( + batch_entries, + conversion_plan, + conversion_setup, + playing_sources, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import ( + compose_view, + inspected_settings, + inspected_source, + settings_slots, + stem_rows, +) +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import ( + CHANNEL_SLOT, + SLOTS_BY_FIELD, + SettingsSlot, +) +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.converter import ( + ConversionPhase, + ConverterViewModel, +) +from sampletones_application.view_model.main.source import ( + InspectedSourceViewModel, + SettingsSlotViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.converter import ConversionPlan +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from sampletones_shared.exceptions import NoFilesToProcessError +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import PathCallback, PathsCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +class ConverterLogic(CallbackMixin): + """What the Main tab's converter offers, and the one place its parts are settled against. + + The setup a reader builds is one value (:class:`ConverterState`), and every gesture rewrites a + part of it and hands the whole back to ``_settle``, which follows it wherever it reaches: the + destination the run now names, and the view the panel draws. The run itself is held apart, so + a conversion under way reports where it stands without knowing what it was set up from. + """ + + def __init__( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + conversion_service: ConversionServiceProtocol, + *, + scheduling: SchedulingBehavior, + language_manager: LanguageManager, + is_operation_active: Callable[[], bool], + ) -> None: + self._config_manager = config_manager + self._session_manager = session_manager + self._scheduling = scheduling + self._is_operation_active = is_operation_active + self._messages = ConverterMessages(language_manager) + self._state = ConverterState( + settings=RunSettings( + joining=session_manager.converter_settings, + output=session_manager.converter_output, + channel_cap=session_manager.converter_channel_cap, + hierarchy_mode=session_manager.converter_hierarchy_mode, + ), + gathering=Gathering.empty(), + destination=Destination.unset(), + selected=None, + ) + self._rows = self._read_rows() + + self._run = ConversionRun(conversion_service, messages=self._messages) + self._run.on_report = self._on_report + self._run.on_success = self._on_run_success + self._run.on_error = self._on_run_error + self._run.on_canceled = self._on_run_canceled + + self.on_view_changed: Optional[Callable[[ConverterViewModel], None]] = None + self.on_success: Optional[Callable[[ConversionSuccess], None]] = None + self.on_error: Optional[Callable[[Exception], None]] = None + self.on_no_files_to_process: Optional[VoidCallback] = None + self.on_no_generators: Optional[VoidCallback] = None + self.on_target_exists: Optional[PathsCallback] = None + self.on_load_file: Optional[PathCallback] = None + self.on_load_directory: Optional[VoidCallback] = None + self.on_canceled: Optional[VoidCallback] = None + self.generate_library: Optional[VoidCallback] = None + self.cancel_library_generation: Optional[VoidCallback] = None + self.is_library_available: Optional[Callable[[], bool]] = None + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self._settings.mixes + + @property + def source_count(self) -> int: + """How many recordings the stems list holds.""" + return self._state.gathering.count + + @property + def room_for_sources(self) -> int: + """How many more recordings the mix has room for.""" + return self._state.gathering.room + + @property + def mix_ceiling(self) -> int: + """How many recordings one mix reaches, which is the room a reader picks one within.""" + return self._state.gathering.ceiling + + @property + def list_fits_a_mix(self) -> bool: + """One mix has room for the whole list, so turning to one takes it as it stands.""" + return self._state.gathering.fits_a_mix + + @property + def gathered_paths(self) -> Tuple[Path, ...]: + """Every gathered recording, which is what a run writing one apiece converts.""" + return self._state.gathering.paths + + @property + def gathered_rows(self) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as one run of rows, which is what a reader picking a mix reads.""" + return stem_rows(self._state.gathering, mixes=False) + + def rows_offered(self, found: Sequence[Path]) -> Tuple[StemRowViewModel, ...]: + """The recordings among ``found`` the setup has yet to gather, as one row apiece. + + A mix reaches a fixed number of recordings, so a folder bringing in more than the room + left is put to a reader as the same question the output switch asks: which of these to mix. + The rows are what the folder offers, which the question stands beside the recordings the + mix is built from so that the reader chooses between the two. + """ + standing = frozenset(self.gathered_paths) + offered = tuple(self._gathered(path) for path in found if path not in standing) + return stem_rows(Gathering(sources=SourceList(rows=offered), levels=MixLevels()), mixes=False) + + @property + def is_active(self) -> bool: + """A conversion is occupying resources, from the request until it settles.""" + return self._run.is_active + + def emit_initial_view(self) -> None: + self._emit(self._messages.idle, 0.0) + + def refresh_view(self) -> None: + """Re-emits the idle view so the Convert button reflects whether another exclusive operation + is active. Only the idle phase carries the Convert button; the other phases disable it by + phase alone, so re-emitting them would add nothing.""" + if self._run.phase == ConversionPhase.IDLE: + self._emit(self._messages.idle, 0.0) + + def gather_recordings(self, paths: Sequence[Path]) -> None: + """Gathers recordings into the setup, each converted under what a recording joins with. + + A path already gathered keeps the row and the settings it has. A mix reaches a fixed + number of recordings, so a full one takes no more; a per-recording run takes whatever is + offered, which is what converting a whole folder amounts to. + """ + gathering = self._state.gathering + for path in paths: + gathering = self._joined(gathering, self._gathered(path)) + + self._settle(self._state.with_gathering(gathering)) + + def gather_folder(self, root: Path, found: Sequence[Path]) -> None: + """Gathers a folder standing for the recordings ``found`` below it. + + A run writing one reconstruction per recording mirrors this folder's tree for what it + holds; a mix takes the recordings loose, which is what flattening leaves. The recordings + are handed in because reading them off the disk is work of its own, reported to the reader + while it runs. + """ + if self.mixes: + self.gather_recordings(found) + return + + self._settle(self._state.with_gathering(self._gathering_folder(root, found))) + + def convert_recording(self, path: Path) -> None: + """Converts exactly the recording the reader named, which is what a Reconstruct asks for.""" + self._replace_setup() + self.gather_recordings([path]) + self.start_conversion() + + def convert_folder(self, root: Path, found: Sequence[Path]) -> None: + """Converts the recordings ``found`` below a folder, writing one reconstruction apiece.""" + self._replace_setup() + self.gather_folder(root, found) + self.start_conversion() + + def _replace_setup(self) -> None: + """Lets whatever was gathered go, since a Reconstruct names what it converts on its own.""" + self._settle( + self._state.with_settings(self._settings.with_output(OutputKind.PER_RECORDING)).with_gathering( + Gathering.empty() + ) + ) + + def select_row(self, path: Path, kind: SourceKind) -> None: + """Names the row a reader is inspecting, which the settings card edits.""" + self._settle(self._state.with_selected(SourceKey(kind=kind, path=path))) + + def clear_selection(self) -> None: + """Lets the inspected row go, which leaves the settings card and the keys with none.""" + self._settle(self._state.with_selected(None)) + + def remove_source(self, path: Path) -> None: + """Takes one gathered recording out of the setup.""" + self._settle(self._state.with_gathering(self._state.gathering.remove(SourceKey.recording(path)))) + + def remove_folder(self, root: Path) -> None: + """Takes a folder out of the setup, along with every recording it stands for.""" + self._settle(self._state.with_gathering(self._state.gathering.remove(SourceKey.folder(root)))) + + @property + def settings_slots(self) -> Tuple[SettingsSlotViewModel, ...]: + """The choices the settings card edits, read from the row a reader picked.""" + return settings_slots(self._state) + + @property + def inspected_source(self) -> Optional[InspectedSourceViewModel]: + """The row the settings card is editing, where a reader picked one out of the list.""" + return inspected_source(self._state) + + @property + def live(self) -> bool: + """Whether a gesture reaches the setup, which a conversion holding resources answers.""" + return not self._run.is_active + + def toggle_slot(self, field: SettingsField, channel_name: ChannelName) -> None: + """Settles one choice on ``channel_name`` for the row the settings card is pointed at. + + A picked row settles the same way a folder's own box does — already agreeing lets the + choice go, every other reading takes it up — so one gesture answers for a folder and for + a recording alike. + """ + selected = self._state.selected + if selected is None: + return + + slot = SLOTS_BY_FIELD[field] + held = self._inspected_agreement(slot, channel_name).settles_to + self._settle(self._state.with_gathering(self._state.gathering.settled(selected, slot, channel_name, held))) + + def toggle_channel(self, channel_name: ChannelName) -> None: + """Settles one channel on the row a reader picked out, which the channel's key reaches. + + The key answers for the row the settings card is pointed at, so a press reaches exactly + the recordings the box beside that row reaches. With no row picked out there is nothing + for the press to settle, and it leaves the list as it stands. + """ + self.toggle_slot(SettingsField.CHANNELS, channel_name) + + def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> None: + """Names the channels one recording may take, which is the whole of what it reaches.""" + gathering = self._state.gathering.written(path, CHANNEL_SLOT, channels) + self._settle(self._state.with_gathering(gathering)) + + def toggle_folder_channel(self, root: Path, channel_name: ChannelName) -> None: + """Settles one channel on every recording a folder stands for, in one gesture. + + A folder its recordings already agree on lets the channel go; every other reading settles + the whole folder on it, so one gesture always moves the group somewhere. + """ + gathering = self._state.gathering.toggled(SourceKey.folder(root), CHANNEL_SLOT, channel_name) + self._settle(self._state.with_gathering(gathering)) + + def move_source_within_level(self, path: Path, offset: int) -> None: + """Moves a recording past the neighbor it shares a level with.""" + self._relevel(self._state.gathering.levels.move_within_level(path, offset)) + + def join_source_level(self, path: Path, offset: int) -> None: + """Sends a recording to the level above or below the one it picks on.""" + self._relevel(self._state.gathering.levels.join_level(path, offset)) + + def isolate_source(self, path: Path) -> None: + """Gives a recording a level of its own, picking after the one it shared.""" + self._relevel(self._state.gathering.levels.isolate(path)) + + def move_source_onto(self, path: Path, target_path: Path) -> None: + """Moves a recording to the level and the place another one holds.""" + self._relevel(self._state.gathering.levels.move_onto(path, target_path)) + + def move_source_to_new_level(self, path: Path, position: int) -> None: + """Gives a recording a level of its own, in the slot the levels are broken at.""" + self._relevel(self._state.gathering.levels.move_to_new_level(path, position)) + + def set_output(self, output: OutputKind) -> None: + """Names what the run writes: one reconstruction per recording, or one from them all. + + A mix converts loose recordings and holds a fixed number of them, so turning to one + flattens the folders standing in the list and keeps what fits. Turning away leaves the + list as it is and lets the picking order go. + """ + if output == self._settings.output: + return + + gathering = self._state.gathering + settled = ( + gathering.mixing_only(gathering.recordings[: gathering.ceiling]) if output.mixes else gathering.unmixed() + ) + self._settle(self._state.with_settings(self._settings.with_output(output)).with_gathering(settled)) + + def mix_only(self, paths: Sequence[Path]) -> None: + """Names the recordings a mix converts, gathering each one the list has still to take up. + + This is the answer to both places a mix is put to the reader: narrowing a list longer than + one holds, and choosing between what the mix stands on and what a folder offers beside it. + Either way the answer names the whole mix, so what it leaves out goes and what it names + joins — a recording already listed keeping the settings it has. + """ + gathering = self._state.gathering + mixed = tuple(self._standing(gathering, path) for path in tuple(paths)[: gathering.ceiling]) + self._settle( + self._state.with_settings(self._settings.with_output(OutputKind.MIXED)).with_gathering( + gathering.mixing_only(mixed) + ) + ) + + def set_channel_cap(self, channel_cap: int) -> None: + """Names how many channels one recording may hold in a frame, for every conversion.""" + self._settle(self._state.with_settings(self._settings.with_channel_cap(channel_cap))) + + def set_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> None: + """Names how the levels take turns: round by round, or one level exhausted before the next.""" + self._settle(self._state.with_settings(self._settings.with_hierarchy_mode(hierarchy_mode))) + + def start_conversion(self, confirmed: bool = False) -> None: + """Starts the run the current setup describes, asking first where it would write over work. + + ``confirmed`` states that the reader has already answered for the file standing at the + target, which is what lets the prompt's answer come back and run. + """ + if self._is_operation_active(): + logger.warning("A conversion or library generation is already in progress") + return + + if not self._state.gathering.count: + logger.warning("Nothing is gathered to convert") + return + + plan = conversion_plan(self._state) + if plan is None: + self.call(self.on_no_generators) + return + + standing_targets = self._standing_targets(plan) + if standing_targets and not confirmed: + self.call(self.on_target_exists, standing_targets) + return + + self._run.wait() + self.call(self.generate_library) + self._wait_for_library_and_start() + + def cancel(self) -> None: + if self._run.is_running: + self._run.cancel() + elif self._run.phase == ConversionPhase.WAITING: + self.call(self.cancel_library_generation) + self._run.abandon() + + def close(self) -> None: + try: + self._run.close() + finally: + self._emit(self._messages.idle, 0.0) + + def handle_load_request(self) -> None: + written = self._run.written + if len(written) == 1: + self.call(self.on_load_file, written[0]) + else: + self.call(self.on_load_directory) + + self.close() + + def cleanup(self) -> None: + self._run.cleanup() + + @property + def _settings(self) -> RunSettings: + return self._state.settings + + def _inspected_agreement(self, slot: SettingsSlot, channel_name: ChannelName) -> Agreement: + """How the settings the card is editing read on ``channel_name`` in ``slot``.""" + return Agreement.over(channel_name in slot.read(settings) for settings in inspected_settings(self._state)) + + def _gathered(self, path: Path) -> Recording: + """A recording joining the list, holding the settings a recording joins with.""" + return Recording(path=path, settings=self._joining_settings) + + def _standing(self, gathering: Gathering, path: Path) -> Recording: + """The recording ``path`` names: the one the list holds, or one joining it.""" + recording = gathering.recording(path) + return recording if recording is not None else self._gathered(path) + + def _gathering_folder(self, root: Path, found: Sequence[Path]) -> Gathering: + """The setup with ``root`` standing as one row, or as it stands where the folder is empty.""" + recordings = tuple(self._gathered(path) for path in found) + if not recordings: + return self._state.gathering + + return self._state.gathering.listing_folder(Folder(root=root, recordings=recordings)) + + def _joined(self, gathering: Gathering, recording: Recording) -> Gathering: + """One more recording in the setup, joining the mix where the run is one.""" + return gathering.mixing(recording) if self.mixes else gathering.listing(recording) + + @property + def _joining_settings(self) -> StemSettings: + """What a recording is converted with when it joins the list, as the reader last left it.""" + return self._settings.joining + + def _relevel(self, levels: MixLevels) -> None: + """Takes up rewritten levels and follows them wherever the setup changed. + + The levels are the mix's own order, so a run writing one reconstruction apiece has none to + rewrite and a gesture reaching it changes nothing. + """ + if not self.mixes: + return + + self._settle(self._state.with_gathering(self._state.gathering.with_levels(levels))) + + def _settle(self, state: ConverterState) -> None: + """Takes up a rewritten setup and follows it wherever it reaches. + + A mix names its destination after the recordings that take part, so the path the panel + shows follows every gesture; a settled run returns to idle, since the screen has moved on + from the setup it reported. + """ + self._remember(state.settings) + self._state = self._redirected(state.selecting(state.selected)) + self._rows = self._read_rows() + if not self.is_active: + self._run.return_to_idle() + self._emit(self._messages.idle, 0.0) + + def _remember(self, settings: RunSettings) -> None: + """Write down the shape of the run, so a launch opens where the last one left off. + + The settings a recording joins with and the run's own shape are carried between launches, + which is what makes the converter open on the setup the reader last worked in. + """ + if settings == self._settings: + return + + self._session_manager.set_converter_settings(settings.joining) + self._session_manager.set_converter_output(settings.output) + self._session_manager.set_converter_channel_cap(settings.channel_cap) + self._session_manager.set_converter_hierarchy_mode(settings.hierarchy_mode) + + def _redirected(self, state: ConverterState) -> ConverterState: + """The setup with its destination following the sources that take part in it.""" + config = self._config_manager.config + destination = state.destination.named_after(state.gathering.sources) + if state.settings.mixes: + setup = conversion_setup(state) + return state.with_destination(destination.aimed_at_mix(config, setup.sources, setup.stems.covered_channels)) + + return state.with_destination(destination.aimed_at_batch(config, batch_entries(state))) + + def _standing_targets(self, plan: ConversionPlan) -> Tuple[Path, ...]: + """Every reconstruction ``plan`` would write over, in the order the run reaches them. + + A recording gathered from a folder is left where its reconstruction already stands, so a + repeated folder run picks up where the last one stopped and puts nothing to the reader. + A recording the reader named is written whenever the run goes, so all of those are named + together: the answer covers each one it is given. + """ + return plan.existing_targets(self._config_manager.config) + + def _wait_for_library_and_start(self) -> None: + if self._run.phase != ConversionPhase.WAITING: + return + + if not self.call(self.is_library_available): + CallbackQueue.add( + self._wait_for_library_and_start, + priority=self._scheduling.priorities.schedule, + delay=self._scheduling.delays.schedule, + ) + else: + self._begin_conversion() + + def _begin_conversion(self) -> None: + plan = conversion_plan(self._state) + if plan is None: + logger.warning("Nothing is selected to convert") + return + + config: Config = self._config_manager.config.model_copy() + self._run.begin(config, plan, self._state.destination.reconstruction_name) + + def _on_report(self, report: RunReport) -> None: + self._emit(report.status_text, report.progress, running_input=report.input_path) + + def _on_run_success(self, success: ConversionSuccess) -> None: + if success.is_single: + self._state = self._state.with_destination(self._state.destination.writing_to(success.written[0])) + + self.call(self.on_success, success) + + def _on_run_error(self, exception: Exception) -> None: + self._schedule_return_to_idle() + if isinstance(exception, NoFilesToProcessError): + self.call(self.on_no_files_to_process) + else: + self.call(self.on_error, exception) + + def _on_run_canceled(self) -> None: + self._schedule_return_to_idle() + self.call(self.on_canceled) + + def _schedule_return_to_idle(self) -> None: + CallbackQueue.add( + self.close, + priority=self._scheduling.priorities.schedule, + delay=self._scheduling.delays.cancel, + ) + + def _emit( + self, + status_text: str, + progress: float, + running_input: Optional[Path] = None, + ) -> None: + view_model = compose_view( + self._state, + phase=self._run.phase, + status_text=status_text, + action_label=self._action_label(), + progress=progress, + running_input=running_input, + reconstructions_directory=self._config_manager.get_reconstructions_directory(), + other_operation_active=self._is_operation_active(), + rows=self._rows, + ) + self.call(self.on_view_changed, view_model) + + def _read_rows(self) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as the list draws them, read once for the setup now standing. + + Reading a row reaches the disk for whether its recording is still there, and a folder is + read down to the recordings it holds, so the reading is taken where the setup changes and + stands through every report a run makes about it. + """ + return stem_rows(self._state.gathering, mixes=self.mixes) + + def _action_label(self) -> str: + """What the button says the run writes, read from the recordings taking part in it.""" + return self._messages.action_label( + phase=self._run.phase, + mixes=self.mixes, + converted=playing_sources(self._state), + ) diff --git a/src/sampletones_application/logic/main/converter/messages.py b/src/sampletones_application/logic/main/converter/messages.py new file mode 100644 index 000000000..d20b280de --- /dev/null +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -0,0 +1,110 @@ +from pathlib import Path +from typing import Dict, Final, Tuple + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.services.conversion.result import ConversionItem +from sampletones_application.services.result import ServiceProgress +from sampletones_application.view_model.main.converter import ACTIVE_PHASES, ConversionPhase +from sampletones_core.parallelization import ETAEstimator +from sampletones_core.reconstructions.stage import ReconstructionStage + +SINGLE_JOB: Final[int] = 1 +SINGLE_SOURCE: Final[int] = 1 + + +class ConverterMessages: + """What the converter puts to a reader: the line under the bar and the label on its button. + + Every phrase the panel shows is composed here, so the words a run reports and the words a + settled setup reports read as one voice and the keys they come from stand in one place. + """ + + def __init__(self, language_manager: LanguageManager) -> None: + self._language_manager = language_manager + self.idle: str = language_manager["main.converter.message.status_idle"] + self.waiting: str = language_manager["main.converter.message.status_waiting"] + self.generating_library: str = language_manager["main.converter.message.status_generating_library"] + self.canceling: str = language_manager["main.converter.message.status_canceling"] + self.canceled: str = language_manager["main.converter.message.status_canceled"] + self.completed: str = language_manager["main.converter.message.status_reconstruction_completed"] + self.failed: str = language_manager["main.converter.message.status_error"] + self._stages: Dict[ReconstructionStage, str] = { + ReconstructionStage.LOADING: language_manager["main.converter.message.stage_loading"], + ReconstructionStage.MATCHING: language_manager["main.converter.message.stage_matching"], + ReconstructionStage.DECODING: language_manager["main.converter.message.stage_decoding"], + ReconstructionStage.RENDERING: language_manager["main.converter.message.stage_rendering"], + } + + def progress_text( + self, + progress: ServiceProgress[ConversionItem], + reconstruction_name: str, + ) -> str: + """What the run is doing, how far it has come, and how long it has left. + + A batch is many reconstructions and a count says where it stands; a single job counts to + one, so it names the document it is writing instead. Either way the reconstruction under + way says which stage it is in, which is the whole of what a reader watching one job has. + """ + return ( + self._run_text(progress, reconstruction_name) + self._stage_text(progress) + self._estimate_text(progress) + ) + + def action_label( + self, + *, + phase: ConversionPhase, + mixes: bool, + converted: Tuple[Path, ...], + ) -> str: + """The label the single action button shows. + + While a conversion holds resources the button cancels it. Otherwise it says what the run + writes, counted from the recordings taking part, so the button and the output switch read + as one sentence rather than two. + """ + if phase in ACTIVE_PHASES: + return self._language_manager["main.converter.label.cancel_button"] + + if len(converted) > SINGLE_SOURCE: + template = ( + self._language_manager["main.converter.template.mix_recordings"] + if mixes + else self._language_manager["main.converter.template.convert_recordings"] + ) + return template.format(count=len(converted)) + + if converted: + return self._language_manager["main.converter.template.convert_recording"].format(name=converted[0].stem) + + return self._language_manager["main.converter.label.convert_button"] + + def _run_text( + self, + progress: ServiceProgress[ConversionItem], + reconstruction_name: str, + ) -> str: + if progress.total > SINGLE_JOB: + return self._language_manager["main.converter.template.progress_template"].format( + progress.completed, progress.total + ) + + return self._language_manager["main.converter.template.single_progress_template"].format(reconstruction_name) + + def _stage_text(self, progress: ServiceProgress[ConversionItem]) -> str: + step = progress.current_item.step if progress.current_item is not None else None + if step is None: + return "" + + return self._language_manager["main.converter.template.stage_template"].format( + stage=self._stages[step.stage], + completed=step.completed, + total=step.total, + ) + + def _estimate_text(self, progress: ServiceProgress[ConversionItem]) -> str: + eta_string = ETAEstimator.format_duration(progress.eta_seconds) + if not eta_string: + return "" + + return self._language_manager["global.dialog.template.time_estimation"].format(eta_string=eta_string) diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py new file mode 100644 index 000000000..7d6a3e198 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/run.py @@ -0,0 +1,237 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, Optional, Protocol, Tuple + +from sampletones_application.logic.main.converter.messages import ConverterMessages +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ( + ServiceCanceled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.utils.progress import SystemProgress +from sampletones_application.view_model.main.converter import ACTIVE_PHASES, ConversionPhase +from sampletones_core.configs import Config +from sampletones_core.parallelization import TaskProgress +from sampletones_core.reconstructions.converter import ConversionPlan +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +SYSTEM_PROGRESS_STEPS: Final[int] = 1000 + + +@dataclass(frozen=True) +class ConversionSuccess: + """The outcome a completed conversion hands to its listener: the reconstructions it wrote. + + One written reconstruction is one the reader can open straight away; several are a batch, + which the reader reaches as a folder.""" + + written: Tuple[Path, ...] + + @property + def is_single(self) -> bool: + """One reconstruction was written, so it is the one a follow-up offer would load.""" + return len(self.written) == 1 + + +@dataclass(frozen=True) +class RunReport: + """Where a run stands, in the words a reader watching it reads. + + ``input_path`` names the recording under way, which a batch changes as it goes; a run naming + none leaves the reader looking at what they picked. + """ + + status_text: str + progress: float + input_path: Optional[Path] + + +class ConversionServiceProtocol(Protocol): + """The slice of the conversion service a run drives. + + Typing the collaborator structurally keeps the logic layer bound to the + service's result contract alone; the composition root supplies the real + service. + """ + + def subscribe(self, handler: Callable[[ConversionResult], None]) -> None: ... + + def start(self, config: Config, plan: ConversionPlan) -> None: ... + + def cancel(self) -> None: ... + + def cleanup(self) -> None: ... + + def shutdown(self) -> None: ... + + def is_running(self) -> bool: ... + + +class ConversionRun(CallbackMixin): + """One conversion from the moment it is requested to the moment it is closed. + + The run owns the phase, the service driving it and the taskbar progress that follows it, and + reports where it stands after every step it takes. What it converts is settled before it + starts: a run takes a plan and the name of the document it is writing, and answers only for + what happens to them. + """ + + def __init__( + self, + conversion_service: ConversionServiceProtocol, + *, + messages: ConverterMessages, + ) -> None: + self._service = conversion_service + self._messages = messages + self._system_progress = SystemProgress() + self._phase: ConversionPhase = ConversionPhase.IDLE + self._written: Tuple[Path, ...] = () + self._reconstruction_name: str = "" + + self._service.subscribe(self._on_service_result) + + self.on_report: Optional[Callable[[RunReport], None]] = None + self.on_success: Optional[Callable[[ConversionSuccess], None]] = None + self.on_error: Optional[Callable[[Exception], None]] = None + self.on_canceled: Optional[VoidCallback] = None + + @property + def phase(self) -> ConversionPhase: + return self._phase + + @property + def is_active(self) -> bool: + """A conversion is occupying resources from the moment of request (the WAITING phase, during + which the library is prepared and the run is scheduled) until it reaches a terminal phase.""" + return self._phase in ACTIVE_PHASES + + @property + def is_running(self) -> bool: + """The service holds the run, so canceling it is the service's business.""" + return self._service.is_running() + + @property + def written(self) -> Tuple[Path, ...]: + """The reconstructions the last completed run wrote.""" + return self._written + + def wait(self) -> None: + """Takes up a request, while the library it converts against is prepared.""" + self._phase = ConversionPhase.WAITING + self._report(self._messages.waiting, 0.0) + + def begin(self, config: Config, plan: ConversionPlan, reconstruction_name: str) -> None: + """Hands the plan to the service, which is where the conversion itself starts.""" + self._reconstruction_name = reconstruction_name + self._system_progress.initialize() + self._service.start(config, plan) + + def cancel(self) -> None: + """Asks the service to give up the run it holds.""" + self._phase = ConversionPhase.CANCELING + self._report(self._messages.canceling, 0.0) + self._system_progress.error() + self._service.cancel() + + def abandon(self) -> None: + """Gives up a request still standing this side of the service, which cancels it all the same.""" + self._settle_as_canceled() + + def close(self) -> None: + """Lets the run go, whatever it came to, and returns to idle.""" + try: + self._service.cleanup() + finally: + self._system_progress.clear() + self._written = () + self.return_to_idle() + + def return_to_idle(self) -> None: + """Marks a settled run as done with, so the panel offers to convert again.""" + self._phase = ConversionPhase.IDLE + + def cleanup(self) -> None: + """Shuts the service down, which is the end of every run this object could hold.""" + self._service.shutdown() + self._system_progress.clear() + + def _on_service_result(self, result: ConversionResult) -> None: + match result: + case ServiceStarted(total=total): + self._system_progress.start(total) + case ServiceProgress() as progress: + self._handle_progress_result(progress) + case ServiceIntermediate(data=progress): + self._handle_library_progress(progress) + case ServiceSuccess(value=written): + self._settle_as_complete(written) + case ServiceError(exception=exception): + self._settle_as_failed(exception) + case ServiceCanceled(): + self._settle_as_canceled() + + def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: + if self._phase == ConversionPhase.CANCELING: + self._report(self._messages.canceling, progress.fraction) + return + + self._phase = ConversionPhase.RUNNING + self._system_progress.set( + round(progress.fraction * SYSTEM_PROGRESS_STEPS), + SYSTEM_PROGRESS_STEPS, + ) + self._report( + self._messages.progress_text(progress, self._reconstruction_name), + progress.fraction, + input_path=self._item_path(progress), + ) + + def _item_path(self, progress: ServiceProgress[ConversionItem]) -> Optional[Path]: + """The recording the run names itself by, where one is under way.""" + return progress.current_item.source if progress.current_item is not None else None + + def _handle_library_progress(self, progress: TaskProgress) -> None: + if self._phase != ConversionPhase.WAITING: + return + + total = max(progress.total, 1) + self._report(self._messages.generating_library, progress.completed / total) + + def _settle_as_complete(self, written: Tuple[Path, ...]) -> None: + """Settles a finished run, telling its listener what was written before reporting. + + The reconstruction a run wrote is what a reader is then looking at, so the listener takes + it up first and the report that follows names it. + """ + self._written = written + self._phase = ConversionPhase.COMPLETED + self.call(self.on_success, ConversionSuccess(written=written)) + self._report(self._messages.completed, 1.0) + + def _settle_as_failed(self, exception: Exception) -> None: + self._system_progress.error() + self._phase = ConversionPhase.FAILED + self._report(self._messages.failed, 0.0) + self.call(self.on_error, exception) + + def _settle_as_canceled(self) -> None: + self._phase = ConversionPhase.CANCELED + self._report(self._messages.canceled, 0.0) + self.call(self.on_canceled) + + def _report( + self, + status_text: str, + progress: float, + input_path: Optional[Path] = None, + ) -> None: + self.call( + self.on_report, + RunReport(status_text=status_text, progress=progress, input_path=input_path), + ) diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py new file mode 100644 index 000000000..dee87cfa5 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass, replace +from typing import Self + +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +from sampletones_application.constants.output import OutputKind +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +@dataclass(frozen=True) +class RunSettings: + """The choices a run holds to, whatever it converts. + + ``joining`` is what a recording is given when it joins the setup, and each recording carries + its own settings from there, so what a run reaches is what its rows hold. The rest name the + shape of the run itself — what it writes, how many channels one recording may hold in a frame, + and how the levels take turns. + """ + + joining: StemSettings + output: OutputKind + channel_cap: int + hierarchy_mode: HierarchyMode + + @property + def max_channel_cap(self) -> int: + """The highest cap there is, which is one recording holding every channel in a frame.""" + return len(ChannelName) + + @property + def effective_channel_cap(self) -> int: + """The cap a run holds to, within the channels the hardware has.""" + return min(self.channel_cap, self.max_channel_cap) + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self.output.mixes + + def with_output(self, output: OutputKind) -> Self: + """The run writing one reconstruction per gathered recording, or one from them all.""" + return replace(self, output=output) + + def with_channel_cap(self, channel_cap: int) -> Self: + """The cap the reader asked for, held between one channel and the channels enabled.""" + return replace(self, channel_cap=min(max(channel_cap, MIN_CHANNEL_CAP), self.max_channel_cap)) + + def with_hierarchy_mode(self, hierarchy_mode: HierarchyMode) -> Self: + """The run taking its levels round by round, or one level exhausted before the next.""" + return replace(self, hierarchy_mode=hierarchy_mode) diff --git a/src/sampletones_application/logic/main/converter/setup.py b/src/sampletones_application/logic/main/converter/setup.py new file mode 100644 index 000000000..67205ca27 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/setup.py @@ -0,0 +1,76 @@ +from pathlib import Path +from typing import Optional, Tuple + +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.sources.derive import ( + ConversionSetup, + derive_conversion_setup, +) +from sampletones_core.reconstructions.converter import ( + BatchConversion, + BatchEntry, + ConversionPlan, + GroupConversion, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +def conversion_setup(state: ConverterState) -> ConversionSetup: + """The recordings and the stems setup a mix converts under, carrying the channel cap.""" + settings = state.settings + return derive_conversion_setup( + state.gathering.sources, + state.gathering.levels, + channel_cap=settings.effective_channel_cap, + hierarchy_mode=settings.hierarchy_mode, + ) + + +def playing_sources(state: ConverterState) -> Tuple[Path, ...]: + """The recordings that take part, in the order the run reaches them.""" + if state.settings.mixes: + return conversion_setup(state).sources + + return tuple(entry.source for entry in batch_entries(state)) + + +def batch_entries(state: ConverterState) -> Tuple[BatchEntry, ...]: + """One entry per gathered recording still holding a channel. + + Each carries a setup of its own, so what a reader settled on a row is what that recording's + reconstruction records. The folder a recording was gathered from decides where it is written, + which is what makes a run over a folder mirror that folder's tree. + """ + settings = state.settings + gathering = state.gathering + entries = [] + for recording in gathering.sources.recordings: + if not recording.settings.channels: + continue + + entries.append( + BatchEntry( + source=recording.path, + stems=StemsConfig.single_entry( + recording.settings.channels, + recording.settings.bends, + channel_cap=settings.effective_channel_cap, + ), + base_directory=gathering.folder_root_of(recording.path), + ) + ) + + return tuple(entries) + + +def conversion_plan(state: ConverterState) -> Optional[ConversionPlan]: + """What a request amounts to: one reconstruction from the recordings gathered, or one apiece. + + A run with nobody taking part names no plan, which is what a converter aimed at nothing is. + """ + if state.settings.mixes: + setup = conversion_setup(state) + return GroupConversion(sources=setup.sources, stems=setup.stems) if setup.sources else None + + entries = batch_entries(state) + return BatchConversion(entries=entries) if entries else None diff --git a/src/sampletones_application/logic/main/converter/state.py b/src/sampletones_application/logic/main/converter/state.py new file mode 100644 index 000000000..0ec1f9b7a --- /dev/null +++ b/src/sampletones_application/logic/main/converter/state.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass, replace +from typing import Optional, Self + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.sources.key import SourceKey + + +@dataclass(frozen=True) +class ConverterState: + """What the converter is set up to do, as one value every gesture rewrites a part of. + + The sides answer to each other — the settings say what a run hands out, the gathering says + which recordings take part, and the destination follows from both — so a gesture states the one + side it changes and hands the whole state back to be settled at once. ``selected`` names the + row a reader is inspecting, which the settings card edits and the list draws as picked out. + """ + + settings: RunSettings + gathering: Gathering + destination: Destination + selected: Optional[SourceKey] + + def with_settings(self, settings: RunSettings) -> Self: + return replace(self, settings=settings) + + def with_gathering(self, gathering: Gathering) -> Self: + return replace(self, gathering=gathering) + + def with_destination(self, destination: Destination) -> Self: + return replace(self, destination=destination) + + def with_selected(self, selected: Optional[SourceKey]) -> Self: + return replace(self, selected=selected) + + def selecting(self, selected: Optional[SourceKey]) -> Self: + """The state with ``selected`` inspected, where the list still holds the row it names.""" + if selected is not None and self.gathering.sources.row(selected) is None: + return replace(self, selected=None) + + return replace(self, selected=selected) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py new file mode 100644 index 000000000..16e5a149d --- /dev/null +++ b/src/sampletones_application/logic/main/converter/view.py @@ -0,0 +1,264 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import FrozenSet, Optional, Tuple + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.sources.row import SourceRow +from sampletones_application.logic.main.sources.slots import ( + ALL_CHANNELS, + CHANNEL_SLOT, + SETTINGS_SLOTS, + SettingsSlot, +) +from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.main.source import ( + InspectedSourceViewModel, + SettingsSlotViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def compose_view( + state: ConverterState, + *, + phase: ConversionPhase, + status_text: str, + action_label: str, + progress: float, + running_input: Optional[Path], + reconstructions_directory: Path, + other_operation_active: bool, + rows: Tuple[StemRowViewModel, ...], +) -> ConverterViewModel: + """The panel's whole reading of the converter at one moment. + + ``running_input`` is the recording a batch is on, which stands in for what the reader gathered + while a run is under way; ``reconstructions_directory`` is where a converter that has gathered + nothing yet would write. ``rows`` are the gathered sources as :func:`stem_rows` last read + them, which stand for as long as the gathering does and are therefore read once a gesture + rather than once a progress report. + """ + settings = state.settings + destination = state.destination + return ConverterViewModel( + phase=phase, + status_text=status_text, + action_label=action_label, + progress=progress, + input_path=running_input if running_input is not None else destination.input_path, + output_path=_display_output(destination, reconstructions_directory), + is_file=destination.is_file, + other_operation_active=other_operation_active, + output=settings.output, + stem_sources=rows, + channel_cap=settings.effective_channel_cap, + max_channel_cap=settings.max_channel_cap, + hierarchy_mode=settings.hierarchy_mode, + max_sources=state.gathering.ceiling, + selected_key=_selected_key(state), + ) + + +def settings_slots(state: ConverterState) -> Tuple[SettingsSlotViewModel, ...]: + """The choices the settings card edits, read through the recordings the picked row stands for. + + With no row picked the card has nothing to answer for, so every choice offers no channel and + the card says which gesture picks one. + """ + inspected = inspected_settings(state) + return tuple(_slot_reading(slot, inspected) for slot in SETTINGS_SLOTS) + + +def inspected_settings(state: ConverterState) -> Tuple[StemSettings, ...]: + """The settings the card is editing, read from the recordings the picked row stands for.""" + selected = state.selected + if selected is None: + return () + + row = state.gathering.sources.row(selected) + if row is None: + return () + + return tuple(recording.settings for recording in row.recordings) + + +def inspected_source(state: ConverterState) -> Optional[InspectedSourceViewModel]: + """The row the card is editing, named the way the list names it.""" + selected = state.selected + if selected is None: + return None + + row = state.gathering.sources.row(selected) + if row is None: + return None + + return InspectedSourceViewModel( + name=selected.path.name if selected.names_folder else selected.path.stem, + kind=selected.kind, + holds=row.count, + ) + + +def _slot_reading( + slot: SettingsSlot, + inspected: Tuple[StemSettings, ...], +) -> SettingsSlotViewModel: + offered = frozenset().union(*(slot.offered(settings) for settings in inspected)) if inspected else frozenset() + held = set() + partial = set() + for channel_name in offered: + agreement = Agreement.over(channel_name in slot.read(settings) for settings in inspected) + if agreement is Agreement.ALL: + held.add(channel_name) + elif agreement is Agreement.SOME: + partial.add(channel_name) + + return SettingsSlotViewModel( + field=slot.field, + offered_channels=offered, + held_channels=frozenset(held), + partial_channels=frozenset(partial), + ) + + +def _selected_key(state: ConverterState) -> Optional[str]: + """The row a reader is inspecting, as the list names it.""" + return None if state.selected is None else str(state.selected.path) + + +def stem_rows( + gathering: Gathering, + *, + mixes: bool, +) -> Tuple[StemRowViewModel, ...]: + """The gathered sources as the panel reads them, each stating where it stands. + + A row is named by its path, so the list reports every gesture under the path it landed on, and + it offers a box on every channel, since the channels a row holds are the whole of what its + reconstruction reaches. A source that has left the disk since it was gathered reports itself as + missing. A mix bands its recordings by the level each picks on; a run writing one reconstruction + apiece draws one band holding the whole list, folders included. + """ + placements = _mixed_placements(gathering) if mixes else _listed_placements(gathering) + return tuple(_row(placement) for placement in placements) + + +@dataclass(frozen=True) +class _Placement: + """One source and where it stands in the list the panel draws.""" + + source: SourceRow + path: Path + level: int + position: int + level_size: int + level_count: int + + +def _row(placement: _Placement) -> StemRowViewModel: + source = placement.source + key = source.key + channels, partial = _readings(source) + return StemRowViewModel( + key=str(placement.path), + kind=key.kind, + path=placement.path, + held=_held(placement) if key.names_folder else (), + channels=channels, + partial_channels=partial, + bends=frozenset(), + offered_channels=ALL_CHANNELS, + available=placement.path.is_dir() if key.names_folder else placement.path.is_file(), + level=placement.level, + position=placement.position, + level_size=placement.level_size, + level_count=placement.level_count, + ) + + +def _held(placement: _Placement) -> Tuple[StemRowViewModel, ...]: + """The recordings a folder stands for, each answering for itself. + + They stand where the folder stands, since the folder is the row the list holds them under, and + each reads on a channel the way a recording does — plainly ticked or clear. + """ + return tuple( + StemRowViewModel( + key=str(recording.path), + kind=recording.key.kind, + path=recording.path, + held=(), + channels=frozenset(CHANNEL_SLOT.read(recording.settings)), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=ALL_CHANNELS, + available=recording.path.is_file(), + level=placement.level, + position=placement.position, + level_size=placement.level_size, + level_count=placement.level_count, + ) + for recording in placement.source.recordings + ) + + +def _readings(source: SourceRow) -> Tuple[FrozenSet[ChannelName], FrozenSet[ChannelName]]: + """How the recordings a row stands for read on each channel. + + A channel every one of them holds is ticked, one some of them hold is half-lit, and the rest + are clear — which for a single recording is the plain ticked-or-clear reading. + """ + held = set() + partial = set() + for channel_name in ALL_CHANNELS: + agreement = Agreement.over( + channel_name in CHANNEL_SLOT.read(recording.settings) for recording in source.recordings + ) + if agreement is Agreement.ALL: + held.add(channel_name) + elif agreement is Agreement.SOME: + partial.add(channel_name) + + return frozenset(held), frozenset(partial) + + +def _mixed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + levels = gathering.levels + return tuple( + _Placement( + source=recording, + path=path, + level=level_index, + position=position, + level_size=len(level), + level_count=levels.level_count, + ) + for level_index, level in enumerate(levels.levels) + for position, path in enumerate(level) + for recording in (gathering.recording(path),) + if recording is not None + ) + + +def _listed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + rows = gathering.sources.rows + return tuple( + _Placement( + source=source, + path=source.key.path, + level=0, + position=position, + level_size=len(rows), + level_count=1, + ) + for position, source in enumerate(rows) + ) + + +def _display_output(destination: Destination, reconstructions_directory: Path) -> Path: + return destination.output_path if destination.output_path is not None else reconstructions_directory diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py deleted file mode 100644 index 40a1ec061..000000000 --- a/src/sampletones_application/logic/main/explorer.py +++ /dev/null @@ -1,51 +0,0 @@ -from pathlib import Path -from typing import AbstractSet, Set - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.logic.main.explorer_manager import ExplorerManager -from sampletones_core.structures.tree import FileSystemNode, Tree - - -class ExplorerLogic: - def __init__( - self, - 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 - def tree(self) -> Tree: - return self._manager.tree - - def refresh_tree(self) -> None: - self._manager.refresh_tree() - - 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) - - def collapse_all(self) -> None: - self._manager.collapse_all() - - def has_relevant_content(self, filepath: Path) -> bool: - return self._manager.has_relevant_content(filepath) diff --git a/src/sampletones_application/logic/main/sources/__init__.py b/src/sampletones_application/logic/main/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/main/sources/derive.py b/src/sampletones_application/logic/main/sources/derive.py new file mode 100644 index 000000000..02f839dfa --- /dev/null +++ b/src/sampletones_application/logic/main/sources/derive.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Sequence, Tuple + +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_core.constants.enums import HierarchyMode +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy + + +@dataclass(frozen=True) +class ConversionSetup: + """What a mixed conversion runs with: the recordings it mixes and the setup handing out channels. + + Both sides are built from one pass over the levels, so the entry ids the assignment records + name the recordings in the order the job mixes them. + """ + + sources: Tuple[Path, ...] + stems: StemsConfig + + +def derive_conversion_setup( + sources: SourceList, + levels: MixLevels, + *, + channel_cap: int, + hierarchy_mode: HierarchyMode, +) -> ConversionSetup: + """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. + + A recording left holding no channel takes no part: it reaches neither the mix nor the entries. + What remains is numbered in level order, which is the id the conversion records per frame and a + stem selection later reads back. + """ + playing = [_recordings_of(sources, level) for level in levels.levels] + ordered = [recording for level in playing for recording in level] + + entries = [StemEntry(id=stem_id, settings=recording.settings) for stem_id, recording in enumerate(ordered)] + return ConversionSetup( + sources=tuple(recording.path for recording in ordered), + stems=StemsConfig( + entries=entries, + hierarchy=_hierarchy(playing, hierarchy_mode), + channel_cap=channel_cap, + ), + ) + + +def _recordings_of(sources: SourceList, level: Sequence[Path]) -> List[Recording]: + """The recordings of one level that hold a channel, which is what takes part in the mix.""" + playing = [] + for path in level: + recording = sources.recording(path) + if recording is not None and recording.settings.channels: + playing.append(recording) + + return playing + + +def _hierarchy( + playing: Sequence[Sequence[Recording]], + hierarchy_mode: HierarchyMode, +) -> StemsHierarchy: + levels: List[List[int]] = [] + stem_id = 0 + for level in playing: + if not level: + continue + + levels.append([stem_id + offset for offset in range(len(level))]) + stem_id += len(level) + + return StemsHierarchy(levels=levels, mode=hierarchy_mode) diff --git a/src/sampletones_application/logic/main/sources/folder.py b/src/sampletones_application/logic/main/sources/folder.py new file mode 100644 index 000000000..5e3062ad0 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/folder.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Iterable, Self, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording + + +@dataclass(frozen=True) +class Folder: + """A directory a reader gathered, standing for the recordings found below it. + + This is where the folder relation lives: a recording states its own path and its settings, and + the folder holding it is what says a run mirrors that folder's tree for it. A recording taken + out of a folder is a loose recording, with nothing left over to say otherwise. + """ + + root: Path + recordings: Tuple[Recording, ...] + + @property + def key(self) -> SourceKey: + return SourceKey.folder(self.root) + + @property + def count(self) -> int: + return len(self.recordings) + + def with_recordings(self, recordings: Iterable[Recording]) -> Self: + return replace(self, recordings=tuple(recordings)) diff --git a/src/sampletones_application/logic/main/sources/key.py b/src/sampletones_application/logic/main/sources/key.py new file mode 100644 index 000000000..d8ec6adfc --- /dev/null +++ b/src/sampletones_application/logic/main/sources/key.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Self + +from sampletones_application.constants.sources import SourceKind + + +@dataclass(frozen=True) +class SourceKey: + """What a gesture names: one recording, or one folder standing for the recordings below it. + + A path alone leaves the two kinds to be told apart by looking them up, so a key carries the + kind it was made for and every layer reads the same answer. + """ + + kind: SourceKind + path: Path + + @classmethod + def recording(cls, path: Path) -> Self: + """The key naming the recording at ``path``.""" + return cls(kind=SourceKind.RECORDING, path=path) + + @classmethod + def folder(cls, root: Path) -> Self: + """The key naming the folder gathered at ``root``.""" + return cls(kind=SourceKind.FOLDER, path=root) + + @property + def names_folder(self) -> bool: + return self.kind is SourceKind.FOLDER diff --git a/src/sampletones_application/logic/main/sources/levels.py b/src/sampletones_application/logic/main/sources/levels.py new file mode 100644 index 000000000..4d5c6fc96 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/levels.py @@ -0,0 +1,174 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Self, Sequence, Tuple + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES + +Level = Tuple[Path, ...] + + +@dataclass(frozen=True) +class MixLevels: + """The recordings one mixed reconstruction is built from, in the precedence levels they pick on. + + A level holds the recordings that compete on cost; the levels pick in the order they are + listed. Position within a level is the order the entries are numbered in, which is what settles + a tie between two equal-cost choices. Every gesture answers with a new value whose levels are + all occupied, so the bands a reader sees stay consecutive. + + Only paths stand here. What each recording is converted with belongs to the list it was + gathered in, so the precedence structure and the settings each have one home. + + A mix reaches at most ``MAX_STEM_SOURCES`` recordings, which is the ceiling the hardware's four + channels and the assignment's per-frame budget leave worth mixing. + """ + + levels: Tuple[Level, ...] = () + + @classmethod + def of(cls, levels: Sequence[Sequence[Path]]) -> Self: + """Builds a value from levels given in any shape, keeping the ones that hold a recording.""" + return cls(levels=tuple(tuple(level) for level in levels if level)) + + @property + def paths(self) -> Tuple[Path, ...]: + """Every recording, in the order it is mixed and numbered.""" + return tuple(path for level in self.levels for path in level) + + @property + def count(self) -> int: + return len(self.paths) + + @property + def level_count(self) -> int: + return len(self.levels) + + @property + def ceiling(self) -> int: + """How many recordings one mix reaches, which is the ceiling every reader of it asks for.""" + return MAX_STEM_SOURCES + + @property + def room(self) -> int: + """How many more recordings this mix reaches.""" + return self.ceiling - self.count + + def holds(self, path: Path) -> bool: + return path in self.paths + + def level_of(self, path: Path) -> int: + """The level the recording picks on, counted from the first. + + Raises: + KeyError: If ``path`` is gathered in no level of this mix. + """ + for level_index, level in enumerate(self.levels): + if path in level: + return level_index + + raise KeyError(f"{path} is not gathered in this mix") + + def position_of(self, path: Path) -> int: + """The place the recording takes among the ones sharing its level.""" + return self.levels[self.level_of(path)].index(path) + + def add(self, path: Path) -> Self: + """Gathers another recording, on the level the first recordings picked on. + + A mix already at its ceiling stands as it is, so a caller reads ``room`` to know what it + can still gather. + """ + if self.holds(path) or not self.room: + return self + + levels = self._mutable() + if not levels: + return self.of([[path]]) + + levels[0].append(path) + return self.of(levels) + + def remove(self, path: Path) -> Self: + """Lets a recording go, together with the level it emptied.""" + return self.of(self._without(path)) + + def move_within_level(self, path: Path, offset: int) -> Self: + """Moves a recording past the neighbor it shares a level with, changing which ties first.""" + if not self.holds(path): + return self + + level_index = self.level_of(path) + position = self.position_of(path) + offset + level = list(self.levels[level_index]) + if not 0 <= position < len(level): + return self + + level.remove(path) + level.insert(position, path) + levels = self._mutable() + levels[level_index] = level + return self.of(levels) + + def join_level(self, path: Path, offset: int) -> Self: + """Sends a recording to the neighboring level, where it picks with that level's recordings.""" + if not self.holds(path): + return self + + target = self.level_of(path) + offset + if not 0 <= target < self.level_count: + return self + + levels = self._without(path) + levels[target].append(path) + return self.of(levels) + + def isolate(self, path: Path) -> Self: + """Gives a recording a level of its own, picking directly after the one it shared.""" + if not self.holds(path) or len(self.levels[self.level_of(path)]) == 1: + return self + + levels = self._without(path) + levels.insert(self.level_of(path) + 1, [path]) + return self.of(levels) + + def move_onto(self, path: Path, target_path: Path) -> Self: + """Moves a recording to the level and the place another one holds.""" + if not self.holds(path) or path == target_path or not self.holds(target_path): + return self + + levels = self._without(path) + for level in levels: + if target_path in level: + level.insert(level.index(target_path), path) + return self.of(levels) + + return self + + def move_to_new_level(self, path: Path, position: int) -> Self: + """Gives a recording a level of its own, in the slot the levels are broken at. + + ``position`` counts the gaps a reader sees: zero is above the first level and the level + count is below the last, so the slot names itself the same way whichever level the + recording is leaving. + """ + if not self.holds(path): + return self + + level_index = self.level_of(path) + levels = self._without(path) + target = position + if not levels[level_index]: + del levels[level_index] + target = position - 1 if position > level_index else position + if target == level_index: + return self + + levels.insert(target, [path]) + return self.of(levels) + + def _mutable(self) -> List[List[Path]]: + return [list(level) for level in self.levels] + + def _without(self, path: Path) -> List[List[Path]]: + """The levels with one recording taken out, keeping a level it emptied for the callers that count on it.""" + return [[held for held in level if held != path] for level in self.levels] diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py new file mode 100644 index 000000000..7b747ad44 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/list.py @@ -0,0 +1,275 @@ +from dataclasses import dataclass, replace +from functools import cached_property +from pathlib import Path +from typing import Callable, Dict, FrozenSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.row import SourceRow +from sampletones_application.logic.main.sources.slots import SettingsSlot +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +@dataclass(frozen=True) +class SourceList: + """The rows a reader gathered a run from, in the order they were added. + + A row is one recording the reader named, or a folder standing for every recording gathered + below it, and both kinds share the one order. A path stands here once: a folder takes over the + loose recordings it covers, carrying the settings they already had, and a recording another + folder holds stays where it is. + + The list holds whatever a reader gathers, a folder of thousands included. What a single mix + may hold is the mix's own ceiling rather than this one's, since a list of that size is exactly + what a run writing one reconstruction per recording is for. + """ + + rows: Tuple[SourceRow, ...] = () + + @cached_property + def recordings(self) -> Tuple[Recording, ...]: + """Every recording the list stands for, folders walked through to what they hold. + + This is the one reading that goes from rows to recordings; whoever needs the recordings a + run converts asks for them here. A list holding a folder of thousands is read many times + over in the course of one gesture, so the walk is taken once and held; the list is settled + rather than edited, so each reading belongs to the list that took it. + """ + return tuple(recording for row in self.rows for recording in row.recordings) + + @cached_property + def _by_path(self) -> Dict[Path, Recording]: + """Every recording under the path naming it, which is how a path is looked up.""" + return {recording.path: recording for recording in self.recordings} + + @cached_property + def _roots(self) -> Dict[Path, Path]: + """The folder each gathered recording was found below, where a folder stands for it.""" + return { + recording.path: row.key.path for row in self.rows if row.key.names_folder for recording in row.recordings + } + + @property + def paths(self) -> Tuple[Path, ...]: + return tuple(recording.path for recording in self.recordings) + + @property + def count(self) -> int: + """How many recordings the list stands for.""" + return sum(row.count for row in self.rows) + + @property + def row_count(self) -> int: + """How many rows a reader sees, a folder counting as the one row it draws.""" + return len(self.rows) + + def holds(self, path: Path) -> bool: + return path in self._by_path + + def recording(self, path: Path) -> Optional[Recording]: + return self._by_path.get(path) + + def row(self, key: SourceKey) -> Optional[SourceRow]: + """The row a key names, wherever it stands. + + A reader who opens a folder answers for one of the recordings inside it, so a key naming a + recording reaches that recording whether it was gathered by name or through the folder + holding it. + """ + listed = next((row for row in self.rows if row.key == key), None) + if listed is not None or key.names_folder: + return listed + + return self.recording(key.path) + + def folder_root_of(self, path: Path) -> Optional[Path]: + """The root of the folder holding ``path``, which is the tree a run mirrors for it. + + A recording the reader named answers with nothing, and its reconstruction sits directly in + the directory the run's settings are named after. + """ + return self._roots.get(path) + + def add_recording(self, recording: Recording) -> Self: + """Gathers one recording the reader named, leaving a path already standing as it is.""" + if self.holds(recording.path): + return self + + return replace(self, rows=self.rows + (recording,)) + + def add_folder(self, folder: Folder) -> Self: + """Gathers ``folder``, taking over the loose recordings it covers. + + A loose recording the folder lists joins it holding the settings it already had, so what a + reader settled before gathering stands. A recording another folder holds stays there, which + is what keeps every path standing in the list once, and a folder left standing for nothing + stays out, since a row that names no recording names nothing a run would write. + """ + if self.row(folder.key) is not None: + return self + + gathered = self._gathered_by(folder) + if not gathered: + return self + + taken = frozenset(recording.path for recording in gathered) + kept = tuple(row for row in self.rows if row.key.names_folder or row.key.path not in taken) + return replace(self, rows=kept + (folder.with_recordings(gathered),)) + + def remove(self, key: SourceKey) -> Self: + """Lets go of what ``key`` names: a whole folder, or one recording wherever it stands. + + A folder goes together with every recording it holds, and a folder left holding nothing + goes as well, since it stands for nothing a run would write. + """ + if key.names_folder: + return replace(self, rows=tuple(row for row in self.rows if row.key != key)) + + return replace(self, rows=tuple(self._without_recording(key.path))) + + def settled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Self: + """The list with ``channel_name`` settled in ``slot``, on every recording ``key`` stands for. + + A folder settles by making the same edit to each recording it holds, so one gesture reads + the same whichever kind of row answered it. + """ + return replace(self, rows=tuple(self._settled_rows(key, slot, channel_name, held))) + + def written( + self, + key: SourceKey, + slot: SettingsSlot, + channels: FrozenSet[ChannelName], + ) -> Self: + """The list with ``slot`` holding exactly ``channels`` on every recording ``key`` stands for. + + This is the gesture that hands a whole reading back at once, where ``settled`` answers one + channel at a time. + """ + return replace(self, rows=tuple(self._rewritten_rows(key, slot, channels))) + + def toggled( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + ) -> Self: + """The list one gesture on ``key`` leaves behind. + + A row every recording of which already makes the choice lets it go; every other reading + settles the whole row on it, so one gesture always moves a group somewhere. + """ + held = self.agreement(key, slot, channel_name).settles_to + return self.settled(key, slot, channel_name, held) + + def agreement( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + ) -> Agreement: + """How the recordings ``key`` stands for read on ``channel_name`` in ``slot``.""" + row = self.row(key) + if row is None: + return Agreement.NONE + + return Agreement.over(slot.holds(recording.settings, channel_name) for recording in row.recordings) + + def _gathered_by(self, folder: Folder) -> Tuple[Recording, ...]: + """The recordings ``folder`` takes on, each holding the settings it already stood with.""" + loose = self._loose_recordings() + standing = self._recordings_inside_folders() + return tuple( + loose.get(recording.path, recording) for recording in folder.recordings if recording.path not in standing + ) + + def _loose_recordings(self) -> Dict[Path, Recording]: + return { + recording.path: recording for row in self.rows if not row.key.names_folder for recording in row.recordings + } + + def _recordings_inside_folders(self) -> FrozenSet[Path]: + return frozenset(recording.path for row in self.rows if row.key.names_folder for recording in row.recordings) + + def _without_recording(self, path: Path) -> Tuple[SourceRow, ...]: + rows: Tuple[SourceRow, ...] = () + for row in self.rows: + if not row.key.names_folder: + if row.key.path != path: + rows += (row,) + continue + + kept = tuple(recording for recording in row.recordings if recording.path != path) + if kept: + rows += (Folder(root=row.key.path, recordings=kept),) + + return rows + + def _settled_rows( + self, + key: SourceKey, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Tuple[SourceRow, ...]: + return self._rows_with( + key, + lambda settings: slot.settled(settings, channel_name, held), + ) + + def _rewritten_rows( + self, + key: SourceKey, + slot: SettingsSlot, + channels: FrozenSet[ChannelName], + ) -> Tuple[SourceRow, ...]: + return self._rows_with(key, lambda settings: slot.write(settings, channels)) + + def _rows_with( + self, + key: SourceKey, + change: Callable[[StemSettings], StemSettings], + ) -> Tuple[SourceRow, ...]: + rows: Tuple[SourceRow, ...] = () + for row in self.rows: + rows += self._row_with(row, key, change) + + return rows + + def _row_with( + self, + row: SourceRow, + key: SourceKey, + change: Callable[[StemSettings], StemSettings], + ) -> Tuple[SourceRow, ...]: + """The row as ``key`` leaves it. + + A key naming the row changes every recording it stands for; one naming a recording a + folder holds changes that recording where it stands, so a reader settles a folder and the + recordings inside it through the same gesture. + """ + if row.key == key: + changed = tuple(recording.with_settings(change(recording.settings)) for recording in row.recordings) + return (Folder(root=key.path, recordings=changed),) if key.names_folder else changed + + if not row.key.names_folder or not any(recording.path == key.path for recording in row.recordings): + return (row,) + + return ( + Folder( + root=row.key.path, + recordings=tuple( + recording.with_settings(change(recording.settings)) if recording.path == key.path else recording + for recording in row.recordings + ), + ), + ) diff --git a/src/sampletones_application/logic/main/sources/recording.py b/src/sampletones_application/logic/main/sources/recording.py new file mode 100644 index 000000000..64552856f --- /dev/null +++ b/src/sampletones_application/logic/main/sources/recording.py @@ -0,0 +1,35 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Self, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +@dataclass(frozen=True) +class Recording: + """One audio file a run converts, together with the settings it converts under. + + The settings are the value the core records as the stem's own, so what a reader edits in the + list is what the reconstruction carries. A recording states its own path and nothing about + where it was gathered from: the folder holding it is what knows that. + """ + + path: Path + settings: StemSettings + + @property + def key(self) -> SourceKey: + return SourceKey.recording(self.path) + + @property + def recordings(self) -> Tuple[Self, ...]: + """The recordings this row stands for, which for one recording is itself.""" + return (self,) + + @property + def count(self) -> int: + return 1 + + def with_settings(self, settings: StemSettings) -> Self: + return replace(self, settings=settings) diff --git a/src/sampletones_application/logic/main/sources/row.py b/src/sampletones_application/logic/main/sources/row.py new file mode 100644 index 000000000..bb465db0e --- /dev/null +++ b/src/sampletones_application/logic/main/sources/row.py @@ -0,0 +1,24 @@ +from typing import Protocol, Tuple + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording + + +class SourceRow(Protocol): + """One row of the list a run is set up in. + + A row is either a recording the reader named or a folder standing for the recordings gathered + below it, and both answer the same three questions: what a gesture names it by, which + recordings it stands for, and how many those are. Every reader works through those answers, so + the two kinds are told apart in one place — the kind a key carries — rather than at each site + that walks the list. + """ + + @property + def key(self) -> SourceKey: ... + + @property + def recordings(self) -> Tuple[Recording, ...]: ... + + @property + def count(self) -> int: ... diff --git a/src/sampletones_application/logic/main/sources/scan.py b/src/sampletones_application/logic/main/sources/scan.py new file mode 100644 index 000000000..43d08b7b7 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -0,0 +1,98 @@ +import threading +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_core.reconstructions.converter.paths import is_audio_file, walk_entries +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +CountCallback = Callable[[int], None] +FoundCallback = Callable[[Path, Tuple[Path, ...]], None] + +REPORT_EVERY: Final[int] = 64 +REPORT_DUE: Final[int] = 0 + + +class FolderScan(CallbackMixin): + """The recordings below a folder, read beside the interface rather than in front of it. + + A folder a reader points at holds a handful of recordings or a disk's worth, and finding out + costs what the tree costs — seconds where the tree is large. The walk therefore runs on a + worker, reports how many it has met as it goes, and stops when the reader asks it to, so the + window keeps answering and the reader knows what it is waiting for. + + The reports arrive on the worker's own thread, so whoever draws from them crosses to the + thread DearPyGui's context belongs to. + """ + + def __init__(self) -> None: + self._stopping = threading.Event() + self._running = threading.Event() + + self.on_started: Optional[PathCallback] = None + self.on_progress: Optional[CountCallback] = None + self.on_stopped: Optional[VoidCallback] = None + + @property + def running(self) -> bool: + """A walk is under way, which is what the reader is being shown.""" + return self._running.is_set() + + def start(self, root: Path, answer: FoundCallback) -> None: + """Reads what ``root`` holds and hands it to ``answer``, counting as the walk goes. + + The answer travels with the walk that earns it, so the same scan serves a gathering and a + conversion and each hears back from its own reading. One walk runs at a time: a folder + asked for while another is being read is turned away, and asking again once the window + closes reads it. + """ + if self.running: + return + + self._stopping.clear() + self._running.set() + self.call(self.on_started, root) + self._walk(root, answer) + + def stop(self) -> None: + """Asks the walk to give up, which it does at the next recording it meets.""" + self._stopping.set() + + @concurrent(wait=False) + def _walk(self, root: Path, answer: FoundCallback) -> None: + """Reads the tree, reports how it ended, and lets the walk go, in that order. + + The walk holds its claim until its report has gone out, so the worker and the scan agree + on the moment a folder may next be asked for. It is let go whatever becomes of the reading, + so one that fails partway leaves the next folder free to be asked for. + """ + try: + found = self._gather(root) + if self._stopping.is_set(): + self.call(self.on_stopped) + return + + self.call(answer, root, tuple(sorted(found))) + finally: + self._running.clear() + + def _gather(self, root: Path) -> List[Path]: + """The recordings met below ``root``, giving up at the entry the reader stops the walk on. + + Every entry the tree holds is offered, so a folder of thousands holding a handful of + recordings answers **Stop** as promptly as one holding thousands. + """ + found: List[Path] = [] + for path in walk_entries(root): + if self._stopping.is_set(): + return found + + if not is_audio_file(path): + continue + + found.append(path) + if len(found) % REPORT_EVERY == REPORT_DUE: + self.call(self.on_progress, len(found)) + + return found diff --git a/src/sampletones_application/logic/main/sources/slots.py b/src/sampletones_application/logic/main/sources/slots.py new file mode 100644 index 000000000..661d6d80f --- /dev/null +++ b/src/sampletones_application/logic/main/sources/slots.py @@ -0,0 +1,105 @@ +from dataclasses import dataclass +from typing import Callable, Dict, Final, FrozenSet, Tuple + +from sampletones_application.constants.sources import SettingsField +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName, ordered_channels +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + +SettingsReader = Callable[[StemSettings], FrozenSet[ChannelName]] +SettingsWriter = Callable[[StemSettings, FrozenSet[ChannelName]], StemSettings] +SettingsOffer = Callable[[StemSettings], FrozenSet[ChannelName]] + +ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) + + +def _channels_of(settings: StemSettings) -> FrozenSet[ChannelName]: + return settings.channel_set + + +def _with_channels( + settings: StemSettings, + channels: FrozenSet[ChannelName], +) -> StemSettings: + """The settings occupying ``channels``, keeping the bends that still reach one of them. + + A bend belongs to a channel the recording occupies, so narrowing the channels narrows the + bends along with them and one gesture leaves a value the model accepts. + """ + held = ordered_channels(channels) + return StemSettings(channels=held, bends=ordered_channels(settings.bend_set & channels)) + + +def _channels_offered(_settings: StemSettings) -> FrozenSet[ChannelName]: + return ALL_CHANNELS + + +def _bends_of(settings: StemSettings) -> FrozenSet[ChannelName]: + return settings.bend_set + + +def _bends_offered(settings: StemSettings) -> FrozenSet[ChannelName]: + """A bend belongs to a channel the recording occupies whose hardware reads one.""" + return settings.channel_set & TONE_CHANNELS + + +def _with_bends( + settings: StemSettings, + bends: FrozenSet[ChannelName], +) -> StemSettings: + """The settings bending ``bends``, holding each to a channel occupied whose hardware reads one.""" + reached = bends & settings.channel_set & TONE_CHANNELS + return StemSettings(channels=settings.channels, bends=ordered_channels(reached)) + + +@dataclass(frozen=True) +class SettingsSlot: + """One per-recording choice, in the form every reader of it works through. + + A slot states how the choice is read from a recording's settings, how a settled value is + written back, and which channels put it to a reader as those settings stand. The list, the + folder fold and the settings card all work through slots, so a further choice reaches each of + them as one more slot rather than as a field spelled out again in every layer. + """ + + field: SettingsField + read: SettingsReader + write: SettingsWriter + offered: SettingsOffer + + def offers(self, settings: StemSettings, channel_name: ChannelName) -> bool: + """Whether this choice is put to a reader on ``channel_name``, as ``settings`` stand.""" + return channel_name in self.offered(settings) + + def holds(self, settings: StemSettings, channel_name: ChannelName) -> bool: + """Whether ``settings`` makes this choice on ``channel_name``.""" + return channel_name in self.read(settings) + + def settled( + self, + settings: StemSettings, + channel_name: ChannelName, + held: bool, + ) -> StemSettings: + """The settings with ``channel_name`` settled in this slot.""" + reached = self.read(settings) + channels = reached | {channel_name} if held else reached - {channel_name} + return self.write(settings, frozenset(channels)) + + +CHANNEL_SLOT: Final[SettingsSlot] = SettingsSlot( + field=SettingsField.CHANNELS, + read=_channels_of, + write=_with_channels, + offered=_channels_offered, +) + +BEND_SLOT: Final[SettingsSlot] = SettingsSlot( + field=SettingsField.BENDS, + read=_bends_of, + write=_with_bends, + offered=_bends_offered, +) + +SETTINGS_SLOTS: Final[Tuple[SettingsSlot, ...]] = (CHANNEL_SLOT, BEND_SLOT) + +SLOTS_BY_FIELD: Final[Dict[SettingsField, SettingsSlot]] = {slot.field: slot for slot in SETTINGS_SLOTS} diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py deleted file mode 100644 index 236a77ff4..000000000 --- a/src/sampletones_application/logic/main/stems.py +++ /dev/null @@ -1,259 +0,0 @@ -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Callable, FrozenSet, List, Optional, Self, Sequence, Tuple - -from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry -from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy - -Level = Tuple["StemSource", ...] - - -@dataclass(frozen=True) -class StemSource: - """One recording in a stems conversion, together with the channels it may take.""" - - path: Path - channels: FrozenSet[ChannelName] - - def with_channels(self, channels: FrozenSet[ChannelName]) -> Self: - return replace(self, channels=channels) - - -@dataclass(frozen=True) -class ConversionSetup: - """What a stems conversion runs with: the recordings it mixes and the setup handing out channels. - - Both sides are built from one pass over the levels, so the entry ids the assignment records - name the recordings in the order the job mixes them. - """ - - sources: Tuple[Path, ...] - stems: StemsConfig - - -@dataclass(frozen=True) -class StemLevels: - """The recordings a stems conversion gathers, in the precedence levels they pick on. - - A level holds the recordings that compete on cost; the levels pick in the order they are - listed. Position within a level is the order the entries are numbered in, which is what - settles a tie between two equal-cost choices. Every gesture answers with a new value whose - levels are all occupied, so the bands a reader sees stay consecutive. - """ - - levels: Tuple[Level, ...] = () - - @classmethod - def of(cls, levels: Sequence[Sequence[StemSource]]) -> Self: - """Builds a value from levels given in any shape, leaving out the ones holding nothing.""" - return cls(levels=tuple(tuple(level) for level in levels if level)) - - @property - def sources(self) -> Tuple[StemSource, ...]: - """Every recording, in the order it is mixed and numbered.""" - return tuple(source for level in self.levels for source in level) - - @property - def paths(self) -> Tuple[Path, ...]: - return tuple(source.path for source in self.sources) - - @property - def count(self) -> int: - return len(self.sources) - - @property - def level_count(self) -> int: - return len(self.levels) - - def holds(self, path: Path) -> bool: - return any(source.path == path for source in self.sources) - - def level_of(self, path: Path) -> int: - """The level the recording picks on, counted from the first.""" - for level_index, level in enumerate(self.levels): - if any(source.path == path for source in level): - return level_index - - raise KeyError(f"{path} is not gathered in this conversion") - - def position_of(self, path: Path) -> int: - """The place the recording takes among the ones sharing its level.""" - level = self.levels[self.level_of(path)] - return next(index for index, source in enumerate(level) if source.path == path) - - def add(self, source: StemSource) -> Self: - """Gathers another recording, on the level the first recordings picked on.""" - if self.holds(source.path): - return self - - levels = self._mutable() - if not levels: - return self.of([[source]]) - - levels[0].append(source) - return self.of(levels) - - def remove(self, path: Path) -> Self: - """Lets a recording go, together with the level it emptied.""" - return self.of(self._without(path)) - - def replace_source(self, path: Path, change: Callable[[StemSource], StemSource]) -> Self: - """Rewrites one recording where it stands, leaving the rest of the setup as it is.""" - return self.of( - [[change(source) if source.path == path else source for source in level] for level in self.levels] - ) - - def keep_first(self) -> Self: - """Keeps the recording that picks first, which is the one a classic conversion carries.""" - sources = self.sources - return self.of([[sources[0]]]) if sources else self.of([]) - - def move_within_level(self, path: Path, offset: int) -> Self: - """Moves a recording past the neighbor it shares a level with, changing which of them ties first.""" - source = self._source(path) - if source is None: - return self - - level_index = self.level_of(path) - position = self.position_of(path) + offset - level = list(self.levels[level_index]) - if not 0 <= position < len(level): - return self - - level.remove(source) - level.insert(position, source) - levels = self._mutable() - levels[level_index] = level - return self.of(levels) - - def join_level(self, path: Path, offset: int) -> Self: - """Sends a recording to the neighboring level, where it picks with that level's recordings.""" - source = self._source(path) - if source is None: - return self - - target = self.level_of(path) + offset - if not 0 <= target < self.level_count: - return self - - levels = self._without(path) - levels[target].append(source) - return self.of(levels) - - def isolate(self, path: Path) -> Self: - """Gives a recording a level of its own, picking directly after the one it shared.""" - source = self._source(path) - if source is None or len(self.levels[self.level_of(path)]) == 1: - return self - - levels = self._without(path) - levels.insert(self.level_of(path) + 1, [source]) - return self.of(levels) - - def move_onto(self, path: Path, target_path: Path) -> Self: - """Moves a recording to the level and the place another one holds.""" - source = self._source(path) - if source is None or path == target_path or not self.holds(target_path): - return self - - levels = self._without(path) - for level in levels: - for position, candidate in enumerate(level): - if candidate.path == target_path: - level.insert(position, source) - return self.of(levels) - - return self - - def move_to_new_level(self, path: Path, position: int) -> Self: - """Gives a recording a level of its own, in the slot the levels are broken at. - - ``position`` counts the gaps a reader sees: zero is above the first level and the level - count is below the last, so the slot names itself the same way whichever level the - recording is leaving. - """ - source = self._source(path) - if source is None: - return self - - level_index = self.level_of(path) - levels = self._without(path) - target = position - if not levels[level_index]: - del levels[level_index] - target = position - 1 if position > level_index else position - if target == level_index: - return self - - levels.insert(target, [source]) - return self.of(levels) - - def _source(self, path: Path) -> Optional[StemSource]: - return next((source for source in self.sources if source.path == path), None) - - def _mutable(self) -> List[List[StemSource]]: - return [list(level) for level in self.levels] - - def _without(self, path: Path) -> List[List[StemSource]]: - """The levels with one recording taken out, keeping a level it emptied for the callers that count on it.""" - return [[source for source in level if source.path != path] for level in self.levels] - - -def effective_channels( - source: StemSource, - enabled_channels: Sequence[ChannelName], -) -> List[ChannelName]: - """The channels a source may take in the run being set up, in the order the run enables them. - - A source keeps whichever of its channels the configuration still enables. One left holding - none takes no part in the conversion, which is what unticking every channel of a row says. - """ - return [channel_name for channel_name in enabled_channels if channel_name in source.channels] - - -def derive_conversion_setup( - levels: StemLevels, - enabled_channels: Sequence[ChannelName], - *, - channel_cap: int, - hierarchy_mode: HierarchyMode, -) -> ConversionSetup: - """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. - - Recordings holding no enabled channel take no part, so they reach neither the mix nor the - entries. What remains is numbered in list order, which is the id the conversion records per - frame and a stem selection later reads back. - """ - taking_part = [ - [(source, effective_channels(source, enabled_channels)) for source in level] for level in levels.levels - ] - playing = [[pair for pair in level if pair[1]] for level in taking_part] - ordered = [pair for level in playing if level for pair in level] - - entries = [StemEntry(id=stem_id, channels=channels) for stem_id, (_source, channels) in enumerate(ordered)] - return ConversionSetup( - sources=tuple(source.path for source, _channels in ordered), - stems=StemsConfig( - entries=entries, - hierarchy=_hierarchy(playing, hierarchy_mode), - channel_cap=channel_cap, - ), - ) - - -def _hierarchy( - playing: Sequence[Sequence[Tuple[StemSource, List[ChannelName]]]], - hierarchy_mode: HierarchyMode, -) -> StemsHierarchy: - levels: List[List[int]] = [] - stem_id = 0 - for level in playing: - if not level: - continue - - levels.append([stem_id + offset for offset in range(len(level))]) - stem_id += len(level) - - return StemsHierarchy(levels=levels, mode=hierarchy_mode) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index be6b0aae0..95c43ca35 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -347,7 +347,7 @@ def _existing_row( """Reads a row even before its pattern has been created. An order position may reference an empty (uncreated) pattern; partial and - clear edits treat that as a blank row, and :meth:`set_row` materialises the + clear edits treat that as a blank row, and :meth:`set_row` materializes the pattern when it writes. """ pattern = self.song.pattern(channel, pattern_index) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py index 44ffafb69..15bcebd62 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -10,7 +10,7 @@ def collapse_single_child_containers(node: TreeNode) -> None: 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 + rows upward 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. diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py index 1bc61eef9..3f664b213 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py @@ -8,7 +8,7 @@ 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 + nothing and leaves. Working from the deepest rows upward 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. diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 03fbd1bcf..4bf7bb6dd 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -12,7 +12,7 @@ from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model from sampletones_shared.utils.system.paths import first_missing from sampletones_shared.utils.system.reveal.selection import open_paths_in_explorer diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 910b23939..cd7f4c196 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -14,6 +14,7 @@ import numpy as np from sampletones_application.config.managers.session import SessionManager +from sampletones_application.constants.sources import SourceKind from sampletones_application.logic.export.instrument.source import ExportableInstrument from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.manager import ReconstructionManager @@ -60,13 +61,7 @@ open_path_in_explorer, ) -EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel( - rows=(), - channels_in_play=(), - muted_channels=frozenset(), - live=True, - collapse_levels=False, -) +EMPTY_STEMS_LIST: Final[StemsListViewModel] = StemsListViewModel.empty() class ExportServiceProtocol(Protocol): @@ -357,12 +352,17 @@ def _build_stems_view_model( ) recordings = {entry.id: source_paths[index] for index, entry in enumerate(stems_data.config.entries)} + entries = stems_data.config.entries_by_id levels = stems_data.config.hierarchy.levels rows = tuple( StemRowViewModel( key=str(stem_id), + kind=SourceKind.RECORDING, path=recordings[stem_id], + held=(), channels=self._stem_channels.get(stem_id, frozenset()), + partial_channels=frozenset(), + bends=entries[stem_id].settings.bend_set, offered_channels=self._offered_stem_channels.get(stem_id, frozenset()), available=recordings[stem_id].is_file(), level=level_index, @@ -382,8 +382,11 @@ def _build_stems_view_model( rows=rows, channels_in_play=tuple(channels_in_play), muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels), + picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=False, + selected_key=None, ), hierarchy_mode=stems_data.config.hierarchy.mode, channel_cap=stems_data.config.channel_cap, diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py index 051f5e2ec..4789fa31c 100644 --- a/src/sampletones_application/logic/render/logic.py +++ b/src/sampletones_application/logic/render/logic.py @@ -68,7 +68,7 @@ def __init__( 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_canceling = language_manager["settings.render.message.status_canceling"] self._msg_canceled = language_manager["settings.render.message.status_canceled"] self._msg_completed = language_manager["settings.render.message.status_completed"] self._msg_failed = language_manager["settings.render.message.status_failed"] @@ -187,8 +187,8 @@ def cancel(self) -> None: if not self._service.is_running(): return - self._phase = RenderPhase.CANCELLING - self._status_text = self._msg_cancelling + self._phase = RenderPhase.CANCELING + self._status_text = self._msg_canceling self._emit_view() self._service.cancel() @@ -211,7 +211,7 @@ def _on_service_result(self, result: RenderResult) -> None: 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: + if self._phase == RenderPhase.CANCELING: return self._phase = RenderPhase.RENDERING diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py index 27c105706..f123000de 100644 --- a/src/sampletones_application/logic/sequencer/browser.py +++ b/src/sampletones_application/logic/sequencer/browser.py @@ -44,7 +44,7 @@ def add_reconstruction( ) -> Sample: """Adds an already-loaded reconstruction as a sample. - The sample embeds the reconstruction object and can be renamed afterwards + The sample embeds the reconstruction object and can be renamed afterward from the samples panel. """ return self._controller.add_sample(reconstruction, name=name) diff --git a/src/sampletones_application/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py index b7371316d..1cb2f796e 100644 --- a/src/sampletones_application/logic/sequencer/channels.py +++ b/src/sampletones_application/logic/sequencer/channels.py @@ -22,7 +22,7 @@ class SequencerChannelsLogic(CallbackMixin): silences the other three and remembers the set it replaced, so leaving the solo returns to the mix it interrupted. - The synthesiser reads :attr:`active_channels` on every rendered row, so a change is heard + The synthesizer reads :attr:`active_channels` on every rendered row, so a change is heard while playback continues. """ @@ -34,7 +34,7 @@ def __init__(self) -> None: @property def active_channels(self) -> FrozenSet[ChannelName]: - """The channels that sound, the mask the synthesiser mixes.""" + """The channels that sound, the mask the synthesizer mixes.""" return ALL_CHANNELS - self._muted def build_channels(self) -> SequencerChannelsViewModel: diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py index 37e50b531..18eba6002 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -8,9 +8,9 @@ class ChannelGeneratorProtocol(Protocol): """Minimal generator interface required by the synthesis engine. - Each NES channel's generator synthesises one tick of audio from an + Each NES channel's generator synthesizes one tick of audio from an instruction. The concrete type is generic (``Generator[InstructionT, - TimerT]``); this protocol captures only the surface the synthesiser + TimerT]``); this protocol captures only the surface the synthesizer actually uses so that invariant generic instantiations (e.g. ``PulseGenerator``) are accepted while keeping their precise generic types. @@ -18,7 +18,7 @@ class ChannelGeneratorProtocol(Protocol): pairing is a runtime invariant maintained by ``CHANNEL_CLASSES`` dispatch, which lies outside the static type system. - ``frame_length`` is settable so the synthesiser can give each tick the span its clock + ``frame_length`` is settable so the synthesizer 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. """ diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py index b256c6b06..b70e4a133 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py @@ -12,7 +12,7 @@ class EngineRates: 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. + tick spans, so the synthesizer follows both. Attributes: nes_frequency: The engine ticks consumed each second. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py index 9f757221d..d12d35ee9 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -7,7 +7,7 @@ @dataclass class ChannelState: - """One channel of the synthesiser: the voice filling its ticks, beside what it carries. + """One channel of the synthesizer: the voice filling its ticks, beside what it carries. The pattern state is the engine's own (:class:`~sampletones_core.performance.state.ChannelPerformance`), so what a channel is sounding, how far into it, and at what transpose and volume are read @@ -16,7 +16,7 @@ class ChannelState: over several rows keeps one continuous waveform. Attributes: - generator: The synthesiser filling the channel's ticks. + generator: The synthesizer filling the channel's ticks. performance: What the channel carries from row to row. """ diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 709633b18..f1c615ca4 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -440,7 +440,7 @@ def carried_voice( return carried def set_note_off(self, channel: ChannelName, row_index: int) -> None: - """Writes a note-off into one channel's cell, materialising the pattern if needed.""" + """Writes a note-off into one channel's cell, materializing the pattern if needed.""" self.set_row(channel, row_index, command=NoteOff()) def set_note_off_all_generators(self, row_index: int) -> None: @@ -588,7 +588,7 @@ def _pattern_index_at_frame(self, channel: ChannelName) -> Optional[int]: return None def _create_frame_pattern(self, channel: ChannelName) -> Optional[int]: - """Materialises a pattern for an empty slot at the current frame, on first edit. + """Materializes a pattern for an empty slot at the current frame, on first edit. Providing content to a channel whose current frame is an empty (None) slot creates a fresh pattern and assigns it to that order position, so the diff --git a/src/sampletones_application/logic/shared/file_playback.py b/src/sampletones_application/logic/shared/file_playback.py new file mode 100644 index 000000000..6a0f5ef29 --- /dev/null +++ b/src/sampletones_application/logic/shared/file_playback.py @@ -0,0 +1,56 @@ +from pathlib import Path +from typing import Callable, Optional + +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_core.audio import AudioDeviceManager +from sampletones_core.reconstructions import Reconstruction +from sampletones_shared.exceptions import SampleToNESError +from sampletones_shared.logger import logger +from sampletones_shared.paths import extensions +from sampletones_shared.utils.callbacks import CallbackMixin + + +class FilePlayback(CallbackMixin): + """Wherever a file is named and asked to sound, this is what answers. + + A reconstruction is read and its approximation played; an audio file is played from disk. The + browser's tree, a row in the converter's list and a menu item all name a file the same way, so + what a suffix means and what a failure to read one reports stand in one place. + """ + + def __init__(self, audio_device_manager: AudioDeviceManager) -> None: + self._audio_device_manager = audio_device_manager + + self.on_error: Optional[Callable[[Exception], None]] = None + + @staticmethod + def plays(path: Path) -> bool: + """Whether this is a file the player knows how to sound.""" + suffix = path.suffix.lower() + return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO + + def play(self, path: Path) -> None: + """Play a file on demand, preempting the auxiliary preview and the players. + + Asking for a file by name is a deliberate action, so it sounds at ``NORMAL`` priority and + outranks the reconstruction and sequencer players. + """ + self.play_at(path, PlaybackPriority.NORMAL) + + def play_at(self, path: Path, priority: PlaybackPriority) -> None: + """Play a file at the priority the gesture asking for it carries.""" + match path.suffix.lower(): + case extensions.EXT_FILE_RECONSTRUCTION: + self._play_reconstruction(path, priority) + case suffix if suffix in extensions.EXT_FILES_AUDIO: + self._audio_device_manager.play_file(path, update=False, priority=priority) + + def _play_reconstruction(self, path: Path, priority: PlaybackPriority) -> None: + try: + reconstruction = Reconstruction.load(path) + except (OSError, SampleToNESError) as exception: + logger.error_with_traceback(exception, f"Failed to play reconstruction file: {path}") + self.call(self.on_error, exception) + return + + self._audio_device_manager.play(reconstruction.approximation, update=False, priority=priority) diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index c42332c9a..bf7100d61 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -3,14 +3,10 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.logic.shared.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -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 @@ -19,12 +15,12 @@ class TreeLogic(CallbackMixin): def __init__( self, session_manager: SessionManager, - audio_device_manager: AudioDeviceManager, + file_playback: FilePlayback, *, scheduling: SchedulingBehavior, ) -> None: self._session_manager = session_manager - self._audio_device_manager = audio_device_manager + self._file_playback = file_playback self._scheduling = scheduling self._lock_counter: int = 0 @@ -37,7 +33,6 @@ def __init__( self.on_lock_state_changed: Optional[Callable[[bool], None]] = None self.on_favorite_changed: Optional[Callable[[FileSystemNode], None]] = None self.on_search_update_needed: Optional[VoidCallback] = None - self.on_autoplay_error: Optional[Callable[[Exception], None]] = None def lock(self) -> None: with self._thread_lock: @@ -89,21 +84,16 @@ def cancel_autoplay(self) -> None: self._pending_autoplay_node = None def play_node(self, node: FileSystemNode) -> None: - """Play a file on demand, preempting the auxiliary preview and the players. - - Unlike autoplay this ignores the session autoplay flag and uses ``NORMAL`` - priority: it is a deliberate user action, so it always plays and outranks the - reconstruction/sequencer players. - """ - self._play_file(node, PlaybackPriority.NORMAL) + """Play the file a browser node stands for, where it is one the player can sound.""" + if node.node_type == NodeType.FILE: + self._file_playback.play(node.filepath) def is_playable_file(self, node: TreeNode) -> bool: - """Whether the node is a file this logic knows how to play (reconstruction or audio).""" + """Whether the node is a file the player knows how to sound.""" if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return False - suffix = node.filepath.suffix.lower() - return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO + return FilePlayback.plays(node.filepath) def _execute_autoplay(self) -> None: if self._pending_autoplay_node is not None: @@ -111,34 +101,9 @@ def _execute_autoplay(self) -> None: self._pending_autoplay_node = None def _autoplay_file(self, node: FileSystemNode) -> None: - if self._session_manager.autoplay: - self._play_file(node, PlaybackPriority.PREVIEW) - - def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return - - match node.filepath.suffix.lower(): - case extensions.EXT_FILE_RECONSTRUCTION: - try: - reconstruction = Reconstruction.load(node.filepath) - self._audio_device_manager.play( - reconstruction.approximation, - update=False, - priority=priority, - ) - except (OSError, SampleToNESError) as exception: - logger.error_with_traceback( - exception, - f"Failed to play reconstruction file: {node.filepath}", - ) - self.call(self.on_autoplay_error, exception) - case suffix if suffix in extensions.EXT_FILES_AUDIO: - self._audio_device_manager.play_file( - node.filepath, - update=False, - priority=priority, - ) + """Sound what a selection selects, where the session says a selection sounds at all.""" + if self._session_manager.autoplay and node.node_type == NodeType.FILE: + self._file_playback.play_at(node.filepath, PlaybackPriority.PREVIEW) def is_node_favorite(self, node: TreeNode) -> bool: if not isinstance(node, FileSystemNode): diff --git a/src/sampletones_application/services/conversion/service.py b/src/sampletones_application/services/conversion/service.py index 9f34f648a..8127fd42e 100644 --- a/src/sampletones_application/services/conversion/service.py +++ b/src/sampletones_application/services/conversion/service.py @@ -93,7 +93,7 @@ def _on_progress( task_progress: TaskProgress, ) -> None: match task_status: - case TaskStatus.RUNNING | TaskStatus.CANCELLING: + case TaskStatus.RUNNING | TaskStatus.CANCELING: self._emit( ServiceProgress( completed=task_progress.completed, diff --git a/src/sampletones_application/services/export/error.py b/src/sampletones_application/services/export/error.py index bc44bdb5f..761cf57f8 100644 --- a/src/sampletones_application/services/export/error.py +++ b/src/sampletones_application/services/export/error.py @@ -10,7 +10,7 @@ class ExportError: """A failed export, carrying the exception the result dialog reports. Attributes: - kind: The artefact the run set out to produce. + kind: The artifact the run set out to produce. export_format: The format the run set out to write, and ``None`` for an audio export. exception: The failure raised while writing. """ diff --git a/src/sampletones_application/services/export/kind.py b/src/sampletones_application/services/export/kind.py index 5d65376c1..ae795b774 100644 --- a/src/sampletones_application/services/export/kind.py +++ b/src/sampletones_application/services/export/kind.py @@ -2,7 +2,7 @@ class ExportKind(str, Enum): - """The artefact one export run produced, naming the dialog that reports it.""" + """The artifact one export run produced, naming the dialog that reports it.""" WAV = "wav" INSTRUMENT = "instrument" diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index 2ffc23402..a837f3c4b 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -150,7 +150,7 @@ def _submit( slices rather than the destination itself. Args: - kind: The artefact the run produces, naming the dialog that reports it. + kind: The artifact the run produces, naming the dialog that reports it. destination: The destination the run was given. export_format: The format the run writes, carried through to the result, and ``None`` for an audio export. diff --git a/src/sampletones_application/services/export/success.py b/src/sampletones_application/services/export/success.py index 5864f082f..aeb016250 100644 --- a/src/sampletones_application/services/export/success.py +++ b/src/sampletones_application/services/export/success.py @@ -12,7 +12,7 @@ class ExportSuccess: """A completed export, with the path it wrote and what the file kept. Attributes: - kind: The artefact the run produced. + kind: The artifact the run produced. filepath: A file the run wrote, which a batch reports as the first of its slices. export_format: The format the run wrote, and ``None`` for an audio export. truncation: What the target format's item limit left out, and ``None`` when diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py index 67eaf113a..a4f195b6c 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -25,13 +25,13 @@ 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 + The synthesizer 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 honored between rows and between encoded blocks, + A render is one at a time. Canceling is honored between rows and between encoded blocks, and the file a canceled or failed run was writing is removed, so a result names a path only where a finished file stands. """ diff --git a/src/sampletones_application/services/render/sink.py b/src/sampletones_application/services/render/sink.py index 4f46dae3a..018888cda 100644 --- a/src/sampletones_application/services/render/sink.py +++ b/src/sampletones_application/services/render/sink.py @@ -23,7 +23,7 @@ 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. + they are synthesized, 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. @@ -50,9 +50,9 @@ def discard(self) -> None: ... class DirectRenderSink: - """Writes each row to the destination as it is synthesised. + """Writes each row to the destination as it is synthesized. - One pass over the song, at the level the synthesiser produced: the encoder receives a row as + One pass over the song, at the level the synthesizer 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. """ @@ -101,7 +101,7 @@ def discard(self) -> None: 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 + The loudest sample is known only once the last row is synthesized, 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. diff --git a/src/sampletones_application/services/song_player/service.py b/src/sampletones_application/services/song_player/service.py index e4e5351b4..d58682587 100644 --- a/src/sampletones_application/services/song_player/service.py +++ b/src/sampletones_application/services/song_player/service.py @@ -137,7 +137,7 @@ def resume(self) -> None: def seek(self, order_position: int) -> None: """Moves the live playhead to another order while playback continues. - This keeps the synthesiser's state (where ``start`` resets it), so voices sounding at the + This keeps the synthesizer's state (where ``start`` resets it), so voices sounding at the moment of the move carry over to the new order — the playhead jumps while the audio plays on. Rows already buffered ahead play out first, so the jump lands within one look-ahead window. """ @@ -151,7 +151,7 @@ def relocate(self, order_position: int) -> None: Used to follow a structural order edit (insert/remove/move) so playback stays on the frame it was sounding: the row within the frame continues from where it was, and - voices carry over (the synthesiser keeps its state). + voices carry over (the synthesizer keeps its state). """ if not self.alive: return diff --git a/src/sampletones_application/services/synthesis/protocol.py b/src/sampletones_application/services/synthesis/protocol.py index 7b257f702..56aa43161 100644 --- a/src/sampletones_application/services/synthesis/protocol.py +++ b/src/sampletones_application/services/synthesis/protocol.py @@ -8,7 +8,7 @@ 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 + This is the input contract every consumer of a song's audio takes; the concrete synthesizer 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. diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 51ade2c2d..ffda1c087 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -38,6 +38,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.callbacks import hold_callbacks from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -172,6 +173,7 @@ def setup( initial_menu_state: MenuBarViewModel, ) -> None: dpg.create_context() + hold_callbacks() self._set_fonts() self._set_textures() self._register_shortcuts(bindings) diff --git a/src/sampletones_application/tags/compose.py b/src/sampletones_application/tags/compose.py index d30b33093..e6a40331d 100644 --- a/src/sampletones_application/tags/compose.py +++ b/src/sampletones_application/tags/compose.py @@ -1,7 +1,10 @@ import re from typing import Final +from sampletones_shared.utils.hashing import identity_digest + TAG_SEPARATOR: Final[str] = "." +TAG_DIGEST_LENGTH: Final[int] = 8 _WHITESPACE: Final[re.Pattern[str]] = re.compile(r"\s+") @@ -36,3 +39,20 @@ def compose_tag(*parts: str) -> str: raise ValueError("A tag needs at least one part") return TAG_SEPARATOR.join(_normalize_segment(part) for part in parts) + + +def identity_part(*parts: str) -> str: + """Composes the tag part standing for one identity, whatever text its name normalizes to. + + A tag part built from a runtime name arrives lowercased with its whitespace runs collapsed, so + two names that differ only in case or spacing spell the same segment. Adding this part beside + the name keeps each identity on a widget of its own, and leaves the name itself in the tag for + whoever reads a DearPyGui error. + + Args: + *parts: The pieces the identity is spelled in, in the order they belong. + + Returns: + str: A digest of the identity, short enough to read beside the name it stands for. + """ + return identity_digest(*parts, length=TAG_DIGEST_LENGTH) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 996f1212d..53c50e515 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -266,12 +266,54 @@ Widget.THEME, "channel_muted", ) +TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_pulse1_partial", +) +TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_pulse2_partial", +) +TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_triangle_partial", +) +TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "channel_noise_partial", +) TAG_GLOBAL_THEME_STEMS_DROP_STRIP = TagName( Page.GLOBAL, Panel.IMPLICIT, Widget.THEME, "stems_drop_strip", ) +TAG_GLOBAL_THEME_STEMS_GRID = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_grid", +) +TAG_GLOBAL_THEME_STEMS_MARKER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_marker", +) +TAG_GLOBAL_THEME_STEMS_GROUP_ROW = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_group_row", +) TAG_GLOBAL_THEME_STEMS_ROW = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -284,6 +326,24 @@ Widget.THEME, "stems_row_inert", ) +TAG_GLOBAL_THEME_STEMS_PICK = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_pick", +) +TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_pick_partial", +) +TAG_GLOBAL_THEME_STEMS_SLOT_LABEL = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_slot_label", +) TAG_GLOBAL_THEME_TOOLTIP = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -765,8 +825,10 @@ 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") +SUF_BENDS = "bends" SUF_CHANNELS = "channels" SUF_GROUP = "group" +SUF_HEADING = "heading" SUF_GROUP_TRACEBACK = compose_tag(SUF_GROUP, "traceback") SUF_HANDLER_REGISTRY = compose_tag("handler", "registry") SUF_HANDLER_STATUS = compose_tag("handler", "status") @@ -776,6 +838,7 @@ SUF_HANDLER_DRAG = compose_tag("handler", "drag") SUF_HANDLER_LIST = compose_tag("handler", "list") SUF_LABEL = "label" +SUF_LEAD = "lead" SUF_PATH = "path" SUF_TEXT = "text" SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites") @@ -785,7 +848,10 @@ SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") SUF_STRIP = "strip" SUF_TABLE = "table" +SUF_TABLE_COLUMN = compose_tag(SUF_TABLE, "column") +SUF_TABLE_GAP = compose_tag(SUF_TABLE, "gap") SUF_TOOLTIP = "tooltip" +SUF_TWISTY = "twisty" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") SUF_DIALOG_INFO = compose_tag("dialog", "info") SUF_PANEL_LEFT = compose_tag("panel", "left") @@ -795,7 +861,11 @@ SUF_COLLAPSE_BODY = compose_tag("collapse", "body") SUF_COLLAPSE_RAIL = compose_tag("collapse", "rail") SUF_COLLAPSE_CHEVRON = compose_tag("collapse", "chevron") +SUF_FOLDER = "folder" +SUF_REGION = "region" SUF_ROW = "row" +SUF_SPACER_ABOVE = compose_tag("spacer", "above") +SUF_SPACER_BELOW = compose_tag("spacer", "below") SUF_LEVEL = "level" SUF_WELL = "well" SUF_PAYLOAD = "payload" diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 71650ce53..5d33914a4 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -14,11 +14,11 @@ Widget.TABLE, "config_row", ) -TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL = TagName( +TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.ADVANCED, Widget.PANEL, - "reconstructor_cell", + "advanced_cell", ) TAG_MAIN_EXPLORER_TREE = TagName( Page.MAIN, @@ -50,6 +50,12 @@ Widget.GROUP, "controls", ) +TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW = TagName( + Page.MAIN, + Panel.EXPLORER, + Widget.DIALOG, + "nothing_below", +) TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING = TagName( Page.MAIN, Panel.EXPLORER, @@ -92,15 +98,15 @@ Widget.INPUT, "nes_frequency", ) -TAG_MAIN_RECONSTRUCTOR_PANEL = TagName( +TAG_MAIN_SOURCE_PANEL = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.PANEL, - "reconstructor", + "source", ) -TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE = TagName( +TAG_MAIN_SOURCE_SLIDER_DRIVE = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.SLIDER, "drive", ) @@ -236,25 +242,55 @@ Widget.GROUP, "summary", ) -TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT = TagName( + +PRE_MAIN_SOURCE_SLOT = "slot" +TAG_MAIN_SOURCE_TEXT_INSPECTING = TagName( Page.MAIN, - Panel.CONVERTER, + Panel.SOURCE, Widget.TEXT, - "summary_hint", + "inspecting", +) +TAG_MAIN_SOURCE_TEXT_UNPICKED = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.TEXT, + "unpicked", +) +TAG_MAIN_SOURCE_GROUP_GRID = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.GROUP, + "grid", +) +TAG_MAIN_SOURCE_TABLE_GRID = TagName( + Page.MAIN, + Panel.SOURCE, + Widget.TABLE, + "grid", ) - -PRE_MAIN_RECONSTRUCTOR_CHANNEL = "channel" TAG_MAIN_CONVERTER_GROUP_CONTROLS = TagName( Page.MAIN, Panel.CONVERTER, Widget.GROUP, "controls", ) -TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE = TagName( +TAG_MAIN_CONVERTER_RADIO_MODE = TagName( Page.MAIN, Panel.CONVERTER, - Widget.CHECKBOX, - "stems_mode", + Widget.RADIO, + "mode", +) +TAG_MAIN_CONVERTER_GROUP_ORDER = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "order", +) +TAG_MAIN_CONVERTER_GROUP_INPUT = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.GROUP, + "input", ) TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP = TagName( Page.MAIN, @@ -268,11 +304,11 @@ Widget.COMBO, "hierarchy_mode", ) -TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE = TagName( +TAG_MAIN_CONVERTER_TOOLTIP_MODE = TagName( Page.MAIN, Panel.CONVERTER, Widget.TOOLTIP, - "stems_mode", + "mode", ) TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP = TagName( Page.MAIN, @@ -310,6 +346,30 @@ Widget.DIALOG, "overwrite_target", ) +TAG_MAIN_CONVERTER_WINDOW_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.WINDOW, + "scan", +) +TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.TEXT, + "scan_folder", +) +TAG_MAIN_CONVERTER_PROGRESS_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.PROGRESS, + "scan", +) +TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN = TagName( + Page.MAIN, + Panel.CONVERTER, + Widget.BUTTON, + "stop_scan", +) TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION = TagName( Page.MAIN, Panel.CONVERTER, diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index ca1710623..51bdda230 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -36,6 +36,8 @@ def add_play_menu_item( label: str, on_play: VoidCallback, shortcut: str = "", + *, + enabled: bool = True, ) -> None: """Add the shared "Play" context-menu item; the caller supplies the play action. @@ -44,12 +46,14 @@ def add_play_menu_item( them all through one builder keeps the item from drifting apart across panels. ``shortcut`` is shown as the item's accelerator hint when the action has a bound key, - and left blank for sources reached only by clicking. + and left blank for sources reached only by clicking. ``enabled`` states that the source is + there to be heard, which a caller listing files answers from the disk. """ dpg.add_menu_item( label=label, shortcut=shortcut, callback=on_play, + enabled=enabled, ) diff --git a/src/sampletones_application/ui/elements/dialog.py b/src/sampletones_application/ui/elements/dialog.py index 4b29db792..c7a6bd445 100644 --- a/src/sampletones_application/ui/elements/dialog.py +++ b/src/sampletones_application/ui/elements/dialog.py @@ -57,7 +57,7 @@ def _install_navigation( Args: stops: The controls the focus ring cycles, in reading order. - on_escape: What cancelling this dialog means. + on_escape: What canceling this dialog means. initial_index: The stop focus opens on, which points a prompt at the answer it expects. """ self._navigator = DialogKeyboardNavigator( diff --git a/src/sampletones_application/ui/elements/layout/columns.py b/src/sampletones_application/ui/elements/layout/columns.py index 85ba26481..9e9d43db6 100644 --- a/src/sampletones_application/ui/elements/layout/columns.py +++ b/src/sampletones_application/ui/elements/layout/columns.py @@ -1,13 +1,22 @@ from dataclasses import dataclass -from typing import Optional, Sequence +from typing import AbstractSet, Final, Optional, Sequence import dearpygui.dearpygui as dpg -from sampletones_application.tags.general import TAG_GLOBAL_THEME_PANEL_GROUND +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_TABLE_COLUMN, + SUF_TABLE_GAP, + TAG_GLOBAL_THEME_PANEL_GROUND, +) from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import StringCallback +_STRETCH_WEIGHT: Final[float] = 1.0 +_PUT_AWAY: Final[float] = 0.0 + @dataclass(frozen=True) class ColumnSpec: @@ -33,6 +42,11 @@ def stretches(self) -> bool: """Whether the column expands to absorb the space the fixed columns leave.""" return self.width == 0 + @property + def declared_size(self) -> float: + """The share a stretching column takes of what is left, or the width a fixed one holds.""" + return _STRETCH_WEIGHT if self.stretches else float(self.width) + class TabColumns: """The shared scaffold every tab lays its panels out on. @@ -118,6 +132,36 @@ def row( cls._bind_column_themes(columns) + @classmethod + def stand_columns( + cls, + columns: Sequence[ColumnSpec], + standing: AbstractSet[str], + panel_gap: int, + ) -> None: + """Divides a row built by :meth:`row` among the columns in ``standing``. + + A card the reader puts away leaves its column with nothing to hold, so the column drops to + no width and the ones still standing divide the whole row between them. A gap holds its + width where a column stands on each side of it, so what is left sits flush to the row's + edges and keeps one gap between neighbors. A column comes back at the size it was declared + with. + """ + preceded = False + for index, column in enumerate(columns): + stands = column.tag in standing + dpg_configure_item( + compose_tag(column.tag, SUF_TABLE_COLUMN), + init_width_or_weight=column.declared_size if stands else _PUT_AWAY, + ) + if index > 0: + dpg_configure_item( + compose_tag(column.tag, SUF_TABLE_GAP), + init_width_or_weight=panel_gap if stands and preceded else _PUT_AWAY, + ) + + preceded = preceded or stands + @staticmethod def _bind_column_themes(columns: Sequence[ColumnSpec]) -> None: """Binds each column's declared depth theme, leaving card-hosting columns to their cards.""" @@ -149,20 +193,27 @@ def _declare_row_columns( panel_gap: int, columns: Sequence[ColumnSpec], ) -> None: - """Declares each content column with a fixed gap column between neighbors only.""" + """Declares each content column at the size it states, with a fixed gap between neighbors. + + A stretching column takes an explicit share rather than one read back from the card inside + it, so the row keeps the proportions it was declared with whatever its cards draw. Each + column and each gap is named after the cell it serves, which is how :meth:`stand_columns` + reaches them once the row is standing. + """ for index, column in enumerate(columns): if index > 0: dpg.add_table_column( width_fixed=True, init_width_or_weight=panel_gap, + tag=compose_tag(column.tag, SUF_TABLE_GAP), ) - if column.stretches: - dpg.add_table_column() - else: - dpg.add_table_column( - width_fixed=True, - init_width_or_weight=column.width, - ) + + dpg.add_table_column( + width_stretch=column.stretches, + width_fixed=not column.stretches, + init_width_or_weight=column.declared_size, + tag=compose_tag(column.tag, SUF_TABLE_COLUMN), + ) @staticmethod def _build_column(column: ColumnSpec) -> None: diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py new file mode 100644 index 000000000..fafb7e5dc --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from typing import Final, Self, Tuple + +Window = Tuple[int, int] + +GEOMETRY_TOLERANCE: Final[float] = 1.0 +UNMEASURED: Final[float] = 0.0 +ONE_ROW: Final[int] = 1 +MINIMUM_ROW_PITCH: Final[float] = 8.0 + + +@dataclass +class RowGeometry: + """The room one row takes, read from the rows a list has drawn. + + A region showing part of a long list decides two things from this: how many rows its height + holds, and which of them a scroll position reaches. Both are properties of the theme and the + font a row is drawn under rather than of any one list, so the reading is taken from whatever + rows have already been placed and shared by every region drawing rows of that shape. A folder + opens knowing what a row takes because the list the folder stands in measured it. + + A geometry that has yet to read anything works from ``opening`` — the room the layout says a + row takes — so a first draw builds about the rows it shows, and the reading taken from them + settles it from the next frame on. + + ``overscan`` is how many rows stand beyond each edge of what a region shows, so a scroll in + either direction meets rows that are already there. + """ + + overscan: int + pitch: float + opening: float + + @classmethod + def opening_at(cls, *, overscan: int, opening: float) -> Self: + """The reading a list starts from, which the height its layout gives a row opens.""" + return cls(overscan=overscan, pitch=UNMEASURED, opening=max(MINIMUM_ROW_PITCH, opening)) + + @property + def measured(self) -> bool: + """Whether a reading has been taken, which is what a region's own room is held to.""" + return self.pitch > UNMEASURED + + @property + def room(self) -> float: + """The room one row is worked from: what was read, or what the layout opens it at. + + ``MINIMUM_ROW_PITCH`` is the floor an opening is held to, so a window taken before + anything has been measured covers a region rather than a sliver of one. + """ + return self.pitch if self.measured else self.opening + + def size(self, height: float) -> int: + """How many rows a window over a region of this height holds, overscan included.""" + showing = max(ONE_ROW, int(height / self.room) + ONE_ROW) + return showing + 2 * self.overscan + + def windows(self, *, height: float, total: int) -> bool: + """Whether a list of this length outgrows the region, which is what asks for a window.""" + return total > self.size(height) + + def slice_of(self, *, offset: float, height: float, total: int) -> Window: + """The rows a scroll position reaches: where the window opens, and how many it holds. + + The window opens where the offset stands counted in the same rooms the undrawn rows are + reserved in, so the block a region builds covers the position it was chosen for whatever + the reading of a row stands at. The overscan above it is what a scroll back the way it + came meets, and the end of the list is what a region scrolled past its last window holds. + """ + if not self.windows(height=height, total=total): + return (0, total) + + count = self.size(height) + reached = int(offset / self.room) - self.overscan + return (max(0, min(reached, total - count)), count) + + def reserve(self, rows: int) -> int: + """The room a number of rows takes, which stands in place of the ones left undrawn.""" + return int(rows * self.room) + + def take(self, *, block: float, rows: int) -> bool: + """Read what a row takes from a block of drawn rows, reporting a reading worth redrawing. + + The block is measured whole rather than row by row, so whatever a table lays around its + rows is carried by the same number that reserves room for them. A move worth a pixel is + worth drawing again. + + A block giving less than ``MINIMUM_ROW_PITCH`` a row was measured while its rows stood + clipped or unplaced, since no theme draws a row that small. The reading already taken is + kept, and the answer is that nothing moved. + """ + if rows <= 0: + return False + + pitch = block / rows + if pitch < MINIMUM_ROW_PITCH: + return False + + moved = abs(pitch - self.pitch) > GEOMETRY_TOLERANCE + self.pitch = pitch + return moved diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py new file mode 100644 index 000000000..190abc465 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -0,0 +1,376 @@ +from typing import Callable, Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_LEAD, + SUF_SPACER_ABOVE, + SUF_SPACER_BELOW, +) +from sampletones_application.ui.elements.layout.geometry import RowGeometry, Window +from sampletones_application.ui.elements.layout.well import well +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_children +from sampletones_shared.types.callback import VoidCallback + +SliceBuilder = Callable[[int, int], None] +LeadBuilder = Callable[[str], None] + +NO_ROWS: Final[Window] = (0, 0) +NO_TOTAL: Final[int] = 0 +NO_GUTTER: Final[int] = 0 +NO_MARGIN: Final[int] = 0 +NO_LEAD: Final[float] = 0.0 +AUTO_HEIGHT: Final[int] = 0 +NO_SCROLL: Final[float] = 0.0 +SCROLL_TOLERANCE: Final[float] = 1.0 + + +class WindowedRegion: + """A recessed region that builds the rows it shows and reserves the room for the rest. + + A region sizes itself to what it holds up to a ceiling, and scrolls from there on, so the + card around it keeps its shape however long the list grows. Within it, only the rows the + reader can reach are built; the ones above and below stand as reserved room, which keeps the + scrollbar proportional to the whole list and makes drawing a list of thousands cost what + drawing a list of ten costs. + + A region may carry a **lead** — a heading standing above its rows, built and taken down with + them and scrolling with them. Its room counts toward the height the content asks for, so the + travel a window is mapped over covers the whole of what the region holds. + + The region owns every quantity the window is chosen by: the room it reserved, because it + placed it, and the height it holds, because it set it. So a caller draws and then settles, + and there is one order for the two. ``gutter`` is the room a scrollbar takes, which the region + holds clear at the right of its body while it stands without one, so what it holds keeps one + width across the moment it starts scrolling. + + The height a run of rows asks for is worked out from the reading of a row rather than read off + the widgets, since a region already held to its ceiling clips what it holds and would measure + its own ceiling back. A reading is therefore taken only while the region stands at the height + of what it holds, which :attr:`natural` reports. A reading arrives after the height it decides + has been set, so :attr:`settling` asks for the pass that holds the region to it. + + A region redrawn because the reader scrolled leaves the scroll where they put it. The rows it + holds change while the region itself stands, so a position written back would land against the + wheel that asked for the new rows and take the reader somewhere they never scrolled. A region + built in place of one a rebuild took down is a new widget standing at its top, so where the + reader had scrolled the one before it is named by :meth:`opens_at`, and handed back once the + frame that placed its rows has been drawn. + """ + + def __init__( + self, + *, + tag: str, + geometry: RowGeometry, + ceiling: int, + padding: int, + margin: int, + gutter: int, + indent: Optional[int] = None, + ) -> None: + self._tag = tag + self._geometry = geometry + self._ceiling = ceiling + self._padding = padding + self._margin = margin + self._gutter = gutter + self._indent = indent + self._above_tag = compose_tag(tag, SUF_SPACER_ABOVE) + self._below_tag = compose_tag(tag, SUF_SPACER_BELOW) + self._lead_tag = compose_tag(tag, SUF_LEAD) + self._body_tag = "" + self._height = float(ceiling) + self._lead = NO_LEAD + self._total = 0 + self._windowed = False + self._natural = True + self._reading_to_hold = False + self._resized = False + self._drawn: Window = NO_ROWS + self._resting = NO_SCROLL + self._restoring = False + + @property + def tag(self) -> str: + """The region itself, which is what an owner shows, hides and scrolls.""" + return self._tag + + @property + def body(self) -> str: + """The group the rows are built into, which a draw empties and fills.""" + return self._body_tag + + @property + def window(self) -> Window: + """The slice the region was last drawn from: where it opens, and how many rows it holds.""" + return self._drawn + + @property + def windowing(self) -> bool: + """The region holds back rows it has no room for, so a scroll asks it for different ones.""" + return self._windowed and self._drawn[1] < self._total + + @property + def standing(self) -> bool: + """The region's body is on screen, which is what a draw fills and a settle reads.""" + return bool(dpg.does_item_exist(self._body_tag)) + + @property + def settling(self) -> bool: + """The region stands as something other than it will, so whoever drew it settles it again. + + A region holding rows back answers a scroll with a different slice, and one that has + just read what a row takes holds itself to that reading in the pass that follows. A region + that has just taken a new height stands at its old one until the frame after, and whatever + it is drawn inside measures it as it stands, so that too asks for another pass. Either way + what stands now is not what the region comes to rest as. A region whose body has been taken + down comes to rest where it is, so a window closing ends the pass it was keeping. + """ + return self.standing and (self.windowing or self._reading_to_hold or self._resized) + + @property + def natural(self) -> bool: + """The region stands at the height of what it holds, so what it holds measures true.""" + return self._natural + + @property + def offset(self) -> float: + """How far the region has been scrolled, read from the region itself.""" + return self._scroll(dpg.get_y_scroll) + + def opens_at(self, offset: float) -> None: + """Open the region where the reader had scrolled the one it stands in place of. + + A region goes down with the list around it and comes back a new widget at its top, so the + rows the reader was looking at are named by whoever held on to the position. + """ + self._resting = offset + self._restoring = True + + def create(self, parent: str, *, show: bool = True) -> None: + """Sink the region into ``parent``, sized to its rows until they reach its ceiling.""" + self._body_tag = well( + parent, + self._tag, + padding=self._padding, + margin=self._margin, + width=self._body_width(scrolling=False), + indent=self._indent, + show=show, + ) + + def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) -> None: + """Build the rows the region reaches, reserving the room the ones outside it would take. + + ``build`` is handed where the window opens and how many rows it holds, and adds them to + :attr:`body` between the two reserves. ``lead`` builds the heading standing above them, + into the group it is handed. The rows are chosen for where the reader stands, which is a + position they scrolled to themselves unless the region is opening in place of another. + + Before a row has been measured the region builds a first slice at its natural height and + reserves nothing, which is what gives :meth:`settle` a run of rows to read. + """ + if not self.standing: + return + + start, count = self._slice(self._reading, total) + dpg_delete_children(self._body_tag) + measuring = not self._geometry.measured + self._build_lead(lead) + self._reserve(self._above_tag, 0 if measuring else start) + build(start, count) + self._reserve(self._below_tag, 0 if measuring else total - start - count) + self._total = total + self._windowed = True + self._drawn = (start, count) + + def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder]) -> None: + """Build the region's contents entire, for content that is more than a run of rows. + + A region holding captions, strips or regions of its own has no one row to reserve room by, + so it stands as tall as what it holds and scrolls once that reaches its ceiling. What a row + takes is read from the runs of rows a region draws windows over, which is where a block of + one height stands on its own. + """ + if not self.standing: + return + + dpg_delete_children(self._body_tag) + self._build_lead(lead) + build() + self._total = NO_TOTAL + self._windowed = False + self._drawn = NO_ROWS + + def settle(self) -> bool: + """Size the region to what it holds and read what a row takes, a frame after a draw. + + Answers whether the rows standing are still the ones the region reaches, which is what + asks an owner to draw it again. A region whose body has been taken down asks for nothing. + """ + if not self.standing: + return False + + self._resized = False + self._take_lead() + if not self._windowed: + self._take_reading() + self._hold_content() + return self._restore() + + if not self._geometry.measured: + self._stand_at_natural_height() + self._reading_to_hold = self._take_reading() + return self._reading_to_hold + + self._hold_rows() + if self._restore(): + return False + + return self._slice(self.offset, self._total) != self._drawn + + @property + def _reading(self) -> float: + """The position the rows are chosen for: where the reader stands, or where they go back to.""" + return self._resting if self._restoring else self.offset + + def _restore(self) -> bool: + """Put the reader back where they stood, once the frame that placed the rows has drawn. + + Answers whether a scroll was written, since the frame that carries it out is the one whose + position the window is chosen from. + """ + if not self._restoring: + return False + + self._restoring = False + if abs(self.offset - self._resting) <= SCROLL_TOLERANCE: + return False + + dpg.set_y_scroll(self._tag, self._resting) + return True + + def _slice(self, offset: float, total: int) -> Window: + """The rows the region's scroll position reaches, in the list it is a window onto. + + The position is counted in the rooms the region reserves by, which is what the spacer + above the rows is built from, so the block stands over what the reader is looking at. + """ + return self._geometry.slice_of(offset=offset, height=self._height, total=total) + + def _build_lead(self, lead: Optional[LeadBuilder]) -> None: + """Open the group the heading stands in, and let its owner fill it.""" + if lead is None: + return + + dpg.add_group(tag=self._lead_tag, parent=self._body_tag) + lead(self._lead_tag) + + def _reserve(self, tag: str, rows: int) -> None: + """The room a run of undrawn rows would take, standing in place of them.""" + dpg.add_spacer(tag=tag, parent=self._body_tag, height=self._geometry.reserve(rows)) + + def _take_lead(self) -> None: + """Read the room the heading takes, which the room the whole content asks for counts in.""" + if not self._natural or not dpg.does_item_exist(self._lead_tag): + return + + self._lead = float(dpg.get_item_rect_size(self._lead_tag)[1]) + + def _take_reading(self) -> bool: + """Read what one row takes from the rows standing, and report a reading worth redrawing. + + The rows are counted off a block measured whole, so the reading holds whatever a table + lays around them. It is taken while the region stands at its natural height, which is + where the block measures the room its rows asked for. + """ + if not self._natural: + return False + + return self._geometry.take(block=self._body_height() - self._lead, rows=self._standing) + + @property + def _standing(self) -> int: + """How many rows of one height stand in the region, which a reading counts by. + + A windowed region reserves nothing while it measures, so the block it stands as is the + slice it drew; a whole-drawn one stands as every row it was given. + """ + return self._drawn[1] if self._windowed else self._total + + def _hold_rows(self) -> None: + """Size the region to the room its rows ask for, holding it at its ceiling from there on.""" + self._size_to(self._room_for(self._total)) + + def _hold_content(self) -> None: + """Size a whole-drawn region to what it holds, holding it at its ceiling from there on. + + What it holds is measured rather than worked out, since content that is more than a run of + rows has no one row to count by. A region already at its ceiling measures at least its own + height, which answers the ceiling the way an exact reading would. + """ + self._size_to(self._body_height() + 2 * self._margin) + + def _room_for(self, total: int) -> float: + """The room a list of this length asks for: the heading, the rows, and the margins.""" + return float(self._lead + self._geometry.reserve(total) + 2 * self._margin) + + def _stand_at_natural_height(self) -> None: + """Let the region take the height of what it holds, which is what a reading is read from.""" + self._take_height(self._body_height() + 2 * self._margin) + self._natural = True + dpg_configure_item(self._tag, height=AUTO_HEIGHT, auto_resize_y=True, no_scrollbar=True) + self._hold_gutter(scrolling=False) + + def _take_height(self, height: float) -> None: + """Stand at a height, noting whether it is one the region has yet to be drawn at. + + Every height the region takes is taken here, so one it has just moved to asks for the pass + that reads it back however the region came by it. + """ + self._resized = height != self._height + self._height = height + + def _size_to(self, content: float) -> None: + within = content <= self._ceiling + self._take_height(content if within else float(self._ceiling)) + self._natural = within + self._reading_to_hold = False + dpg_configure_item( + self._tag, + height=AUTO_HEIGHT if within else self._ceiling, + auto_resize_y=within, + no_scrollbar=within, + ) + self._hold_gutter(scrolling=not within) + + def _hold_gutter(self, *, scrolling: bool) -> None: + """Keep the body one width, whether the room at its right is a scrollbar or the gutter. + + A region past its ceiling draws a scrollbar, which takes that room out of the width the + body is measured against; one within its ceiling holds the same room as a gutter. So the + columns inside a region stand where they stand however long the list it holds grows. + """ + dpg_configure_item(self._body_tag, width=self._body_width(scrolling=scrolling)) + + def _body_width(self, *, scrolling: bool) -> int: + """What the body is drawn at: its own room, less the padding and whatever stands at its right. + + A scrollbar and the gutter take the same room, so the body comes out one width either way. + """ + return -(self._padding + (NO_GUTTER if scrolling else self._gutter)) + + def _body_height(self) -> float: + """How tall the rows drawn into the region stand, as the frame that placed them left them.""" + if not dpg.does_item_exist(self._body_tag): + return 0.0 + + return float(dpg.get_item_rect_size(self._body_tag)[1]) + + def _scroll(self, read: Callable[[str], float]) -> float: + if not dpg.does_item_exist(self._tag): + return 0.0 + + return float(read(self._tag)) diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index c83d88c68..30e9d97c4 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -1,3 +1,5 @@ +from typing import Optional + import dearpygui.dearpygui as dpg from sampletones_application.tags.compose import compose_tag @@ -14,34 +16,42 @@ def well( *, padding: int, margin: int, - height: int = 0, + width: int, + indent: Optional[int] = None, show: bool = True, ) -> str: """Sink a recessed region into a card and bind its depth theme. A well sinks a list below the card it sits on, the way a column of cards sits below the tab around it, so a run of rows reads as one body rather than as content loose on the - card. Alongside ``card()`` this is where the recessed depth theme is bound; the region - sizes itself to its rows unless ``height`` reserves a footprint. + card. Alongside ``card()`` this is where the recessed depth theme is bound, and the region + sizes itself to the rows it holds, which whoever owns it holds to a ceiling of their own. - Returns the inset body group content is added to, which keeps ``padding`` clear at the - sides. ``margin`` opens the gap above the first row and below the last, which the row - spacing between the content and the spacers adds to. + Returns the inset body group content is added to, which opens at ``indent`` and comes out at + ``width``, the caller stating the room to hold clear at the right. A well sunk under a row of + its own indents to show what it belongs to while its right edge stays where every other row's + is, so the columns line up down the whole list. + ``margin`` opens the gap above the first row and below the last, which the row spacing between + the content and the spacers adds to. A well asked for a margin of zero opens its rows where the + well itself opens, which is what a well nested inside a list takes: its rows are a run of the + list, and the list's own rhythm carries them. """ body_tag = compose_tag(tag, SUF_GROUP) with dpg.child_window( tag=tag, parent=parent, width=-1, - height=height, - auto_resize_y=height == 0, + auto_resize_y=True, border=False, no_scrollbar=True, show=show, ): - dpg.add_spacer(height=margin) - dpg.add_group(tag=body_tag, indent=padding, width=-padding) - dpg.add_spacer(height=margin) + if margin: + dpg.add_spacer(height=margin) + + dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=width) + if margin: + dpg.add_spacer(height=margin) ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag) return body_tag diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py new file mode 100644 index 000000000..536894ec8 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -0,0 +1,233 @@ +from typing import List, Sequence + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.tags.general import ( + SUF_STRIP, + SUF_TABLE, + SUF_TEXT, + TAG_GLOBAL_THEME_SECTION_HEADER, + TAG_GLOBAL_THEME_STEMS_DROP_STRIP, + TAG_GLOBAL_THEME_STEMS_GRID, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.folder import FolderRenderer +from sampletones_application.ui.elements.stems.gestures import StemsGestures +from sampletones_application.ui.elements.stems.heading import StemsHeading +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.shape import ListShape, Reshape +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) + + +class LevelBands: + """The rows of a stems list, grouped into the levels they pick on. + + A band is a caption, a table of the rows standing on that level, and — where the list takes + drops — the strip above it a recording lands in to take a level of its own. Collapsing the + levels draws every row in one table, which is the shape a list takes where the bands record a + setup rather than offer somewhere to drop onto. + + An open folder breaks the run of rows it stands in, so the recordings it opens onto stand + between the rows above it and the rows below. Every table declares the same columns, so the + rows line up down the list however many folders break it. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + offer: StemsListOffer, + language_manager: LanguageManager, + rows: StemRowRenderer, + folders: FolderRenderer, + heading: StemsHeading, + open_folders: OpenFolders, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._rows = rows + self._folders = folders + self._heading = heading + self._open_folders = open_folders + self._gestures = gestures + self._level_template = language_manager["global.stems.template.level_caption"] + self._shape = ListShape.nothing() + + def reshape(self, view_model: StemsListViewModel) -> Reshape: + """What the view asks of the widgets standing: the whole list, some folders, or nothing. + + The shape is taken up as it is read, so a list that has answered a reshape once answers + the same view with a repaint from then on. + """ + shape = ListShape.of(view_model, self._open_folders) + asked = shape.against(self._shape) + self._shape = shape + return asked + + def forget(self) -> None: + """Let go of the shape standing, so the next reading is drawn rather than repainted.""" + self._shape = ListShape.nothing() + + def build_heading(self, view_model: StemsListViewModel, parent: str) -> None: + """Name the channels once above the rows, so a cell below them holds the box alone. + + The heading stands above whatever the list draws, banded or plain, and every table below + it is declared from the same grid. A list drawing no channel columns has nothing to name, + so the rows stand on their own. + """ + columns = self.columns(view_model) + self._folders.reads(columns) + if not columns.channels: + return + + self._heading.create(parent, columns) + self._heading.render(view_model.muted_channels) + + def repaint_heading(self, view_model: StemsListViewModel) -> None: + """Tone the channel names to the reading standing, which a tick moves without a redraw. + + Which channels are switched off elsewhere is drawn onto the widgets already standing, so + the names follow the boxes below them between one draw and the next. + """ + if not view_model.channels_in_play: + return + + self._heading.render(view_model.muted_channels) + + def build_rows(self, view_model: StemsListViewModel, start: int, count: int) -> None: + """One table of the rows a window reaches, in the grid the whole list stands in.""" + self._create_table(self._tags.segment(0), view_model, view_model.rows[start : start + count]) + + def build(self, view_model: StemsListViewModel) -> None: + """Build the bands the view names, into whatever the list has cleared for them.""" + if view_model.collapse_levels: + self._create_listing(view_model) + return + + for level_index in range(view_model.level_count): + self._create_strip(level_index) + self._create_caption(level_index) + self._create_table( + self._tags.level(level_index, SUF_TABLE), + view_model, + view_model.rows_on(level_index), + ) + + if view_model.level_count: + self._create_strip(view_model.level_count) + + def _create_listing(self, view_model: StemsListViewModel) -> None: + """Every row in one run, an open folder breaking it so its recordings stand below it. + + A folder's own row is a row like any other and stands in the table of the rows around it, + so the list keeps one rhythm down its whole length. What breaks the run is the region an + open folder opens onto, which is drawn between the row it belongs to and the next one. + """ + standing: List[StemRowViewModel] = [] + segment = 0 + for row in view_model.rows: + standing.append(row) + if row.stands_for_a_folder and self._open_folders.stands_open(row.key): + segment = self._flush(standing, view_model, segment) + self._folders.open(row, view_model) + + self._flush(standing, view_model, segment) + + def _flush(self, standing: List[StemRowViewModel], view_model: StemsListViewModel, segment: int) -> int: + """Draw the run of rows gathered since the last open folder, and open the next run empty.""" + if not standing: + return segment + + self._create_table(self._tags.segment(segment), view_model, tuple(standing)) + standing.clear() + return segment + 1 + + def _create_strip(self, position: int) -> None: + """The gap a level is broken at: a recording dropped here takes a level of its own. + + The strip reads as the gap it is and lights up only while a payload hovers it, so a + band separator stays a separator to everything but a drag. + """ + if not self._offer.dragging: + return + + strip = dpg.add_button( + label="", + tag=self._tags.level(position, SUF_STRIP), + parent=self._tags.body, + height=self._layout.level_strip_height, + user_data=position, + payload_type=self._tags.payload, + drop_callback=self._gestures.on_level_drop, + ) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(strip) + + def _create_caption(self, level_index: int) -> None: + """The band's own name, in the accent a section header takes, so a level reads as one.""" + caption = dpg.add_text( + self._level_template.format(level_index + 1).upper(), + tag=self._tags.level(level_index, SUF_TEXT), + parent=self._tags.body, + ) + FontRegistry.bind_to_item(caption, Font.BOLD) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(caption) + + def _create_table( + self, + tag: str, + view_model: StemsListViewModel, + rows: Sequence[StemRowViewModel], + ) -> None: + """One grid of rows, every band declaring the same columns so they line up across bands. + + The grid carries a row background so a group's own row can take a band of its own, and + states that background as clear, so every other row reads on the well behind it. Rules run + between the rows as well as between the columns, and around the grid's own edges, so the + run of rows an open folder breaks still reads as one ruled list down its whole length. + """ + columns = self.columns(view_model) + with dpg.table( + tag=tag, + parent=self._tags.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + borders_innerH=True, + borders_outerH=True, + row_background=True, + ) as grid: + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_GRID).bind_to_item(grid) + columns.declare() + for row in rows: + self._rows.create(row, view_model, columns) + + def columns(self, view_model: StemsListViewModel) -> StemsColumns: + """The grid every table of this list stands in, the heading above them included. + + A list holding a folder says so to the grid, which is what stands the columns around a + folder where the columns inside its own region stand and opens a loose row's name where a + folder's marker opens. + """ + return StemsColumns( + layout=self._layout, + channels=view_model.channels_in_play, + master=self._offer.master_box, + removable=self._offer.removal, + bends=self._offer.bends, + folders=view_model.holds_folders, + ) diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py new file mode 100644 index 000000000..b892a8ca1 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -0,0 +1,165 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName + +NO_RESERVE: Final[int] = 0 +NO_INDENT: Final[int] = 0 +COLUMN_BORDER: Final[int] = 1 +ONE_SLOT: Final[int] = 1 +TWO_SLOTS: Final[int] = 2 + + +@dataclass(frozen=True) +class StemsColumns: + """The columns every table of a stems grid stands in. + + A list draws one table per band, one for each run of rows a folder breaks, one inside each open + folder, and one for the heading above them all; the settings card draws one row of the same + grid. The reader meets them as a single grid because each declares these columns in the same + order at the same widths, which is why this declaration stands in one place and every table + asks it. + + The name column is the one that stretches, so a wider card spends its room on the recordings + rather than on the boxes beside them. A channel column holds one box, or two where ``bends`` + states that a cell carries the bend on its channel, and takes the width that fits. + + ``folders`` states that the list this grid belongs to holds folders, which settles both ends + of the row. At the right, a folder draws its recordings inside a region of their own, and the + room that region spends there is held clear out here so the columns around a folder stand where + the columns inside it stand. At the left, a folder's own row leads with the marker that opens + it, and a row carrying none opens where that marker's glyph does. + """ + + layout: StemsListLayout + channels: Tuple[ChannelName, ...] + master: bool + removable: bool + bends: bool + folders: bool + + @property + def channel_width(self) -> int: + """The room one channel's column takes, which the boxes standing in it decide.""" + return self.layout.channel_column_width if self.bends else self.layout.channel_solo_width + + @property + def reserve(self) -> int: + """The room held clear at the right of every table outside a folder, which is the region's. + + A list holding no folder opens no region, so its tables run to the edge. + """ + if not self.folders: + return NO_RESERVE + + return self.layout.folder_reserve + + @property + def reserve_width(self) -> int: + """The width the reserve column is declared at, so the room it holds is the region's. + + A column takes its own width plus the padding on either side of its cell and the rule drawn + beside it, so those come off the room the strip is meant to hold clear. + """ + return self.reserve - 2 * self.layout.cell_padding - COLUMN_BORDER + + @property + def reserved(self) -> bool: + """Whether the strip stands at the right of a row, which is the room a region spends. + + A grid holds the strip clear where there is room left to declare a column at, so a list + of folders ends every table on it and every other grid ends on the column before it. + """ + return self.reserve_width > 0 + + def declare(self) -> None: + """Add this grid's columns to the table currently being built.""" + if self.master: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.master_column_width) + + dpg.add_table_column(width_stretch=True) + for _channel_name in self.channels: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.channel_width) + + if self.removable: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.remove_button_width) + + if self.reserved: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self.reserve_width) + + def slots(self, channel_name: ChannelName) -> int: + """How many boxes one channel's cell holds: the channel it takes, and the bend on it. + + A bend moves a note by a fraction of the divider its channel loads, so a channel whose + periods stand at fixed distances holds the first slot alone. + """ + if self.bends and channel_name in TONE_CHANNELS: + return TWO_SLOTS + + return ONE_SLOT + + @property + def master_indent(self) -> int: + """How far the box beside a row sits in, so it stands in the middle of its own column.""" + return self._centered(self.layout.channel_box_width, within=self.layout.master_column_width) + + def box_indent(self, channel_name: ChannelName) -> int: + """How far a channel's boxes sit in, so they stand in the middle of their own column.""" + return self._centered( + self.slots(channel_name) * self.layout.channel_box_width, + within=self.channel_width, + ) + + def marker_indent(self, glyph: str, font: Font) -> int: + """How far a row carrying no marker sits in, so its name opens where a marker's glyph does. + + A folder's row leads with a button as wide as the marker column, and the glyph inside it + stands in the middle of that button. A recording standing loose in the same list opens at + that glyph, so the names read as one column while a row with no marker keeps the room a + marker would have spent. + """ + if not self.folders: + return NO_INDENT + + measured = dpg.get_text_size(glyph, font=FontRegistry.get_tag(font)) + if measured is None: + return NO_INDENT + + return self._centered(int(measured[0]), within=self.layout.twisty_width) + + def name_indent(self, label: str, font: Font) -> int: + """How far a channel's name sits in, so it stands over the middle of its own column. + + The name is measured in the face it is drawn in, so a column reads as one thing however + long the channel is called. + """ + measured = dpg.get_text_size(label, font=FontRegistry.get_tag(font)) + if measured is None: + return NO_INDENT + + return self._centered(int(measured[0]), within=self.channel_width) + + def open_leading_cells(self) -> None: + """Open the cells standing before the channels, which a heading leaves blank.""" + if self.master: + dpg.add_spacer() + + dpg.add_spacer() + + def open_trailing_cell(self) -> None: + """Open the cell standing after the channels, which a heading leaves blank.""" + if self.removable: + dpg.add_spacer() + + def _centered(self, span: int, *, within: int) -> int: + """The indent standing something of this width in the middle of a column of that width. + + A cell opens after its own padding and the column's width is what is left for what stands + in it, so the padding is already spent by the time the indent is measured from. + """ + return max(NO_INDENT, (within - span) // 2) diff --git a/src/sampletones_application/ui/elements/stems/expansion.py b/src/sampletones_application/ui/elements/stems/expansion.py new file mode 100644 index 000000000..9156949f2 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/expansion.py @@ -0,0 +1,38 @@ +from typing import AbstractSet, Set + + +class OpenFolders: + """The folders a reader has opened, held by the key their row is drawn under. + + Whether a folder stands open is what the reader last did to it rather than anything the setup + records, so the list keeps it for as long as it is on screen and the model stays clear of it. + A folder gathered afresh arrives closed, which is what a reader meets when a folder of + thousands joins the list. + """ + + def __init__(self) -> None: + self._open: Set[str] = set() + + def __bool__(self) -> bool: + return bool(self._open) + + @property + def keys(self) -> Set[str]: + """The folders standing open, which is what a draw builds a region for.""" + return set(self._open) + + def stands_open(self, key: str) -> bool: + return key in self._open + + def toggle(self, key: str) -> bool: + """Opens a closed folder and closes an open one, answering how it now stands.""" + if key in self._open: + self._open.discard(key) + return False + + self._open.add(key) + return True + + def hold_to(self, keys: AbstractSet[str]) -> None: + """Holds the memory to the folders the list still draws, a folder beyond them having left.""" + self._open &= set(keys) diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py new file mode 100644 index 000000000..0ed773e0c --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -0,0 +1,168 @@ +from dataclasses import replace +from functools import partial +from typing import Dict, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import NO_MARGIN, NO_SCROLL, WindowedRegion +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) + + +class FolderRenderer: + """One gathered folder: the row standing for it, and the recordings it opens onto. + + A folder arrives closed, reading as its name and how many recordings it brought in — one row + among the rows around it, standing in their table and taking the height they take. Opening it + sinks a region below that row, in which the recordings are drawn the way any other row is, so + a reader answers for one of them without leaving the list. The region holds a folder's worth + of rows and scrolls past that, building only the rows it shows — which is what makes opening a + folder of thousands cost what opening a folder of ten costs. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + geometry: RowGeometry, + open_folders: OpenFolders, + rows: StemRowRenderer, + ) -> None: + self._tags = tags + self._layout = layout + self._geometry = geometry + self._open_folders = open_folders + self._rows = rows + self._regions: Dict[str, WindowedRegion] = {} + self._resting: Dict[str, float] = {} + self._columns = StemsColumns( + layout=layout, + channels=(), + master=False, + removable=False, + bends=False, + folders=False, + ) + + def reads(self, columns: StemsColumns) -> None: + """Takes up the grid the list is drawing, which a folder's own tables stand in too.""" + self._columns = columns + + def forget(self) -> None: + """Take up where each open folder stood, and let go of the regions a rebuild took down. + + A region goes down with the list around it and comes back a new widget at its top, so the + position it was scrolled to is held here and handed to the region that replaces it. + """ + self._resting = {key: region.offset for key, region in self._regions.items()} + self._regions.clear() + + @property + def following(self) -> bool: + """An open folder stands as something other than it will, so the list settles it again.""" + return any(region.settling for region in self._regions.values()) + + def settle(self) -> Tuple[str, ...]: + """Hold every open region to its ceiling and read what a row takes, a frame after a draw. + + Answers the folders whose rows have moved out from under what stands drawn, which is what + a list redraws to follow a scroll. + """ + return tuple(key for key, region in self._regions.items() if region.settle()) + + def redraw(self, key: str, view_model: StemsListViewModel) -> None: + """Build the slice one folder's region now reaches, leaving the rest of the list alone.""" + row = view_model.row(key) + region = self._regions.get(key) + if row is None or region is None: + return + + self._fill(region, row, view_model) + + def repaint(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Draw what the recordings in view currently hold onto the widgets they stand as. + + A recording inside a folder leaves the same way a loose one does, so it answers the same + rule about whether the list is holding on to what it has. + """ + region = self._regions.get(row.key) + if region is None: + return + + for held in self._reached(region, row): + self._rows.repaint(held, view_model) + + def open(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Sink the folder's region below its row and fill it with the rows it reaches. + + The folder's own row stands in the run of rows around it, so what is drawn here is the + space its recordings scroll in, which an open folder is what puts on screen. The region + holds its recordings flush to its own edges, so they carry on from the row above them and + the seam stays as narrow as the list's own rules. + """ + region = WindowedRegion( + tag=self._tags.region(row.key), + geometry=self._geometry, + ceiling=self._layout.folder_ceiling, + padding=self._layout.well_padding, + margin=NO_MARGIN, + gutter=self._layout.scrollbar_width, + indent=self._layout.well_padding + self._layout.folder_indent, + ) + region.create(self._tags.body) + region.opens_at(self._resting.get(row.key, NO_SCROLL)) + self._regions[row.key] = region + self._fill(region, row, view_model) + + def _fill( + self, + region: WindowedRegion, + row: StemRowViewModel, + view_model: StemsListViewModel, + ) -> None: + region.draw(row.holds, partial(self._create_rows, region, row, view_model), lead=None) + + def _create_rows( + self, + region: WindowedRegion, + row: StemRowViewModel, + view_model: StemsListViewModel, + start: int, + count: int, + ) -> None: + """One table of the recordings a region reaches, declaring the columns the list lines up on. + + The room the region spends at its own right is the room the grid outside it holds clear, so + the recordings inside a folder stand in the columns their neighbors stand in and none of + them leads with a marker. Rules run between these rows the way they run outside, and the + grids on either side of the region rule the two seams, so a folder's recordings read as + the same list as the rows above and below them however far the region is scrolled. + """ + held_columns = replace(self._columns, folders=False) + with dpg.table( + tag=self._tags.held(row.key), + parent=region.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + borders_innerH=True, + ): + held_columns.declare() + for held in row.held[start : start + count]: + self._rows.create(held, view_model, held_columns) + + @staticmethod + def _reached(region: WindowedRegion, row: StemRowViewModel) -> Tuple[StemRowViewModel, ...]: + """The recordings the region has widgets for, which are the ones a repaint reaches.""" + start, count = region.window + return row.held[start : start + count] diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py new file mode 100644 index 000000000..4b1178c1f --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -0,0 +1,249 @@ +from typing import Any, Callable, Final, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_TEXT, + SUF_TWISTY, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.host import StemsListHost +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import MessageCallback, StringCallback + +ROW_WIDGET_KINDS: Final[Tuple[str, ...]] = (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON, SUF_TWISTY) + +ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] +ChannelCallback = Callable[[str, ChannelName], None] +KeyOffsetCallback = Callable[[str, int], None] +KeyPairCallback = Callable[[str, str], None] + + +class StemsGestures: + """What a reader does to a stems list, read from DearPyGui's events and reported as keys. + + A row's widgets carry their key as user data and share a registry with their kind, so the + hover explanation and the right-click read the row they landed on rather than needing a + handler apiece. Every event this class answers arrives as a widget and a payload; what + leaves it is the row a gesture named and what the reader asked of it. + + Three of those gestures reach past the row to whoever owns the list — picking a row out, + sounding it, and putting its menu up — so the list is asked whether it has an owner for each, + and a gesture with none rests here. A clicked row's widget is written from the view either + way, so it reads as the last reading of the list left it. + """ + + def __init__( + self, + tags: StemsTags, + *, + messages: StemsMessages, + status_bar: GUIStatusBar, + host: StemsListHost, + ) -> None: + self._tags = tags + self._messages = messages + self._status_bar = status_bar + self._host = host + self._view = StemsListViewModel.empty() + + self.on_channels_settled: Optional[ChannelsCallback] = None + self.on_channel_toggled: Optional[ChannelCallback] = None + self.on_removal_asked: Optional[StringCallback] = None + self.on_menu_asked: Optional[StringCallback] = None + self.on_row_activated: Optional[StringCallback] = None + self.on_dropped_on_row: Optional[KeyPairCallback] = None + self.on_dropped_on_level: Optional[KeyOffsetCallback] = None + self.on_folder_toggled: Optional[StringCallback] = None + self.on_row_opened: Optional[StringCallback] = None + self.on_row_picked: Optional[StringCallback] = None + + def reads(self, view_model: StemsListViewModel) -> None: + """Takes up the view the list is drawing, which is what a gesture is answered against.""" + self._view = view_model + + def create_handlers(self) -> None: + """Register one handler registry per row-widget kind.""" + for kind in ROW_WIDGET_KINDS: + dpg_delete_item(self._tags.handlers(kind)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_TEXT)): + dpg.add_item_clicked_handler(callback=self._on_name_clicked) + dpg.add_item_double_clicked_handler(callback=self._on_name_double_clicked) + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.name)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_CHANNELS)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.channel)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_CHECKBOX)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.master)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_BUTTON)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.remove)) + + with dpg.item_handler_registry(tag=self._tags.handlers(SUF_TWISTY)): + dpg.add_item_hover_handler(callback=self._hover_callback(self._messages.twisty)) + + def bind(self, item: str, kind: str) -> None: + """Puts one row widget under the registry answering for its kind.""" + dpg.bind_item_handler_registry(item, self._tags.handlers(kind)) + + def on_channel_box( + self, + _sender: Sender, + _value: bool, + user_data: Tuple[str, ChannelName], + ) -> None: + """A box settles one channel on the row it belongs to. + + A recording answers with every box it now holds ticked, which is the whole of what it + stands on. A folder's box reads three ways, so it reports the channel alone and its owner + settles every recording below it — one gesture always moving the group somewhere. + """ + key, channel_name = user_data + row = self._view.row(key) + if row is None: + return + + if row.stands_for_a_folder: + self._report(self.on_channel_toggled, key, channel_name) + return + + channels = frozenset( + offered + for offered in self._view.boxes_of(row) + if self._ticked(key, offered, standing=offered in row.channels) + ) + self._report(self.on_channels_settled, key, channels) + + def _ticked(self, key: str, channel_name: ChannelName, *, standing: bool) -> bool: + """Whether a channel's box stands ticked, which is what the row reports it holds. + + A box the list has taken away answers with the reading it last stood for, so a row settles + on the channels it holds rather than on the ones whose boxes happen to be drawn. + """ + tag = self._tags.channel(key, channel_name) + return bool(dpg.get_value(tag)) if dpg.does_item_exist(tag) else standing + + def on_master_box(self, _sender: Sender, value: bool, user_data: str) -> None: + """The master box hands the row every channel it offers, or takes them all away.""" + row = self._view.row(user_data) + if row is None: + return + + channels = frozenset(self._view.boxes_of(row)) if value else frozenset() + self._report(self.on_channels_settled, user_data, channels) + + def on_pick_box(self, _sender: Sender, _value: bool, user_data: str) -> None: + """The box beside a row picks the recordings it stands for, or lets them go.""" + self._report(self.on_row_picked, user_data) + + def on_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + self._report(self.on_removal_asked, user_data) + + def on_twisty(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + """The marker beside a folder's name puts its recordings in view, or away again.""" + self._report(self.on_folder_toggled, user_data) + + def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> None: + """Pick the row a click landed on, whichever way the widget swung. + + DearPyGui reports a selectable once per click, so a double-click that sounds a recording + picks its row the way a single click does. + """ + self._pick(user_data) + + def on_row_drop(self, sender: Sender, app_data: str) -> None: + """A recording was dropped on a row, so it joins that row's level at its place.""" + target = dpg.get_item_user_data(sender) + if isinstance(target, str): + self._report(self.on_dropped_on_row, app_data, target) + + def on_level_drop(self, sender: Sender, app_data: str) -> None: + """A recording was dropped in a gap, so it takes a level of its own there.""" + position = dpg.get_item_user_data(sender) + if isinstance(position, int): + self._report(self.on_dropped_on_level, app_data, position) + + def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + """A right-click picks the row its menu stands over, where the owner puts one up. + + The menu prints the key that takes a row out, so the row the menu stands over is the row + that key reaches: picking it here is what holds the item and the key to one action. + """ + if not self._host.has_menu: + return + + key = self._named_by(app_data, dpg.mvMouseButton_Right) + if key is not None: + self._pick(key) + self._report(self.on_menu_asked, key) + + def _pick(self, key: str) -> None: + """Stand the row as the view holds it, and hand the pick on where an owner takes one. + + The widget is written from the view every time, so a row reads as the last reading left it + and a list recording no selection stands its rows plain. Where an owner answers, the + reading it settles arrives in the same frame and the row follows that instead. + """ + dpg_set_value(self._tags.row(key, SUF_TEXT), key == self._view.selected_key) + if self._host.activatable: + self._report(self.on_row_activated, key) + + def _on_name_double_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: + """A double-click opens what it landed on: a folder shows what it holds, a recording sounds.""" + key = self._named_by(app_data, dpg.mvMouseButton_Left) + if key is None: + return + + row = self._view.row(key) + if row is not None and row.stands_for_a_folder: + self._report(self.on_folder_toggled, key) + return + + if self._host.playable: + self._report(self.on_row_opened, key) + + @staticmethod + def _named_by(app_data: Tuple[int, int], button: int) -> Optional[str]: + """The row a mouse gesture landed on, for the button the gesture speaks for. + + The gesture is answered a frame after DearPyGui gathered it, by which time a rebuilt list + may have taken the widget away, so the answer is for the widgets still standing. + """ + mouse_button, clicked_item = app_data + if mouse_button != button or not dpg.does_item_exist(clicked_item): + return None + + key = dpg.get_item_user_data(clicked_item) + return key if isinstance(key, str) else None + + def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]: + """Route a hovered row widget's explanation to the status bar. + + An item hover handler names the hovered item, whose user data is the row it belongs to, + so one callback per widget kind explains every row of that kind. The hover is reported a + frame after it happened, by which time a rebuilt list may have taken the widget away, so + the callback answers for the widgets still standing. + """ + + def hover_callback(_sender: Sender, app_data: int) -> None: + if not dpg.does_item_exist(app_data): + return + + self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data)) + + return hover_callback + + def _report(self, callback: Optional[Callable[..., None]], *args: Any) -> None: + """Hands a gesture on where the list has an owner for it, and drops it where it has none.""" + if callback is not None: + callback(*args) diff --git a/src/sampletones_application/ui/elements/stems/heading.py b/src/sampletones_application/ui/elements/stems/heading.py new file mode 100644 index 000000000..1de160723 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -0,0 +1,133 @@ +from typing import FrozenSet + +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.stems import StemsListLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_HEADING, + SUF_TABLE, + SUF_TEXT, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_STEMS_SLOT_LABEL, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName + + +class StemsHeading: + """The channels a stems grid stands under, named once above the rows. + + Naming a channel here leaves each cell below free for the boxes it holds, and the cell reads as + one channel because the name spans it. Where a cell holds two boxes, a second line names them: + the channel the recording takes, and the bend on it. + """ + + def __init__( + self, + *, + prefix: str, + layout: StemsListLayout, + language_manager: LanguageManager, + bends: bool, + ) -> None: + self._prefix = prefix + self._layout = layout + self._language_manager = language_manager + self._bends = bends + self._lbl_on = language_manager["global.stems.label.channel_on"] + self._lbl_bend = language_manager["global.stems.label.channel_bend"] + self._msg_bend = language_manager["global.stems.message.bend_tooltip"] + self._columns = StemsColumns( + layout=layout, + channels=(), + master=False, + removable=False, + bends=bends, + folders=False, + ) + + @property + def table(self) -> str: + """The table the channel names stand in, which is what lines them up with the rows.""" + return compose_tag(self._prefix, SUF_HEADING, SUF_TABLE) + + def name(self, channel_name: ChannelName) -> str: + """The tag one channel's name carries.""" + return compose_tag(self._prefix, SUF_HEADING, channel_name, SUF_TEXT) + + def create(self, parent: str, columns: StemsColumns) -> None: + """Draw the heading above the rows, in the columns those rows stand in. + + The grid below rules its own top edge, which is the line dividing the names from the rows + they stand over. + """ + self._columns = columns + with dpg.table( + tag=self.table, + parent=parent, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + ): + columns.declare() + self._create_names() + if self._bends: + self._create_slots() + + def render(self, muted_channels: FrozenSet[ChannelName]) -> None: + """Tone each channel's name the way its boxes are toned, so a column reads as one.""" + for channel_name in self._columns.channels: + theme = ( + TAG_GLOBAL_THEME_CHANNEL_MUTED if channel_name in muted_channels else CHANNEL_THEME_TAGS[channel_name] + ) + ThemeRegistry.get(theme).bind_to_item(self.name(channel_name)) + + def _create_names(self) -> None: + with dpg.table_row(): + self._columns.open_leading_cells() + for channel_name in self._columns.channels: + label = channel_label(self._language_manager, channel_name) + name = dpg.add_text( + label, + tag=self.name(channel_name), + indent=self._columns.name_indent(label, Font.BOLD_SMALL), + ) + FontRegistry.bind_to_item(name, Font.BOLD_SMALL) + + self._columns.open_trailing_cell() + + def _create_slots(self) -> None: + """Label the slots a cell holds, each standing over the box it names.""" + with dpg.table_row(): + self._columns.open_leading_cells() + for channel_name in self._columns.channels: + self._create_channel_slots(channel_name) + + self._columns.open_trailing_cell() + + def _create_channel_slots(self, channel_name: ChannelName) -> None: + with dpg.group(horizontal=True, indent=self._columns.box_indent(channel_name)): + self._create_slot_label(self._lbl_on) + if channel_name in TONE_CHANNELS: + bend = self._create_slot_label(self._lbl_bend) + show_tooltip(bend, self._msg_bend, tag=compose_tag(self.name(channel_name), SUF_TOOLTIP)) + + def _create_slot_label(self, label: str) -> int: + """One slot's name, held to the width of the box it stands over so the two line up.""" + with dpg.group(): + text = dpg.add_text(label) + FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_SLOT_LABEL).bind_to_item(text) + dpg.add_spacer(width=self._layout.channel_box_width, height=0) + + return int(text) diff --git a/src/sampletones_application/ui/elements/stems/host.py b/src/sampletones_application/ui/elements/stems/host.py new file mode 100644 index 000000000..bdf3bcbfb --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/host.py @@ -0,0 +1,19 @@ +from typing import Protocol + + +class StemsListHost(Protocol): + """Which gestures a stems list reaches an owner with, as the list itself answers them. + + Picking a row out, sounding it and putting its menu up each reach past the row to whoever owns + the list, and each is offered while an owner answers it. The list holds those hooks and an + owner sets them when it likes, so the answer is read at the moment a gesture lands. + """ + + @property + def activatable(self) -> bool: ... + + @property + def playable(self) -> bool: ... + + @property + def has_menu(self) -> bool: ... diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 16acae045..5619c8dfe 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,70 +1,50 @@ -from typing import Any, Callable, Dict, FrozenSet, Optional, Sequence, Tuple +from functools import partial +from typing import Optional, Tuple -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.stems import StemsListLayout -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_BUTTON, - SUF_CHANNELS, - SUF_CHECKBOX, - SUF_GROUP, - SUF_HANDLER_REGISTRY, - SUF_LEVEL, - SUF_PAYLOAD, - SUF_ROW, - SUF_STRIP, - SUF_TABLE, - SUF_TEXT, - SUF_TOOLTIP, - SUF_WELL, - TAG_GLOBAL_THEME_CHANNEL_MUTED, - TAG_GLOBAL_THEME_DANGER_BUTTON, - TAG_GLOBAL_THEME_STEMS_DROP_STRIP, - TAG_GLOBAL_THEME_STEMS_ROW, - TAG_GLOBAL_THEME_STEMS_ROW_INERT, -) -from sampletones_application.ui.elements.fonts.font import Font -from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.layout.well import well +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import NO_GUTTER, WindowedRegion 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_delete_item, - dpg_set_value, +from sampletones_application.ui.elements.stems.bands import LevelBands +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.folder import FolderRenderer +from sampletones_application.ui.elements.stems.gestures import ( + ChannelCallback, + ChannelsCallback, + KeyOffsetCallback, + KeyPairCallback, + StemsGestures, ) -from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.ui.elements.stems.heading import StemsHeading +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.row import StemRowRenderer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.view_model.shared.stems import ( StemRowViewModel, StemsListViewModel, ) -from sampletones_core.constants.enums import ChannelName -from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import MessageCallback, StringCallback +from sampletones_shared.types.callback import StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -RowShape = Tuple[Tuple[str, ...], bool, Tuple[Tuple[str, int, Tuple[str, ...]], ...]] - -ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] -KeyOffsetCallback = Callable[[str, int], None] -KeyPairCallback = Callable[[str, str], None] - class GUIStemsList(CallbackMixin): """The stems of one setup, as a table of rows banded by the levels they pick on. Both the converter's gathered recordings and a reconstruction's recorded assignment are the - same list, so one definition draws them and each owner turns on the affordances it can - honor: ``draggable`` makes a row itself the thing you drag and opens a drop strip between - the bands, ``master_checkbox`` gives the row a leading box moving every channel at once, - ``removable`` gives it the danger-toned button that takes it out, and ``retain_last_row`` - holds that button back once one row is all that stands. Rows are keyed by the - identity their owner reports gestures under, and every column lines up across the bands - because each table holds one fixed column per channel in play. + same list, so one composition draws them and each owner states what it offers a reader + (:class:`StemsListOffer`). Rows are keyed by the identity their owner reports gestures under, + and every column lines up across the bands because each table declares the same columns. + + The list holds the view it was last given and nothing beside it: the rows, the columns and + what a gesture may reach are all read from that one value. + + ``ceiling`` is the room its owner gives it: the list stands as tall as what it holds up to + that, and scrolls from there on. An owner drawing the list inside a space of its own states + the room that space has, so the list is the one thing in it that scrolls. """ def __init__( @@ -72,496 +52,283 @@ def __init__( *, prefix: str, layout: StemsListLayout, + ceiling: int, + glyphs: CommonGlyphs, language_manager: LanguageManager, status_bar: GUIStatusBar, - draggable: bool, - removable: bool, - retain_last_row: bool, - master_checkbox: bool, + offer: StemsListOffer, ) -> None: - self._prefix = prefix + self._tags = StemsTags(prefix=prefix) self._layout = layout - self._language_manager = language_manager - self._status_bar = status_bar - self._draggable = draggable - self._removable = removable - self._retain_last_row = retain_last_row - self._master_checkbox = master_checkbox - - self._level_template = language_manager["global.stems.template.level_caption"] - self._lbl_remove = language_manager["global.stems.label.remove"] - self._msg_drag = language_manager["global.stems.message.drag_tooltip"] - self._msg_inert = language_manager["global.stems.message.inert_tooltip"] - self._msg_missing = language_manager["global.stems.message.missing_tooltip"] - self._msg_unoffered = language_manager["global.stems.message.unoffered_tooltip"] - - self._payload = compose_tag(prefix, SUF_PAYLOAD) - self._well_tag = compose_tag(prefix, SUF_WELL) - self._body_tag = compose_tag(self._well_tag, SUF_GROUP) - self._table_tag = compose_tag(prefix, SUF_TABLE) - self._name_handler_tag = compose_tag(prefix, SUF_TEXT, SUF_HANDLER_REGISTRY) - self._channel_handler_tag = compose_tag(prefix, SUF_CHANNELS, SUF_HANDLER_REGISTRY) - self._master_handler_tag = compose_tag(prefix, SUF_CHECKBOX, SUF_HANDLER_REGISTRY) - self._button_handler_tag = compose_tag(prefix, SUF_BUTTON, SUF_HANDLER_REGISTRY) - - self._rows: Dict[str, StemRowViewModel] = {} - self._channels_in_play: Tuple[ChannelName, ...] = () - self._muted_channels: FrozenSet[ChannelName] = frozenset() - self._shape: RowShape = ((), False, ()) - self._live = True + self._offer = offer + self._view = StemsListViewModel.empty() + self._open_folders = OpenFolders() + self._geometry = RowGeometry.opening_at( + overscan=layout.window_overscan, + opening=float(layout.name_height), + ) + self._settling = False + self._region = WindowedRegion( + tag=self._tags.well, + geometry=self._geometry, + ceiling=ceiling, + padding=layout.well_padding, + margin=layout.well_margin, + gutter=NO_GUTTER, + ) + + self._messages = StemsMessages( + language_manager, + offer=offer, + open_folders=self._open_folders, + host=self, + ) + self._gestures = StemsGestures( + self._tags, + messages=self._messages, + status_bar=status_bar, + host=self, + ) + self._rows = StemRowRenderer( + self._tags, + layout=layout, + offer=offer, + glyphs=glyphs, + open_folders=self._open_folders, + language_manager=language_manager, + messages=self._messages, + gestures=self._gestures, + ) + self._folders = FolderRenderer( + self._tags, + layout=layout, + geometry=self._geometry, + open_folders=self._open_folders, + rows=self._rows, + ) + self._heading = StemsHeading( + prefix=prefix, + layout=layout, + language_manager=language_manager, + bends=offer.bends, + ) + self._bands = LevelBands( + self._tags, + layout=layout, + offer=offer, + language_manager=language_manager, + rows=self._rows, + folders=self._folders, + heading=self._heading, + open_folders=self._open_folders, + gestures=self._gestures, + ) self.on_channels_changed: Optional[ChannelsCallback] = None + self.on_channel_toggled: Optional[ChannelCallback] = None self.on_remove_requested: Optional[StringCallback] = None self.on_menu_requested: Optional[StringCallback] = None self.on_row_activated: Optional[StringCallback] = None self.on_dropped_on_row: Optional[KeyPairCallback] = None self.on_dropped_on_level: Optional[KeyOffsetCallback] = None + self.on_row_opened: Optional[StringCallback] = None + self.on_row_picked: Optional[StringCallback] = None + self.on_selection_cleared: Optional[VoidCallback] = None + + self._gestures.on_channels_settled = lambda key, channels: self.call(self.on_channels_changed, key, channels) + self._gestures.on_channel_toggled = lambda key, channel: self.call(self.on_channel_toggled, key, channel) + self._gestures.on_removal_asked = lambda key: self.call(self.on_remove_requested, key) + self._gestures.on_menu_asked = lambda key: self.call(self.on_menu_requested, key) + self._gestures.on_row_activated = lambda key: self.call(self.on_row_activated, key) + self._gestures.on_dropped_on_row = lambda key, target: self.call(self.on_dropped_on_row, key, target) + self._gestures.on_dropped_on_level = lambda key, position: self.call(self.on_dropped_on_level, key, position) + self._gestures.on_row_opened = lambda key: self.call(self.on_row_opened, key) + self._gestures.on_row_picked = lambda key: self.call(self.on_row_picked, key) + self._gestures.on_folder_toggled = self.toggle_folder @property - def _handler_tags(self) -> Tuple[str, ...]: - """The registries the rows bind to, one per widget kind the list draws.""" - return ( - self._name_handler_tag, - self._channel_handler_tag, - self._master_handler_tag, - self._button_handler_tag, - ) + def tags(self) -> StemsTags: + """The grammar the list's widgets are named under.""" + return self._tags @property def tag(self) -> str: """The recessed region the list is drawn in, which is what an owner shows and hides.""" - return self._well_tag + return self._tags.well @property def activatable(self) -> bool: - """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" + """The owner answers a click on a row, so the list hands the row it picked on.""" return self.on_row_activated is not None @property - def table_tag(self) -> str: - """The one table every row stands in while the levels are collapsed.""" - return self._table_tag - - def create(self, parent: str, *, show: bool = True) -> None: - """Build the list's recessed region and the handlers its rows share.""" - self._create_handlers() - well( - parent, - self._well_tag, - padding=self._layout.well_padding, - margin=self._layout.well_margin, - show=show, - ) - - def update_view(self, view_model: StemsListViewModel) -> None: - self._rows = {row.key: row for row in view_model.rows} - self._channels_in_play = view_model.channels_in_play - self._muted_channels = view_model.muted_channels - self._live = view_model.live - self._sync_rows(view_model) - for row in view_model.rows: - self._render_row(row) - - def row(self, key: str) -> Optional[StemRowViewModel]: - """The row a gesture named, as the list last rendered it.""" - return self._rows.get(key) - - def _create_handlers(self) -> None: - """Register one handler registry per row-widget kind. - - A row's widgets carry their key as user data and share a registry with their kind, so - the hover explanation and the right-click both read the row they landed on rather than - needing a handler of their own. - """ - for handler_tag in self._handler_tags: - dpg_delete_item(handler_tag) - - with dpg.item_handler_registry(tag=self._name_handler_tag): - dpg.add_item_clicked_handler(callback=self._on_name_clicked) - dpg.add_item_hover_handler(callback=self._hover_callback(self._name_message)) + def playable(self) -> bool: + """The owner sounds a recording, so a double-click on a row reaches something.""" + return self.on_row_opened is not None - with dpg.item_handler_registry(tag=self._channel_handler_tag): - dpg.add_item_hover_handler(callback=self._hover_callback(self._channel_message)) - - with dpg.item_handler_registry(tag=self._master_handler_tag): - dpg.add_item_hover_handler(callback=self._hover_callback(self._master_message)) - - with dpg.item_handler_registry(tag=self._button_handler_tag): - dpg.add_item_hover_handler(callback=self._hover_callback(self._remove_message)) + @property + def has_menu(self) -> bool: + """The owner puts a menu up over a row, so a right-click on one reaches something.""" + return self.on_menu_requested is not None - def _hover_callback(self, message_function: MessageCallback) -> Callable[[Sender, int], None]: - """Route a hovered row widget's explanation to the status bar. + def create(self, parent: str, *, show: bool = True) -> None: + """Build the list's recessed region and the handlers its rows share. - An item hover handler names the hovered item, whose user data is the row it belongs to, - so one callback per widget kind explains every row of that kind. The hover is reported a - frame after it happened, by which time a rebuilt list may have taken the widget away, so - the callback answers for the widgets still standing. + A list built again stands on none of the widgets it drew before — a window raised a second + time takes its whole tree down between one opening and the next — so what it remembers + having drawn is let go of here, and the next reading it takes is a draw rather than a + repaint of widgets that are gone. """ + self._bands.forget() + self._folders.forget() + self._gestures.create_handlers() + self._region.create(parent, show=show) - def hover_callback(_sender: Sender, app_data: int) -> None: - if not dpg.does_item_exist(app_data): - return - - self._status_bar.set(message_function, user_data=dpg.get_item_user_data(app_data)) - - return hover_callback - - def _sync_rows(self, view_model: StemsListViewModel) -> None: - """Rebuild the bands when the recordings, their levels or their boxes change. + def update_view(self, view_model: StemsListViewModel) -> None: + """Take up a new reading of the setup: draw what it reshapes, repaint the rows either way. - Collapsing the levels draws every recording in one table, which is the shape a list - takes where the bands are a record of the setup rather than somewhere to drop onto. + A reading that moved the recordings of one folder alone is answered inside that folder, so + the rows around it keep the widgets they stand as and the reader keeps the scroll they left. """ - shape = self._row_shape(view_model) - if shape == self._shape: + self._view = view_model + self._open_folders.hold_to({row.key for row in view_model.rows}) + self._messages.reads(view_model) + self._gestures.reads(view_model) + asked = self._bands.reshape(view_model) + if asked.whole: + self._rebuild(view_model) + else: + for key in asked.folders: + self._folders.redraw(key, view_model) + + if asked.redraws: + self._settle_soon() + + self._repaint(view_model) + + def _rebuild(self, view_model: StemsListViewModel) -> None: + """Draw the list afresh: the bands the view names, and a region under each open folder.""" + self._folders.forget() + if self._windows(view_model): + self._draw_window(view_model) return - self._shape = shape - dpg.delete_item(self._body_tag, children_only=True) - if view_model.collapse_levels: - self._create_table(self._table_tag, view_model, view_model.rows) - return - - for level_index in range(view_model.level_count): - self._create_level_strip(level_index) - self._create_level_caption(level_index) - self._create_level_table(level_index, view_model) + self._region.draw_whole( + partial(self._bands.build, view_model), + lead=partial(self._bands.build_heading, view_model), + ) - if view_model.level_count: - self._create_level_strip(view_model.level_count) + def _draw_window(self, view_model: StemsListViewModel) -> None: + """Draw the rows the well reaches, reserving the room the rest of them would take.""" + self._region.draw( + view_model.row_count, + partial(self._bands.build_rows, view_model), + lead=partial(self._bands.build_heading, view_model), + ) @staticmethod - def _row_shape(view_model: StemsListViewModel) -> RowShape: - """What the bands are built from: the columns, the banding, and where each row stands. + def _windows(view_model: StemsListViewModel) -> bool: + """Whether the well draws a window over its rows rather than the whole run of them. - Which channels a row holds is drawn onto the widgets already standing, so a tick keeps - the bands as they are and the pointer keeps whatever it was over. + A window slides over rows of one height, which is what a list of recordings alone is: a + list banded by levels stands captions and strips among its rows, and a folder stands a + region of its own under one. A folder answers for its own length inside that region, so + the well holds the rows around it entire. """ - return ( - tuple(str(channel_name) for channel_name in view_model.channels_in_play), - view_model.collapse_levels, - tuple( - ( - row.key, - row.level, - tuple(sorted(str(channel_name) for channel_name in row.offered_channels)), - ) - for row in view_model.rows - ), - ) - - def _create_level_strip(self, position: int) -> None: - """The gap a level is broken at: a recording dropped here takes a level of its own. + return view_model.collapse_levels and not view_model.holds_folders - The strip reads as the gap it is and lights up only while a payload hovers it, so a - band separator stays a separator to everything but a drag. - """ - if not self._draggable: - return + def _repaint(self, view_model: StemsListViewModel) -> None: + """Draw what the rows in view currently hold onto the widgets they stand as.""" + self._bands.repaint_heading(view_model) + for row in self._reached(view_model): + self._rows.repaint(row, view_model) + self._folders.repaint(row, view_model) - strip = dpg.add_button( - label="", - tag=self.level_tag(position, SUF_STRIP), - parent=self._body_tag, - height=self._layout.level_strip_height, - user_data=position, - payload_type=self._payload, - drop_callback=self._on_dropped_on_level, - ) - ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(strip) + def _reached(self, view_model: StemsListViewModel) -> Tuple[StemRowViewModel, ...]: + """The rows the well has widgets for, which are the ones a repaint reaches.""" + if not self._region.windowing: + return view_model.rows - def _create_level_caption(self, level_index: int) -> None: - caption = dpg.add_text( - self._level_template.format(level_index + 1).upper(), - tag=self.level_tag(level_index, SUF_TEXT), - parent=self._body_tag, - ) - FontRegistry.bind_to_item(caption, Font.MONO_SMALL) + start, count = self._region.window + return view_model.rows[start : start + count] - def _create_level_table(self, level_index: int, view_model: StemsListViewModel) -> None: - self._create_table( - self.level_tag(level_index, SUF_TABLE), - view_model, - [row for row in view_model.rows if row.level == level_index], - ) + def row(self, key: str) -> Optional[StemRowViewModel]: + """The row a gesture named, as the list last rendered it.""" + return self._view.row(key) - def _create_table( - self, - tag: str, - view_model: StemsListViewModel, - rows: Sequence[StemRowViewModel], - ) -> None: - """One grid of rows: the master box, the name, a column per channel in play, the button.""" - with dpg.table( - tag=tag, - parent=self._body_tag, - header_row=False, - policy=dpg.mvTable_SizingFixedFit, - resizable=False, - ): - if self._master_checkbox: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.master_column_width) - - dpg.add_table_column(width_stretch=True) - for _channel_name in view_model.channels_in_play: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.channel_column_width) - - if self._removable: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) - - for row in rows: - self._create_row(row, view_model) - - def _create_row(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: - with dpg.table_row(tag=self.row_tag(row.key, SUF_GROUP)): - if self._master_checkbox: - self._create_master(row) - - self._create_name(row) - for channel_name in view_model.channels_in_play: - self._create_channel(row, channel_name) - - if self._removable: - self._create_remove(row) - - def _create_master(self, row: StemRowViewModel) -> None: - """The box moving every channel the row offers at once.""" - master = dpg.add_checkbox( - tag=self.row_tag(row.key, SUF_CHECKBOX), - default_value=row.takes_part, - user_data=row.key, - callback=self._on_master_changed, - ) - dpg.bind_item_handler_registry(master, self._master_handler_tag) - - def _create_name(self, row: StemRowViewModel) -> None: - """The row itself: what names the recording, what you drag it by, and what you drop onto.""" - name = dpg.add_selectable( - label=row.name, - tag=self.row_tag(row.key, SUF_TEXT), - user_data=row.key, - callback=self._on_name_clicked_off, - payload_type=self._payload, - drop_callback=self._on_dropped_on_row, - ) - if self._draggable: - with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._payload): - dpg.add_text(row.name) + @property + def picked_key(self) -> Optional[str]: + """The row standing picked out, which is what a key press acts on. - FontRegistry.bind_to_item(name, Font.REGULAR_SMALL) - dpg.bind_item_handler_registry(name, self._name_handler_tag) - show_tooltip(name, self._row_explanation(row), text_tag=self.row_tag(row.key, SUF_TOOLTIP)) + The reading is what holds the pick, so a key reaches the same row the settings card names + however the list is drawn: a long list scrolled past it builds its rows in slices, and a + card put away keeps them where they stand. + """ + return self._view.selected_key - def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: - """The box giving the recording a channel, where the recording holds frames on it. + @property + def lets_a_row_go(self) -> bool: + """Whether a row may be taken out: the list is live, and it holds more than it keeps. - A recording holding none on this channel leaves the cell open, so the columns keep - lining up across the rows while only a reachable choice is drawn. + A gesture reaching removal from outside the row's own button — a key press, a menu item — + asks this, so every way out of the list answers to the one rule the button reads. """ - if channel_name not in row.offered_channels: - dpg.add_spacer() - return + return self._view.live and self._rows.releasable(self._view) - checkbox_tag = self.channel_tag(row.key, channel_name) - dpg.add_checkbox( - label=channel_label(self._language_manager, channel_name), - tag=checkbox_tag, - default_value=channel_name in row.channels, - user_data=(row.key, channel_name), - callback=self._on_channels_changed, - ) - dpg.bind_item_handler_registry(checkbox_tag, self._channel_handler_tag) - - def _create_remove(self, row: StemRowViewModel) -> None: - remove = dpg.add_button( - label=self._lbl_remove, - tag=self.row_tag(row.key, SUF_BUTTON), - width=self._layout.remove_button_width, - user_data=row.key, - callback=self._on_remove_requested, - ) - FontRegistry.bind_to_item(remove, Font.MONO_SMALL) - ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) - dpg.bind_item_handler_registry(remove, self._button_handler_tag) + def stands_open(self, key: str) -> bool: + """Whether the folder's recordings are in view, which is what a menu names its move by.""" + return self._open_folders.stands_open(key) - def _render_row(self, row: StemRowViewModel) -> None: - """Draw what the row currently holds onto the widgets it already stands as. + def toggle_folder(self, key: str) -> None: + """Put a folder's recordings in view or away again, and draw the list as it now stands. - A row contributing nothing grays through its theme rather than through ``enabled``, so - it answers a drag and a right-click as readily as one in play. A box on a channel - switched off elsewhere takes the muted tone and stays as clickable as any other. + A folder closing over the row picked out takes that row off the list, so the pick is + reported as gone and the reading that comes back names none. This is the one gesture that + moves a pick without landing on a row, which is what keeps the pick and the rows in step. """ - for channel_name in self._row_boxes(row): - tag = self.channel_tag(row.key, channel_name) - dpg_configure_item(tag, enabled=self._live) - dpg_set_value(tag, channel_name in row.channels) - ThemeRegistry.get(self._channel_theme(channel_name)).bind_to_item(tag) - - name_tag = self.row_tag(row.key, SUF_TEXT) - dpg_set_value(name_tag, False) - dpg_configure_item(name_tag, enabled=self._live) - dpg_set_value(self.row_tag(row.key, SUF_TOOLTIP), self._row_explanation(row)) - row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.in_play else TAG_GLOBAL_THEME_STEMS_ROW_INERT - ThemeRegistry.get(row_theme).bind_to_item(name_tag) - if self._master_checkbox: - master_tag = self.row_tag(row.key, SUF_CHECKBOX) - dpg_configure_item(master_tag, enabled=self._live and row.offers_channels) - dpg_set_value(master_tag, row.takes_part) - - if self._removable: - dpg_configure_item(self.row_tag(row.key, SUF_BUTTON), enabled=self._live and self._releasable) + closing = self._open_folders.stands_open(key) and self._holds_picked(key) + self._open_folders.toggle(key) + self.update_view(self._view) + if closing: + self.call(self.on_selection_cleared) + + def _holds_picked(self, key: str) -> bool: + """Whether the folder stands for the row picked out, which closing it would take away.""" + row = self._view.row(key) + picked = self._view.selected_key + return row is not None and any(held.key == picked for held in row.held) @property - def _releasable(self) -> bool: - """Whether a row may leave, which a list holding on to its last one answers by its count.""" - return len(self._rows) > 1 or not self._retain_last_row - - def _row_boxes(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: - """The channels the row actually draws a box for, in the order the columns stand.""" - return tuple(channel_name for channel_name in self._channels_in_play if channel_name in row.offered_channels) - - def _channel_theme(self, channel_name: ChannelName) -> str: - """The tone a channel's boxes take: its own color, muted where the channel is off.""" - if channel_name in self._muted_channels: - return TAG_GLOBAL_THEME_CHANNEL_MUTED - - return CHANNEL_THEME_TAGS[channel_name] - - def _row_explanation(self, row: StemRowViewModel) -> str: - """What the row's hover states: where the recording is, why it is grayed out where it - contributes nothing, and how it moves where the list lets it.""" - lines = [str(row.path)] - if not row.available: - lines.append(self._msg_missing) - elif not row.offers_channels: - lines.append(self._msg_unoffered) - elif not row.takes_part: - lines.append(self._msg_inert) - - if self._draggable: - lines.append(self._msg_drag) - - return "\n".join(lines) - - def _name_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: - row = self._rows.get(user_data) - if row is None: - return "" + def _following(self) -> bool: + """A region stands as something other than it will, so the list settles it once more.""" + return self._region.settling or self._folders.following - if self._draggable: - return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) + def _settle_soon(self) -> None: + """Ask to read the drawn rows back once the frame that placed them has been rendered. - if self.activatable: - return self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name) - - return row.name - - def _channel_message( - self, - *_args: Any, - user_data: Tuple[str, ChannelName], - **_kwargs: Any, - ) -> str: - key, channel_name = user_data - row = self._rows.get(key) - if row is None: - return "" - - channel = channel_label(self._language_manager, channel_name) - if channel_name in self._muted_channels: - return self._language_manager["global.stems.message.status_channel_muted"].format( - channel=channel, - name=row.name, - ) - - return self._language_manager["global.stems.message.status_channel"].format( - channel=channel, - name=row.name, - ) - - def _master_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: - row = self._rows.get(user_data) - if row is None: - return "" - - return self._language_manager["global.stems.message.status_master"].format(name=row.name) - - def _remove_message(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: - row = self._rows.get(user_data) - if row is None: - return "" - - return self._language_manager["global.stems.message.status_remove"].format(name=row.name) - - def _on_channels_changed( - self, - _sender: Sender, - _value: bool, - user_data: Tuple[str, ChannelName], - ) -> None: - key, _channel_name = user_data - row = self._rows.get(key) - if row is None: - return - - channels = frozenset( - channel_name for channel_name in self._row_boxes(row) if dpg.get_value(self.channel_tag(key, channel_name)) - ) - self.call(self.on_channels_changed, key, channels) - - def _on_master_changed(self, _sender: Sender, value: bool, user_data: str) -> None: - """The master box hands the row every channel it offers, or takes them all away.""" - row = self._rows.get(user_data) - if row is None: - return - - channels = frozenset(self._row_boxes(row)) if value else frozenset() - self.call(self.on_channels_changed, user_data, channels) - - def _on_remove_requested(self, _sender: Sender, _app_data: Any, user_data: str) -> None: - self.call(self.on_remove_requested, user_data) - - def _on_name_clicked_off(self, sender: Sender, _value: bool, user_data: str) -> None: - """Let go of a clicked row and hand it on: the list names recordings, it selects none.""" - dpg_set_value(sender, False) - if self.activatable: - self.call(self.on_row_activated, user_data) - - def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: - mouse_button, clicked_item = app_data - if mouse_button != dpg.mvMouseButton_Right: + The list keeps this going for as long as a region it drew is holding rows back, so a + region refills itself rather than waiting on a frame hook an owner remembered to wire. A + list short enough to be drawn whole asks for nothing after the frame that placed it. + """ + if self._settling: return - key = dpg.get_item_user_data(clicked_item) - if isinstance(key, str): - self.call(self.on_menu_requested, key) - - def _on_dropped_on_row(self, sender: Sender, app_data: str) -> None: - """A recording was dropped on a row, so it joins that row's level at its place.""" - target = dpg.get_item_user_data(sender) - if isinstance(target, str): - self.call(self.on_dropped_on_row, app_data, target) - - def _on_dropped_on_level(self, sender: Sender, app_data: str) -> None: - """A recording was dropped in a gap, so it takes a level of its own there.""" - position = dpg.get_item_user_data(sender) - if isinstance(position, int): - self.call(self.on_dropped_on_level, app_data, position) - - def row_tag(self, key: str, suffix: str) -> str: - """The tag one of a row's widgets carries, which is how anything outside addresses it.""" - return compose_tag(self._prefix, SUF_ROW, key, suffix) - - def level_tag(self, level_index: int, suffix: str) -> str: - """The tag one of a band's widgets carries: its caption, its table, or the strip above it.""" - return compose_tag(self._prefix, SUF_LEVEL, str(level_index), suffix) - - def channel_tag(self, key: str, channel_name: ChannelName) -> str: - """The tag the box giving ``key`` a channel carries.""" - return compose_tag( - self._prefix, - SUF_ROW, - key, - SUF_CHANNELS, - compose_tag(channel_name, SUF_CHECKBOX), - ) + self._settling = True + FrameCallbackManager.set_frame_callback(self._settle) + + def _settle(self) -> None: + """Read back what the regions drew, refill the ones a scroll has moved on from, and keep + watching for as long as one of them holds rows it has yet to build.""" + self._settling = False + if self._region.settle() and self._windows(self._view): + self._draw_window(self._view) + self._repaint(self._view) + + for key in self._folders.settle(): + self._folders.redraw(key, self._view) + row = self._view.row(key) + if row is not None: + self._folders.repaint(row, self._view) + + if self._following: + self._settle_soon() diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py new file mode 100644 index 000000000..2af4a1b66 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -0,0 +1,156 @@ +from typing import Any, Optional, Tuple + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.host import StemsListHost +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName + + +class StemsMessages: + """What a stems list puts to a reader: the hover explanation and the status-bar line. + + The status bar asks the widget under the pointer to explain itself, so there is one answer per + widget kind and each reads the row the widget carries. What a list offers decides which answer + a row gives, which is why the offer is stated once and read here. + """ + + def __init__( + self, + language_manager: LanguageManager, + *, + offer: StemsListOffer, + open_folders: OpenFolders, + host: StemsListHost, + ) -> None: + self._language_manager = language_manager + self._offer = offer + self._open_folders = open_folders + self._host = host + self._view = StemsListViewModel.empty() + self._msg_drag = language_manager["global.stems.message.drag_tooltip"] + self._msg_inert = language_manager["global.stems.message.inert_tooltip"] + self._msg_missing = language_manager["global.stems.message.missing_tooltip"] + self._msg_unoffered = language_manager["global.stems.message.unoffered_tooltip"] + self._msg_folder = language_manager["global.stems.message.folder_tooltip"] + + def reads(self, view_model: StemsListViewModel) -> None: + """Takes up the view the list is drawing, which is what every answer is read from.""" + self._view = view_model + + def row_explanation(self, row: StemRowViewModel) -> str: + """What the row's hover states: where the source is, why it is grayed out where it + contributes nothing, and how it moves where the list lets it.""" + lines = [str(row.path)] + if row.stands_for_a_folder: + lines.append(self._msg_folder) + elif not row.available: + lines.append(self._msg_missing) + elif not row.offers_channels: + lines.append(self._msg_unoffered) + elif not row.takes_part: + lines.append(self._msg_inert) + + if self._offer.drags(self._view): + lines.append(self._msg_drag) + + return "\n".join(lines) + + def name(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + """The status line a row's name puts up: what the gestures it takes would do to it.""" + row = self._row(user_data) + if row is None: + return "" + + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_row"].format( + name=row.name, + count=row.holds, + ) + + return " ".join(self._row_gestures(row)) + + def _row_gestures(self, row: StemRowViewModel) -> Tuple[str, ...]: + """What a reader can do to a recording, in the order the list offers it. + + A list that sounds a row says so, since a double-click is the one gesture nothing on the + row draws; a list that neither drags nor reveals reads as the name alone. + """ + lines: Tuple[str, ...] = () + if self._offer.drags(self._view): + lines += (self._language_manager["global.stems.message.status_row_drag"].format(name=row.name),) + if self._host.activatable: + lines += (self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name),) + + if self._host.playable: + lines += (self._language_manager["global.stems.message.status_row_play"].format(name=row.name),) + + return lines or (row.name,) + + def channel( + self, + *_args: Any, + user_data: Tuple[str, ChannelName], + **_kwargs: Any, + ) -> str: + key, channel_name = user_data + row = self._row(key) + if row is None: + return "" + + channel = channel_label(self._language_manager, channel_name) + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_channel"].format( + channel=channel, + name=row.name, + ) + + if channel_name in self._view.muted_channels: + return self._language_manager["global.stems.message.status_channel_muted"].format( + channel=channel, + name=row.name, + ) + + return self._language_manager["global.stems.message.status_channel"].format( + channel=channel, + name=row.name, + ) + + def master(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._row(user_data) + if row is None: + return "" + + return self._language_manager["global.stems.message.status_master"].format(name=row.name) + + def remove(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._row(user_data) + if row is None: + return "" + + if row.stands_for_a_folder: + return self._language_manager["global.stems.message.status_folder_remove"].format(name=row.name) + + return self._language_manager["global.stems.message.status_remove"].format(name=row.name) + + def twisty(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + """What the marker beside a folder's name does from where it now stands.""" + row = self._row(user_data) + if row is None: + return "" + + return self._language_manager[ + ( + "global.stems.message.status_folder_close" + if self._open_folders.stands_open(user_data) + else "global.stems.message.status_folder_open" + ) + ].format(name=row.name) + + def _row(self, key: str) -> Optional[StemRowViewModel]: + return self._view.row(key) diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py new file mode 100644 index 000000000..7130bd064 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -0,0 +1,66 @@ +from dataclasses import dataclass +from typing import Final + +from sampletones_application.view_model.shared.stems import StemsListViewModel + + +@dataclass(frozen=True) +class StemsListOffer: + """What a stems list lets a reader do with a row, which is what its owner can answer for. + + The converter's gathered recordings, a reconstruction's recorded assignment and the question + of which recordings to mix are the same rows drawn the same way; what differs is the gestures + each owner honors. A list states that here, once, so a drawing step reads one declaration + rather than asking a flag of its own. + + ``bends`` states that a channel's cell carries the bend on it beside the channel itself, which + a list recording what a finished conversion took draws and a list setting a run up leaves to + the settings card. + + ``picking`` states that the box beside a row picks the row for a mix rather than answering for + its channels, which is the reading a list asking which recordings to mix draws. + """ + + master_box: bool + removal: bool + keeps_last_row: bool + dragging: bool + bends: bool + picking: bool + + def drags(self, view_model: StemsListViewModel) -> bool: + """Whether a row is dragged, which the bands a drag rearranges are what it takes. + + A drag moves a recording between levels, so it reaches a list drawing them. Whoever draws + the row and whoever explains it both read this, so what a row offers and what it says it + offers are one answer. + """ + return self.dragging and not view_model.collapse_levels + + +GATHERED_SOURCES: Final[StemsListOffer] = StemsListOffer( + master_box=False, + removal=True, + keeps_last_row=False, + dragging=True, + bends=False, + picking=False, +) + +RECORDED_ASSIGNMENT: Final[StemsListOffer] = StemsListOffer( + master_box=True, + removal=True, + keeps_last_row=True, + dragging=False, + bends=True, + picking=False, +) + +PICKED_SOURCES: Final[StemsListOffer] = StemsListOffer( + master_box=True, + removal=False, + keeps_last_row=False, + dragging=False, + bends=False, + picking=True, +) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py new file mode 100644 index 000000000..baf19a821 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -0,0 +1,347 @@ +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_TEXT, + SUF_TOOLTIP, + SUF_TWISTY, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_MARKER, + TAG_GLOBAL_THEME_STEMS_PICK, + TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL, + TAG_GLOBAL_THEME_STEMS_ROW, + TAG_GLOBAL_THEME_STEMS_ROW_INERT, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import NO_INDENT, StemsColumns +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.gestures import StemsGestures +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.channels import ( + CHANNEL_THEME_TAGS, + PARTIAL_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 +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName + + +class StemRowRenderer: + """One row of a stems list: the widgets it stands as, and what it currently holds. + + A row is the master box, the name, a box per channel in play, and the button that takes it + out — whichever of those the list offers. It answers for one row against the view it is drawn + from, and knows nothing about the bands the rows are grouped into. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + offer: StemsListOffer, + glyphs: CommonGlyphs, + open_folders: OpenFolders, + language_manager: LanguageManager, + messages: StemsMessages, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._glyphs = glyphs + self._open_folders = open_folders + self._language_manager = language_manager + self._messages = messages + self._gestures = gestures + self._lbl_remove = language_manager["global.stems.label.remove"] + self._folder_template = language_manager["global.stems.template.folder_row"] + + def create( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """Build the widgets one row stands as, in the columns its grid was declared with. + + A folder's row takes a band of its own behind it, which is what reads a group apart + from the recordings standing loose around it, and the band lies within the row's height. + """ + with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)) as line: + if row.stands_for_a_folder: + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_GROUP_ROW).bind_to_item(line) + + if self._offer.master_box: + self._create_master(row, view_model, columns) + + self._create_name(row, view_model, columns) + for channel_name in view_model.channels_in_play: + self._create_channel(row, channel_name, columns) + + if self._offer.removal: + self._create_remove(row) + + if columns.reserved: + dpg.add_spacer() + + def repaint(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Draw what the row currently holds onto the widgets it already stands as. + + A row contributing nothing grays through its theme rather than through ``enabled``, so + it answers a drag and a right-click as readily as one in play. A box on a channel + switched off elsewhere takes the muted tone and stays as clickable as any other. + + A folder reads out how many recordings it stands for, so its label is written here as well + as at the draw: a recording leaving the folder is answered inside the folder's own region, + and the count on the row above it follows from the same reading. + """ + live = view_model.live + for channel_name in view_model.boxes_of(row): + tag = self._tags.channel(row.key, channel_name) + agreement = row.agreement_on(channel_name) + dpg_configure_item(tag, enabled=live) + dpg_set_value(tag, agreement.reads_held) + ThemeRegistry.get(self._channel_theme(channel_name, agreement, view_model)).bind_to_item(tag) + + name_tag = self._tags.row(row.key, SUF_TEXT) + dpg_set_value(name_tag, row.key == view_model.selected_key) + dpg_configure_item(name_tag, enabled=live, label=self._row_label(row)) + dpg_set_value(self._tags.row(row.key, SUF_TOOLTIP), self._messages.row_explanation(row)) + row_theme = TAG_GLOBAL_THEME_STEMS_ROW if row.in_play else TAG_GLOBAL_THEME_STEMS_ROW_INERT + ThemeRegistry.get(row_theme).bind_to_item(name_tag) + + if self._offer.master_box: + master_tag = self._tags.row(row.key, SUF_CHECKBOX) + dpg_configure_item(master_tag, enabled=live and self._master_reaches(row, view_model)) + dpg_set_value(master_tag, self._master_value(row, view_model)) + self._tone_master(row, view_model) + + if self._offer.removal: + dpg_configure_item(self._tags.row(row.key, SUF_BUTTON), enabled=live and self.releasable(view_model)) + + def releasable(self, view_model: StemsListViewModel) -> bool: + """Whether a row may leave, which a list holding on to its last one answers by its count. + + The button on the row reads this, and so does every gesture reaching removal from outside + the row — a key press, a menu item — so one rule answers them all. + """ + return view_model.row_count > 1 or not self._offer.keeps_last_row + + def _create_master( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """The box beside the row: what picks it for a mix, or what moves its channels at once.""" + master = dpg.add_checkbox( + tag=self._tags.row(row.key, SUF_CHECKBOX), + indent=columns.master_indent, + default_value=self._master_value(row, view_model), + user_data=row.key, + callback=self._gestures.on_pick_box if self._offer.picking else self._gestures.on_master_box, + ) + self._gestures.bind(master, SUF_CHECKBOX) + self._tone_master(row, view_model) + + def _master_reaches(self, row: StemRowViewModel, view_model: StemsListViewModel) -> bool: + """Whether the box beside the row answers a click: the pick has room, or the row has boxes.""" + if self._offer.picking: + return view_model.reaches(row) + + return row.offers_channels + + def _master_value(self, row: StemRowViewModel, view_model: StemsListViewModel) -> bool: + """What the box beside the row reads: whether it is picked, or whether it takes part.""" + if self._offer.picking: + return view_model.picking_of(row).reads_held + + return row.takes_part + + def _tone_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Fill a picking box where the folder it stands for is picked only in part. + + A tick states an answer the folder has yet to give, so a half-picked one reads clear and + takes the accent as a fill instead: the reader sees at a glance that some of what the + folder holds is going into the mix, and one click settles the whole of it either way. + """ + if not self._offer.picking: + return + + agreement = view_model.picking_of(row) + theme = TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL if agreement is Agreement.SOME else TAG_GLOBAL_THEME_STEMS_PICK + ThemeRegistry.get(theme).bind_to_item(self._tags.row(row.key, SUF_CHECKBOX)) + + def _create_name( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + columns: StemsColumns, + ) -> None: + """The row itself: what names the source, what you drag it by, and what you drop onto. + + A folder leads with the marker that opens it, and a recording standing loose beside one + opens where that marker's glyph does, so the names read as one column. The name takes the + height its boxes take, so the band a row reads as covers the whole of what stands beside it. + """ + with dpg.group(horizontal=True): + self._create_disclosure(row) + name = dpg.add_selectable( + label=self._row_label(row), + tag=self._tags.row(row.key, SUF_TEXT), + height=self._layout.name_height, + indent=self._name_indent(row, columns), + user_data=row.key, + callback=self._gestures.on_name_selected, + payload_type=self._tags.payload, + drop_callback=self._gestures.on_row_drop, + ) + if self._offer.drags(view_model): + with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._tags.payload): + dpg.add_text(row.name) + + FontRegistry.bind_to_item(name, Font.BOLD_SMALL if row.stands_for_a_folder else Font.REGULAR_SMALL) + self._gestures.bind(name, SUF_TEXT) + show_tooltip( + name, + self._messages.row_explanation(row), + text_tag=self._tags.row(row.key, SUF_TOOLTIP), + ) + + def _name_indent(self, row: StemRowViewModel, columns: StemsColumns) -> int: + """How far the row's name sits in: a folder opens at its marker, anything else at the glyph.""" + if row.stands_for_a_folder: + return NO_INDENT + + return columns.marker_indent(self._glyphs.collapsed, Font.ICON) + + def _create_disclosure(self, row: StemRowViewModel) -> None: + """The marker a folder opens by, which stands beside the folder's own name. + + The marker is drawn to the height of the name it leads, its glyph filling that frame, + which stands a folder's row in the rhythm every other row keeps. Its frame is the room it + was given, so the shading the pointer draws covers the marker exactly. + """ + if not row.stands_for_a_folder: + return + + twisty = dpg.add_button( + label=self._twisty_glyph(row.key), + tag=self._tags.row(row.key, SUF_TWISTY), + width=self._layout.twisty_width, + height=self._layout.name_height, + user_data=row.key, + callback=self._gestures.on_twisty, + ) + FontRegistry.bind_to_item(twisty, Font.ICON) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_MARKER).bind_to_item(twisty) + self._gestures.bind(twisty, SUF_TWISTY) + + def _twisty_glyph(self, key: str) -> str: + """The marker stating whether the folder's recordings are in view.""" + if self._open_folders.stands_open(key): + return self._glyphs.expanded + + return self._glyphs.collapsed + + def _row_label(self, row: StemRowViewModel) -> str: + """What the row reads as: the source's name, and for a folder how many it stands for.""" + if not row.stands_for_a_folder: + return row.name + + return self._folder_template.format(name=row.name, count=row.holds) + + def _create_channel( + self, + row: StemRowViewModel, + channel_name: ChannelName, + columns: StemsColumns, + ) -> None: + """The box giving the recording a channel, where the recording holds frames on it. + + The box carries no label of its own: the heading names the channel once for the whole + column, and the box stands in the middle of that column under it. A recording holding no + frames on this channel leaves the cell open, so the columns keep lining up across the rows + while only a reachable choice is drawn. + """ + if channel_name not in row.offered_channels: + dpg.add_spacer() + return + + checkbox_tag = self._tags.channel(row.key, channel_name) + with dpg.group(horizontal=True, indent=columns.box_indent(channel_name)): + dpg.add_checkbox( + tag=checkbox_tag, + default_value=row.agreement_on(channel_name).reads_held, + user_data=(row.key, channel_name), + callback=self._gestures.on_channel_box, + ) + self._create_bend(row, channel_name, columns) + + self._gestures.bind(checkbox_tag, SUF_CHANNELS) + + def _create_bend( + self, + row: StemRowViewModel, + channel_name: ChannelName, + columns: StemsColumns, + ) -> None: + """The box stating the bend the recording carried on this channel, where the list draws one. + + A list describing a conversion that has already run states what it took, so the box reports + rather than asks: the choice was made when the reconstruction was written. + """ + if not columns.bends or channel_name not in row.bendable_channels: + return + + dpg.add_checkbox( + tag=self._tags.bend(row.key, channel_name), + default_value=channel_name in row.bends, + enabled=False, + ) + + def _create_remove(self, row: StemRowViewModel) -> None: + remove = dpg.add_button( + label=self._lbl_remove, + tag=self._tags.row(row.key, SUF_BUTTON), + width=self._layout.remove_button_width, + user_data=row.key, + callback=self._gestures.on_remove_button, + ) + FontRegistry.bind_to_item(remove, Font.MONO_SMALL) + ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) + self._gestures.bind(remove, SUF_BUTTON) + + def _channel_theme( + self, + channel_name: ChannelName, + agreement: Agreement, + view_model: StemsListViewModel, + ) -> str: + """The tone a channel's box takes: its own color, softened where the row half-holds it, + muted where a choice made elsewhere has switched the channel off.""" + if channel_name in view_model.muted_channels: + return TAG_GLOBAL_THEME_CHANNEL_MUTED + + if agreement is Agreement.SOME: + return PARTIAL_CHANNEL_THEME_TAGS[channel_name] + + return CHANNEL_THEME_TAGS[channel_name] diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py new file mode 100644 index 000000000..1f820128b --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -0,0 +1,119 @@ +from dataclasses import dataclass, replace +from typing import FrozenSet, List, Self, Tuple + +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class Reshape: + """What a new reading asks of a list that has already drawn one. + + ``whole`` asks for the list afresh, which is what a change to the columns, the banding or the + rows themselves comes to. ``folders`` names the folders whose own regions are drawn again, + which is what a change confined to the recordings a folder stands for comes to: the rows + around it keep the widgets they stand as, and the reader keeps the scroll they left. + """ + + whole: bool + folders: Tuple[str, ...] + + @classmethod + def everything(cls) -> Self: + """What a reading asks for once it moves more than a repaint settles.""" + return cls(whole=True, folders=()) + + @classmethod + def within(cls, folders: Tuple[str, ...]) -> Self: + """What a reading that moved the recordings of these folders alone asks for. + + Naming no folder is what a reading the list already stands at asks for, which a repaint + answers on its own. + """ + return cls(whole=False, folders=folders) + + @property + def redraws(self) -> bool: + """Widgets are built, so whoever answers settles the regions once the frame has drawn.""" + return self.whole or bool(self.folders) + + +@dataclass(frozen=True) +class RowPlacement: + """Where one row stands: what it is, which band holds it, and which boxes it draws. + + ``held`` names the recordings a folder stands for, so one of them leaving is met by the folder + it left, and the region that folder opens onto is the only thing drawn again. + """ + + key: str + level: int + offered: FrozenSet[ChannelName] + opened: bool + held: Tuple[str, ...] + + def stands_where(self, other: Self) -> bool: + """Both placements put the same row in the same place, whatever it now holds.""" + return replace(self, held=()) == replace(other, held=()) + + +@dataclass(frozen=True) +class ListShape: + """What the bands are built from, so a change here is a redraw and anything else a repaint. + + Which channels a row holds is drawn onto the widgets already standing, so a tick keeps the + bands as they are and the pointer keeps whatever it was over. + """ + + columns: Tuple[ChannelName, ...] + collapsed: bool + rows: Tuple[RowPlacement, ...] + + @classmethod + def of(cls, view_model: StemsListViewModel, open_folders: OpenFolders) -> Self: + """The shape a view amounts to, which is what a list compares against what it drew.""" + return cls( + columns=view_model.channels_in_play, + collapsed=view_model.collapse_levels, + rows=tuple( + RowPlacement( + key=row.key, + level=row.level, + offered=row.offered_channels, + opened=open_folders.stands_open(row.key), + held=tuple(recording.key for recording in row.held), + ) + for row in view_model.rows + ), + ) + + @classmethod + def nothing(cls) -> Self: + """The shape a list stands at before it has drawn anything.""" + return cls(columns=(), collapsed=False, rows=()) + + def against(self, standing: Self) -> Reshape: + """What a list standing at ``standing`` is asked for to come to this shape. + + A folder opening or closing, a row arriving or leaving, and a change to the columns or the + banding all reach the whole list, since the tables are built around them. A recording + leaving the folder it was gathered under reaches that folder alone: the region below its + row draws a row for each recording the folder still stands for, and the row itself reads + out how many that is. + """ + if self.columns != standing.columns or self.collapsed != standing.collapsed: + return Reshape.everything() + + if len(self.rows) != len(standing.rows): + return Reshape.everything() + + folders: List[str] = [] + for row, stood in zip(self.rows, standing.rows): + if not row.stands_where(stood): + return Reshape.everything() + + if row.held != stood.held: + folders.append(row.key) + + return Reshape.within(tuple(folders)) diff --git a/src/sampletones_application/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py new file mode 100644 index 000000000..fa6dabf0a --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -0,0 +1,95 @@ +from dataclasses import dataclass + +from sampletones_application.tags.compose import compose_tag, identity_part +from sampletones_application.tags.general import ( + SUF_BENDS, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_FOLDER, + SUF_GROUP, + SUF_HANDLER_REGISTRY, + SUF_LEVEL, + SUF_PAYLOAD, + SUF_REGION, + SUF_ROW, + SUF_TABLE, + SUF_WELL, +) +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class StemsTags: + """The tag grammar one stems list draws under. + + Every widget a list builds is named from the list's own prefix, so the grammar stands in one + place and whatever addresses a row — the list, its handlers, a test — spells it the same way. + + A row is named by its key, which is the path the recording was gathered from. A tag part + lowercases that path and collapses its whitespace, so the key stands beside an identity part + that keeps two recordings of one normalized name — ``Kick.wav`` and ``kick.wav`` gathered + together — on widgets of their own. + """ + + prefix: str + + @property + def well(self) -> str: + """The recessed region the list is drawn in, which is what an owner shows and hides.""" + return compose_tag(self.prefix, SUF_WELL) + + @property + def body(self) -> str: + """The group the bands are built into, which a rebuild empties.""" + return compose_tag(self.well, SUF_GROUP) + + @property + def table(self) -> str: + """The one table every row stands in while the levels are collapsed.""" + return compose_tag(self.prefix, SUF_TABLE) + + def segment(self, position: int) -> str: + """One run of rows standing between two folders, each declaring the same columns. + + A folder breaks the run it stands in, so the recordings on either side of one line up in + tables of their own; the first of them is the list's own table, and the only one a list + holding no folder draws. + """ + return self.table if position == 0 else compose_tag(self.table, str(position)) + + def folder(self, key: str, suffix: str) -> str: + """The tag one of an open folder's own widgets carries: its region, or the rows in it.""" + return compose_tag(self.prefix, SUF_FOLDER, key, identity_part(key), suffix) + + def region(self, key: str) -> str: + """The bounded space a folder's recordings scroll in while it stands open.""" + return self.folder(key, SUF_REGION) + + def held(self, key: str) -> str: + """The table the recordings inside one open folder stand in.""" + return self.folder(key, compose_tag(SUF_REGION, SUF_TABLE)) + + @property + def payload(self) -> str: + """The kind of payload this list's drags carry, so one list's rows land in it alone.""" + return compose_tag(self.prefix, SUF_PAYLOAD) + + def handlers(self, kind: str) -> str: + """The registry the widgets of one kind share, which is where their hover is answered.""" + return compose_tag(self.prefix, kind, SUF_HANDLER_REGISTRY) + + def row(self, key: str, suffix: str) -> str: + """The tag one of a row's widgets carries, which is how anything outside addresses it.""" + return compose_tag(self.prefix, SUF_ROW, key, identity_part(key), suffix) + + def level(self, level_index: int, suffix: str) -> str: + """The tag one of a band's widgets carries: its caption, its table, or the strip above it.""" + return compose_tag(self.prefix, SUF_LEVEL, str(level_index), suffix) + + def channel(self, key: str, channel_name: ChannelName) -> str: + """The tag the box giving ``key`` a channel carries.""" + return self.row(key, compose_tag(SUF_CHANNELS, channel_name, SUF_CHECKBOX)) + + def bend(self, key: str, channel_name: ChannelName) -> str: + """The tag the box stating the bend ``key`` took on a channel carries.""" + return self.row(key, compose_tag(SUF_BENDS, channel_name, SUF_CHECKBOX)) diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py index c4e375e81..cad282c86 100644 --- a/src/sampletones_application/ui/elements/tree/tag.py +++ b/src/sampletones_application/ui/elements/tree/tag.py @@ -1,29 +1,20 @@ -from typing import Final - -from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.compose import compose_tag, identity_part 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. + The names read the row back to whoever inspects the widget tree, and the identity part 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. """ names = "_".join(str(ancestor.name) for ancestor in node.path) - return compose_tag(panel_tag, f"node_{names}", _node_digest(node)) + return compose_tag(panel_tag, f"node_{names}", _node_identity(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)) +def _node_identity(node: TreeNode) -> str: + return identity_part( + *(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/window.py b/src/sampletones_application/ui/elements/window.py index bf7535e31..41a3138ab 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -30,9 +30,14 @@ class GUIWindow(GUIPanel, ABC): A window holding prose of a length it learns at the moment it opens sets ``_fits_content``, which lets it grow past the height it states. + + A window claims the screen while it stands, so the reader answers it before going on. One + reporting work already under way clears ``_claims_the_screen`` instead, which leaves the rest + of the interface live beside it. """ _fits_content: bool = False + _claims_the_screen: bool = True def yield_to(self, raise_modal: VoidCallback) -> None: """Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open. @@ -86,7 +91,7 @@ def dialog_window( no_collapse=True, no_close=on_close is None, on_close=on_close, - modal=True, + modal=self._claims_the_screen, **geometry, ): yield diff --git a/src/sampletones_application/ui/panels/dialogs/export.py b/src/sampletones_application/ui/panels/dialogs/export.py index 0fa69707d..25f53c6f9 100644 --- a/src/sampletones_application/ui/panels/dialogs/export.py +++ b/src/sampletones_application/ui/panels/dialogs/export.py @@ -39,7 +39,7 @@ class GUIExportWindow(GUIDialogWindow): the foot of the list, and it carries either a bar filling toward its end or the turning indicator of work whose length the data decides. - Cancelling is offered for as long as the run can still answer one. + Canceling is offered for as long as the run can still answer one. """ _fits_content = True diff --git a/src/sampletones_application/ui/panels/dialogs/scanning.py b/src/sampletones_application/ui/panels/dialogs/scanning.py new file mode 100644 index 000000000..e0710f043 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/scanning.py @@ -0,0 +1,129 @@ +from pathlib import Path +from typing import Any, Final, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.general import TAG_GLOBAL_THEME_DANGER_BUTTON +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_PROGRESS_SCAN, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_WINDOW_SCAN, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.window import GUIWindow +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.frame import FrameCallbackManager +from sampletones_shared.types.callback import VoidCallback + +ROLL_STEP: Final[float] = 0.02 +ROLL_WIDTH: Final[float] = 0.25 +FULL: Final[float] = 1.0 +NOTHING: Final[float] = 0.0 + + +class GUIScanWindow(GUIWindow): + """What the reader is waiting for while a folder is being read. + + Reading a folder of thousands takes seconds, so the wait is put on screen: the folder being + read, how many recordings have turned up so far, an indicator that keeps moving while the walk + does, and a **Stop**. The window stands beside the interface rather than over it, so the reader + carries on with everything else while it reads. + + The indicator rolls rather than filling, since how much of a tree is left is not known until + the walk reaches the end of it. + """ + + _claims_the_screen = False + + def __init__( + self, + *, + layout: ConverterLayout, + language_manager: LanguageManager, + ) -> None: + self._title = language_manager["main.converter.title.scan_dialog"] + self._opening = language_manager["main.converter.message.scan_opening"] + self._progress = language_manager["main.converter.template.scan_progress"] + self._stop_label = language_manager["main.converter.label.stop_scan_button"] + self._root = Path() + self._rolling = False + self._position = NOTHING + + self.on_stop: Optional[VoidCallback] = None + + super().__init__( + tag=TAG_MAIN_CONVERTER_WINDOW_SCAN, + width=layout.scan.width, + height=layout.scan.height, + ) + + def open(self, root: Path) -> None: + """Says which folder is being read, and starts the indicator moving.""" + self._root = root + self._position = NOTHING + self.show() + self._roll_on() + + def close(self) -> None: + """Takes the window away, which is what a walk ending or giving up leaves behind.""" + self._rolling = False + self.hide() + + def report(self, count: int) -> None: + """Says how many recordings the walk has met so far.""" + dpg_set_value( + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + self._progress.format(count=count, name=self._root.name), + ) + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The folder is named by :meth:`open` before the tree is built.""" + + def create_window(self) -> None: + with self.dialog_window(label=self._title, on_close=None): + dpg.add_text( + self._opening.format(name=self._root.name), + tag=TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + wrap=self.width, + ) + dpg.add_progress_bar( + tag=TAG_MAIN_CONVERTER_PROGRESS_SCAN, + default_value=NOTHING, + width=-1, + ) + dpg.add_separator() + GUIButton( + tag=TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + label=self._stop_label, + callback=self._stop, + width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), + ) + + def _stop(self) -> None: + """Takes the window away and asks the walk to give up, which is what **Stop** means here. + + The window answers the gesture itself rather than the walk's next word, so pressing + **Stop** is what closes it however the reading ends. A walk that reaches the end of its + tree finds the window already gone and takes it away again, which leaves it where it is. + """ + self.close() + self.call(self.on_stop) + + def _roll_on(self) -> None: + """Keeps the indicator moving for as long as the walk it stands for runs.""" + self._rolling = True + FrameCallbackManager.set_frame_callback(self._roll) + + def _roll(self) -> None: + if not self._rolling or not dpg.does_item_exist(TAG_MAIN_CONVERTER_PROGRESS_SCAN): + return + + self._position = (self._position + ROLL_STEP) % (FULL + ROLL_WIDTH) + dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS_SCAN, min(self._position, FULL)) + dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS_SCAN, overlay="") + FrameCallbackManager.set_frame_callback(self._roll) diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index b8751d5a6..f162bda35 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -1,10 +1,12 @@ from pathlib import Path -from typing import Any, Callable, Final, List, Optional, Sequence, Tuple +from typing import Any, Callable, Final, FrozenSet, List, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs from sampletones_application.layout.tabs.main.converter import ConverterLayout -from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.main import ( PRE_MAIN_CONVERTER_CANDIDATE, TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, @@ -15,26 +17,52 @@ ) from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import PICKED_SOURCES 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.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_shared.types.callback import PathCallback + +Answer = Callable[[List[Path]], None] ADD_FOCUS_STOP: Final[int] = 1 +NO_PICK: Final[int] = 0 class GUIStemSelectionWindow(GUIDialogWindow): - """A modal offering the recordings a folder holds, with the ones that fit already ticked. + """The question of which recordings a mix is built from, drawn the way the converter's list is. + + A mix reaches a fixed number of recordings, so a longer list is put to the reader as the same + rows the card shows — folders standing as folders, opening onto what they hold, each row + picked by the box beside it. As many as the mix has room for arrive picked, and any row moves, + so swapping the eighth for the ninth is a click each. The line above reads what stands picked + against the room, and the mix is settled once the pick fits. - A folder can hold more recordings than one conversion has room for, so the reader is shown - what was found and which of it fits: the first ones up to the room left arrive ticked, the - rest stand disabled beneath a line stating the limit. What comes back is the reader's choice. + One layout answers both places a mix runs out of room: turning the output switch on a longer + list, and gathering a folder that overflows what is left. Both put the same question — which + recordings the mix is built from — so a folder is offered beside what the mix already stands + on and the answer names the whole of it. + + A double-click sounds the recording it lands on, the way the converter's own list does, so the + reader hears what a row stands for before deciding whether it belongs in the mix. """ def __init__( self, *, layout: ConverterLayout, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + language_manager: LanguageManager, + status_bar: GUIStatusBar, title: str, message: str, limit_template: str, @@ -48,11 +76,24 @@ def __init__( self._limit_template = limit_template self._add_label = add_label self._cancel_label = cancel_label - self._candidates: Tuple[Path, ...] = () - self._room = 0 self._footer_height = layout.stem_selection_footer + self._rows: Tuple[StemRowViewModel, ...] = () + self._picked: FrozenSet[str] = frozenset() + self._room = 0 + self._list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_CANDIDATE, + layout=stems_layout, + ceiling=layout.stem_selection_list, + glyphs=glyphs, + language_manager=language_manager, + status_bar=status_bar, + offer=PICKED_SOURCES, + ) + self.on_source_played: Optional[PathCallback] = None + self._answer: Optional[Answer] = None - self.on_add: Optional[Callable[[List[Path]], None]] = None + self._list.on_row_picked = self._on_picked + self._list.on_row_opened = lambda key: self.call(self.on_source_played, Path(key)) super().__init__( tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, @@ -62,14 +103,24 @@ def __init__( shortcut_source=shortcut_source, ) - def open(self, candidates: Sequence[Path], room: int) -> None: - """Shows the recordings found, ticking as many as the conversion still has room for.""" - self._candidates = tuple(candidates) + def open( + self, + rows: Sequence[StemRowViewModel], + room: int, + answer: Answer, + ) -> None: + """Shows the rows offered, picking as many recordings as the mix has room for. + + ``answer`` is what the pick reaches, which is the question this opening puts. + """ + self._rows = tuple(rows) self._room = room + self._answer = answer + self._picked = frozenset(recording.key for recording in self._view().recordings[:room]) self.show() def prepare(self, *_args: Any, **_kwargs: Any) -> None: - """The candidates and the room left are seeded by :meth:`open` before the tree rebuilds.""" + """The rows and the room left are seeded by :meth:`open` before the tree rebuilds.""" def create_window(self) -> None: with self.dialog_window(label=self._title, on_close=None): @@ -82,11 +133,12 @@ def create_window(self) -> None: height=-self._footer_height, border=False, ): - self._create_candidate_rows() + self._list.create(TAG_MAIN_CONVERTER_GROUP_STEM_SELECTION) dpg.add_separator() self._create_action_buttons() + self._render() self._install_navigation( [ FocusStop.button(TAG_MAIN_CONVERTER_BUTTON_CANCEL_STEMS, self.hide), @@ -96,16 +148,6 @@ def create_window(self) -> None: initial_index=ADD_FOCUS_STOP, ) - def _create_candidate_rows(self) -> None: - for index, candidate in enumerate(self._candidates): - fits = index < self._room - dpg.add_checkbox( - label=candidate.name, - tag=self._candidate_tag(candidate), - default_value=fits, - enabled=fits, - ) - @table_wrapper(columns=2) def _create_action_buttons(self) -> None: GUIButton( @@ -119,23 +161,58 @@ def _create_action_buttons(self) -> None: label=self._add_label, callback=self._add, width=-1, + enabled=self._fits, + ) + + def _view(self) -> StemsListViewModel: + """The rows as the question draws them: one plain run, with what stands picked.""" + return StemsListViewModel( + rows=self._rows, + channels_in_play=(), + muted_channels=frozenset(), + picked_keys=self._picked, + picking_room=self._room, + live=True, + collapse_levels=True, + selected_key=None, ) + def _render(self) -> None: + """Draw what stands picked: the rows, the line counting them, and whether the mix fits.""" + self._list.update_view(self._view()) + dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, self._limit_text()) + dpg_configure_item(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, enabled=self._fits) + def _limit_text(self) -> str: - return self._limit_template.format(self._room, len(self._candidates)) + return self._limit_template.format( + picked=len(self._picked), + total=len(self._view().recordings), + room=self._room, + ) - def _selected(self) -> List[Path]: - return [ - candidate - for candidate in self._candidates - if dpg.does_item_exist(self._candidate_tag(candidate)) and dpg.get_value(self._candidate_tag(candidate)) - ] + def _on_picked(self, key: str) -> None: + """Picks the recordings a row stands for, or lets them go where they all stand picked. + + A mix is built from a fixed number of recordings, so a row takes as many as the room left + reaches and a full pick waits for something to leave. + """ + view_model = self._view() + row = view_model.row(key) + if row is None: + return + + self._picked = view_model.picking_settled(row) + self._render() + + @property + def _fits(self) -> bool: + """The pick is one a mix can be built from: at least one recording, and no more than fit.""" + return NO_PICK < len(self._picked) <= self._room def _add(self) -> None: - selected = self._selected() - self.hide() - self.call(self.on_add, selected) + if not self._fits: + return - @staticmethod - def _candidate_tag(candidate: Path) -> str: - return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate)) + picked = list(self._view().picked_paths) + self.hide() + self.call(self._answer, picked) diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 23186dad4..2a113ce43 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -262,7 +262,7 @@ 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.""" + """Locks the tree and the control reading it again, leaving a running generation cancelable.""" dpg_configure_item( self._tags.group_tree, enabled=enabled, diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py deleted file mode 100644 index 0233a8499..000000000 --- a/src/sampletones_application/ui/panels/main/converter.py +++ /dev/null @@ -1,490 +0,0 @@ -from pathlib import Path -from typing import Any, Callable, Dict, FrozenSet, List, Optional, Tuple - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.elements.main import ConverterStemMoveElements -from sampletones_application.categories.hierarchy import Page, Panel, TextType -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.constants.conversion import MIN_CHANNEL_CAP -from sampletones_application.layout.general.colors.path import PathColors -from sampletones_application.layout.general.inputs import InputsLayout -from sampletones_application.layout.general.stems import StemsListLayout -from sampletones_application.layout.tabs.main.converter import ConverterLayout -from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_HANDLER_REGISTRY, - TAG_GLOBAL_THEME_DANGER_BUTTON, - TAG_GLOBAL_THEME_PANEL_EMPHASIS, - TAG_GLOBAL_THEME_PRIMARY_BUTTON, -) -from sampletones_application.tags.main import ( - PRE_MAIN_CONVERTER_STEMS, - TAG_MAIN_CONVERTER_BUTTON_ACTION, - TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, - TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, - TAG_MAIN_CONVERTER_GROUP, - TAG_MAIN_CONVERTER_GROUP_CONTROLS, - TAG_MAIN_CONVERTER_GROUP_CONVERT, - TAG_MAIN_CONVERTER_GROUP_SUMMARY, - TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, - TAG_MAIN_CONVERTER_PANEL, - TAG_MAIN_CONVERTER_PATH_INPUT_PATH, - TAG_MAIN_CONVERTER_PROGRESS, - TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, - TAG_MAIN_CONVERTER_TEXT_STATUS, - TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, - TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, - TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, - TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, - TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE, - TAG_MAIN_CONVERTER_WINDOW_STEMS, -) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import add_path_menu_items, context_menu -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.panel import GUIPanel -from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText -from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.stems.list import GUIStemsList -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.gui.dpg import ( - dpg_configure_item, - dpg_set_item_callback, - dpg_set_value, -) -from sampletones_application.utils.gui.tooltip import ( - attach_disabled_tooltip, - set_tooltip_visible, - show_tooltip, -) -from sampletones_application.utils.gui.widgets import clamp_widget_value -from sampletones_application.view_model.main.converter import ( - ConverterAction, - ConverterViewModel, -) -from sampletones_application.view_model.shared.stems import StemRowViewModel -from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import PathCallback, VoidCallback - -PathOffsetCallback = Callable[[Path, int], None] - -LEVEL_ABOVE: int = -1 -LEVEL_BELOW: int = 1 -POSITION_EARLIER: int = -1 -POSITION_LATER: int = 1 - - -class GUIConverterPanel(GUIPanel): - """The card a conversion is set up on: what it converts, how, and what it is doing. - - In stems mode the card lists the recordings being gathered under the levels they pick on. - A row is dragged onto another row to share that row's level, or onto the gap between two - levels to open one of its own; the row's menu names the same moves in words and offers the - recording's own filesystem actions. - """ - - def __init__( - self, - *, - layout: ConverterLayout, - stems_layout: StemsListLayout, - inputs: InputsLayout, - path_colors: PathColors, - initial_collapsed: bool = False, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - ) -> None: - self._language_manager = language_manager - self.input_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 - self._theme_cancel: Optional[Theme] = None - - self.on_convert_requested: Optional[VoidCallback] = None - self.on_cancel_requested: Optional[VoidCallback] = None - self.on_stems_mode_changed: Optional[Callable[[bool], None]] = None - self.on_channel_cap_changed: Optional[Callable[[int], None]] = None - self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None - self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None - self.on_source_removed: Optional[PathCallback] = None - self.on_source_moved: Optional[PathOffsetCallback] = None - self.on_source_level_joined: Optional[PathOffsetCallback] = None - self.on_source_isolated: Optional[PathCallback] = None - self.on_source_dropped_on_source: Optional[Callable[[Path, Path], None]] = None - self.on_source_dropped_on_level: Optional[Callable[[Path, int], None]] = None - - self._layout = layout - self._input_width = inputs.default_width - self._label_width = inputs.label_width - 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 - self._hierarchy_labels: Dict[HierarchyMode, str] = { - HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"], - HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"], - } - self._settings_handler_tag = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_HANDLER_REGISTRY) - self._stems_list = GUIStemsList( - prefix=PRE_MAIN_CONVERTER_STEMS, - layout=stems_layout, - language_manager=language_manager, - status_bar=status_bar, - draggable=True, - removable=True, - retain_last_row=False, - master_checkbox=False, - ) - - super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) - - def create_panel(self, parent: str) -> None: - self._create_handlers() - with self._collapsible_card( - parent, - self._language_manager["main.converter.label.section"], - glyph=self._glyphs.headers.converter, - width=self.width, - no_scrollbar=True, - card_theme=TAG_GLOBAL_THEME_PANEL_EMPHASIS, - ): - self._create_action_button() - dpg.add_separator() - self._create_controls() - self._create_stems_list() - self._create_summary() - dpg.add_separator() - self._create_conversion_status() - - @property - def stems_list(self) -> GUIStemsList: - """The list the gathered recordings are drawn in, which is what addresses their widgets.""" - return self._stems_list - - def is_visible(self) -> bool: - return bool(dpg.get_item_configuration(self.tag)["show"]) - - def update_view(self, view_model: ConverterViewModel) -> None: - self._update_status(view_model) - self._update_paths(view_model) - self._update_controls(view_model) - self._update_setup(view_model) - self._update_visibility(view_model) - - def _create_handlers(self) -> None: - with dpg.item_handler_registry(tag=self._settings_handler_tag): - dpg.add_item_deactivated_after_edit_handler(callback=self._on_channel_cap_edited) - - def _update_visibility(self, view_model: ConverterViewModel) -> None: - dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) - dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, show=not view_model.has_input) - dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=view_model.has_input) - - def _update_status(self, view_model: ConverterViewModel) -> None: - dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text) - dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS, view_model.progress) - dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS, overlay=view_model.progress_overlay) - - def _update_paths(self, view_model: ConverterViewModel) -> None: - if self.input_path_text is not None and view_model.input_path is not None: - self.input_path_text.set_path(view_model.input_path) - if self.output_path_text is not None and view_model.output_path is not None: - self.output_path_text.set_path(view_model.output_path) - - def _update_controls(self, view_model: ConverterViewModel) -> None: - match view_model.primary_action: - case ConverterAction.CANCEL: - callback: VoidCallback = self._on_cancel_clicked - theme = self._theme_cancel - self._status_action_message = self._language_manager["main.converter.message.status_cancel"] - case ConverterAction.CONVERT: - callback = self._on_convert_clicked - theme = self._theme_convert - self._status_action_message = self._msg_status_convert - - dpg_configure_item( - TAG_MAIN_CONVERTER_BUTTON_ACTION, - label=view_model.action_label, - enabled=view_model.primary_action_enabled, - ) - dpg_set_item_callback(TAG_MAIN_CONVERTER_BUTTON_ACTION, callback) - if self._action_button is not None and theme is not None: - self._action_button.set_theme(theme) - dpg_configure_item( - TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT, - ) - - def _create_controls(self) -> None: - """The row of choices every conversion carries: stems mode, the cap, and the picking order.""" - with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS): - dpg.add_checkbox( - label=self._language_manager["main.converter.label.stems_mode"], - tag=TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, - callback=self._on_stems_mode_toggled, - ) - with labeled_field(self._language_manager["main.converter.label.channel_cap"], self._label_width): - cap_input = dpg.add_input_int( - tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, - width=self._input_width, - min_value=MIN_CHANNEL_CAP, - min_clamped=True, - max_clamped=True, - default_value=len(ChannelName), - callback=self._on_channel_cap_edited, - ) - FontRegistry.bind_to_item(cap_input, Font.MONO) - - with labeled_field(self._language_manager["main.converter.label.hierarchy_mode"], self._label_width): - dpg.add_combo( - items=list(self._hierarchy_labels.values()), - tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, - width=self._input_width, - default_value=self._hierarchy_labels[HierarchyMode.ROUND_ROBIN], - callback=self._on_hierarchy_mode_changed, - ) - - dpg.bind_item_handler_registry(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, self._settings_handler_tag) - self._attach_control_tooltips() - self._status_bar.bind_to_item( - TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, - self._language_manager["main.converter.message.status_stems_mode"], - ) - - def _attach_control_tooltips(self) -> None: - for tag, message, tooltip_tag in ( - ( - TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, - self._language_manager["main.converter.message.stems_mode_tooltip"], - TAG_MAIN_CONVERTER_TOOLTIP_STEMS_MODE, - ), - ( - TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, - self._language_manager["main.converter.message.channel_cap_tooltip"], - TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, - ), - ( - TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, - self._language_manager["main.converter.message.hierarchy_mode_tooltip"], - TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, - ), - ): - show_tooltip(tag, message, tag=tooltip_tag) - - def _create_stems_list(self) -> None: - """The recordings gathered so far, under the levels they pick on.""" - with dpg.group(tag=TAG_MAIN_CONVERTER_WINDOW_STEMS, show=False): - hint = dpg.add_text( - self._language_manager["main.converter.message.stems_empty_hint"], - tag=TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, - wrap=0, - ) - FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) - self._stems_list.create(TAG_MAIN_CONVERTER_WINDOW_STEMS) - - self._stems_list.on_channels_changed = self._on_source_channels_changed - self._stems_list.on_remove_requested = self._on_source_removed - self._stems_list.on_menu_requested = self._show_row_menu - self._stems_list.on_dropped_on_row = self._on_dropped_on_source - self._stems_list.on_dropped_on_level = self._on_dropped_on_level - - def _update_setup(self, view_model: ConverterViewModel) -> None: - dpg_set_value(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, view_model.stems_mode) - dpg_configure_item( - TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, - max_value=view_model.max_channel_cap, - enabled=not view_model.is_active, - ) - dpg_set_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, view_model.channel_cap) - dpg_set_value( - TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, - self._hierarchy_labels[view_model.hierarchy_mode], - ) - dpg_configure_item(TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, show=view_model.stems_mode) - set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.stems_mode) - dpg_configure_item(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, enabled=not view_model.is_active) - self._update_stems_list(view_model) - - def _update_stems_list(self, view_model: ConverterViewModel) -> None: - dpg_configure_item(TAG_MAIN_CONVERTER_WINDOW_STEMS, show=view_model.stems_mode) - dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=view_model.source_count == 0) - self._stems_list.update_view(view_model.stems_list) - - def _on_stems_mode_toggled(self, _sender: Sender, value: bool) -> None: - self.call(self.on_stems_mode_changed, value) - - def _on_channel_cap_edited(self, _sender: Sender, _app_data: Any) -> None: - self.call(self.on_channel_cap_changed, int(clamp_widget_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP))) - - def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: - for hierarchy_mode, label in self._hierarchy_labels.items(): - if label == value: - self.call(self.on_hierarchy_mode_changed, hierarchy_mode) - return - - def _on_source_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: - self.call(self.on_source_channels_changed, Path(key), channels) - - def _on_source_removed(self, key: str) -> None: - self.call(self.on_source_removed, Path(key)) - - def _on_dropped_on_source(self, key: str, target_key: str) -> None: - self.call(self.on_source_dropped_on_source, Path(key), Path(target_key)) - - def _on_dropped_on_level(self, key: str, position: int) -> None: - self.call(self.on_source_dropped_on_level, Path(key), position) - - def _show_row_menu(self, key: str) -> None: - """Names the moves the row can make, graying out the ones that would change nothing, - and offers the recording's own filesystem actions below them.""" - row = self._stems_list.row(key) - if row is None: - return - - with context_menu(): - header = dpg.add_text(row.name) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() - for element, enabled, callback in self._row_moves(row): - dpg.add_menu_item( - label=self._label(element), - enabled=enabled, - callback=callback, - ) - - add_path_menu_items(self._language_manager, row.path) - - def _row_moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: - path = row.path - return [ - ( - ConverterStemMoveElements.CONTEXT_MOVE_UP, - not row.is_first_on_level, - lambda: self.call(self.on_source_moved, path, POSITION_EARLIER), - ), - ( - ConverterStemMoveElements.CONTEXT_MOVE_DOWN, - not row.is_last_on_level, - lambda: self.call(self.on_source_moved, path, POSITION_LATER), - ), - ( - ConverterStemMoveElements.CONTEXT_JOIN_ABOVE, - row.has_level_above, - lambda: self.call(self.on_source_level_joined, path, LEVEL_ABOVE), - ), - ( - ConverterStemMoveElements.CONTEXT_JOIN_BELOW, - row.has_level_below, - lambda: self.call(self.on_source_level_joined, path, LEVEL_BELOW), - ), - ( - ConverterStemMoveElements.CONTEXT_ISOLATE, - not row.alone_on_level, - lambda: self.call(self.on_source_isolated, path), - ), - ( - ConverterStemMoveElements.CONTEXT_REMOVE_STEM, - True, - lambda: self.call(self.on_source_removed, path), - ), - ] - - def _label(self, element: ConverterStemMoveElements) -> str: - return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] - - def _create_action_button(self) -> None: - self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON) - self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON) - with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONVERT): - self._action_button = GUIButton( - label=self._language_manager["main.converter.label.convert_sample_button"], - tag=TAG_MAIN_CONVERTER_BUTTON_ACTION, - width=self._layout.width, - height=self._layout.button_height, - font=Font.BOLD_LARGE, - enabled=False, - callback=self._on_convert_clicked, - theme=self._theme_convert, - ) - attach_disabled_tooltip( - TAG_MAIN_CONVERTER_GROUP_CONVERT, - self._language_manager["global.dialog.message.operation_in_progress"], - tag=TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, - ) - self._status_bar.bind_to_item( - TAG_MAIN_CONVERTER_BUTTON_ACTION, - self._action_status_message, - ) - - def _action_status_message(self, *_args: Any, **_kwargs: Any) -> str: - return self._status_action_message - - def _create_summary(self) -> None: - dpg.add_separator() - hint = dpg.add_text( - self._language_manager["main.converter.message.status_empty_hint"], - tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, - ) - FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) - with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False): - self.input_path_text = GUIPathText( - path=None, - prefix=self._language_manager["main.converter.message.status_input_label"], - tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH, - parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, - 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, - ) - 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_destination, - font=Font.REGULAR_SMALL, - status_bar=self._status_bar, - ) - - def _create_conversion_status(self) -> None: - with dpg.group( - tag=TAG_MAIN_CONVERTER_GROUP, - show=False, - ): - dpg.add_text( - self._language_manager["main.converter.message.status_waiting"], - tag=TAG_MAIN_CONVERTER_TEXT_STATUS, - parent=TAG_MAIN_CONVERTER_GROUP, - ) - FontRegistry.bind_to_item( - TAG_MAIN_CONVERTER_TEXT_STATUS, - Font.MONO_SMALL, - ) - dpg.add_progress_bar( - tag=TAG_MAIN_CONVERTER_PROGRESS, - parent=TAG_MAIN_CONVERTER_GROUP, - default_value=0.0, - width=-1, - overlay="0%", - ) - FontRegistry.bind_to_item(TAG_MAIN_CONVERTER_PROGRESS, Font.MONO) - - def _on_convert_clicked(self) -> None: - self.call(self.on_convert_requested) - - def _on_cancel_clicked(self) -> None: - self.call(self.on_cancel_requested) diff --git a/src/sampletones_application/ui/panels/main/converter/__init__.py b/src/sampletones_application/ui/panels/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/converter/action.py b/src/sampletones_application/ui/panels/main/converter/action.py new file mode 100644 index 000000000..db949a135 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/action.py @@ -0,0 +1,111 @@ +from typing import Any, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_PRIMARY_BUTTON, +) +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_GROUP_CONVERT, + TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_item_callback +from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip +from sampletones_application.view_model.main.converter import ConverterAction, ConverterViewModel +from sampletones_shared.types.callback import VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + + +class ConverterActionButton(CallbackMixin): + """The one button a run is started and stopped from. + + Its label says what the run writes, so the switch above it and the button read as one sentence; + while a conversion holds resources it cancels instead, and takes the tone that says so. + """ + + def __init__( + self, + *, + layout: ConverterLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._layout = layout + self._language_manager = language_manager + self._status_bar = status_bar + self._button: Optional[GUIButton] = None + self._theme_convert: Optional[Theme] = None + self._theme_cancel: Optional[Theme] = None + self._msg_convert = language_manager["main.converter.message.status_convert"] + self._msg_cancel = language_manager["main.converter.message.status_cancel"] + self._status_message = self._msg_convert + + self.on_convert_requested: Optional[VoidCallback] = None + self.on_cancel_requested: Optional[VoidCallback] = None + + def create(self) -> None: + """Build the button, opening on the label a converter with nothing gathered carries.""" + self._theme_convert = ThemeRegistry.get(TAG_GLOBAL_THEME_PRIMARY_BUTTON) + self._theme_cancel = ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON) + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONVERT): + self._button = GUIButton( + label=self._language_manager["main.converter.label.convert_button"], + tag=TAG_MAIN_CONVERTER_BUTTON_ACTION, + width=self._layout.width, + height=self._layout.button_height, + font=Font.BOLD_LARGE, + enabled=False, + callback=self._on_convert_clicked, + theme=self._theme_convert, + ) + + attach_disabled_tooltip( + TAG_MAIN_CONVERTER_GROUP_CONVERT, + self._language_manager["global.dialog.message.operation_in_progress"], + tag=TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, + ) + self._status_bar.bind_to_item(TAG_MAIN_CONVERTER_BUTTON_ACTION, self._explanation) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Take up what the run now is: the label it writes, and whether the button starts or stops.""" + match view_model.primary_action: + case ConverterAction.CANCEL: + callback: VoidCallback = self._on_cancel_clicked + theme = self._theme_cancel + self._status_message = self._msg_cancel + case ConverterAction.CONVERT: + callback = self._on_convert_clicked + theme = self._theme_convert + self._status_message = self._msg_convert + + dpg_configure_item( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + label=view_model.action_label, + enabled=view_model.primary_action_enabled, + ) + dpg_set_item_callback(TAG_MAIN_CONVERTER_BUTTON_ACTION, callback) + if self._button is not None and theme is not None: + self._button.set_theme(theme) + + dpg_configure_item( + TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, + show=view_model.other_operation_active and view_model.primary_action == ConverterAction.CONVERT, + ) + + def _explanation(self, *_args: Any, **_kwargs: Any) -> str: + return self._status_message + + def _on_convert_clicked(self) -> None: + self.call(self.on_convert_requested) + + def _on_cancel_clicked(self) -> None: + self.call(self.on_cancel_requested) diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py new file mode 100644 index 000000000..6af3a4bcc --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -0,0 +1,181 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.glyphs.common import CommonGlyphs +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_STEMS, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_STEMS, +) +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.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.gui.keyboard import ( + PRIORITY_PANEL, + ActivePredicate, + KeyEvent, + KeyRouter, + panel_scope_active, +) +from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import PathCallback, StringCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +ChannelsCallback = Callable[[Path, FrozenSet[ChannelName]], None] +ChannelCallback = Callable[[Path, ChannelName], None] +RowCallback = Callable[[Path, SourceKind], None] +PathPairCallback = Callable[[Path, Path], None] +PathOffsetCallback = Callable[[Path, int], None] + + +class ConverterListing(CallbackMixin): + """What a run converts: the gathered recordings, and the hint standing where none are. + + The list reports its gestures by the key a row is drawn under, and this is where that key + becomes the path the logic answers for — including the two a folder answers differently: + removing one takes everything it holds, and its box settles every recording under it. + + A row picked out puts the list on the keyboard: while the Main tab is in front and no field + holds the keys, the list answers the presses its own category names and yields every other, so + a press it has no action for still reaches the application's shortcuts. + """ + + def __init__( + self, + *, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + ) -> None: + self._language_manager = language_manager + self._router = key_router + self._shortcuts = shortcut_source + self._tab_active = tab_active + self._stems_list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_STEMS, + layout=stems_layout, + ceiling=stems_layout.well_ceiling, + glyphs=glyphs, + language_manager=language_manager, + status_bar=status_bar, + offer=GATHERED_SOURCES, + ) + + self.on_source_channels_changed: Optional[ChannelsCallback] = None + self.on_folder_channel_toggled: Optional[ChannelCallback] = None + self.on_row_selected: Optional[RowCallback] = None + self.on_source_removed: Optional[PathCallback] = None + self.on_folder_removed: Optional[PathCallback] = None + self.on_source_played: Optional[PathCallback] = None + self.on_source_dropped_on_source: Optional[PathPairCallback] = None + self.on_source_dropped_on_level: Optional[PathOffsetCallback] = None + self.on_menu_requested: Optional[StringCallback] = None + self.on_selection_cleared: Optional[VoidCallback] = None + + self._router.register(self._on_key_pressed, priority=PRIORITY_PANEL, active=self._keys_active) + + @property + def stems_list(self) -> GUIStemsList: + """The list the gathered recordings are drawn in, which is what addresses their widgets.""" + return self._stems_list + + def create(self) -> None: + """Build the hint and the list below it, and take up the gestures the list reports.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_WINDOW_STEMS): + hint = dpg.add_text( + self._language_manager["main.converter.message.stems_empty_hint"], + tag=TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + wrap=0, + ) + FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) + self._stems_list.create(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + self._stems_list.on_channels_changed = self._on_channels_changed + self._stems_list.on_channel_toggled = self._on_folder_channel_toggled + self._stems_list.on_remove_requested = self._on_removed + self._stems_list.on_row_activated = self._on_selected + self._stems_list.on_menu_requested = lambda key: self.call(self.on_menu_requested, key) + self._stems_list.on_row_opened = lambda key: self.call(self.on_source_played, Path(key)) + self._stems_list.on_dropped_on_row = self._on_dropped_on_row + self._stems_list.on_dropped_on_level = self._on_dropped_on_level + self._stems_list.on_selection_cleared = lambda: self.call(self.on_selection_cleared) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Draw the gathered recordings, with the hint standing while none are. + + A list holding nothing stands its heading and its rules over empty room, so the hint takes + the whole of that room instead and the list comes back with the first recording gathered. + """ + dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=not view_model.listed) + dpg_configure_item(self._stems_list.tag, show=view_model.listed) + self._stems_list.update_view(view_model.stems_list) + + def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: + self.call(self.on_source_channels_changed, Path(key), channels) + + def _on_folder_channel_toggled(self, key: str, channel_name: ChannelName) -> None: + """A folder's box moves every recording it stands for, whichever way they were standing.""" + self.call(self.on_folder_channel_toggled, Path(key), channel_name) + + def _on_selected(self, key: str) -> None: + """A clicked row is the one the settings card inspects.""" + row = self._stems_list.row(key) + if row is not None: + self.call(self.on_row_selected, Path(key), row.kind) + + def _keys_active(self) -> bool: + """Whether the list owns the next key, which the row it holds picked out is what decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._stems_list.picked_key is not None, + ) + + def _on_key_pressed(self, event: KeyEvent) -> bool: + """Act on the row picked out, reporting whether the list consumed the press. + + The scheme says which press each of the list's actions answers to; a press its category + leaves unnamed goes on to the application's shortcuts, as does one naming a move the list + is standing inert against. + """ + key = self._stems_list.picked_key + if key is None: + return False + + match self._shortcuts.action(ShortcutCategory.SOURCES, event): + case ShortcutId.SOURCES_REMOVE_SOURCE if self._stems_list.lets_a_row_go: + self._on_removed(key) + case _: + return False + + return True + + def _on_removed(self, key: str) -> None: + """Taking a folder out takes everything it holds, which is a move of its own.""" + row = self._stems_list.row(key) + if row is not None and row.stands_for_a_folder: + self.call(self.on_folder_removed, Path(key)) + return + + self.call(self.on_source_removed, Path(key)) + + def _on_dropped_on_row(self, key: str, target_key: str) -> None: + self.call(self.on_source_dropped_on_source, Path(key), Path(target_key)) + + def _on_dropped_on_level(self, key: str, position: int) -> None: + self.call(self.on_source_dropped_on_level, Path(key), position) diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py new file mode 100644 index 000000000..79b8e18ff --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -0,0 +1,185 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.elements.main import ( + ConverterFolderElements, + ConverterStemMoveElements, +) +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.elements.context_menu import ( + add_path_menu_items, + add_play_menu_item, + context_menu, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +LEVEL_ABOVE: Final[int] = -1 +LEVEL_BELOW: Final[int] = 1 +POSITION_EARLIER: Final[int] = -1 +POSITION_LATER: Final[int] = 1 + +PathOffsetCallback = Callable[[Path, int], None] + + +class ConverterMenus(CallbackMixin): + """What a right-click on the list offers, which follows the row it landed on. + + A recording is played wherever it is met, so its menu leads with the item the file browser + leads with, and offers the moves that rearrange a mix while one is being built. A folder + stands for everything gathered below it, so its menu reaches all of them at once and leaves + the recordings inside it to their own menus. + """ + + def __init__( + self, + *, + stems_list: GUIStemsList, + language_manager: LanguageManager, + shortcut_source: ShortcutSource, + ) -> None: + self._stems_list = stems_list + self._language_manager = language_manager + self._shortcuts = shortcut_source + self._lbl_play = language_manager["global.context.label.play"] + + self.on_source_played: Optional[PathCallback] = None + self.on_source_removed: Optional[PathCallback] = None + self.on_source_moved: Optional[PathOffsetCallback] = None + self.on_source_level_joined: Optional[PathOffsetCallback] = None + self.on_source_isolated: Optional[PathCallback] = None + self.on_folder_removed: Optional[PathCallback] = None + self.on_folder_toggled: Optional[PathCallback] = None + + def show(self, key: str, *, banded: bool) -> None: + """Offer what the row a gesture landed on can do, reading the kind of row it is. + + ``banded`` states that the list is drawing levels, which is what the moves rearrange. + """ + row = self._stems_list.row(key) + if row is None: + return + + if row.stands_for_a_folder: + self._show_folder(row) + return + + self._show_row(row, banded=banded) + + def _show_row(self, row: StemRowViewModel, *, banded: bool) -> None: + with context_menu(): + self._header(row.name) + add_play_menu_item( + self._lbl_play, + lambda: self.call(self.on_source_played, row.path), + enabled=row.available, + ) + dpg.add_separator() + for element, enabled, callback in self._moves(row, banded=banded): + dpg.add_menu_item( + label=self._label(element), + enabled=enabled, + callback=callback, + ) + + self._create_removal( + self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM), + lambda: self.call(self.on_source_removed, row.path), + ) + add_path_menu_items(self._language_manager, row.path) + + def _show_folder(self, row: StemRowViewModel) -> None: + opened = self._stems_list.stands_open(row.key) + with context_menu(): + self._header(row.name) + dpg.add_menu_item( + label=self._folder_label( + ConverterFolderElements.CONTEXT_CLOSE_FOLDER + if opened + else ConverterFolderElements.CONTEXT_OPEN_FOLDER + ), + callback=lambda: self.call(self.on_folder_toggled, row.path), + ) + self._create_removal( + self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), + lambda: self.call(self.on_folder_removed, row.path), + ) + add_path_menu_items(self._language_manager, row.path) + + def _moves( + self, + row: StemRowViewModel, + *, + banded: bool, + ) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: + """The moves the row can make, which are the level moves while a mix is banded. + + A run writing a reconstruction apiece keeps its recordings in one flat list, so it has no + order to rearrange and names no moves. + """ + path = row.path + if not banded: + return [] + + return [ + ( + ConverterStemMoveElements.CONTEXT_MOVE_UP, + not row.is_first_on_level, + lambda: self.call(self.on_source_moved, path, POSITION_EARLIER), + ), + ( + ConverterStemMoveElements.CONTEXT_MOVE_DOWN, + not row.is_last_on_level, + lambda: self.call(self.on_source_moved, path, POSITION_LATER), + ), + ( + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE, + row.has_level_above, + lambda: self.call(self.on_source_level_joined, path, LEVEL_ABOVE), + ), + ( + ConverterStemMoveElements.CONTEXT_JOIN_BELOW, + row.has_level_below, + lambda: self.call(self.on_source_level_joined, path, LEVEL_BELOW), + ), + ( + ConverterStemMoveElements.CONTEXT_ISOLATE, + not row.alone_on_level, + lambda: self.call(self.on_source_isolated, path), + ), + ] + + def _create_removal(self, label: str, callback: VoidCallback) -> None: + """The item taking a row out, printing the key that does the same thing. + + The key and the item are one action, so the item reads whatever combination the scheme + gives it and a rebind reaches the menu without another edit. + """ + dpg.add_menu_item( + label=label, + shortcut=self._shortcuts.display(ShortcutId.SOURCES_REMOVE_SOURCE), + enabled=self._stems_list.lets_a_row_go, + callback=callback, + ) + + @staticmethod + def _header(name: str) -> None: + """What the menu names above its items: whatever the gesture landed on.""" + header = dpg.add_text(name) + FontRegistry.bind_to_item(header, Font.MONO_BOLD) + dpg.add_separator() + + def _label(self, element: ConverterStemMoveElements) -> str: + return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] + + def _folder_label(self, element: ConverterFolderElements) -> str: + return self._language_manager[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py new file mode 100644 index 000000000..4277dd3fa --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -0,0 +1,192 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.tabs.main.converter import ConverterLayout +from sampletones_application.tags.general import TAG_GLOBAL_THEME_PANEL_EMPHASIS +from sampletones_application.tags.main import TAG_MAIN_CONVERTER_PANEL +from sampletones_application.ui.elements.panel import GUIPanel +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.panels.main.converter.action import ConverterActionButton +from sampletones_application.ui.panels.main.converter.listing import ConverterListing +from sampletones_application.ui.panels.main.converter.menus import ConverterMenus +from sampletones_application.ui.panels.main.converter.setup import ConverterSetup +from sampletones_application.ui.panels.main.converter.summary import ConverterSummary +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_shared.types.callback import PathCallback, VoidCallback + +PathOffsetCallback = Callable[[Path, int], None] + + +class GUIConverterPanel(GUIPanel): + """The card a conversion is set up on: what it writes, what it converts, and how far it is. + + The card reads top to bottom as one sentence. The output switch says what a run writes, the + button below repeats it in the words of what is listed, and the list itself is what the run + converts. The choices that shape a run stand under the list, since they answer for what it + holds, and the destination stands under them. + """ + + def __init__( + self, + *, + layout: ConverterLayout, + stems_layout: StemsListLayout, + inputs: InputsLayout, + path_colors: PathColors, + initial_collapsed: bool = False, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, + ) -> None: + self._language_manager = language_manager + self._setup = ConverterSetup(inputs=inputs, language_manager=language_manager) + self._listing = ConverterListing( + stems_layout=stems_layout, + glyphs=self._glyphs.common, + language_manager=language_manager, + status_bar=status_bar, + key_router=key_router, + shortcut_source=shortcut_source, + tab_active=tab_active, + ) + self._menus = ConverterMenus( + stems_list=self._listing.stems_list, + language_manager=language_manager, + shortcut_source=shortcut_source, + ) + self._action = ConverterActionButton( + layout=layout, + language_manager=language_manager, + status_bar=status_bar, + ) + self._summary = ConverterSummary( + path_colors=path_colors, + language_manager=language_manager, + status_bar=status_bar, + ) + self._banded = False + + self.on_convert_requested: Optional[VoidCallback] = None + self.on_cancel_requested: Optional[VoidCallback] = None + self.on_output_changed: Optional[Callable[[OutputKind], None]] = None + self.on_channel_cap_changed: Optional[Callable[[int], None]] = None + self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None + self.on_source_channels_changed: Optional[Callable[[Path, FrozenSet[ChannelName]], None]] = None + self.on_folder_channel_toggled: Optional[Callable[[Path, ChannelName], None]] = None + self.on_row_selected: Optional[Callable[[Path, SourceKind], None]] = None + self.on_selection_cleared: Optional[VoidCallback] = None + self.on_source_removed: Optional[PathCallback] = None + self.on_folder_removed: Optional[PathCallback] = None + self.on_source_moved: Optional[PathOffsetCallback] = None + self.on_source_level_joined: Optional[PathOffsetCallback] = None + self.on_source_isolated: Optional[PathCallback] = None + self.on_source_dropped_on_source: Optional[Callable[[Path, Path], None]] = None + self.on_source_dropped_on_level: Optional[PathOffsetCallback] = None + self.on_source_played: Optional[PathCallback] = None + + super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._wire() + + def create_panel(self, parent: str) -> None: + self._setup.create_handlers() + with self._collapsible_card( + parent, + self._language_manager["main.converter.label.section"], + glyph=self._glyphs.headers.converter, + width=self.width, + no_scrollbar=True, + card_theme=TAG_GLOBAL_THEME_PANEL_EMPHASIS, + ): + self._setup.create_output() + self._action.create() + dpg.add_separator() + self._listing.create() + self._setup.create_controls() + self._summary.create_paths() + dpg.add_separator() + self._summary.create_status() + + @property + def stems_list(self) -> GUIStemsList: + """The list the gathered recordings are drawn in, which is what addresses their widgets.""" + return self._listing.stems_list + + @property + def input_path_text(self) -> Optional[GUIPathText]: + """The line naming the recording a running conversion is on.""" + return self._summary.input_path_text + + @property + def output_path_text(self) -> Optional[GUIDestinationPathText]: + """The line naming where a run writes.""" + return self._summary.output_path_text + + def is_visible(self) -> bool: + return bool(dpg.get_item_configuration(self.tag)["show"]) + + def update_view(self, view_model: ConverterViewModel) -> None: + self._banded = view_model.mixes + self._action.update_view(view_model) + self._summary.update_view(view_model) + self._setup.update_view(view_model) + self._listing.update_view(view_model) + + def _wire(self) -> None: + """Hand each section's reports on to the card's own hooks, which the coordinator wires.""" + self._setup.on_output_changed = lambda output: self.call(self.on_output_changed, output) + self._setup.on_channel_cap_changed = lambda cap: self.call(self.on_channel_cap_changed, cap) + self._setup.on_hierarchy_mode_changed = lambda mode: self.call(self.on_hierarchy_mode_changed, mode) + + self._action.on_convert_requested = lambda: self.call(self.on_convert_requested) + self._action.on_cancel_requested = lambda: self.call(self.on_cancel_requested) + + self._listing.on_source_channels_changed = lambda path, channels: self.call( + self.on_source_channels_changed, path, channels + ) + self._listing.on_folder_channel_toggled = lambda path, channel: self.call( + self.on_folder_channel_toggled, path, channel + ) + self._listing.on_row_selected = lambda path, kind: self.call(self.on_row_selected, path, kind) + self._listing.on_selection_cleared = lambda: self.call(self.on_selection_cleared) + self._listing.on_source_removed = lambda path: self.call(self.on_source_removed, path) + self._listing.on_folder_removed = lambda path: self.call(self.on_folder_removed, path) + self._listing.on_source_played = lambda path: self.call(self.on_source_played, path) + self._listing.on_source_dropped_on_source = lambda path, target: self.call( + self.on_source_dropped_on_source, path, target + ) + self._listing.on_source_dropped_on_level = lambda path, position: self.call( + self.on_source_dropped_on_level, path, position + ) + self._listing.on_menu_requested = self._show_menu + + self._menus.on_source_played = lambda path: self.call(self.on_source_played, path) + self._menus.on_source_removed = lambda path: self.call(self.on_source_removed, path) + self._menus.on_source_moved = lambda path, offset: self.call(self.on_source_moved, path, offset) + self._menus.on_source_level_joined = lambda path, offset: self.call(self.on_source_level_joined, path, offset) + self._menus.on_source_isolated = lambda path: self.call(self.on_source_isolated, path) + self._menus.on_folder_removed = lambda path: self.call(self.on_folder_removed, path) + self._menus.on_folder_toggled = self._toggle_folder + + def _show_menu(self, key: str) -> None: + """The moves a menu offers follow the run being set up, which decides what a move means.""" + self._menus.show(key, banded=self._banded) + + def _toggle_folder(self, root: Path) -> None: + """Whether a folder stands open is the list's own memory, so the menu asks the list.""" + self.stems_list.toggle_folder(str(root)) diff --git a/src/sampletones_application/ui/panels/main/converter/setup.py b/src/sampletones_application/ui/panels/main/converter/setup.py new file mode 100644 index 000000000..01c50114f --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/setup.py @@ -0,0 +1,166 @@ +from typing import Any, Callable, Dict, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +from sampletones_application.constants.output import OutputKind +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_GROUP_CONTROLS, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + TAG_MAIN_CONVERTER_TOOLTIP_MODE, +) +from sampletones_application.ui.elements.field import labeled_field +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.tooltip import set_tooltip_visible, show_tooltip +from sampletones_application.utils.gui.widgets import clamp_widget_value +from sampletones_application.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + + +class ConverterSetup(CallbackMixin): + """What a run is set up as: the output it writes, and the choices that shape it. + + The output switch stands at the head of the card and names the run in words, so the button + below it and the switch above read as one sentence. The rest — how many channels one recording + may hold, and the order the levels pick in — answers for a list that already holds something, + and stands under it. + """ + + def __init__( + self, + *, + inputs: InputsLayout, + language_manager: LanguageManager, + ) -> None: + self._language_manager = language_manager + self._input_width = inputs.default_width + self._label_width = inputs.label_width + self._settings_handler_tag = compose_tag(TAG_MAIN_CONVERTER_PANEL, SUF_HANDLER_REGISTRY) + self._mode_labels: Dict[OutputKind, str] = { + OutputKind.PER_RECORDING: language_manager["main.converter.label.mode_each"], + OutputKind.MIXED: language_manager["main.converter.label.mode_mixed"], + } + self._hierarchy_labels: Dict[HierarchyMode, str] = { + HierarchyMode.ROUND_ROBIN: language_manager["main.converter.label.hierarchy_round_robin"], + HierarchyMode.STRICT: language_manager["main.converter.label.hierarchy_strict"], + } + + self.on_output_changed: Optional[Callable[[OutputKind], None]] = None + self.on_channel_cap_changed: Optional[Callable[[int], None]] = None + self.on_hierarchy_mode_changed: Optional[Callable[[HierarchyMode], None]] = None + + def create_handlers(self) -> None: + """Register the handler the channel cap reports its finished edit through.""" + with dpg.item_handler_registry(tag=self._settings_handler_tag): + dpg.add_item_deactivated_after_edit_handler(callback=self._on_channel_cap_edited) + + def create_output(self) -> None: + """The switch naming what the run writes, which the card opens on.""" + with labeled_field(self._language_manager["main.converter.label.mode"], self._label_width): + mode = dpg.add_radio_button( + items=list(self._mode_labels.values()), + tag=TAG_MAIN_CONVERTER_RADIO_MODE, + horizontal=True, + default_value=self._mode_labels[OutputKind.PER_RECORDING], + callback=self._on_mode_changed, + ) + FontRegistry.bind_to_item(mode, Font.REGULAR_SMALL) + + show_tooltip( + TAG_MAIN_CONVERTER_RADIO_MODE, + self._language_manager["main.converter.message.mode_tooltip"], + tag=TAG_MAIN_CONVERTER_TOOLTIP_MODE, + ) + + def create_controls(self) -> None: + """The choices a list that holds something answers for, which stand below it.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_CONTROLS, show=False): + with labeled_field(self._language_manager["main.converter.label.channel_cap"], self._label_width): + cap_input = dpg.add_input_int( + tag=TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + width=self._input_width, + min_value=MIN_CHANNEL_CAP, + min_clamped=True, + max_clamped=True, + default_value=len(ChannelName), + callback=self._on_channel_cap_edited, + ) + FontRegistry.bind_to_item(cap_input, Font.MONO) + + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_ORDER, show=False): + with labeled_field(self._language_manager["main.converter.label.hierarchy_mode"], self._label_width): + dpg.add_combo( + items=list(self._hierarchy_labels.values()), + tag=TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + width=self._input_width, + default_value=self._hierarchy_labels[DEFAULT_STEMS_HIERARCHY_MODE], + callback=self._on_hierarchy_mode_edited, + ) + + dpg.bind_item_handler_registry(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, self._settings_handler_tag) + self._attach_tooltips() + + def update_view(self, view_model: ConverterViewModel) -> None: + """Draw the setup the view names onto the widgets standing for it.""" + dpg_set_value(TAG_MAIN_CONVERTER_RADIO_MODE, self._mode_labels[view_model.output]) + dpg_configure_item(TAG_MAIN_CONVERTER_RADIO_MODE, enabled=not view_model.is_active) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_CONTROLS, show=view_model.listed) + dpg_configure_item( + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + max_value=view_model.max_channel_cap, + enabled=not view_model.is_active, + ) + dpg_set_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, view_model.channel_cap) + dpg_set_value( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._hierarchy_labels[view_model.hierarchy_mode], + ) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_ORDER, show=view_model.mixes_several) + set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, view_model.listed) + set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.mixes_several) + + def _attach_tooltips(self) -> None: + for tag, message, tooltip_tag in ( + ( + TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, + self._language_manager["main.converter.message.channel_cap_tooltip"], + TAG_MAIN_CONVERTER_TOOLTIP_CHANNEL_CAP, + ), + ( + TAG_MAIN_CONVERTER_COMBO_HIERARCHY_MODE, + self._language_manager["main.converter.message.hierarchy_mode_tooltip"], + TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, + ), + ): + show_tooltip(tag, message, tag=tooltip_tag) + + def _on_mode_changed(self, _sender: Sender, value: str) -> None: + """The switch names what the run writes, which the reader states in words.""" + for output, label in self._mode_labels.items(): + if label == value: + self.call(self.on_output_changed, output) + return + + def _on_channel_cap_edited(self, _sender: Sender, _app_data: Any) -> None: + self.call(self.on_channel_cap_changed, int(clamp_widget_value(TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP))) + + def _on_hierarchy_mode_edited(self, _sender: Sender, value: str) -> None: + for hierarchy_mode, label in self._hierarchy_labels.items(): + if label == value: + self.call(self.on_hierarchy_mode_changed, hierarchy_mode) + return diff --git a/src/sampletones_application/ui/panels/main/converter/summary.py b/src/sampletones_application/ui/panels/main/converter/summary.py new file mode 100644 index 000000000..08e1b5a4c --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/summary.py @@ -0,0 +1,105 @@ +from typing import 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.tags.main import ( + TAG_MAIN_CONVERTER_GROUP, + TAG_MAIN_CONVERTER_GROUP_INPUT, + TAG_MAIN_CONVERTER_GROUP_SUMMARY, + TAG_MAIN_CONVERTER_PATH_INPUT_PATH, + TAG_MAIN_CONVERTER_PROGRESS, + TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, + TAG_MAIN_CONVERTER_TEXT_STATUS, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIDestinationPathText, 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.view_model.main.converter import ConverterViewModel + + +class ConverterSummary: + """Where a run writes, and how far it has come. + + The destination stands whatever the list holds, so a reader always knows where a run would + land. The input line names the recording a running conversion is on, and stands while it is + reporting one. + """ + + def __init__( + self, + *, + path_colors: PathColors, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._path_colors = path_colors + self._status_bar = status_bar + self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] + self.input_path_text: Optional[GUIPathText] = None + self.output_path_text: Optional[GUIDestinationPathText] = None + + def create_paths(self) -> None: + """The recording a run is on, and the place its result lands.""" + dpg.add_separator() + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY): + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_INPUT, show=False): + self.input_path_text = GUIPathText( + path=None, + prefix=self._language_manager["main.converter.message.status_input_label"], + tag=TAG_MAIN_CONVERTER_PATH_INPUT_PATH, + parent=TAG_MAIN_CONVERTER_GROUP_INPUT, + 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, + ) + + 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_destination, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + + def create_status(self) -> None: + """What the running conversion says it is doing, and how far along it is.""" + with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP, show=False): + status = dpg.add_text( + self._language_manager["main.converter.message.status_waiting"], + tag=TAG_MAIN_CONVERTER_TEXT_STATUS, + parent=TAG_MAIN_CONVERTER_GROUP, + ) + FontRegistry.bind_to_item(status, Font.MONO_SMALL) + dpg.add_progress_bar( + tag=TAG_MAIN_CONVERTER_PROGRESS, + parent=TAG_MAIN_CONVERTER_GROUP, + default_value=0.0, + width=-1, + overlay="0%", + ) + FontRegistry.bind_to_item(TAG_MAIN_CONVERTER_PROGRESS, Font.MONO) + + def update_view(self, view_model: ConverterViewModel) -> None: + """Name where the run writes, and say where it has got to.""" + if self.input_path_text is not None and view_model.input_path is not None: + self.input_path_text.set_path(view_model.input_path) + if self.output_path_text is not None and view_model.output_path is not None: + self.output_path_text.set_path(view_model.output_path) + + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP_INPUT, show=view_model.shows_input) + dpg_configure_item(TAG_MAIN_CONVERTER_GROUP, show=view_model.subpanel_visible) + dpg_set_value(TAG_MAIN_CONVERTER_TEXT_STATUS, view_model.status_text) + dpg_set_value(TAG_MAIN_CONVERTER_PROGRESS, view_model.progress) + dpg_configure_item(TAG_MAIN_CONVERTER_PROGRESS, overlay=view_model.progress_overlay) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 8d79e3521..ded1a89d4 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -55,15 +55,15 @@ def refresh_tree(self) -> None: ... def collapse_all(self) -> None: ... - def expand_directory(self, node: FileSystemNode) -> None: ... + def expand_directory(self, directory_node: FileSystemNode) -> None: ... - def has_loaded_children(self, filepath: Path) -> bool: ... + def has_loaded_children(self, directory_path: Path) -> bool: ... - def is_directory_open(self, filepath: Path) -> bool: ... + def is_directory_open(self, directory_path: Path) -> bool: ... - def set_directory_open(self, filepath: Path, is_open: bool) -> None: ... + def set_directory_open(self, directory_path: Path, is_open: bool) -> None: ... - def has_relevant_content(self, filepath: Path) -> bool: ... + def has_relevant_content(self, directory_path: Path) -> bool: ... class GUIExplorerPanel(GUIFileBrowserPanel): @@ -92,8 +92,6 @@ def __init__( self._language_manager = language_manager self._explorer_logic = explorer_logic - self.on_wave_file_clicked: Optional[PathCallback] = None - self.on_directory_clicked: Optional[PathCallback] = None self.on_directory_add_requested: Optional[PathCallback] = None self.on_file_add_requested: Optional[PathCallback] = None self.can_add_stems: Optional[Callable[[], bool]] = None @@ -152,7 +150,7 @@ def _refresh_model(self) -> 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, + The rows fold while the model still states them, and the folders the model held go afterward, which is what makes a later open list the folder as it stands on disk. """ super()._on_collapse_all_clicked() @@ -284,19 +282,27 @@ def _on_file_node_clicked( return None def _audio_node_clicked(self, node: FileSystemNode) -> None: - """Answers a click on a recording: Ctrl gathers it as a stem, else it becomes the selection. + """Answers a click on a recording: Ctrl gathers it as a stem, and a plain click plays it. - Ctrl is the gathering gesture throughout the browser, so it reaches a recording the same - way it reaches a folder and does what **Add as stem** does, opening a stems conversion - where none is being built. A plain click hands the recording to the converter and plays it. + A plain click previews the recording and leaves the conversion as it stands, so walking the + browser to hear what a file holds costs the run nothing. Ctrl is the gathering gesture + throughout the browser, so it reaches a recording the same way it reaches a folder and does + what **Add as stem** does, opening a stems conversion where none is being built; where the + converter is busy it is a plain click, and the recording plays. """ - if Modifier.CTRL in capture_modifiers() and self.query(self.can_add_stems, default=False): - self.call(self.on_file_add_requested, node.filepath) + if Modifier.CTRL in capture_modifiers() and self._gather_audio_node(node): return - self.call(self.on_wave_file_clicked, node.filepath) self._logic.request_autoplay(node) + def _gather_audio_node(self, node: FileSystemNode) -> bool: + """Hands a recording to the converter where it is free to take one, saying whether it went.""" + if not self.query(self.can_add_stems, default=False): + return False + + self.call(self.on_file_add_requested, node.filepath) + return True + def _on_file_node_double_clicked( self, _sender: Sender, @@ -310,8 +316,7 @@ def _on_file_node_double_clicked( case extensions.EXT_FILE_RECONSTRUCTION: self._load_reconstruction(node) case suffix if suffix in extensions.EXT_FILES_AUDIO: - self._logic.cancel_autoplay() - return self._reconstruct_file(node) + self._gather_audio_node(node) case extensions.EXT_FILE_LIBRARY: return self._load_library(node) @@ -349,10 +354,13 @@ def _directory_node_clicked( node: FileSystemNode, node_tag: str, ) -> None: - """Answers a click on a folder: Ctrl offers its recordings as stems, else it opens. + """Answers a click on a folder: Ctrl gathers its recordings, and a plain click opens it. - Ctrl does what **Add folder as stems** does, opening a stems conversion where none is - being built. While the converter is busy the folder opens the way a plain click opens it. + Gathering a folder reads every recording below it, which is work a reader asks for rather + than work that follows them around the browser. Ctrl does what **Add folder** does, so the + folder joins the conversion without the reader leaving the row; a plain click opens the + folder and leaves the conversion as it stands, and so does every click while the converter + is busy. """ has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: @@ -363,7 +371,6 @@ def _directory_node_clicked( return self._toggle_directory_expansion(node, node_tag) - self.call(self.on_directory_clicked, node.filepath) def _load_reconstruction(self, node: FileSystemNode) -> None: filepath = node.filepath @@ -381,12 +388,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool: return True - def _reconstruct_file(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return - - self.call(self.on_reconstruct_file, node.filepath) - def _toggle_directory_expansion( self, node: FileSystemNode, diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py deleted file mode 100644 index dc24775ce..000000000 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ /dev/null @@ -1,163 +0,0 @@ -from typing import Any, Callable, List, Optional, Tuple - -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 -from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_CHANNEL, - TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, -) -from sampletones_application.ui.elements.field import labeled_field, subheader -from sampletones_application.ui.elements.fonts.font import Font -from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.panel import GUIPanel -from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.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 -from sampletones_application.utils.gui.widgets import clamp_widget_value -from sampletones_application.view_model.main.reconstructor import ( - ReconstructorPanelViewModel, -) -from sampletones_application.view_model.main.updates import GenerationSettingsUpdate -from sampletones_core.constants.algorithm import MAX_DRIVE -from sampletones_core.constants.enums import ChannelName -from sampletones_shared.types.application import Sender - - -class GUIReconstructorPanel(GUIPanel): - def __init__( - self, - initial_view: ReconstructorPanelViewModel, - *, - layout: ReconstructorLayout, - inputs: InputsLayout, - language_manager: LanguageManager, - status_bar: GUIStatusBar, - initial_collapsed: bool = False, - ) -> None: - self._language_manager = language_manager - self._view = initial_view - self._layout = layout - self._input_width = inputs.default_width - self._label_width = inputs.label_width - self._status_bar = status_bar - self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None - self._item_handler_tag = compose_tag(TAG_MAIN_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) - - super().__init__( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL, - height=layout.height, - ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed) - - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with self._collapsible_card( - parent, - self._language_manager["main.reconstructor.label.section_settings"], - glyph=self._glyphs.headers.reconstruction, - width=self.width, - ): - self._create_generator_selection() - dpg.add_separator() - self._create_drive_slider() - self._create_tooltips() - - def _setup_handlers(self) -> None: - with dpg.item_handler_registry(tag=self._item_handler_tag): - dpg.add_item_deactivated_handler(callback=self._on_parameter_change) - dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change) - dpg.add_item_edited_handler(callback=self._on_parameter_change) - - def _create_generator_selection(self) -> None: - subheader(self._language_manager["main.reconstructor.label.section_channels"]) - - with dpg.group(): - for channel, label, theme_tag in self._channel_chips(): - checkbox_tag = self._get_generator_checkbox_tag(channel) - dpg.add_checkbox( - label=label, - default_value=channel in self._view.channels, - tag=checkbox_tag, - callback=self._on_parameter_change, - ) - ThemeRegistry.get(theme_tag).bind_to_item(checkbox_tag) - - def _channel_chips(self) -> List[Tuple[ChannelName, str, str]]: - return [ - ( - channel_name, - channel_label(self._language_manager, channel_name), - CHANNEL_THEME_TAGS[channel_name], - ) - for channel_name in ChannelName.items() - ] - - def _create_drive_slider(self) -> None: - with labeled_field(self._language_manager["main.reconstructor.label.slider_drive"], self._label_width): - dpg.add_slider_float( - tag=TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - min_value=0.0, - max_value=MAX_DRIVE, - default_value=self._view.drive, - width=self._input_width, - format=self._layout.drive_format, - ) - - dpg.bind_item_handler_registry( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._item_handler_tag, - ) - self._status_bar.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._language_manager["global.status.message.input"], - ) - FontRegistry.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - Font.MONO, - ) - - def _create_tooltips(self) -> None: - show_tooltip( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._language_manager["main.reconstructor.tooltip.tooltip_drive"], - ) - - def toggle_channel(self, channel: ChannelName) -> None: - """Switches one channel in or out of the set a reconstruction is built from. - - This is the gesture a click on the channel'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(channel) - 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: - channels = [channel for channel in ChannelName if dpg.get_value(self._get_generator_checkbox_tag(channel))] - generation_update = GenerationSettingsUpdate( - drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)), - channels=channels, - ) - self.call(self.on_generation_settings_changed, generation_update) - - def update_view(self, view_model: ReconstructorPanelViewModel) -> None: - self._view = view_model - dpg.set_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, view_model.drive) - for channel in ChannelName: - dpg_set_value(self._get_generator_checkbox_tag(channel), channel in view_model.channels) - - @staticmethod - def _get_generator_checkbox_tag(channel: ChannelName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_CHANNEL, channel.value) diff --git a/src/sampletones_application/ui/panels/main/source/__init__.py b/src/sampletones_application/ui/panels/main/source/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/source/grid.py b/src/sampletones_application/ui/panels/main/source/grid.py new file mode 100644 index 000000000..5c95f206f --- /dev/null +++ b/src/sampletones_application/ui/panels/main/source/grid.py @@ -0,0 +1,187 @@ +from typing import Callable, Dict, Final, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_SLOT, + TAG_MAIN_SOURCE_GROUP_GRID, + TAG_MAIN_SOURCE_TABLE_GRID, +) +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.ui.elements.stems.heading import StemsHeading +from sampletones_application.ui.themes.channels import ( + CHANNEL_THEME_TAGS, + PARTIAL_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.view_model.main.source import ( + SettingsSlotViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.callbacks import CallbackMixin + +SlotCallback = Callable[[SettingsField, ChannelName], None] + +NO_MUTED_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset() +SETTINGS_FIELDS: Final[Tuple[SettingsField, ...]] = tuple(SettingsField) + + +class SettingsGrid(CallbackMixin): + """The choices a settings card edits, as one row of the grid the converter's list stands in. + + The channels are named once above the row and each cell holds the boxes its channel offers — + the channel a recording takes, and the bend on it — so the card reads the way a row of the + list reads and the two share one vocabulary. The row is generated from the slots the model + declares, which is what makes a further choice one more box in each cell. + """ + + def __init__( + self, + *, + layout: StemsListLayout, + language_manager: LanguageManager, + ) -> None: + self._layout = layout + self._heading = StemsHeading( + prefix=TAG_MAIN_SOURCE_GROUP_GRID, + layout=layout, + language_manager=language_manager, + bends=True, + ) + self._columns = StemsColumns( + layout=layout, + channels=tuple(ChannelName.items()), + master=False, + removable=False, + bends=True, + folders=False, + ) + + self.on_slot_toggled: Optional[SlotCallback] = None + + @property + def tag(self) -> str: + """The grid as a whole, which the card shows once a reader has picked a row out.""" + return TAG_MAIN_SOURCE_GROUP_GRID + + def create(self, view_model: SourceSettingsPanelViewModel) -> None: + """Build the channel names and the one row of boxes standing under them. + + The grid rules its own top edge, which is the line dividing the names from the boxes. + """ + with dpg.group(tag=TAG_MAIN_SOURCE_GROUP_GRID): + self._heading.create(TAG_MAIN_SOURCE_GROUP_GRID, self._columns) + self._heading.render(NO_MUTED_CHANNELS) + with dpg.table( + tag=TAG_MAIN_SOURCE_TABLE_GRID, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + borders_outerH=True, + ): + self._columns.declare() + self._create_row(view_model) + + def render(self, view_model: SourceSettingsPanelViewModel) -> None: + """Draw what the picked row currently holds onto the boxes it already stands as.""" + boxes = self._boxes(view_model) + for field, channel_name in self._cells(): + slot = boxes.get(field) + self._render_box(field, channel_name, slot, live=view_model.live) + + def _create_row(self, view_model: SourceSettingsPanelViewModel) -> None: + """One row of the grid: the name column left open, and a cell for every channel.""" + with dpg.table_row(): + self._columns.open_leading_cells() + boxes = self._boxes(view_model) + for channel_name in self._columns.channels: + self._create_cell(channel_name, boxes) + + def _create_cell( + self, + channel_name: ChannelName, + boxes: Dict[SettingsField, SettingsSlotViewModel], + ) -> None: + """One channel's boxes, standing in the slots the heading above them names.""" + with dpg.group(horizontal=True, indent=self._columns.box_indent(channel_name)): + for field in self._fields_on(channel_name): + self._create_box(field, channel_name, boxes.get(field)) + + def _create_box( + self, + field: SettingsField, + channel_name: ChannelName, + slot: Optional[SettingsSlotViewModel], + ) -> None: + checkbox_tag = self._box_tag(field, channel_name) + dpg.add_checkbox( + tag=checkbox_tag, + user_data=(field, channel_name), + callback=self._on_box, + ) + self._render_box(field, channel_name, slot, live=True) + + def _render_box( + self, + field: SettingsField, + channel_name: ChannelName, + slot: Optional[SettingsSlotViewModel], + *, + live: bool, + ) -> None: + """Draw one box: what its channel reads, in the tone that reading takes.""" + checkbox_tag = self._box_tag(field, channel_name) + agreement = slot.agreement_on(channel_name) if slot is not None else Agreement.NONE + offered = slot is not None and slot.offers(channel_name) + dpg_configure_item(checkbox_tag, show=offered, enabled=live) + dpg_set_value(checkbox_tag, agreement.reads_held) + ThemeRegistry.get(self._box_theme(channel_name, agreement)).bind_to_item(checkbox_tag) + + def _cells(self) -> Tuple[Tuple[SettingsField, ChannelName], ...]: + """Every box the grid stands as, which is what a render walks.""" + return tuple( + (field, channel_name) for channel_name in self._columns.channels for field in self._fields_on(channel_name) + ) + + def _fields_on(self, channel_name: ChannelName) -> Tuple[SettingsField, ...]: + """The choices one channel's cell holds, in the order the heading names its slots. + + A cell holds as many boxes as the grid gives its channel slots, and the fields stand in + the order they are declared in, which is the order the slots that read them stand in. + """ + return SETTINGS_FIELDS[: self._columns.slots(channel_name)] + + @staticmethod + def _boxes(view_model: SourceSettingsPanelViewModel) -> Dict[SettingsField, SettingsSlotViewModel]: + """The choices the card is editing, reachable by the field each answers for.""" + return {slot.field: slot for slot in view_model.slots} + + def _on_box( + self, + _sender: Sender, + _value: bool, + user_data: Tuple[SettingsField, ChannelName], + ) -> None: + field, channel_name = user_data + self.call(self.on_slot_toggled, field, channel_name) + + @staticmethod + def _box_theme(channel_name: ChannelName, agreement: Agreement) -> str: + """The tone a box takes: the channel's own color, softened where the group half-holds it.""" + if agreement is Agreement.SOME: + return PARTIAL_CHANNEL_THEME_TAGS[channel_name] + + return CHANNEL_THEME_TAGS[channel_name] + + @staticmethod + def _box_tag(field: SettingsField, channel_name: ChannelName) -> str: + return compose_tag(PRE_MAIN_SOURCE_SLOT, field.value, channel_name.value) diff --git a/src/sampletones_application/ui/panels/main/source/panel.py b/src/sampletones_application/ui/panels/main/source/panel.py new file mode 100644 index 000000000..0d38214c0 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/source/panel.py @@ -0,0 +1,178 @@ +from typing import Any, Callable, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField +from sampletones_application.layout.general.inputs import InputsLayout +from sampletones_application.layout.general.stems import StemsListLayout +from sampletones_application.layout.tabs.main.source import SourceSettingsLayout +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_HANDLER_REGISTRY, + TAG_GLOBAL_THEME_SECTION_HEADER, +) +from sampletones_application.tags.main import ( + TAG_MAIN_SOURCE_PANEL, + TAG_MAIN_SOURCE_SLIDER_DRIVE, + TAG_MAIN_SOURCE_TEXT_INSPECTING, + TAG_MAIN_SOURCE_TEXT_UNPICKED, +) +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.panel import GUIPanel +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.main.source.grid import SettingsGrid +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 +from sampletones_application.utils.gui.widgets import clamp_widget_value +from sampletones_application.view_model.main.source import ( + InspectedSourceViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_application.view_model.main.updates import GenerationSettingsUpdate +from sampletones_core.constants.algorithm import MAX_DRIVE +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.application import Sender + + +class GUISourceSettingsPanel(GUIPanel): + """The source settings card: the drive a run holds to, and the choices the picked row is given. + + Drive stands above the rule and answers for the run as a whole, so it is there whatever the + reader is looking at. Below the rule the card names the row picked out of the converter's + list and draws its choices as one row of that list's own grid; with nothing picked it says + which gesture picks one. + """ + + def __init__( + self, + initial_view: SourceSettingsPanelViewModel, + *, + layout: SourceSettingsLayout, + inputs: InputsLayout, + stems_layout: StemsListLayout, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + initial_collapsed: bool = False, + ) -> None: + self._language_manager = language_manager + self._view = initial_view + self._layout = layout + self._input_width = inputs.default_width + self._label_width = inputs.label_width + self._status_bar = status_bar + self._grid = SettingsGrid(layout=stems_layout, language_manager=language_manager) + self._msg_unpicked = language_manager["main.source.message.nothing_picked"] + self._tpl_folder = language_manager["global.stems.template.folder_row"] + self._item_handler_tag = compose_tag(TAG_MAIN_SOURCE_PANEL, SUF_HANDLER_REGISTRY) + + self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None + self.on_slot_toggled: Optional[Callable[[SettingsField, ChannelName], None]] = None + + super().__init__( + tag=TAG_MAIN_SOURCE_PANEL, + height=layout.height, + ) + self._enable_vertical_collapse(initial_collapsed=initial_collapsed) + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + with self._collapsible_card( + parent, + self._language_manager["main.source.label.section_settings"], + glyph=self._glyphs.headers.reconstruction, + width=self.width, + ): + self._create_drive_slider() + dpg.add_separator() + self._create_subject_line() + self._create_unpicked_hint() + self._grid.create(self._view) + self._create_tooltips() + + self._grid.on_slot_toggled = self._on_slot_toggled + self.update_view(self._view) + + def update_view(self, view_model: SourceSettingsPanelViewModel) -> None: + """Take up what the card now edits: the drive, the row picked out, and its choices.""" + self._view = view_model + dpg.set_value(TAG_MAIN_SOURCE_SLIDER_DRIVE, view_model.drive) + dpg_set_value(TAG_MAIN_SOURCE_TEXT_INSPECTING, self._subject_text(view_model.inspected)) + dpg_configure_item(TAG_MAIN_SOURCE_TEXT_INSPECTING, show=view_model.inspecting) + dpg_configure_item(TAG_MAIN_SOURCE_TEXT_UNPICKED, show=not view_model.inspecting) + dpg_configure_item(self._grid.tag, show=view_model.inspecting) + self._grid.render(view_model) + + def _setup_handlers(self) -> None: + with dpg.item_handler_registry(tag=self._item_handler_tag): + dpg.add_item_deactivated_handler(callback=self._on_parameter_change) + dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change) + dpg.add_item_edited_handler(callback=self._on_parameter_change) + + def _create_subject_line(self) -> None: + """The row the card is editing, named the way the list names it.""" + text = dpg.add_text( + self._subject_text(self._view.inspected), + tag=TAG_MAIN_SOURCE_TEXT_INSPECTING, + ) + FontRegistry.bind_to_item(text, Font.BOLD) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(text) + + def _create_unpicked_hint(self) -> None: + """What to do to give the card something to edit, standing where the row's name stands.""" + text = dpg.add_text(self._msg_unpicked, tag=TAG_MAIN_SOURCE_TEXT_UNPICKED, wrap=self.width) + FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) + + def _subject_text(self, inspected: Optional[InspectedSourceViewModel]) -> str: + if inspected is None: + return "" + + if inspected.stands_for_a_folder: + return self._tpl_folder.format(name=inspected.name, count=inspected.holds) + + return inspected.name + + def _create_drive_slider(self) -> None: + with labeled_field(self._language_manager["main.source.label.slider_drive"], self._label_width): + dpg.add_slider_float( + tag=TAG_MAIN_SOURCE_SLIDER_DRIVE, + min_value=0.0, + max_value=MAX_DRIVE, + default_value=self._view.drive, + width=self._input_width, + format=self._layout.drive_format, + ) + + dpg.bind_item_handler_registry( + TAG_MAIN_SOURCE_SLIDER_DRIVE, + self._item_handler_tag, + ) + self._status_bar.bind_to_item( + TAG_MAIN_SOURCE_SLIDER_DRIVE, + self._language_manager["global.status.message.input"], + ) + FontRegistry.bind_to_item( + TAG_MAIN_SOURCE_SLIDER_DRIVE, + Font.MONO, + ) + + def _create_tooltips(self) -> None: + show_tooltip( + TAG_MAIN_SOURCE_SLIDER_DRIVE, + self._language_manager["main.source.tooltip.tooltip_drive"], + ) + + def _on_slot_toggled(self, field: SettingsField, channel_name: ChannelName) -> None: + self.call(self.on_slot_toggled, field, channel_name) + + def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None: + self._report_generation_settings() + + def _report_generation_settings(self) -> None: + generation_update = GenerationSettingsUpdate( + drive=float(clamp_widget_value(TAG_MAIN_SOURCE_SLIDER_DRIVE)), + ) + self.call(self.on_generation_settings_changed, generation_update) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index ba2f149c7..05ae2b8c7 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -77,6 +77,7 @@ ActivePredicate, KeyEvent, KeyRouter, + panel_scope_active, ) from sampletones_application.utils.gui.keyboard.piano import PIANO_KEYS from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color @@ -580,10 +581,13 @@ def _audition_keys_active(self) -> bool: """Whether a note key sounds the instrument the panel has in front of it. The keys reach an instrument alone, since a recording plays the audio it was made from, - and only while the Reconstructions tab is in front. A field being typed into keeps its own - characters, so a sequence entered by hand types letters rather than sounding notes. + so an open audition is what the panel holds them for. """ - return self._audition_open and self._tab_active() and not self._router.is_field_focused + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._audition_open, + ) def _on_key_pressed(self, event: KeyEvent) -> bool: """Sounds the open instrument at the note a piano key names, reporting whether it did.""" diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index cab5ff5e3..9c0354658 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -17,6 +17,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import RECORDED_ASSIGNMENT from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.stems import ( @@ -64,12 +65,11 @@ def __init__( self._stems_list = GUIStemsList( prefix=PRE_RECONSTRUCTION_STEMS, layout=stems_layout, + ceiling=stems_layout.well_ceiling, + glyphs=self._glyphs.common, language_manager=language_manager, status_bar=status_bar, - draggable=False, - removable=True, - retain_last_row=True, - master_checkbox=True, + offer=RECORDED_ASSIGNMENT, ) self.on_stem_channels_changed: Optional[Callable[[int, FrozenSet[ChannelName]], None]] = None @@ -182,7 +182,7 @@ def _on_remove_requested(self, key: str) -> None: self.call(self.on_stem_remove_requested, int(key)) def _on_row_activated(self, key: str) -> None: - """A clicked row shows its recording where it sits on disk.""" + """A clicked row shows its recording where it sits on disk, whichever way it now reads.""" row = self._stems_list.row(key) if row is not None and row.available: open_path_in_explorer(row.path) diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index e39dc5033..d6b279afe 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -173,7 +173,7 @@ def _window(self, view_model: HistoryViewModel) -> EntryWindow: Rendering is capped at ``max_rendered_entries`` so a full-budget history keeps the panel responsive; the window tracks the cursor, keeping the current entry visible and clickable. Entries beyond the window are - reached by stepping the cursor towards them. + reached by stepping the cursor toward them. """ limit = self._layout.history.max_rendered_entries entries = view_model.entries diff --git a/src/sampletones_application/ui/panels/sequencer/order/panel.py b/src/sampletones_application/ui/panels/sequencer/order/panel.py index 8dda52a4d..ceb47aa15 100644 --- a/src/sampletones_application/ui/panels/sequencer/order/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/order/panel.py @@ -87,6 +87,7 @@ ActivePredicate, KeyEvent, KeyRouter, + panel_scope_active, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS from sampletones_application.utils.gui.keyboard.modifiers import ( @@ -1071,15 +1072,12 @@ def add_action_items(self, target: OrderTarget) -> None: self._menu.add_action_items(target) def _keys_active(self) -> bool: - """Whether the order table owns the next key: its tab is in front, its cursor is set, and - no field holds the keyboard. - - 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._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused + """Whether the order table owns the next key, which the cell its cursor is set on decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._input_state.cursor is not None, + ) # TODO: to extract common parts [_on_key_pressed] def _on_key_pressed(self, event: KeyEvent) -> bool: diff --git a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py index 5f74bd79d..e5d0825e8 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker/panel.py @@ -106,6 +106,7 @@ ActivePredicate, KeyEvent, KeyRouter, + panel_scope_active, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS from sampletones_application.utils.gui.keyboard.modifiers import ( @@ -1385,15 +1386,12 @@ def add_action_items(self, target: TrackerTarget) -> None: self._menu.add_action_items(target) def _keys_active(self) -> bool: - """Whether the grid owns the next key: its tab is in front, its cursor is set, and no - field holds the keyboard. - - The grid keeps its cursor while another tab is worked on, so the tab in front is what - decides whether a press reaches it. A focused field keeps the keyboard, so the grid stands - down while the user types into an input. A modal dialog claims keys at a higher priority in - the router, so the grid carries no modal check of its own. - """ - return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused + """Whether the grid owns the next key, which the cell its cursor is set on decides.""" + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._input_state.cursor is not None, + ) # TODO: to extract common parts [_on_key_pressed] def _on_key_pressed(self, event: KeyEvent) -> bool: diff --git a/src/sampletones_application/ui/panels/sequencer/voices/panel.py b/src/sampletones_application/ui/panels/sequencer/voices/panel.py index 62344e530..f5ee14877 100644 --- a/src/sampletones_application/ui/panels/sequencer/voices/panel.py +++ b/src/sampletones_application/ui/panels/sequencer/voices/panel.py @@ -35,6 +35,7 @@ ActivePredicate, KeyEvent, KeyRouter, + panel_scope_active, ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId @@ -464,12 +465,10 @@ def deselect(self) -> None: self._selected_voice_id = None def _keys_active(self) -> bool: - """Whether the voices panel owns the next key. + """Whether the voices panel owns the next key, which the voice it holds selected decides. - 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 voice 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. + A name being edited claims every press on its own, so Escape reaches the rename it would + cancel rather than the field that holds the keyboard. """ if not self._tab_active(): return False @@ -477,7 +476,11 @@ def _keys_active(self) -> bool: if self._editing_voice_id is not None: return True - return self._selected_voice_id is not None and not self._router.is_field_focused + return panel_scope_active( + tab_active=self._tab_active, + router=self._router, + holds=self._selected_voice_id is not None, + ) def _on_key_pressed(self, event: KeyEvent) -> bool: """Applies a voices key to the selected voice, reporting whether the panel consumed it. diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py index 2c2e94802..6556f0ad5 100644 --- a/src/sampletones_application/ui/themes/channels.py +++ b/src/sampletones_application/ui/themes/channels.py @@ -2,9 +2,13 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_CHANNEL_NOISE, + TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_PULSE1, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_PULSE2, + TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL, TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, + TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL, ) from sampletones_core.constants.enums import ChannelName @@ -14,3 +18,10 @@ ChannelName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, ChannelName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE, } + +PARTIAL_CHANNEL_THEME_TAGS: Final[Dict[ChannelName, str]] = { + ChannelName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, + ChannelName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2_PARTIAL, + ChannelName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE_PARTIAL, + ChannelName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE_PARTIAL, +} diff --git a/src/sampletones_application/utils/file_dialogs/api.py b/src/sampletones_application/utils/file_dialogs/api.py index 2ae187c06..7e7907d86 100644 --- a/src/sampletones_application/utils/file_dialogs/api.py +++ b/src/sampletones_application/utils/file_dialogs/api.py @@ -1,3 +1,4 @@ +from functools import partial from itertools import chain from pathlib import Path from typing import Optional, Tuple @@ -7,6 +8,7 @@ from sampletones_application.utils.file_dialogs.selection import ( select_file_dialog_backend, ) +from sampletones_application.utils.gui.render_thread import answered_while_drawing from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.system.paths import ensure_suffix, to_path @@ -17,11 +19,19 @@ def open_file_dialog( initial_directory: Optional[Pathlike] = None, filters: Tuple[FileFilter, ...] = (), ) -> Optional[Path]: + """Asks for a file to open, yielding ``None`` once the dialog is dismissed. + + The dialog stands in front of the interface until the reader answers it, and the frames keep + being drawn behind it meanwhile. + """ backend = select_file_dialog_backend() - return backend.open_file( - title=title, - initial_directory=_optional_path(initial_directory), - filters=filters, + return answered_while_drawing( + partial( + backend.open_file, + title=title, + initial_directory=_optional_path(initial_directory), + filters=filters, + ) ) @@ -40,11 +50,14 @@ def save_file_dialog( finds one. ``filters`` is ordered, and its first type is the one the dialog opens on. """ backend = select_file_dialog_backend() - destination = backend.save_file( - title=title, - initial_directory=_optional_path(initial_directory), - suggested_name=default_filename, - filters=filters, + destination = answered_while_drawing( + partial( + backend.save_file, + title=title, + initial_directory=_optional_path(initial_directory), + suggested_name=default_filename, + filters=filters, + ) ) if destination is None: @@ -58,10 +71,18 @@ def select_directory_dialog( title: str, initial_directory: Optional[Pathlike] = None, ) -> Optional[Path]: + """Asks for a directory, yielding ``None`` once the dialog is dismissed. + + The dialog stands in front of the interface until the reader answers it, and the frames keep + being drawn behind it meanwhile. + """ backend = select_file_dialog_backend() - return backend.select_directory( - title=title, - initial_directory=_optional_path(initial_directory), + return answered_while_drawing( + partial( + backend.select_directory, + title=title, + initial_directory=_optional_path(initial_directory), + ) ) diff --git a/src/sampletones_application/utils/gui/callbacks.py b/src/sampletones_application/utils/gui/callbacks.py new file mode 100644 index 000000000..8ff0b2a62 --- /dev/null +++ b/src/sampletones_application/utils/gui/callbacks.py @@ -0,0 +1,43 @@ +from inspect import Parameter, signature +from typing import Any, Final, Sequence + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import Callback + +POSITIONAL: Final = (Parameter.POSITIONAL_ONLY, Parameter.POSITIONAL_OR_KEYWORD) + + +def hold_callbacks() -> None: + """Have DearPyGui gather a widget's callback rather than running it where the gesture lands. + + DearPyGui answers a gesture on a thread of its own, so a callback that rebuilds widgets there + runs while the render loop is walking the very items it drops — a crash rather than a glitch. + Gathered callbacks wait in a queue the frame drains, which is what makes every gesture reach + the interface from the thread the context belongs to. + """ + dpg.configure_app(manual_callback_management=True) + + +def run_held_callbacks() -> None: + """Run what DearPyGui gathered since the last frame, on the thread that drew it. + + A callback is handed as many of DearPyGui's three arguments as it declares, and each runs + through the reporting queued work runs through, so one failing gesture leaves the rest to run. + """ + for job in dpg.get_callback_queue() or (): + callback, *arguments = job + if callback is None: + continue + + CallbackQueue.run(callback, *_taken(callback, arguments)) + + +def _taken(callback: Callback, arguments: Sequence[Any]) -> Sequence[Any]: + """The arguments ``callback`` declares, out of the sender, the payload and the user data.""" + parameters = signature(callback).parameters.values() + if any(parameter.kind is Parameter.VAR_POSITIONAL for parameter in parameters): + return arguments + + return arguments[: sum(1 for parameter in parameters if parameter.kind in POSITIONAL)] diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index b8d467ebc..890be7d1a 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -118,13 +118,18 @@ def dpg_get_item_parent( ) -> Optional[Sender]: """The item's parent, or None when the item is absent. - Queued callbacks mutate the item tree on the callback-queue thread, so an item read - from another thread can be removed underneath this lookup; DearPyGui reports the - absent item by raising, which resolves here to None. + Queued callbacks mutate the item tree on the callback-queue thread, so an item read from + another thread can be removed underneath this lookup; DearPyGui reports the absent item by + raising, which resolves here to None. + + The catch is as broad as what it answers: DearPyGui raises ``Exception`` itself for an absent + item rather than a type of its own, so there is nothing narrower to name. + A test pins that, and the day the library raises something of its own is the day this + narrows to it. """ try: parent: Optional[Sender] = dpg.get_item_parent(tag, *args, **kwargs) - except Exception: # unsafe broad exception + except Exception: # pylint: disable=broad-exception-caught return None return parent diff --git a/src/sampletones_application/utils/gui/frame.py b/src/sampletones_application/utils/gui/frame.py index 9542c3962..44943c475 100644 --- a/src/sampletones_application/utils/gui/frame.py +++ b/src/sampletones_application/utils/gui/frame.py @@ -21,6 +21,15 @@ def __lt__(self, other: FrameCallback) -> bool: class FrameCallbackManager(metaclass=NonInstantiableMeta): + """Work picked up once a named frame has been drawn. + + Reading a laid-out size or letting a configuration take effect needs a frame drawn with it, and + naming the frame is how a callback asks for one. The render thread inside the callback queue's + drain stands between frames rather than inside one, which leaves the next frame the drain's own + to reach: ``dpg.split_frame`` there waits for what the wait itself prevents and the application + stops for good, while a frame count asks for the same thing and lets the loop keep running. + """ + _callbacks: ClassVar[List[FrameCallback]] = [] _lock: ClassVar[threading.Lock] = threading.Lock() diff --git a/src/sampletones_application/utils/gui/keyboard/__init__.py b/src/sampletones_application/utils/gui/keyboard/__init__.py index 1d6e00ca7..659abdf85 100644 --- a/src/sampletones_application/utils/gui/keyboard/__init__.py +++ b/src/sampletones_application/utils/gui/keyboard/__init__.py @@ -8,6 +8,7 @@ KeyRouter, ModalKeyHandler, ) +from sampletones_application.utils.gui.keyboard.scope import panel_scope_active __all__ = [ "PRIORITY_MODAL", @@ -18,4 +19,5 @@ "KeyEvent", "KeyRouter", "ModalKeyHandler", + "panel_scope_active", ] diff --git a/src/sampletones_application/utils/gui/keyboard/combination.py b/src/sampletones_application/utils/gui/keyboard/combination.py index 828e9ef8b..2075317e6 100644 --- a/src/sampletones_application/utils/gui/keyboard/combination.py +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -65,7 +65,7 @@ def parse(cls, text: str) -> KeyCombination: plus key. Args: - text: A combination as :meth:`display` writes it, in any capitalisation. + text: A combination as :meth:`display` writes it, in any capitalization. Returns: KeyCombination: The combination the text names. diff --git a/src/sampletones_application/utils/gui/keyboard/focus/search.py b/src/sampletones_application/utils/gui/keyboard/focus/search.py index c751211e7..36cdcdd6d 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/search.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/search.py @@ -53,7 +53,7 @@ def _active_descendant_field_kind(node: ItemNode) -> FieldKind: def _leads_to_focused_widget(node: ItemNode) -> bool: - """Whether the search follows ``node`` towards the field being edited. + """Whether the search follows ``node`` toward the field being edited. Through a container that carries its children's state the search follows the one branch that reports focus, which keeps a key press to the cost of the path down to its field. Every other diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py index 5042625af..e0bb26d6b 100644 --- a/src/sampletones_application/utils/gui/keyboard/keys.py +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -160,7 +160,7 @@ def key_display(key: int) -> str: def key_code(name: str) -> int: - """The key a written name stands for, however the name is capitalised. + """The key a written name stands for, however the name is capitalized. Reading a name back into a code is what lets a binding be written down, so a configured combination and a declared one arrive at the same key. A key answers to the name it displays diff --git a/src/sampletones_application/utils/gui/keyboard/scope.py b/src/sampletones_application/utils/gui/keyboard/scope.py new file mode 100644 index 000000000..0e7e5b4b0 --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/scope.py @@ -0,0 +1,15 @@ +from sampletones_application.utils.gui.keyboard.router import ActivePredicate, KeyRouter + + +def panel_scope_active(*, tab_active: ActivePredicate, router: KeyRouter, holds: bool) -> bool: + """Whether a panel scope owns the next key. + + A panel answers while its tab is in front, while it holds the thing the keys act on, and + while no field has the keyboard. What a panel holds differs — a cursor, a row picked out, an + open audition — so each states its own and the rest of the rule is answered here. + + The tab is read at the moment of the press, since a cursor, a pick and an audition all outlive + a move to another tab. A modal dialog claims keys above this priority in the router, which is + what holds every panel off while one stands open. + """ + return tab_active() and holds and not router.is_field_focused diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py new file mode 100644 index 000000000..5c12531bb --- /dev/null +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -0,0 +1,83 @@ +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Callable, Final, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import Callback + +AnswerT = TypeVar("AnswerT") + +FRAME_PAUSE: Final[float] = 1 / 60 + +_RENDER_THREAD: Optional[int] = None + + +def claim_render_thread() -> None: + """Names the thread DearPyGui's context belongs to, which is the one drawing the frames.""" + global _RENDER_THREAD # pylint: disable=global-statement + _RENDER_THREAD = threading.get_ident() + + +def release_render_thread() -> None: + """Lets the render thread go, which a run does once its loop has stopped.""" + global _RENDER_THREAD # pylint: disable=global-statement + _RENDER_THREAD = None + + +def is_render_thread() -> bool: + """Whether the caller stands where DearPyGui's context is. + + A run claims the thread when its loop starts and lets it go when the loop stops, so before and + after that — while the interface is being built, and while it is being taken down — whichever + thread is asking is the one holding the context. + """ + return _RENDER_THREAD is None or threading.get_ident() == _RENDER_THREAD + + +def on_render_thread( + work: Callback, + *args: Any, + priority: int = 0, + **kwargs: Any, +) -> None: + """Runs ``work`` where DearPyGui's context belongs: the thread drawing the frames. + + A worker of our own reaches the interface while the render thread is walking the very items it + would create and drop, and an item freed there is freed with no Python thread state — a crash + rather than a glitch. Work already on the render thread runs where it stands; work arriving + from any other thread joins the queue the render loop drains, so it lands between frames. + + A widget's own callback reaches this as a direct call, since DearPyGui gathers it for the + frame to run rather than answering the gesture on a thread of its own. + """ + if is_render_thread(): + work(*args, **kwargs) + return + + CallbackQueue.add(work, *args, priority=priority, **kwargs) + + +def answered_while_drawing(work: Callable[[], AnswerT]) -> AnswerT: + """Run ``work`` beside the frames rather than in place of them, and report what it answers. + + A native dialog answers when the reader does, which is as long as they take. Standing on the + render thread for that leaves the window with nothing drawing it, so the work goes to a thread + of its own and the frames keep being drawn until it reports back. The gestures those frames + gather wait for the drain that follows, so the interface stays painted while it stands inert — + which is what a dialog standing in front of it means. + + Work reached from any other thread runs where it stands, since nothing there holds the frames. + """ + if not is_render_thread(): + return work() + + with ThreadPoolExecutor(max_workers=1) as pool: + answer = pool.submit(work) + while not answer.done() and dpg.is_dearpygui_running(): + dpg.render_dearpygui_frame() + time.sleep(FRAME_PAUSE) + + return answer.result() diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index afd04fd8d..52aa7003d 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -18,6 +18,7 @@ class ShortcutCategory(StrEnum): """ APPLICATION = "application" + SOURCES = "sources" ORDER = "order" TRACKER = "tracker" VOICES = "voices" @@ -107,6 +108,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: SELECT_TAB_SEQUENCER = ("SelectTabSequencer", ShortcutCategory.APPLICATION) SELECT_TAB_INSTRUCTIONS = ("SelectTabInstructions", ShortcutCategory.APPLICATION) + SOURCES_REMOVE_SOURCE = ("SourcesRemoveSource", ShortcutCategory.SOURCES) + ORDER_PREVIOUS_POSITION = ("OrderPreviousPosition", ShortcutCategory.ORDER) ORDER_NEXT_POSITION = ("OrderNextPosition", ShortcutCategory.ORDER) ORDER_PREVIOUS_CHANNEL = ("OrderPreviousChannel", ShortcutCategory.ORDER) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 4e6a3a87d..db58d27d0 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -4,6 +4,7 @@ from pydantic import BaseModel +from sampletones_application.constants.output import OutputKind from sampletones_application.view_model.shared.percent import format_percent from sampletones_application.view_model.shared.stems import ( StemRowViewModel, @@ -16,7 +17,7 @@ class ConversionPhase(StrEnum): IDLE = "idle" WAITING = "waiting" RUNNING = "running" - CANCELLING = "cancelling" + CANCELING = "canceling" COMPLETED = "completed" CANCELED = "canceled" FAILED = "failed" @@ -37,9 +38,10 @@ class ConverterAction(StrEnum): { ConversionPhase.WAITING, ConversionPhase.RUNNING, - ConversionPhase.CANCELLING, + ConversionPhase.CANCELING, } ) +SINGLE_SOURCE: Final[int] = 1 class ConverterViewModel(BaseModel, frozen=True): @@ -59,13 +61,18 @@ class ConverterViewModel(BaseModel, frozen=True): output_path: Optional[Path] is_file: bool other_operation_active: bool - stems_mode: bool + output: OutputKind stem_sources: Tuple[StemRowViewModel, ...] - enabled_channels: FrozenSet[ChannelName] channel_cap: int max_channel_cap: int hierarchy_mode: HierarchyMode max_sources: int + selected_key: Optional[str] + + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self.output.mixes @property def progress_overlay(self) -> str: @@ -82,34 +89,53 @@ def subpanel_visible(self) -> bool: @property def has_input(self) -> bool: - """Something is there to convert: a listed recording holding a channel, or a selected path.""" - if self.stems_mode: - return any(row.takes_part for row in self.stem_sources) - - return self.input_path is not None + """Something is there to convert: a gathered recording holding a channel the run enables.""" + return any(row.takes_part for row in self.stem_sources) @property def source_count(self) -> int: return len(self.stem_sources) + @property + def listed(self) -> bool: + """Something stands in the list, which is what the run's own choices answer for.""" + return bool(self.stem_sources) + + @property + def mixes_several(self) -> bool: + """A mix is being built from more than one recording, which is what an order decides.""" + return self.mixes and self.source_count > SINGLE_SOURCE + + @property + def shows_input(self) -> bool: + """A run is reporting the recording it is on, which is what the input line names.""" + return self.input_path is not None and self.is_active + @property def channels_in_play(self) -> Tuple[ChannelName, ...]: - """The channels a conversion may reach, in the order the application names them. + """The channels a row draws a box on, in the order the application names them. - A stems row offers a checkbox per channel in play, so a channel the configuration leaves - out costs the row no column at all. + What a run reaches is what its rows hold, so every channel is put to a reader and the + settings card narrows a row that should reach fewer. """ - return tuple(channel_name for channel_name in ChannelName.items() if channel_name in self.enabled_channels) + return tuple(ChannelName.items()) @property def stems_list(self) -> StemsListViewModel: - """The gathered recordings as the stems list draws them, inert while a conversion runs.""" + """The gathered recordings as the stems list draws them, inert while a conversion runs. + + A level is a turn to choose, so the bands and the drag that rearranges them arrive with + the second recording of a mix; one recording is its own order and reads as a plain run. + """ return StemsListViewModel( rows=self.stem_sources, channels_in_play=self.channels_in_play, muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, live=not self.is_active, - collapse_levels=False, + collapse_levels=not self.mixes_several, + selected_key=self.selected_key, ) @property @@ -117,15 +143,10 @@ def level_count(self) -> int: """How many levels the gathered recordings are spread over.""" return max((row.level + 1 for row in self.stem_sources), default=0) - @property - def playing_count(self) -> int: - """How many of the listed recordings take part in the conversion.""" - return sum(1 for row in self.stem_sources if row.takes_part) - @property def can_add_source(self) -> bool: - """The list has room for another recording.""" - return self.source_count < self.max_sources + """Another recording would reach the run, which a full mix answers no to.""" + return not self.mixes or self.source_count < self.max_sources @property def convert_button_enabled(self) -> bool: @@ -142,6 +163,6 @@ def primary_action(self) -> ConverterAction: @property def primary_action_enabled(self) -> bool: if self.primary_action == ConverterAction.CANCEL: - return self.phase != ConversionPhase.CANCELLING + return self.phase != ConversionPhase.CANCELING return self.convert_button_enabled diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py deleted file mode 100644 index 7799a6d61..000000000 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ /dev/null @@ -1,10 +0,0 @@ -from typing import FrozenSet - -from pydantic import BaseModel - -from sampletones_core.constants.enums import ChannelName - - -class ReconstructorPanelViewModel(BaseModel, frozen=True): - channels: FrozenSet[ChannelName] - drive: float diff --git a/src/sampletones_application/view_model/main/source.py b/src/sampletones_application/view_model/main/source.py new file mode 100644 index 000000000..ef80e1add --- /dev/null +++ b/src/sampletones_application/view_model/main/source.py @@ -0,0 +1,69 @@ +from typing import FrozenSet, Optional, Tuple + +from pydantic import BaseModel + +from sampletones_application.constants.sources import SettingsField, SourceKind +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName + + +class SettingsSlotViewModel(BaseModel, frozen=True): + """One per-recording choice as the settings card draws it: a box per channel it offers. + + ``held_channels`` are the ones every recording the card inspects makes the choice on and + ``partial_channels`` the ones only some of them do, so a folder reads the same three ways in + the card as it does in the list. + """ + + field: SettingsField + offered_channels: FrozenSet[ChannelName] + held_channels: FrozenSet[ChannelName] + partial_channels: FrozenSet[ChannelName] + + def offers(self, channel_name: ChannelName) -> bool: + """Whether this choice is put to a reader on ``channel_name``.""" + return channel_name in self.offered_channels + + def agreement_on(self, channel_name: ChannelName) -> Agreement: + """How the recordings the card inspects read on ``channel_name``.""" + if channel_name in self.held_channels: + return Agreement.ALL + + return Agreement.SOME if channel_name in self.partial_channels else Agreement.NONE + + +class InspectedSourceViewModel(BaseModel, frozen=True): + """The row the settings card is editing, as the card names it. + + A folder reads its own name and how many recordings it stands for, so the card says what a + choice made here reaches; a recording reads its name alone. + """ + + name: str + kind: SourceKind + holds: int + + @property + def stands_for_a_folder(self) -> bool: + """The row is a folder, so its count is part of what names it.""" + return self.kind is SourceKind.FOLDER + + +class SourceSettingsPanelViewModel(BaseModel, frozen=True): + """What the settings card shows: the choices it edits, and the row it edits them on. + + ``inspected`` names the row a reader picked out of the converter's list, which is the whole of + what the choices below it reach. ``drive`` holds for the run as a whole and stands above them + whatever is picked. ``live`` says whether the choices take a gesture, which a conversion under + way answers. + """ + + slots: Tuple[SettingsSlotViewModel, ...] + inspected: Optional[InspectedSourceViewModel] + drive: float + live: bool + + @property + def inspecting(self) -> bool: + """A row is picked out, so the card has something to draw its choices on.""" + return self.inspected is not None diff --git a/src/sampletones_application/view_model/main/updates.py b/src/sampletones_application/view_model/main/updates.py index a42aeecb8..62f7c7688 100644 --- a/src/sampletones_application/view_model/main/updates.py +++ b/src/sampletones_application/view_model/main/updates.py @@ -1,9 +1,8 @@ from pathlib import Path -from typing import List from pydantic import BaseModel -from sampletones_core.constants.enums import ChannelName, SpectrumMethod +from sampletones_core.constants.enums import SpectrumMethod class AudioSettingsUpdate(BaseModel, frozen=True): @@ -17,8 +16,9 @@ class LibrarySettingsUpdate(BaseModel, frozen=True): class GenerationSettingsUpdate(BaseModel, frozen=True): + """What the settings card reports for the whole run, whichever row it is editing.""" + drive: float - channels: List[ChannelName] class AdvancedSettingsUpdate(BaseModel, frozen=True): diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 8ddd0aa2d..96f92911b 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -35,7 +35,7 @@ class GridRegion(BaseModel, frozen=True): 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 + what makes a selection dragged upward name the same region as one dragged down to the same pair of cells. """ diff --git a/src/sampletones_application/view_model/shared/agreement.py b/src/sampletones_application/view_model/shared/agreement.py new file mode 100644 index 000000000..4f07cad42 --- /dev/null +++ b/src/sampletones_application/view_model/shared/agreement.py @@ -0,0 +1,44 @@ +from enum import StrEnum +from typing import Iterable, Self + + +class Agreement(StrEnum): + """How a group of recordings stands on one choice: all of them make it, some do, or none does. + + A row standing for several recordings shows this reading, and one gesture settles the whole + group from it. + """ + + NONE = "none" + SOME = "some" + ALL = "all" + + @classmethod + def over(cls, holdings: Iterable[bool]) -> Self: + """The reading a group of recordings gives, each stating whether it makes the choice. + + A group holding nothing reads as ``NONE``, which is what an empty folder shows. + """ + readings = tuple(holdings) + if readings and all(readings): + return cls.ALL + + return cls.SOME if any(readings) else cls.NONE + + @property + def settles_to(self) -> bool: + """What one gesture makes of this reading: every recording holds the choice. + + A group already agreeing on it lets it go instead, so a reader reaches both answers from + wherever the group stands. + """ + return self is not Agreement.ALL + + @property + def reads_held(self) -> bool: + """Whether a box standing for this reading is drawn ticked. + + A group only some of which makes the choice reads clear and takes the softened tone that + says so, since a tick states an answer the group has yet to give. + """ + return self is Agreement.ALL diff --git a/src/sampletones_application/view_model/shared/export.py b/src/sampletones_application/view_model/shared/export.py index 92fe560c7..64aece0e0 100644 --- a/src/sampletones_application/view_model/shared/export.py +++ b/src/sampletones_application/view_model/shared/export.py @@ -15,13 +15,13 @@ class ExportPhase(StrEnum): IDLE = "idle" EXPORTING = "exporting" - CANCELLING = "cancelling" + CANCELING = "canceling" ACTIVE_PHASES: Final[FrozenSet[ExportPhase]] = frozenset( { ExportPhase.EXPORTING, - ExportPhase.CANCELLING, + ExportPhase.CANCELING, } ) diff --git a/src/sampletones_application/view_model/shared/keybindings.py b/src/sampletones_application/view_model/shared/keybindings.py index eb272731b..1d0d68954 100644 --- a/src/sampletones_application/view_model/shared/keybindings.py +++ b/src/sampletones_application/view_model/shared/keybindings.py @@ -21,7 +21,7 @@ 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. + text: What the reader typed, matched in any capitalization. Returns: bool: True while the label or the combination holds the text, and for an empty filter. diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 926e145ae..f8bb628d5 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -26,7 +26,7 @@ class RenderPhase(StrEnum): IDLE = "idle" CONFIGURING = "configuring" RENDERING = "rendering" - CANCELLING = "cancelling" + CANCELING = "canceling" COMPLETED = "completed" CANCELED = "canceled" FAILED = "failed" @@ -36,7 +36,7 @@ class RenderPhase(StrEnum): { RenderPhase.CONFIGURING, RenderPhase.RENDERING, - RenderPhase.CANCELLING, + RenderPhase.CANCELING, } ) diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index eba1f7b37..364f1011f 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -1,25 +1,43 @@ +from functools import cached_property from pathlib import Path -from typing import FrozenSet, Tuple +from typing import Dict, FrozenSet, Iterator, Optional, Self, Tuple from pydantic import BaseModel -from sampletones_core.constants.enums import ChannelName +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName class StemRowViewModel(BaseModel, frozen=True): - """One recording in a stems list, as the list renders it. - - A row states where it stands — the level it picks on, the place it takes among the - recordings sharing that level, and how many of each the list holds — so the moves a list - offers gray themselves out from the row alone. ``key`` is the identity the list reports a - gesture under: the recording's path where the list gathers files, the stem id where it - describes a recorded assignment. ``offered_channels`` names the boxes the row draws and - ``channels`` the ones ticked among them. + """One row of a stems list, as the list renders it. + + A row stands for a recording or for a folder of them, and answers the same way either way: + ``offered_channels`` names the boxes it draws, ``channels`` the ones every recording it stands + for holds, and ``partial_channels`` the ones some of them hold. A recording reads as ticked or + clear; a folder its recordings disagree on reads as half-lit, and one gesture settles it. + + A row states where it stands — the level it picks on, the place it takes among the recordings + sharing that level, and how many of each the list holds — so the moves a list offers gray + themselves out from the row alone. ``key`` is the identity the list reports a gesture under: + the source's path where the list gathers files, the stem id where it describes a recorded + assignment. + + ``held`` carries the recordings a folder stands for, each a row of its own, which is what a + reader reaches by opening it. They stand where the folder stands, so a recording answers for + itself while the folder answers for them all. + + ``bends`` names the channels whose notes the recording carries to the pitch it sounds, which a + list recording what a finished conversion took draws beside the channel itself. """ key: str + kind: SourceKind path: Path + held: Tuple["StemRowViewModel", ...] channels: FrozenSet[ChannelName] + partial_channels: FrozenSet[ChannelName] + bends: FrozenSet[ChannelName] offered_channels: FrozenSet[ChannelName] available: bool level: int @@ -27,15 +45,50 @@ class StemRowViewModel(BaseModel, frozen=True): level_size: int level_count: int + @property + def holds(self) -> int: + """How many recordings the row stands for, which a folder reads out beside its name.""" + return len(self.held) + + @property + def recordings(self) -> Tuple["StemRowViewModel", ...]: + """The recordings this row stands for: what a folder holds, or the row itself. + + This is the one reading that goes from a row to recordings, so whatever counts, picks or + folds them asks here rather than telling the two kinds apart again. + """ + return self.held or (self,) + + @property + def bendable_channels(self) -> FrozenSet[ChannelName]: + """The channels the row draws a bend box for: the offered ones that load a divider. + + A bend moves a note by a fraction of the divider its channel loads, so the channels + holding one are where the choice reaches something. + """ + return self.offered_channels & TONE_CHANNELS + @property def name(self) -> str: - """The recording's own name, which is what the row reads as.""" - return self.path.stem + """The source's own name, which is what the row reads as.""" + return self.path.name if self.stands_for_a_folder else self.path.stem + + @property + def stands_for_a_folder(self) -> bool: + """The row is a folder, standing for every recording gathered below it.""" + return self.kind is SourceKind.FOLDER @property def takes_part(self) -> bool: - """The recording holds a channel, so the list counts it in.""" - return bool(self.channels) + """The row holds a channel, so the list counts it in.""" + return bool(self.channels or self.partial_channels) + + def agreement_on(self, channel_name: ChannelName) -> Agreement: + """How the recordings this row stands for read on ``channel_name``.""" + if channel_name in self.channels: + return Agreement.ALL + + return Agreement.SOME if channel_name in self.partial_channels else Agreement.NONE @property def offers_channels(self) -> bool: @@ -74,24 +127,124 @@ class StemsListViewModel(BaseModel, frozen=True): ``muted_channels`` names the columns a choice made elsewhere has switched off, which the boxes report while staying as clickable as any other. ``collapse_levels`` draws every row in one table, leaving the levels to the reader's memory rather than to a caption. + ``selected_key`` names the row a reader is inspecting, which the list draws picked out. + ``picked_keys`` names the recordings standing picked where the list asks which ones to mix, + and ``picking_room`` how many recordings that pick may hold. A list putting no such question + names no room. """ rows: Tuple[StemRowViewModel, ...] channels_in_play: Tuple[ChannelName, ...] muted_channels: FrozenSet[ChannelName] + picked_keys: FrozenSet[str] + picking_room: Optional[int] live: bool collapse_levels: bool + selected_key: Optional[str] + + @classmethod + def empty(cls) -> Self: + """The view a list stands at before anything has been drawn into it.""" + return cls( + rows=(), + channels_in_play=(), + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=False, + selected_key=None, + ) @property def row_count(self) -> int: return len(self.rows) + @property + def holds_folders(self) -> bool: + """A folder stands among the rows, which is what gives the list a disclosure column.""" + return any(row.stands_for_a_folder for row in self.rows) + @property def level_count(self) -> int: """How many levels the listed recordings are spread over.""" return max((row.level + 1 for row in self.rows), default=0) + def row(self, key: str) -> Optional[StemRowViewModel]: + """The row a gesture named, where the view still holds one.""" + return self._by_key.get(key) + + def rows_on(self, level_index: int) -> Tuple[StemRowViewModel, ...]: + """The rows one band holds, in the order they stand.""" + return tuple(row for row in self.rows if row.level == level_index) + + @property + def recordings(self) -> Tuple[StemRowViewModel, ...]: + """Every recording the rows stand for, folders walked through to what they hold.""" + return tuple(recording for row in self.rows for recording in row.recordings) + + def picking_of(self, row: StemRowViewModel) -> Agreement: + """How the recordings ``row`` stands for read on standing picked. + + A folder reads the three ways its channels read: picked where every recording it holds is, + half-lit where some are, clear where none is, so one gesture answers for the whole folder. + """ + return Agreement.over(recording.key in self.picked_keys for recording in row.recordings) + + @property + def picking_full(self) -> bool: + """The pick holds all the room it has, so what stands clear waits for something to leave.""" + return self.picking_room is not None and len(self.picked_keys) >= self.picking_room + + def reaches(self, row: StemRowViewModel) -> bool: + """Whether a click on the row's box moves it. + + A full pick answers only the rows it already holds, since taking another would build a + reconstruction from more recordings than one is made of. Letting recordings go stays open, + which is how a reader swaps one for another. + """ + return not self.picking_full or self.picking_of(row) is not Agreement.NONE + + def picking_settled(self, row: StemRowViewModel) -> FrozenSet[str]: + """The pick a click on ``row`` leaves behind. + + A row every recording of which stands picked gives them all up, and so does one the mix + has no room to take more of; any other takes as many as the room still has, in the order + they were gathered. So a click settles the row either way from wherever it stands, and + half-lit is a state the reader arrives at rather than one a click makes. + """ + if self._takes(row): + return self.picked_keys | frozenset(self._joining(row)) + + return self.picked_keys - frozenset(recording.key for recording in row.recordings) + + def _takes(self, row: StemRowViewModel) -> bool: + """Whether a click on ``row`` brings recordings in: it settles that way, and there is room.""" + return self.picking_of(row).settles_to and not self.picking_full + + def _joining(self, row: StemRowViewModel) -> Iterator[str]: + """The recordings a click on ``row`` takes in: its own, as far as the room left reaches.""" + standing = len(self.picked_keys) + for recording in row.recordings: + if recording.key in self.picked_keys: + continue + + if self.picking_room is not None and standing >= self.picking_room: + return + + yield recording.key + standing += 1 + @property - def playing_count(self) -> int: - """How many of the listed recordings hold a channel.""" - return sum(1 for row in self.rows if row.takes_part) + def picked_paths(self) -> Tuple[Path, ...]: + """The recordings standing picked, in the order the list draws them.""" + return tuple(recording.path for recording in self.recordings if recording.key in self.picked_keys) + + def boxes_of(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: + """The channels ``row`` draws a box for, in the order the columns stand.""" + return tuple(channel for channel in self.channels_in_play if channel in row.offered_channels) + + @cached_property + def _by_key(self) -> Dict[str, StemRowViewModel]: + """Every row a gesture can land on, the recordings inside a folder among them.""" + return {held.key: held for row in self.rows for held in (*row.held, row)} diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 94e754d36..6d2ae2b97 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -72,6 +72,9 @@ bindings: SelectTabSequencer: {combination: "F3", field_transparent: true} SelectTabInstructions: {combination: "F4", field_transparent: true} + # sources + SourcesRemoveSource: {combination: "Del"} + # order table OrderPreviousPosition: {combination: "Left"} OrderNextPosition: {combination: "Right", aliases: ["Enter"]} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 36f6fcdaa..a598c0f1e 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -72,6 +72,9 @@ bindings: SelectTabSequencer: {combination: "F3", field_transparent: true} SelectTabInstructions: {combination: "F4", field_transparent: true} + # sources + SourcesRemoveSource: {combination: "Del"} + # order table OrderPreviousPosition: {combination: "Left"} OrderNextPosition: {combination: "Right", aliases: ["Enter"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 19772cb6a..9f2902567 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -265,12 +265,23 @@ global.status.message.retuning_samples: "Retuning samples..." # Global — Stems # ============================================================================= global.stems.template.level_caption: "Level {}" +global.stems.template.folder_row: "{name} ({count})" +global.stems.message.folder_tooltip: "Every recording in this folder, converted on its own." +global.stems.message.status_folder_channel: "Turn the {channel} channel on or off for every recording in {name}." +global.stems.message.status_folder_remove: "Remove {name} and the recordings in it." +global.stems.message.status_folder_row: "Open {name} to see its {count} recordings. Right-click for more actions." +global.stems.message.status_folder_open: "Show the recordings in {name}." +global.stems.message.status_folder_close: "Hide the recordings in {name}." global.stems.label.remove: "x" +global.stems.label.channel_on: "on" +global.stems.label.channel_bend: "bend" +global.stems.message.bend_tooltip: "Tune each note to the exact pitch of the recording." global.stems.message.drag_tooltip: "Drag onto another row to share its level, or onto a gap to start a new level." global.stems.message.inert_tooltip: "Tick a channel to use this recording." global.stems.message.missing_tooltip: "This recording is missing from disk." global.stems.message.unoffered_tooltip: "This recording holds no frames." global.stems.message.status_row_drag: "Drag {name} onto another row or a gap to move it, or right-click for more actions." +global.stems.message.status_row_play: "Double-click {name} to hear it." global.stems.message.status_row_reveal: "Show {name} in the file browser." global.stems.message.status_master: "Turn every channel on or off for {name}." global.stems.message.status_channel: "Turn the {channel} channel on or off for {name}." @@ -312,11 +323,11 @@ main.explorer.label.context_load_library: "Load instructions library" main.explorer.label.context_reconstruct_file: "Reconstruct file" main.explorer.label.context_reconstruct_directory: "Reconstruct directory" main.explorer.label.context_add_stem: "Add as stem" -main.explorer.label.context_add_folder_stems: "Add folder as stems" +main.explorer.label.context_add_folder_stems: "Add folder" main.explorer.label.context_set_library_directory: "Set as instructions library directory" 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_node_audio_no_autoplay: "Double-click to add audio to the converter. Right-click to open context menu." +main.explorer.message.status_node_audio: "Click to play audio. Double-click to add it to the converter. Right-click to open context menu." main.explorer.message.status_refresh: "Rescan the filesystem for audio files." 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" @@ -336,12 +347,12 @@ main.config.tooltip.tooltip_sample_rate: "Set the sample rate (in Hz) for audio main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for audio processing.\n • NTSC = 60 Hz,\n • PAL = 50 Hz." # ============================================================================= -# Main tab — Reconstructor panel +# Main tab — Source settings panel # ============================================================================= -main.reconstructor.label.section_channels: "Channels" -main.reconstructor.label.section_settings: "Reconstructor settings" -main.reconstructor.label.slider_drive: "Drive" -main.reconstructor.tooltip.tooltip_drive: "Amplify NES audio during instruction selection and output.\nAt 1.0 amplitudes are calibrated, higher values push the selection harder, introducing a distortion-like effect." +main.source.message.nothing_picked: "Pick a recording or a folder in the Converter to change its settings." +main.source.label.section_settings: "Source settings" +main.source.label.slider_drive: "Drive" +main.source.tooltip.tooltip_drive: "Amplify NES audio during instruction selection and output.\nAt 1.0 amplitudes are calibrated, higher values push the selection harder, introducing a distortion-like effect." # ============================================================================= # Main tab — Converter @@ -353,26 +364,33 @@ main.converter.label.load_button: "Load" main.converter.label.open_button: "Open" main.converter.label.stop_button: "Stop" main.converter.label.continue_button: "Continue" -main.converter.label.convert_sample_button: "Convert sample" -main.converter.label.convert_directory_button: "Convert directory" +main.converter.message.mode_tooltip: "One reconstruction per recording, or one reconstruction mixing them all." +main.converter.template.convert_recordings: "Convert {count} recordings" +main.converter.template.mix_recordings: "Mix {count} recordings" +main.converter.template.convert_recording: "Convert {name}" +main.converter.label.convert_button: "Convert" main.converter.message.status_error: "Reconstruction failed." main.converter.message.status_reconstruction_completed: "Reconstruction completed!" -main.converter.message.status_no_files: "No WAV files found to process." +main.converter.message.status_no_files: "Every gathered recording is reconstructed already." main.converter.message.status_no_channels: "No channels are enabled. Enable at least one channel to reconstruct." main.converter.message.status_idle: "No tasks in progress." main.converter.message.status_waiting: "Waiting to start..." main.converter.message.status_generating_library: "Generating instructions library... (this may take a while)" -main.converter.message.status_cancelling: "Aborting the conversion..." +main.converter.message.status_canceling: "Aborting the conversion..." main.converter.message.stage_loading: "reading the recordings" main.converter.message.stage_matching: "matching frames" main.converter.message.stage_decoding: "reading the channels" main.converter.message.stage_rendering: "rendering the frames" main.converter.message.status_canceled: "Conversion canceled." main.converter.message.status_input_label: "Input:" -main.converter.message.status_output_label: "Output:" -main.converter.message.status_empty_hint: "Select a WAV file or a folder in the browser to begin." +main.converter.message.status_output_label: "Destination:" main.converter.message.status_convert: "Reconstruct the selected audio into NES instructions." main.converter.message.status_cancel: "Stop the running reconstruction." +main.converter.title.scan_dialog: "Reading the folder" +main.converter.template.scan_progress: "Found {count} recordings in {name}" +main.converter.message.scan_opening: "Looking through {name}..." +main.converter.message.scan_nothing_below: "There are no recordings in this folder." +main.converter.label.stop_scan_button: "Stop" main.converter.title.progress_dialog: "Reconstruction progress" main.converter.title.load_dialog: "Reconstruction complete" main.converter.title.cancel_dialog: "Cancel conversion?" @@ -382,35 +400,38 @@ main.converter.message.cancel_prompt: "Stop the current reconstruction?" main.converter.template.progress_template: "Progress: {}/{} files" main.converter.template.stage_template: " — {stage} {completed}/{total}" main.converter.template.single_progress_template: "Reconstructing {}..." -main.converter.template.convert_label_template: "{}: {}" -main.converter.label.stems_mode: "Stems mode" +main.converter.label.mode: "Output" +main.converter.label.mode_each: "One per recording" +main.converter.label.mode_mixed: "One from all" main.converter.label.channel_cap: "Channels per source" main.converter.label.hierarchy_mode: "Order" main.converter.label.hierarchy_round_robin: "Round robin" main.converter.label.hierarchy_strict: "Strict" -main.converter.label.convert_stems_button: "Convert stems" -main.converter.label.discard_stems_button: "Keep the first" -main.converter.label.keep_stems_button: "Stay in stems mode" +main.converter.label.discard_stems_button: "Replace it" +main.converter.label.keep_stems_button: "Keep the list" main.converter.label.add_stems_button: "Add" main.converter.label.overwrite_target_button: "Convert anyway" -main.converter.message.stems_mode_tooltip: "Mix several recordings into one reconstruction, each holding the channels you give it." main.converter.message.channel_cap_tooltip: "How many channels one recording may hold in a single frame." main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a turn each round; strict fills a level before the next one picks." -main.converter.message.stems_empty_hint: "Click recordings in the browser to gather the sources of one reconstruction." -main.converter.message.discard_stems_prompt: "Leaving stems mode keeps the first recording and drops the rest. Continue?" -main.converter.message.overwrite_target_prompt: "A reconstruction of this name already stands here. Converting writes over it." -main.converter.message.stem_selection_prompt: "Pick the recordings to add." -main.converter.message.status_stems_mode: "Mix several recordings into one reconstruction." -main.converter.title.discard_stems_dialog: "Leave stems mode?" -main.converter.title.overwrite_target_dialog: "Write over it?" -main.converter.title.stem_selection_dialog: "Add recordings" -main.converter.template.stem_selection_limit: "Room for {} more of the {} recordings found." +main.converter.message.stems_empty_hint: "Double-click a recording in the browser to add it. Add a folder to convert everything inside it." +main.converter.message.discard_stems_prompt: "This replaces the recordings already in the converter. Continue?" +main.converter.message.overwrite_target_prompt: "A reconstruction with this name already exists. Converting replaces it." +main.converter.message.overwrite_targets_prompt: "{count} reconstructions already exist. Converting replaces them." +main.converter.message.stem_selection_prompt: "Pick the recordings to mix." +main.converter.title.discard_stems_dialog: "Replace the list?" +main.converter.title.overwrite_target_dialog: "Replace it?" +main.converter.title.overwrite_targets_dialog: "Replace them?" +main.converter.title.stem_selection_dialog: "Pick recordings to mix" +main.converter.template.stem_selection_limit: "{picked} of {total} picked. A mix holds up to {room}." main.converter.label.context_move_up: "Move up" main.converter.label.context_move_down: "Move down" main.converter.label.context_join_above: "Join the level above" main.converter.label.context_join_below: "Join the level below" main.converter.label.context_isolate: "Put on its own level" main.converter.label.context_remove_stem: "Remove from the conversion" +main.converter.label.context_open_folder: "Show the recordings" +main.converter.label.context_close_folder: "Hide the recordings" +main.converter.label.context_remove_folder: "Remove the folder from the conversion" # ============================================================================= # Main tab — Advanced @@ -814,7 +835,7 @@ settings.render.template.sample_rate: "{rate} Hz" settings.render.template.bitrate: "{bitrate} kbps" settings.render.message.status_synthesis: "Rendering the song..." settings.render.message.status_encoding: "Writing the file..." -settings.render.message.status_cancelling: "Stopping the render..." +settings.render.message.status_canceling: "Stopping the render..." settings.render.message.status_canceled: "Render canceled." settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." @@ -825,9 +846,10 @@ settings.export.label.stage_walking: "Playing the song out" settings.export.label.stage_compressing: "Compressing the song" settings.export.label.stage_writing: "Writing the file" settings.export.template.size: "{completed} of {total} bytes" -settings.export.message.status_cancelling: "Stopping the export..." +settings.export.message.status_canceling: "Stopping the export..." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" +settings.keybindings.title.sources: "Converter list" settings.keybindings.title.order: "Order list" settings.keybindings.title.tracker: "Tracker" settings.keybindings.title.voices: "Voices" @@ -908,6 +930,7 @@ 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.sources_remove_source: "Remove the picked recording" 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/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index bdd85a517..dd43c2bf8 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,6 +1,16 @@ -master_column_width: 26 -channel_column_width: 90 +master_column_width: 44 +channel_column_width: 78 +channel_solo_width: 62 +channel_box_width: 29 remove_button_width: 30 level_strip_height: 6 well_padding: 8 well_margin: 4 +well_ceiling: 420 +twisty_width: 22 +folder_ceiling: 264 +folder_indent: 14 +window_overscan: 4 +scrollbar_width: 13 +cell_padding: 4 +name_height: 30 diff --git a/src/sampletones_config/layout/tabs/main/config.yaml b/src/sampletones_config/layout/tabs/main/config.yaml index 60a85cd67..612da3332 100644 --- a/src/sampletones_config/layout/tabs/main/config.yaml +++ b/src/sampletones_config/layout/tabs/main/config.yaml @@ -1 +1 @@ -height: 260 +height: 285 diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 3f558a268..8bdfbcc8d 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,6 +1,10 @@ width: -1 button_height: 45 stem_selection: - width: 420 - height: 360 + width: 480 + height: 560 stem_selection_footer: 44 +stem_selection_list: 380 +scan: + width: 420 + height: 150 diff --git a/src/sampletones_config/layout/tabs/main/reconstructor.yaml b/src/sampletones_config/layout/tabs/main/source.yaml similarity index 100% rename from src/sampletones_config/layout/tabs/main/reconstructor.yaml rename to src/sampletones_config/layout/tabs/main/source.yaml diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index cf951bb7e..aaf25054a 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#6fb8ff" accent_active: "#3585d6" accent_muted: "#3a5570" + accent_partial: "#4fa6ffaa" on_accent: "#0b1520" primary: "#0e639c" @@ -62,6 +63,10 @@ colors: channel_pulse2_soft: "#dcd3a4" channel_triangle_soft: "#aecadd" channel_noise_soft: "#c4c6cc" + channel_pulse1_partial: "#f0925688" + channel_pulse2_partial: "#f2d15f88" + channel_triangle_partial: "#7fc3f288" + channel_noise_partial: "#b4b8c088" tab: "#232325" tab_hovered: "#2d2d31" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 04d5d7927..4644a9d8d 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#1163ac" accent_active: "#073a6c" accent_muted: "#9dbcd9" + accent_partial: "#0b4c8caa" on_accent: "#ffffff" primary: "#0b4c8c" @@ -62,6 +63,10 @@ colors: channel_pulse2_soft: "#75683c" channel_triangle_soft: "#456c8c" channel_noise_soft: "#767b85" + channel_pulse1_partial: "#a8410a88" + channel_pulse2_partial: "#7a5c0088" + channel_triangle_partial: "#0f528888" + channel_noise_partial: "#4a4f5988" tab: "#b9bfca" tab_hovered: "#a9b0bd" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 3c67dc74b..8e8aa09cd 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -32,6 +32,7 @@ colors: accent_hover: "#a180ce" accent_active: "#7b629e" accent_muted: "#685386" + accent_partial: "#b98af3aa" on_accent: "#17131f" primary: "#8f6fc0" @@ -62,6 +63,10 @@ colors: channel_pulse2_soft: "#dfd6a8" channel_triangle_soft: "#b9cedf" channel_noise_soft: "#cbcace" + channel_pulse1_partial: "#f0925688" + channel_pulse2_partial: "#f2d15f88" + channel_triangle_partial: "#8cc1ed88" + channel_noise_partial: "#bbb8c288" tab: "#24283a" tab_hovered: "#2f3a56" diff --git a/src/sampletones_config/theme/channels/muted.yaml b/src/sampletones_config/theme/channels/muted.yaml index 659326523..9adbacd9f 100644 --- a/src/sampletones_config/theme/channels/muted.yaml +++ b/src/sampletones_config/theme/channels/muted.yaml @@ -10,3 +10,8 @@ components: - type: color key: Text value: .text_muted + - item_type: Text + entries: + - type: color + key: Text + value: .text_muted diff --git a/src/sampletones_config/theme/channels/noise.yaml b/src/sampletones_config/theme/channels/noise.yaml index bc49519ab..3cd3263f0 100644 --- a/src/sampletones_config/theme/channels/noise.yaml +++ b/src/sampletones_config/theme/channels/noise.yaml @@ -10,3 +10,8 @@ components: - type: color key: Text value: .channel_noise_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_noise diff --git a/src/sampletones_config/theme/channels/noise_partial.yaml b/src/sampletones_config/theme/channels/noise_partial.yaml new file mode 100644 index 000000000..3db9aa5d5 --- /dev/null +++ b/src/sampletones_config/theme/channels/noise_partial.yaml @@ -0,0 +1,21 @@ +name: channel_noise_partial +tag: global.theme.channel_noise_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_noise_partial + - type: color + key: FrameBgHovered + value: .channel_noise_partial + - type: color + key: FrameBgActive + value: .channel_noise_partial + - type: color + key: CheckMark + value: .channel_noise_soft + - type: color + key: Text + value: .channel_noise_soft diff --git a/src/sampletones_config/theme/channels/pulse1.yaml b/src/sampletones_config/theme/channels/pulse1.yaml index d15e69dcd..7cc439708 100644 --- a/src/sampletones_config/theme/channels/pulse1.yaml +++ b/src/sampletones_config/theme/channels/pulse1.yaml @@ -10,3 +10,8 @@ components: - type: color key: Text value: .channel_pulse1_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_pulse1 diff --git a/src/sampletones_config/theme/channels/pulse1_partial.yaml b/src/sampletones_config/theme/channels/pulse1_partial.yaml new file mode 100644 index 000000000..0da399f59 --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse1_partial.yaml @@ -0,0 +1,21 @@ +name: channel_pulse1_partial +tag: global.theme.channel_pulse1_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_pulse1_partial + - type: color + key: FrameBgHovered + value: .channel_pulse1_partial + - type: color + key: FrameBgActive + value: .channel_pulse1_partial + - type: color + key: CheckMark + value: .channel_pulse1_soft + - type: color + key: Text + value: .channel_pulse1_soft diff --git a/src/sampletones_config/theme/channels/pulse2.yaml b/src/sampletones_config/theme/channels/pulse2.yaml index 854ab1b53..7ca80fee3 100644 --- a/src/sampletones_config/theme/channels/pulse2.yaml +++ b/src/sampletones_config/theme/channels/pulse2.yaml @@ -10,3 +10,8 @@ components: - type: color key: Text value: .channel_pulse2_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_pulse2 diff --git a/src/sampletones_config/theme/channels/pulse2_partial.yaml b/src/sampletones_config/theme/channels/pulse2_partial.yaml new file mode 100644 index 000000000..4fd482c45 --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse2_partial.yaml @@ -0,0 +1,21 @@ +name: channel_pulse2_partial +tag: global.theme.channel_pulse2_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_pulse2_partial + - type: color + key: FrameBgHovered + value: .channel_pulse2_partial + - type: color + key: FrameBgActive + value: .channel_pulse2_partial + - type: color + key: CheckMark + value: .channel_pulse2_soft + - type: color + key: Text + value: .channel_pulse2_soft diff --git a/src/sampletones_config/theme/channels/triangle.yaml b/src/sampletones_config/theme/channels/triangle.yaml index 2ee9814c8..d4f6c6ee7 100644 --- a/src/sampletones_config/theme/channels/triangle.yaml +++ b/src/sampletones_config/theme/channels/triangle.yaml @@ -10,3 +10,8 @@ components: - type: color key: Text value: .channel_triangle_soft + - item_type: Text + entries: + - type: color + key: Text + value: .channel_triangle diff --git a/src/sampletones_config/theme/channels/triangle_partial.yaml b/src/sampletones_config/theme/channels/triangle_partial.yaml new file mode 100644 index 000000000..9ac8ade9b --- /dev/null +++ b/src/sampletones_config/theme/channels/triangle_partial.yaml @@ -0,0 +1,21 @@ +name: channel_triangle_partial +tag: global.theme.channel_triangle_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .channel_triangle_partial + - type: color + key: FrameBgHovered + value: .channel_triangle_partial + - type: color + key: FrameBgActive + value: .channel_triangle_partial + - type: color + key: CheckMark + value: .channel_triangle_soft + - type: color + key: Text + value: .channel_triangle_soft diff --git a/src/sampletones_config/theme/stems/grid.yaml b/src/sampletones_config/theme/stems/grid.yaml new file mode 100644 index 000000000..fd7cc4a8e --- /dev/null +++ b/src/sampletones_config/theme/stems/grid.yaml @@ -0,0 +1,12 @@ +name: stems_grid +tag: global.theme.stems_grid + +components: + - item_type: Table + entries: + - type: color + key: TableRowBg + value: .transparent + - type: color + key: TableRowBgAlt + value: .transparent diff --git a/src/sampletones_config/theme/stems/group_row.yaml b/src/sampletones_config/theme/stems/group_row.yaml new file mode 100644 index 000000000..706051636 --- /dev/null +++ b/src/sampletones_config/theme/stems/group_row.yaml @@ -0,0 +1,12 @@ +name: stems_group_row +tag: global.theme.stems_group_row + +components: + - item_type: All + entries: + - type: color + key: TableRowBg + value: .accent/0.16 + - type: color + key: TableRowBgAlt + value: .accent/0.16 diff --git a/src/sampletones_config/theme/stems/marker.yaml b/src/sampletones_config/theme/stems/marker.yaml new file mode 100644 index 000000000..8a8205481 --- /dev/null +++ b/src/sampletones_config/theme/stems/marker.yaml @@ -0,0 +1,19 @@ +name: stems_marker +tag: global.theme.stems_marker + +components: + - item_type: Button + entries: + - type: style + key: FramePadding + x: 0 + y: 0 + - type: color + key: Button + value: .transparent + - type: color + key: ButtonHovered + value: .selection_hovered + - type: color + key: ButtonActive + value: .selection_active diff --git a/src/sampletones_config/theme/stems/pick.yaml b/src/sampletones_config/theme/stems/pick.yaml new file mode 100644 index 000000000..220f8f6f4 --- /dev/null +++ b/src/sampletones_config/theme/stems/pick.yaml @@ -0,0 +1,15 @@ +name: stems_pick +tag: global.theme.stems_pick + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .control + - type: color + key: FrameBgHovered + value: .control_hovered + - type: color + key: FrameBgActive + value: .control_active diff --git a/src/sampletones_config/theme/stems/pick_partial.yaml b/src/sampletones_config/theme/stems/pick_partial.yaml new file mode 100644 index 000000000..34400aac4 --- /dev/null +++ b/src/sampletones_config/theme/stems/pick_partial.yaml @@ -0,0 +1,15 @@ +name: stems_pick_partial +tag: global.theme.stems_pick_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: FrameBg + value: .accent_partial + - type: color + key: FrameBgHovered + value: .accent_partial + - type: color + key: FrameBgActive + value: .accent_partial diff --git a/src/sampletones_config/theme/stems/row.yaml b/src/sampletones_config/theme/stems/row.yaml index a4c2c0188..23b05cb76 100644 --- a/src/sampletones_config/theme/stems/row.yaml +++ b/src/sampletones_config/theme/stems/row.yaml @@ -7,3 +7,7 @@ components: - type: color key: Text value: .text + - type: style + key: SelectableTextAlign + x: 0.0 + y: 0.5 diff --git a/src/sampletones_config/theme/stems/row_inert.yaml b/src/sampletones_config/theme/stems/row_inert.yaml index 2a85e847d..8180fcbf1 100644 --- a/src/sampletones_config/theme/stems/row_inert.yaml +++ b/src/sampletones_config/theme/stems/row_inert.yaml @@ -7,3 +7,7 @@ components: - type: color key: Text value: .text_muted + - type: style + key: SelectableTextAlign + x: 0.0 + y: 0.5 diff --git a/src/sampletones_config/theme/stems/slot_label.yaml b/src/sampletones_config/theme/stems/slot_label.yaml new file mode 100644 index 000000000..11e6034aa --- /dev/null +++ b/src/sampletones_config/theme/stems/slot_label.yaml @@ -0,0 +1,9 @@ +name: stems_slot_label +tag: global.theme.stems_slot_label + +components: + - item_type: Text + entries: + - type: color + key: Text + value: .text_muted diff --git a/src/sampletones_core/calibration/runner.py b/src/sampletones_core/calibration/runner.py index a4036c9a8..c1388eca6 100644 --- a/src/sampletones_core/calibration/runner.py +++ b/src/sampletones_core/calibration/runner.py @@ -1,11 +1,15 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, Final, FrozenSet, List, Optional import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + SpectrumMethod, +) from sampletones_core.fft import Window from sampletones_core.library import InstructionLibrary from sampletones_core.reconstructions import Reconstructor @@ -15,6 +19,8 @@ from .corpus.item import CorpusItem from .referee.protocol import Referee +CALIBRATION_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(DEFAULT_CHANNELS) + @dataclass(frozen=True) class CalibrationVariant: @@ -138,7 +144,7 @@ def evaluate_variants( rows: List[CalibrationRow] = [] for variant in variants: ensure_library(variant.config) - reconstructor = Reconstructor(variant.config) + reconstructor = Reconstructor(variant.config, CALIBRATION_CHANNELS) for item in items: path = item_paths[item.name] reconstruction = reconstructor(path) diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index c5223a00e..ac5345ac4 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -13,6 +13,8 @@ STEMS_DATA: Final = "stems_data" ENTRIES: Final = "entries" ID: Final = "id" +BENDS: Final = "bends" +SETTINGS: Final = "settings" HIERARCHY: Final = "hierarchy" LEVELS: Final = "levels" MODE: Final = "mode" diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py index 38eeb0cc8..aefafda00 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -4,6 +4,7 @@ APPROXIMATIONS_DATA, ASSIGNMENTS, AUDIO_FILEPATH, + BENDS, CHANNEL_CAP, CHANNEL_NAME, CHANNELS, @@ -20,6 +21,7 @@ METADATA, MODE, RECONSTRUCTION_DATA_VERSION, + SETTINGS, STEM_IDS, STEMS_DATA, ) @@ -53,7 +55,7 @@ def _default_stems_data(data: SerializedData) -> SerializedData: One stem covers every enabled channel and owns every frame of each channel that plays, which is the classic run's shape, so the synthesized record states what the - reconstruction is. + reconstruction is. It bends nothing, which is what a build writing this shape did. """ config = data.get(CONFIG) channels = config.get(GENERATION, {}).get(CHANNELS, []) if isinstance(config, dict) else [] @@ -69,7 +71,7 @@ def _default_stems_data(data: SerializedData) -> SerializedData: ] return { CONFIG: { - ENTRIES: [{ID: 0, CHANNELS: channels}], + ENTRIES: [{ID: 0, SETTINGS: {CHANNELS: channels, BENDS: []}}], HIERARCHY: {LEVELS: [[0]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)}, CHANNEL_CAP: DEFAULT_STEMS_CHANNEL_CAP, }, @@ -130,21 +132,46 @@ def _with_default_stems_record(data: SerializedData) -> SerializedData: return updated +def _without_configured_channels(data: SerializedData) -> SerializedData: + """The embedded config with its channel list dropped, now the stems record carries it. + + Which channels a run hands out is the setup's to state, so the configuration holds the + settings that shaped the library and nothing about the channels themselves. + """ + config = data.get(CONFIG) + if not isinstance(config, dict): + return data + + generation = config.get(GENERATION) + if not isinstance(generation, dict): + return data + + updated = dict(data) + updated[CONFIG] = { + **config, + GENERATION: {key: value for key, value in generation.items() if key != CHANNELS}, + } + return updated + + def update(data: SerializedData) -> SerializedData: """Names each stored stream and approximation by its channel. Data version 2.1 stored a channel's stream and approximation under the key ``generator_name`` and the channel selection under - ``config.generation.generators``. Data version 2.2 names them ``channel_name`` - and ``config.generation.channels``, stamps the embedded config's metadata with the - new data version, records the source audio as one path per stem, and carries the - single-entry stems record every reconstruction states. + ``config.generation.generators``. Data version 2.2 names the streams + ``channel_name``, stamps the embedded config's metadata with the new data version, + records the source audio as one path per stem, and carries the single-entry stems + record every reconstruction states, down to the settings each stem is converted + with: the channels it takes, and the ones it carries toward its own recording. The + channel selection moves onto that record, so the embedded configuration lets it go. """ updated = dict(data) updated = _renamed_stream_keys(updated) updated = _stamped_embedded_config(updated) updated = _normalized_source_paths(updated) - return _with_default_stems_record(updated) + updated = _with_default_stems_record(updated) + return _without_configured_channels(updated) V2_2: Final[VersionUpdate] = VersionUpdate( diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index af501b8cf..cdd1e3c0d 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -1,14 +1,13 @@ from __future__ import annotations from pathlib import Path -from typing import Dict, List, Optional, Self +from typing import Dict, Optional, Self from pydantic import ConfigDict, Field from sampletones_core.configs.general import GeneralConfig from sampletones_core.configs.generation import GenerationConfig from sampletones_core.configs.library import InstructionsLibraryConfig -from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata from sampletones_shared.music import Tuning @@ -110,10 +109,6 @@ def output_directory(self) -> Path: def drive(self) -> float: return self.generation.drive - @property - def channels(self) -> List[ChannelName]: - return self.generation.channels.copy() - @property def normalize(self) -> bool: return self.general.normalize diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py index 1c9a7ba88..de182d71d 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -1,5 +1,3 @@ -from typing import List - from pydantic import AliasChoices, ConfigDict, Field from sampletones_core.constants.algorithm import ( @@ -12,7 +10,6 @@ MAX_DRIVE, PERCEPTUAL_EXPONENT, PHASE_ALIGNER, - REFINE_PITCH, REFINEMENT_CHANGE_WEIGHT, REFINEMENT_CONFIDENCE, REFINEMENT_WINDOW, @@ -28,8 +25,6 @@ TRANSITION_VOLUME_WEIGHT, ) from sampletones_core.constants.enums import ( - DEFAULT_CHANNELS, - ChannelName, PhaseAlignerName, SelectorName, SpectralDistance, @@ -77,11 +72,13 @@ class RefinementConfig(DataModel): A note reaches the hardware as a divider, and the divider grid is finer than the note grid everywhere below the top of the range. The refinement reads where each frame's fundamental - actually stands and bends the note it landed on towards it, so material recorded off the grid + actually stands and bends the note it landed on toward it, so material recorded off the grid comes back in tune with itself. + These settle how a bend is shaped and hold for a whole run. Which recordings bend, and on which + channels, each stem entry states for itself. + Attributes: - enabled: Whether a conversion bends the notes it chose. confidence: The share of a frame's energy its harmonics must hold for its reading to count. change_weight: The divider steps of reading error worth avoiding one change of bend. window: The frames on either side whose readings a frame may settle on. @@ -89,7 +86,6 @@ class RefinementConfig(DataModel): model_config = ConfigDict(extra="forbid", frozen=True) - enabled: bool = Field(default=REFINE_PITCH) confidence: float = Field(default=REFINEMENT_CONFIDENCE, ge=0.0, le=1.0) change_weight: float = Field(default=REFINEMENT_CHANGE_WEIGHT, ge=0.0) window: int = Field(default=REFINEMENT_WINDOW, ge=0) @@ -111,13 +107,6 @@ class GenerationConfig(DataModel): reset_phase: bool = Field(default=RESET_PHASE) final_regeneration: bool = Field(default=FINAL_REGENERATION) - channels: List[ChannelName] = Field( - default_factory=DEFAULT_CHANNELS.copy, - validation_alias=AliasChoices( - "channels", - "generators", - ), - ) calculation: CalculationConfig = Field(default_factory=CalculationConfig) weights: WeightsConfig = Field(default_factory=WeightsConfig) metric: MetricConfig = Field(default_factory=MetricConfig) diff --git a/src/sampletones_core/constants/algorithm.py b/src/sampletones_core/constants/algorithm.py index 405401fd1..cc1a42ed6 100644 --- a/src/sampletones_core/constants/algorithm.py +++ b/src/sampletones_core/constants/algorithm.py @@ -67,7 +67,6 @@ # Pitch refinement -REFINE_PITCH: Final[bool] = True REFINEMENT_CONFIDENCE: Final[float] = 0.15 REFINEMENT_CHANGE_WEIGHT: Final[float] = 2.0 REFINEMENT_WINDOW: Final[int] = 4 diff --git a/src/sampletones_core/constants/enums.py b/src/sampletones_core/constants/enums.py index a586a05c5..a8afde97a 100644 --- a/src/sampletones_core/constants/enums.py +++ b/src/sampletones_core/constants/enums.py @@ -2,7 +2,7 @@ import re from enum import StrEnum -from typing import Dict, Final, List, Literal +from typing import AbstractSet, Dict, Final, FrozenSet, List, Literal class GeneratorName(StrEnum): @@ -89,6 +89,15 @@ class CQTWindow(StrEnum): RECTANGULAR = "rectangular" +TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( + { + ChannelName.PULSE1, + ChannelName.PULSE2, + ChannelName.TRIANGLE, + } +) + + CHANNEL_ABBREVIATIONS: Final[Dict[ChannelName, Literal["P", "p", "T", "N"]]] = { ChannelName.PULSE1: "P", ChannelName.PULSE2: "p", @@ -112,5 +121,23 @@ class CQTWindow(StrEnum): ] +def ordered_channels(channel_names: AbstractSet[ChannelName]) -> List[ChannelName]: + """``channel_names`` in the order the application names the channels. + + A set states which channels something reaches; a run hands them out in one settled order, so + everything built from a set is put back into that order here. + """ + return [name for name in ChannelName.items() if name in channel_names] + + +def bending_channels(channel_names: List[ChannelName]) -> List[ChannelName]: + """Those of ``channel_names`` whose hardware loads a divider a bend can move. + + A stem offered a set of channels carries every one of them that can be carried, which is what + a conversion does until a reader says otherwise. + """ + return [name for name in channel_names if name in TONE_CHANNELS] + + def abbreviate_channel_names(channel_names: List[ChannelName]) -> str: return "".join(CHANNEL_ABBREVIATIONS[name] for name in channel_names) diff --git a/src/sampletones_core/exporters/naming.py b/src/sampletones_core/exporters/naming.py index 387f6cbfd..7db929c9d 100644 --- a/src/sampletones_core/exporters/naming.py +++ b/src/sampletones_core/exporters/naming.py @@ -6,7 +6,7 @@ def instrument_slice_name(base_name: str, channel: ChannelName) -> str: Every export path shares this form, so a slice carries the same name whether it reaches a tracker as a standalone instrument file or as one entry of a project's - instrument table. The parenthesised suffix keeps the base name readable while + instrument table. The parenthesized suffix keeps the base name readable while identifying the channel the slice drives. Args: diff --git a/src/sampletones_core/exports/scope.py b/src/sampletones_core/exports/scope.py index c7bd3afe0..af7f5ee54 100644 --- a/src/sampletones_core/exports/scope.py +++ b/src/sampletones_core/exports/scope.py @@ -4,7 +4,7 @@ class ExportScope(StrEnum): """How much of the application's work one export run carries. - A backend decides how each scope materialises on disk, so a format that reads a + A backend decides how each scope materializes on disk, so a format that reads a whole reconstruction from a single file is free to write one. """ diff --git a/src/sampletones_core/generators/tonal.py b/src/sampletones_core/generators/tonal.py index a44096580..f0b877946 100644 --- a/src/sampletones_core/generators/tonal.py +++ b/src/sampletones_core/generators/tonal.py @@ -64,7 +64,7 @@ def sounds_at(self, pitch: int, offset: int) -> float: """ return timer_to_frequency(self.get_timer(pitch, offset)) * self.timer.phase_increment - def bend_towards(self, pitch: int, frequency: float) -> int: + def bend_toward(self, pitch: int, frequency: float) -> int: """The bend that lands this note nearest a frequency, held inside the note's own room. A note owns half the dividers between itself and each neighbor, which is what tiles the diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 0733a5326..27cb47fcd 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -9,7 +9,7 @@ from sampletones_core.constants.field_aliases import ALIASES 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.hashing import HASH_PATTERN from sampletones_shared.utils.system.paths import get_filename FILENAME_SEPARATOR: Final[str] = "_" diff --git a/src/sampletones_core/library/key.py b/src/sampletones_core/library/key.py index f431918b5..2de500849 100644 --- a/src/sampletones_core/library/key.py +++ b/src/sampletones_core/library/key.py @@ -9,7 +9,7 @@ from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.fft import Window from sampletones_core.library.filename.fields import InstructionsFilenameFields -from sampletones_shared.utils.serialization import hash_model +from sampletones_shared.utils.hashing import hash_model class InstructionLibraryKey(BaseModel): diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py index 489318230..eb35facd7 100644 --- a/src/sampletones_core/parallelization/processor.py +++ b/src/sampletones_core/parallelization/processor.py @@ -41,7 +41,7 @@ def __init__( self.status: TaskStatus = TaskStatus.PENDING self.running = False - self.cancelling = False + self.canceling = False self.total_tasks = 0 self.completed_tasks = 0 self.current_item: Optional[str] = None @@ -80,15 +80,15 @@ def wait(self, timeout: Optional[float] = None) -> None: def cleanup(self) -> None: self.status = TaskStatus.CLEANING_UP self.running = False - self.cancelling = True + self.canceling = True self._withdraw() self._notify_progress() self._cleanup() def cancel(self) -> None: - self.status = TaskStatus.CANCELLING - self.cancelling = True + self.status = TaskStatus.CANCELING + self.canceling = True self._withdraw() self._notify_progress() @@ -97,13 +97,13 @@ def cancel(self) -> None: def shutdown(self) -> None: """Stops the pool and reaps its workers on the calling thread before returning. - Cancelling from the interface tears the pool down on a background thread to keep + Canceling from the interface tears the pool down on a background thread to keep the interface responsive. At application exit the process is about to release the shared resources the pool's spawned workers rely on, so the teardown runs inline here and returns only once the pool has stopped.""" self.status = TaskStatus.CLEANING_UP self.running = False - self.cancelling = True + self.canceling = True self._withdraw() self._notify_progress() @@ -124,8 +124,8 @@ def is_completed(self) -> bool: def is_canceled(self) -> bool: return self.status == TaskStatus.CANCELED - def is_cancelling(self) -> bool: - return self.status == TaskStatus.CANCELLING + def is_canceling(self) -> bool: + return self.status == TaskStatus.CANCELING def is_failed(self) -> bool: return self.status == TaskStatus.FAILED @@ -195,7 +195,7 @@ def _release_channel(self) -> None: def _reset_status(self) -> None: self.status = TaskStatus.PENDING self.running = False - self.cancelling = False + self.canceling = False self.total_tasks = 0 self.completed_tasks = 0 self.current_item = None @@ -238,7 +238,7 @@ def _process_tasks(self) -> None: iterator = self.future.result() while True: - if self.cancelling: + if self.canceling: raise CancelledError() result = next(iterator) @@ -251,7 +251,7 @@ def _process_tasks(self) -> None: except KeyboardInterrupt as exception: raise CancelledError() from exception except OperationCanceled: - self.cancelling = True + self.canceling = True self._finalize_cancellation() return except CancelledError: @@ -281,12 +281,12 @@ def _notify_progress(self) -> None: self.logger.debug(f"Status: {self.status}; progress: {progress}") def _finalize_cancellation(self) -> None: - if not self.cancelling: + if not self.canceling: return self.logger.info("Task processing was canceled.") self.status = TaskStatus.CANCELED - self.cancelling = False + self.canceling = False self.running = False self._notify_progress() self.call(self.on_canceled) diff --git a/src/sampletones_core/parallelization/task.py b/src/sampletones_core/parallelization/task.py index 14a0b0983..3d932f63c 100644 --- a/src/sampletones_core/parallelization/task.py +++ b/src/sampletones_core/parallelization/task.py @@ -12,7 +12,7 @@ class TaskStatus(Enum): RUNNING = "RUNNING" COMPLETED = "COMPLETED" FAILED = "FAILED" - CANCELLING = "CANCELLING" + CANCELING = "CANCELING" CANCELED = "CANCELED" CLEANING_UP = "CLEANING_UP" diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py index af76fbcd1..28c8feb68 100644 --- a/src/sampletones_core/project/patterns/channel.py +++ b/src/sampletones_core/project/patterns/channel.py @@ -35,7 +35,7 @@ def _next_index(self, reserved_indices: AbstractSet[int] = frozenset()) -> int: """Returns a free index above every pool key and ``reserved_indices``. The pool alone does not reveal indices an order slot references before its - pattern is materialised, so a caller aware of those passes them as + pattern is materialized, so a caller aware of those passes them as ``reserved_indices`` to keep the new index from taking one of them. """ return max(self.patterns.keys() | reserved_indices, default=-1) + 1 @@ -55,7 +55,7 @@ def ensure_pattern(self, index: int, length: int) -> Pattern: """Returns the pattern at ``index``, creating an empty one if absent. Lets an order position reference an index before its pattern exists; the - pattern is materialised on first write (once the slot gains content). + pattern is materialized on first write (once the slot gains content). """ if index not in self.patterns: self.patterns[index] = Pattern.empty(length) @@ -67,7 +67,7 @@ def clone_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = froz ``reserved_indices`` are extra indices the clone must avoid beyond the pool's own keys, so a caller that knows of indices referenced elsewhere (order slots - whose patterns are not yet materialised) keeps the clone from taking one of them. + whose patterns are not yet materialized) keeps the clone from taking one of them. """ source = self.patterns[index] clone_index = self._next_index(reserved_indices) diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py index 459a820fa..3fc04ecc3 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -84,7 +84,7 @@ def add_pattern(self, channel: ChannelName) -> int: """Adds an empty pattern to ``channel`` at a free index and returns it. The index clears both the channel's pool and every index its order slots - already reference, so it never aliases a slot whose pattern is unmaterialised. + already reference, so it never aliases a slot whose pattern is unmaterialized. """ return self.channels[channel].add_pattern( self.rows_per_pattern, @@ -109,7 +109,7 @@ def duplicate_frame(self, position: int) -> None: 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. + is not yet materialized is carried across as the reference it is. """ self.order.insert(position + 1, dict(self.order[position])) diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py index d40870f70..345cd560a 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -1,29 +1,21 @@ from .conversion import reconstruct_job from .converter import ReconstructionConverter from .job import ConversionJob -from .paths.fields import ConfigDirectoryFields -from .paths.utils import ( - filter_files, - get_audio_files, - get_output_path, - get_relative_path, - group_output_path, - top_level_audio_files, +from .plan import ( + BatchConversion, + BatchEntry, + ConversionPlan, + DirectoryConversion, + GroupConversion, ) -from .plan import ConversionPlan, DirectoryConversion, GroupConversion __all__ = [ - "ConfigDirectoryFields", + "BatchConversion", + "BatchEntry", "ConversionJob", "ConversionPlan", "DirectoryConversion", "GroupConversion", "ReconstructionConverter", - "filter_files", - "get_audio_files", - "get_output_path", - "get_relative_path", - "group_output_path", "reconstruct_job", - "top_level_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/converter.py b/src/sampletones_core/reconstructions/converter/converter.py index 61d28a4fe..6f91bcbdc 100644 --- a/src/sampletones_core/reconstructions/converter/converter.py +++ b/src/sampletones_core/reconstructions/converter/converter.py @@ -1,7 +1,8 @@ from pathlib import Path -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, FrozenSet, List, Optional, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName from sampletones_core.parallelization import TaskProcessor from sampletones_shared.logger import LoggerProtocol from sampletones_shared.logger import logger as default_logger @@ -41,10 +42,15 @@ def start(self) -> None: super().start() def _create_tasks(self) -> List[Any]: - reconstructor = Reconstructor(self.config) + """One task per job, sharing the reconstructor the whole run's channels are built for.""" self.jobs = self.plan.jobs(self.config) + reconstructor = Reconstructor(self.config, self._covered_channels()) return [(reconstructor, job, JobReporter(self._task_reporter(index))) for index, job in enumerate(self.jobs)] + def _covered_channels(self) -> FrozenSet[ChannelName]: + """Every channel the jobs hand out, which is what the run builds generators for.""" + return frozenset(channel_name for job in self.jobs for channel_name in job.stems.covered_channels) + def _get_task_function( self, ) -> Callable[[Tuple[Reconstructor, ConversionJob, ReconstructionReporter]], Path]: diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 4cb6f7fb1..700793310 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -2,20 +2,26 @@ ConfigDirectoryFields, ) from sampletones_core.reconstructions.converter.paths.utils import ( + config_directory_path, filter_files, get_audio_files, get_output_path, get_relative_path, group_output_path, - top_level_audio_files, + is_audio_file, + walk_audio_files, + walk_entries, ) __all__ = [ "ConfigDirectoryFields", + "config_directory_path", "filter_files", "get_audio_files", "get_output_path", "get_relative_path", "group_output_path", - "top_level_audio_files", + "is_audio_file", + "walk_audio_files", + "walk_entries", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index e284dd05a..e238835ea 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -1,4 +1,4 @@ -from typing import Final, Optional, Self, Tuple +from typing import AbstractSet, Final, Optional, Self, Tuple from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -14,9 +14,10 @@ ChannelName, SpectrumMethod, abbreviate_channel_names, + ordered_channels, ) from sampletones_core.constants.field_aliases import ALIASES -from sampletones_shared.utils.serialization import HASH_PATTERN, hash_models +from sampletones_shared.utils.hashing import HASH_PATTERN, hash_models CONFIG_DIRECTORY_SEPARATOR: Final[str] = "_" @@ -45,13 +46,18 @@ def channels(self) -> Tuple[ChannelName, ...]: return tuple(CHANNEL_ABBREVIATION_TO_NAME[character] for character in self.gn) @classmethod - def from_config(cls, config: Config) -> Self: + def from_config(cls, config: Config, channels: AbstractSet[ChannelName]) -> Self: + """The fields a run's own directory is named from: its settings, and the channels it hands out. + + The channels come from the setup rather than the configuration, so the name is written in + the order the application states them however the caller gathered the set. + """ return cls( sr=config.library.sample_rate, nf=config.library.nes_frequency, sm=config.library.spectrum_method, tg=config.library.transformation_gamma, - gn=abbreviate_channel_names(config.generation.channels), + gn=abbreviate_channel_names(ordered_channels(channels)), ch=hash_models(config.library, config.generation), ) @@ -98,5 +104,5 @@ def display_name(self) -> str: ) @classmethod - def generate_config_directory_name(cls, config: Config) -> str: - return cls.from_config(config).directory_name + def generate_config_directory_name(cls, config: Config, channels: AbstractSet[ChannelName]) -> str: + return cls.from_config(config, channels).directory_name diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 4a2269790..f974267b7 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,7 +1,8 @@ from pathlib import Path -from typing import List, Tuple +from typing import AbstractSet, Iterator, List, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) @@ -25,13 +26,31 @@ def get_relative_path( return Path(output_path.absolute()) +def config_directory_path( + config: Config, + channels: AbstractSet[ChannelName], +) -> Path: + """The directory a run writes its reconstructions into. + + The directory is named after the settings that shaped the library and the channels the run + hands out, so runs that differ in either keep their results apart. + """ + config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) + return to_path(config.general.reconstructions_directory) / config_directory + + def get_output_path( config: Config, input_path: Path, + channels: AbstractSet[ChannelName], suffix: str = EXT_FILE_RECONSTRUCTION, ) -> Path: - config_directory = ConfigDirectoryFields.generate_config_directory_name(config) - output_directory = to_path(config.general.reconstructions_directory) / config_directory + """Where the reconstruction of one recording, or the folder of them, is written. + + ``channels`` names what the run hands out, which the configuration's own directory is named + after alongside the settings that shaped the library. + """ + output_directory = config_directory_path(config, channels) if input_path.is_dir(): return output_directory / input_path.name @@ -47,6 +66,7 @@ def get_output_path( def group_output_path( config: Config, sources: Tuple[Path, ...], + channels: AbstractSet[ChannelName], suffix: str = EXT_FILE_RECONSTRUCTION, ) -> Path: """Where the one reconstruction built from ``sources`` is written. @@ -55,38 +75,53 @@ def group_output_path( one source names it after itself, and several after what they share (:func:`sampletones_core.reconstructions.naming.derive.derive_name`). + ``channels`` names what the run hands out, which the configuration's own directory is named + after. + Raises: ValueError: If ``sources`` is empty. """ - config_directory = ConfigDirectoryFields.generate_config_directory_name(config) - output_directory = to_path(config.general.reconstructions_directory) / config_directory + output_directory = config_directory_path(config, channels) return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) -def get_audio_files( +def walk_entries(input_directory: Path) -> Iterator[Path]: + """Every path below a directory, reported as the walk meets it. + + A caller that has to answer between entries — one counting what it has found, or one a reader + may stop partway — reads the tree through this and decides for itself what each entry is. + """ + return input_directory.rglob("*") + + +def is_audio_file(path: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO) -> bool: + """Whether a path names a recording a run converts.""" + return path.is_file() and path.suffix.lower() in extensions + + +def walk_audio_files( input_directory: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO, - sort: bool = False, -) -> List[Path]: - audio_files = [path for path in input_directory.rglob("*") if path.is_file() and path.suffix.lower() in extensions] - if sort: - audio_files.sort() +) -> Iterator[Path]: + """The recordings below a directory, reported as the walk meets them. - return audio_files + A tree is read one entry at a time, so a caller reporting how far it has got hears from the + walk while it runs rather than once it ends. + """ + for path in walk_entries(input_directory): + if is_audio_file(path, extensions): + yield path -def top_level_audio_files( +def get_audio_files( input_directory: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO, + sort: bool = False, ) -> List[Path]: - """The audio files sitting directly in a directory, in name order. + audio_files = list(walk_audio_files(input_directory, extensions)) + if sort: + audio_files.sort() - Where a batch reaches every recording below a folder, gathering the sources of one - reconstruction stays with the folder a reader pointed at, so what it offers is what that - folder itself holds. - """ - audio_files = [path for path in input_directory.iterdir() if path.is_file() and path.suffix.lower() in extensions] - audio_files.sort() return audio_files diff --git a/src/sampletones_core/reconstructions/converter/plan/__init__.py b/src/sampletones_core/reconstructions/converter/plan/__init__.py index 7b7442d8e..16e860bca 100644 --- a/src/sampletones_core/reconstructions/converter/plan/__init__.py +++ b/src/sampletones_core/reconstructions/converter/plan/__init__.py @@ -1,8 +1,11 @@ +from .batch import BatchConversion, BatchEntry from .directory import DirectoryConversion from .group import GroupConversion from .protocol import ConversionPlan __all__ = [ + "BatchConversion", + "BatchEntry", "ConversionPlan", "DirectoryConversion", "GroupConversion", diff --git a/src/sampletones_core/reconstructions/converter/plan/batch.py b/src/sampletones_core/reconstructions/converter/plan/batch.py new file mode 100644 index 000000000..f5345b05b --- /dev/null +++ b/src/sampletones_core/reconstructions/converter/plan/batch.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.job import ConversionJob +from sampletones_core.reconstructions.converter.paths.utils import ( + config_directory_path, + get_relative_path, + group_output_path, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_shared.exceptions import NoFilesToProcessError + + +@dataclass(frozen=True) +class BatchEntry: + """One recording a batch converts on its own, under the setup handing out its channels. + + ``base_directory`` names the folder the recording was gathered from, whose tree the written + reconstructions mirror. A recording gathered by name carries none, and its reconstruction + sits directly in the directory the run's settings are named after. + """ + + source: Path + stems: StemsConfig + base_directory: Optional[Path] + + @property + def is_named(self) -> bool: + """The reader named this recording itself, rather than the folder holding it.""" + return self.base_directory is None + + def output_path(self, config: Config) -> Path: + """The reconstruction this recording is written to.""" + channels = self.stems.covered_channels + if self.base_directory is None: + return group_output_path(config, (self.source,), channels) + + mirrored = config_directory_path(config, channels) / self.base_directory.name + return get_relative_path(self.base_directory, self.source, mirrored) + + +@dataclass(frozen=True) +class BatchConversion: + """One reconstruction per recording gathered, each built from that recording alone. + + Every recording carries the channels its own row holds, so one batch writes as many setups + as the reader worked out. A recording named by the reader is written whenever the batch + runs; one gathered from a folder is left as it stands where its reconstruction is already + written, so a repeated run over a folder picks up where the last one stopped. + """ + + entries: Tuple[BatchEntry, ...] + + def jobs(self, config: Config) -> List[ConversionJob]: + """The single-source jobs this batch writes. + + Raises: + NoFilesToProcessError: If every gathered recording is reconstructed already. + """ + jobs = [ + ConversionJob(sources=(entry.source,), stems=entry.stems, output_path=output_path) + for entry, output_path in self._targets(config) + if entry.is_named or not output_path.exists() + ] + if not jobs: + raise NoFilesToProcessError("Every gathered recording is reconstructed already") + + return jobs + + def existing_targets(self, config: Config) -> Tuple[Path, ...]: + """The reconstructions standing where a recording the reader named would be written.""" + return tuple( + output_path for entry, output_path in self._targets(config) if entry.is_named and output_path.is_file() + ) + + def _targets(self, config: Config) -> List[Tuple[BatchEntry, Path]]: + return [(entry, entry.output_path(config)) for entry in self.entries] diff --git a/src/sampletones_core/reconstructions/converter/plan/directory.py b/src/sampletones_core/reconstructions/converter/plan/directory.py index c27a0abdf..9238a7a33 100644 --- a/src/sampletones_core/reconstructions/converter/plan/directory.py +++ b/src/sampletones_core/reconstructions/converter/plan/directory.py @@ -32,7 +32,7 @@ def jobs(self, config: Config) -> List[ConversionJob]: Raises: NoFilesToProcessError: If the directory holds no audio file still to be converted. """ - output_path = get_output_path(config, self.directory) + output_path = get_output_path(config, self.directory, self.stems.covered_channels) audio_files = filter_files(get_audio_files(self.directory), self.directory, output_path) if not audio_files: raise NoFilesToProcessError(f"No audio files found in {self.directory}") diff --git a/src/sampletones_core/reconstructions/converter/plan/group.py b/src/sampletones_core/reconstructions/converter/plan/group.py index e04989b2e..c9133acc2 100644 --- a/src/sampletones_core/reconstructions/converter/plan/group.py +++ b/src/sampletones_core/reconstructions/converter/plan/group.py @@ -34,4 +34,4 @@ def existing_targets(self, config: Config) -> Tuple[Path, ...]: return (output_path,) if output_path.is_file() else () def _output_path(self, config: Config) -> Path: - return group_output_path(config, self.sources) + return group_output_path(config, self.sources, self.stems.covered_channels) diff --git a/src/sampletones_core/reconstructions/reconstruction/stems/data.py b/src/sampletones_core/reconstructions/reconstruction/stems/data.py index 11ed4c2e8..3846d1945 100644 --- a/src/sampletones_core/reconstructions/reconstruction/stems/data.py +++ b/src/sampletones_core/reconstructions/reconstruction/stems/data.py @@ -28,14 +28,16 @@ class StemsData(DataModel): def single_entry( cls, channels: List[ChannelName], + bends: List[ChannelName], assignments: List[ChannelAssignment], *, channel_cap: int = ALL_STEMS_CHANNEL_CAP, ) -> StemsData: - """The record of one stem covering ``channels`` under ``channel_cap``.""" + """The record of one stem covering ``channels``, bending ``bends``, under ``channel_cap``.""" return cls( config=StemsConfig.single_entry( channels, + bends, channel_cap=channel_cap, ), assignments=assignments, diff --git a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 444d2c142..66dd30fb2 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -1,13 +1,17 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple +from typing import AbstractSet, Dict, List, Optional, Sequence, Tuple import numpy as np from sampletones_core.audio import active_frame_level, common_length, load_audio, load_stems, mix from sampletones_core.configs import Config from sampletones_core.constants.algorithm import MINIMUM_AUDIO_LEVEL -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + ChannelName, + bending_channels, + ordered_channels, +) from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( MIXER_LEVELS, @@ -76,13 +80,15 @@ class Reconstructor: def __init__( self, config: Config, + channels: AbstractSet[ChannelName], library: Optional[InstructionLibrary] = None, ) -> None: """Builds a reconstructor for a configuration and loads its library. Args: - config: The reconstruction configuration selecting channels, window, and - matching settings. + config: The reconstruction configuration selecting the window and the matching + settings. + channels: The channels this run hands out, which it builds generators for. library: The instruction library to match against; a default library rooted at the configured directory is used when omitted. @@ -92,8 +98,8 @@ def __init__( self.config: Config = config self.state: ReconstructionState = ReconstructionState.create([]) - channel_names = self.config.generation.channels - self.channels = get_generators_by_channels(config, channel_names) + self.channel_names: List[ChannelName] = ordered_channels(channels) + self.channels = get_generators_by_channels(config, self.channel_names) self.window: Window = Window.from_config(self.config) self.library_data: InstructionLibraryData = self.load_library(library) @@ -102,8 +108,8 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: """Reconstructs an audio file into a :class:`Reconstruction`. The classic run is the stems pipeline's single-stem case: one stem covering - every enabled channel on one precedence level, with the cap at the channel - count, so every enabled channel is assigned in every frame. + every channel this reconstructor was built for, on one precedence level, with + the cap at the channel count, so every one of them is assigned in every frame. Args: path: Path to the audio file to reconstruct. @@ -114,7 +120,8 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: Raises: TypeError: If ``path`` is not a string or ``Path``. """ - stems_config = StemsConfig.single_entry(list(self.config.generation.channels)) + channels = list(self.channel_names) + stems_config = StemsConfig.single_entry(channels, bending_channels(channels)) return self.reconstruct([path], stems_config) def reconstruct( @@ -160,7 +167,7 @@ def reconstruct( self._drop_resting_channels(assignment) announce(report, ReconstructionStage.DECODING, STAGE_BEGUN, WHOLE_STAGE) streams = worker.decoder.decode(assignment.lattices) - streams = self._refiner().refine(streams, assignment.stem_ids, prepared.recordings) + streams = self._refiner(stems_config).refine(streams, assignment.stem_ids, prepared.recordings) self._record_streams(streams, report) return Reconstruction.from_state( self.state, @@ -170,9 +177,9 @@ def reconstruct( stems_data=self._build_stems_data(stems_config, assignment.stem_ids), ) - def _refiner(self) -> PitchRefiner: - """The pass that carries each chosen note towards the fundamental the recording sounds.""" - return PitchRefiner(config=self.config, channels=self.channels) + def _refiner(self, stems_config: StemsConfig) -> PitchRefiner: + """The pass that carries each chosen note toward the fundamental the recording sounds.""" + return PitchRefiner(config=self.config, channels=self.channels, stems=stems_config) @staticmethod def _check_stem_paths( diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py index 49e2f2120..7ac65abd7 100644 --- a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, replace -from typing import Dict, List, Optional +from typing import Dict, FrozenSet, List, Optional import numpy as np @@ -16,6 +16,7 @@ from sampletones_core.instructions import PulseInstruction, TriangleInstruction from sampletones_core.reconstructions.reconstructor.decoder.base import Streams from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from .smoothing import smoothed @@ -25,7 +26,7 @@ @dataclass(frozen=True) class PitchRefiner: - """Bends each chosen note towards where the recording's own fundamental stands. + """Bends each chosen note toward where the recording's own fundamental stands. The matching stage places every frame on the nearest note of the equal-tempered grid, which is as fine as its candidate catalog goes. The hardware is finer than that everywhere below the top @@ -42,22 +43,28 @@ class PitchRefiner: and the run of bends is then settled against a toll on changing, so a stream holds a tuning rather than chasing one. + Which recordings are carried, and on which channels, each stem entry states for itself, so a + channel a stem leaves alone keeps the note the matching chose. + Attributes: config: The settings the refinement and the render are run under. channels: The generator each channel sounds through, which owns the divider geometry. + stems: The setup the run assigns channels under, which states the bends each stem asks for. """ config: Config channels: Dict[ChannelName, GeneratorUnion] + stems: StemsConfig @property def active(self) -> bool: """Whether this run bends its notes. A run keeping the audio each frame was matched on would write a bend it never sounds, so - the refinement acts where the chosen instructions are rendered afresh. + the refinement acts where the chosen instructions are rendered afresh and some stem asks + for a bend. """ - return self.config.generation.refinement.enabled and self.config.generation.final_regeneration + return bool(self.stems.bent_channels) and self.config.generation.final_regeneration def refine( self, @@ -65,7 +72,7 @@ def refine( stem_ids: StemIds, recordings: StemRecordings, ) -> Streams: - """The decoded streams with every frame's note bent towards what the recording sounds. + """The decoded streams with every frame's note bent toward what the recording sounds. Args: streams: What each channel plays, one candidate per frame. @@ -78,12 +85,22 @@ def refine( if not self.active: return streams - readers = {stem_id: self._reader(recording) for stem_id, recording in recordings.items()} + readers = { + stem_id: self._reader(recording) for stem_id, recording in recordings.items() if self._bends_of(stem_id) + } return { channel_name: self._refined(channel_name, stream, stem_ids.get(channel_name, []), readers) for channel_name, stream in streams.items() } + def _bends_of(self, stem_id: int) -> FrozenSet[ChannelName]: + """The channels one stem carries toward its own recording.""" + entry = self.stems.entries_by_id.get(stem_id) + if entry is None: + return frozenset() + + return entry.settings.bend_set + def _reader(self, recording: np.ndarray) -> InstantaneousPitch: """The instantaneous-pitch reading of one stem, taken once for every channel that took it.""" return InstantaneousPitch( @@ -104,9 +121,13 @@ def _refined( if not isinstance(generator, (PulseGenerator, TriangleGenerator)): return stream + if channel_name not in self.stems.bent_channels: + return stream + settings = self.config.generation.refinement proposals = [ - self._proposal(generator, candidate, frame, stem_ids, readers) for frame, candidate in enumerate(stream) + self._proposal(generator, channel_name, candidate, frame, stem_ids, readers) + for frame, candidate in enumerate(stream) ] bends = smoothed( proposals, @@ -118,6 +139,7 @@ def _refined( def _proposal( self, generator: TonalGeneratorUnion, + channel_name: ChannelName, candidate: ScoredCandidate, frame: int, stem_ids: List[int], @@ -128,7 +150,7 @@ def _proposal( if not isinstance(instruction, (PulseInstruction, TriangleInstruction)) or not instruction.on: return None - reader = self._reader_at(frame, stem_ids, readers) + reader = self._reader_at(channel_name, frame, stem_ids, readers) if reader is None: return None @@ -136,20 +158,21 @@ def _proposal( if reading is None or reading.confidence < self.config.generation.refinement.confidence: return None - return generator.bend_towards(instruction.pitch, reading.frequency) + return generator.bend_toward(instruction.pitch, reading.frequency) - @staticmethod def _reader_at( + self, + channel_name: ChannelName, frame: int, stem_ids: List[int], readers: Dict[int, InstantaneousPitch], ) -> Optional[InstantaneousPitch]: - """The reading of the recording this channel took at one frame, where it took one.""" + """The reading behind one frame, where the stem it took asks for this channel to bend.""" if frame >= len(stem_ids): return None stem_id = stem_ids[frame] - if stem_id == RESTING_STEM_ID: + if stem_id == RESTING_STEM_ID or channel_name not in self._bends_of(stem_id): return None return readers.get(stem_id) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py index 83968bf72..cf81aac6b 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/session.py @@ -271,5 +271,5 @@ def _remaining_channels( self, stem_id: int, ) -> Dict[ChannelName, GeneratorUnion]: - allowed = self.stems_config.entries_by_id[stem_id].channel_set + allowed = self.stems_config.entries_by_id[stem_id].settings.channel_set return {name: self.channels[name] for name in self.free_channels if name in allowed} diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py index 597f307d4..849cea995 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py @@ -9,17 +9,17 @@ def validate_stems_config( stems_config: StemsConfig, channels: Dict[ChannelName, GeneratorUnion], ) -> None: - """Holds a stems setup against the channels the reconstruction enables. + """Holds a stems setup against the channels the run was built for. The setup states its own consistency — unique ids, a hierarchy naming every entry, a cap of at least one — so what is left to check is the pairing with this run: every channel a stem may occupy has a generator to render it. Raises: - ValueError: If a stem allows a channel the configuration lacks. + ValueError: If a stem allows a channel the run has no generator for. """ enabled = set(channels) for entry in stems_config.entries: - foreign = entry.channel_set - enabled + foreign = entry.settings.channel_set - enabled if foreign: - raise ValueError(f"Stem {entry.id} allows channels the configuration lacks: {sorted(foreign)}") + raise ValueError(f"Stem {entry.id} allows channels the run was not built for: {sorted(foreign)}") diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py index fcaa1d30d..926cf5ddd 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -11,6 +11,7 @@ from sampletones_core.data import DataModel from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings class StemsConfig(DataModel): @@ -34,21 +35,27 @@ class StemsConfig(DataModel): def single_entry( cls, channels: List[ChannelName], + bends: List[ChannelName], *, channel_cap: int = ALL_STEMS_CHANNEL_CAP, ) -> Self: - """The setup for one stem covering ``channels``, the classic run's shape. + """The setup for one stem covering ``channels`` and bending ``bends``, the classic run's shape. One entry holding every channel on a single precedence level reproduces the classic greedy pick when the cap equals the channel count, so this setup describes both a single-file conversion and the stems pipeline's simplest case. """ return cls( - entries=[StemEntry(id=0, channels=channels)], + entries=[StemEntry(id=0, settings=StemSettings(channels=channels, bends=bends))], hierarchy=StemsHierarchy(levels=[[0]]), channel_cap=channel_cap, ) + @cached_property + def bent_channels(self) -> FrozenSet[ChannelName]: + """Every channel some stem carries toward its own recording.""" + return frozenset(channel for entry in self.entries for channel in entry.settings.bends) + @cached_property def entries_by_id(self) -> Dict[int, StemEntry]: """The entries keyed by the id the hierarchy names them with.""" @@ -57,7 +64,7 @@ def entries_by_id(self) -> Dict[int, StemEntry]: @cached_property def covered_channels(self) -> FrozenSet[ChannelName]: """Every channel some stem may occupy, which is the set an assignment puts in play.""" - return frozenset(channel for entry in self.entries for channel in entry.channels) + return frozenset(channel for entry in self.entries for channel in entry.settings.channels) @property def frame_budget(self) -> int: diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py index 822ab5b65..c1b65e9db 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -1,25 +1,19 @@ -from functools import cached_property -from typing import FrozenSet, List - from pydantic import ConfigDict, Field -from sampletones_core.constants.enums import ChannelName from sampletones_core.data import DataModel +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings class StemEntry(DataModel): + """One stem a run competes for channels with: the id it is recorded under, and its settings.""" + model_config = ConfigDict(extra="forbid", frozen=True) id: int = Field( ..., description="Identifier of the stem the hierarchy references", ) - channels: List[ChannelName] = Field( + settings: StemSettings = Field( ..., - description="The channels the stem may occupy", + description="What the recording behind this stem is converted with", ) - - @cached_property - def channel_set(self) -> FrozenSet[ChannelName]: - """The channels this stem may occupy, in the form an assignment tests membership against.""" - return frozenset(self.channels) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py new file mode 100644 index 000000000..284c4b844 --- /dev/null +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py @@ -0,0 +1,55 @@ +from functools import cached_property +from typing import FrozenSet, List, Self + +from pydantic import ConfigDict, Field, model_validator + +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.data import DataModel + + +class StemSettings(DataModel): + """What one recording is converted with: the channels it may occupy, and which of them it bends. + + A recording's settings are one value, so the list a reader sets a run up in, the entry that run + records, and whoever reads the record later all state the same thing. A further per-recording + choice is a field here, and each of those readers gains it in the same step. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + channels: List[ChannelName] = Field( + ..., + description="The channels the recording may occupy", + ) + bends: List[ChannelName] = Field( + ..., + description="The channels whose notes the recording carries to the divider it sounds", + ) + + @cached_property + def channel_set(self) -> FrozenSet[ChannelName]: + """The channels the recording may occupy, in the form an assignment tests membership against.""" + return frozenset(self.channels) + + @cached_property + def bend_set(self) -> FrozenSet[ChannelName]: + """The channels the recording bends, in the form the refinement tests membership against.""" + return frozenset(self.bends) + + @model_validator(mode="after") + def _bends_reach_their_channels(self) -> Self: + """Holds a bend to a channel the recording occupies and whose hardware loads a divider. + + Raises: + ValueError: If a bent channel lies outside the recording's own channels, or names the + noise channel, whose sixteen periods stand at fixed distances from each other. + """ + unheld = self.bend_set - self.channel_set + if unheld: + raise ValueError(f"Bent channels lie outside the ones occupied: {sorted(unheld)}") + + toneless = self.bend_set - TONE_CHANNELS + if toneless: + raise ValueError(f"Bent channels read no bend: {sorted(toneless)}") + + return self diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index 6cdf7c7a6..c61d73725 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -1,9 +1,14 @@ from pathlib import Path -from typing import Final, Optional, Tuple +from typing import Final, Optional, Sequence, Tuple from tqdm import tqdm from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + ChannelName, + bending_channels, + ordered_channels, +) from sampletones_core.library import InstructionLibrary from sampletones_core.parallelization import TaskProgress, TaskStatus from sampletones_core.reconstructions import Reconstructor @@ -11,9 +16,9 @@ ConversionJob, DirectoryConversion, ReconstructionConverter, - get_output_path, reconstruct_job, ) +from sampletones_core.reconstructions.converter.paths import get_output_path from sampletones_core.reconstructions.progress import ReconstructionProgress from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.scripts.library import generate_library @@ -25,10 +30,11 @@ def reconstruct_file( input_path: Path, config: Config, + channels: Sequence[ChannelName], output_path: Optional[Path] = None, ) -> None: if output_path is None: - output_path = get_output_path(config, input_path) + output_path = get_output_path(config, input_path, frozenset(channels)) if output_path.exists(): logger.info(f"Reconstructing file {input_path} exists, skipping") @@ -40,7 +46,7 @@ def reconstruct_file( logger.info(f"Starting reconstruction for file {input_path}") job = ConversionJob( sources=(input_path,), - stems=_classic_setup(config), + stems=_classic_setup(channels), output_path=output_path, ) progress_bar = tqdm(total=BAR_STEPS, desc=f"Reconstructing {input_path.name}", unit="step") @@ -51,7 +57,7 @@ def on_progress(progress: ReconstructionProgress) -> bool: return True try: - reconstruct_job((Reconstructor(config), job, on_progress)) + reconstruct_job((Reconstructor(config, frozenset(channels)), job, on_progress)) finally: progress_bar.close() @@ -61,10 +67,11 @@ def on_progress(progress: ReconstructionProgress) -> bool: def reconstruct_directory( input_path: Path, config: Config, + channels: Sequence[ChannelName], output_path: Optional[Path] = None, ) -> None: if output_path is None: - output_path = get_output_path(config, input_path) + output_path = get_output_path(config, input_path, frozenset(channels)) if not input_path.is_dir(): raise NotADirectoryError(f"Expected a directory path, got file path: {input_path}") @@ -117,7 +124,7 @@ def on_error(_exception: Exception) -> None: converter = ReconstructionConverter( config, - DirectoryConversion(directory=input_path, stems=_classic_setup(config)), + DirectoryConversion(directory=input_path, stems=_classic_setup(channels)), logger=null_logger, ) @@ -138,6 +145,7 @@ def on_error(_exception: Exception) -> None: progress_bar.close() -def _classic_setup(config: Config) -> StemsConfig: - """The setup a single-source conversion runs under: one stem over every enabled channel.""" - return StemsConfig.single_entry(list(config.generation.channels)) +def _classic_setup(channels: Sequence[ChannelName]) -> StemsConfig: + """The setup a single-source conversion runs under: one stem over the channels it was given.""" + ordered = ordered_channels(frozenset(channels)) + return StemsConfig.single_entry(ordered, bending_channels(ordered)) diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 7c9fbbc1a..cfc3ccab9 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -11,7 +11,7 @@ class TreeVisibility: 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. + match is answered from its own path upward. """ matches: FrozenSet[TreeNode] diff --git a/src/sampletones_player/compression/matches/played.py b/src/sampletones_player/compression/matches/played.py index b733a2f9d..8ab7fcf97 100644 --- a/src/sampletones_player/compression/matches/played.py +++ b/src/sampletones_player/compression/matches/played.py @@ -38,7 +38,7 @@ def _held_ticks( expected: bytes, limit: int, ) -> int: - """The ticks a phrase covers once it has played out, its final value carrying onwards.""" + """The ticks a phrase covers once it has played out, its final value carrying onward.""" end = position + len(expected) if len(expected) == limit or index.plane[end] != expected[-1]: return len(expected) diff --git a/src/sampletones_player/compression/parse/boundaries.py b/src/sampletones_player/compression/parse/boundaries.py index 4b383416e..ed0ab9c99 100644 --- a/src/sampletones_player/compression/parse/boundaries.py +++ b/src/sampletones_player/compression/parse/boundaries.py @@ -6,7 +6,7 @@ @dataclass(frozen=True) class Boundaries: - """The ticks a token starts on, read forwards and backwards from every tick of a plane. + """The ticks a token starts on, read forward and backward from every tick of a plane. A loop re-enters the stream partway through, so the tick it re-enters at holds a token of its own and nothing spans across it. Knowing the nearest boundary either side of a tick is diff --git a/src/sampletones_player/compression/pitch.py b/src/sampletones_player/compression/pitch.py index 34636621a..1746e034e 100644 --- a/src/sampletones_player/compression/pitch.py +++ b/src/sampletones_player/compression/pitch.py @@ -24,7 +24,7 @@ class PitchTable(BaseModel): where adding one to a timer means nothing. Attributes: - timers: The timer for each pitch, from the lowest the project reaches upwards. + timers: The timer for each pitch, from the lowest the project reaches upward. """ model_config = ConfigDict(extra="forbid", frozen=True) diff --git a/src/sampletones_player/compression/planes/separate.py b/src/sampletones_player/compression/planes/separate.py index cd666d7d7..5ab31e375 100644 --- a/src/sampletones_player/compression/planes/separate.py +++ b/src/sampletones_player/compression/planes/separate.py @@ -1,12 +1,11 @@ from typing import Dict, Final, Sequence -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.compression.pitch import PitchTable from sampletones_player.compression.planes.channel import ChannelPlanes, TonePlanes from sampletones_player.compression.planes.song import SongPlanes from sampletones_player.registers.base import ChannelRegisters from sampletones_player.registers.streams import ChannelStreams -from sampletones_player.specification.channels import TONE_CHANNELS from sampletones_player.specification.registers import TIMER_HIGH_SHIFT CONTROL_VALUE_INDEX: Final[int] = 0 diff --git a/src/sampletones_player/compression/tokens/phrase.py b/src/sampletones_player/compression/tokens/phrase.py index 522606192..194e83e1e 100644 --- a/src/sampletones_player/compression/tokens/phrase.py +++ b/src/sampletones_player/compression/tokens/phrase.py @@ -7,7 +7,7 @@ class PhraseToken: """The plane plays a phrase from the table, shifted by ``transpose``, for ``ticks`` ticks. - A count past the phrase's own length holds its final value onwards, the way a note whose + A count past the phrase's own length holds its final value onward, the way a note whose envelope has finished keeps sounding, and a count short of it cuts the note off. """ diff --git a/src/sampletones_player/driver/assembly/source/channels.s b/src/sampletones_player/driver/assembly/source/channels.s index 4dd833e6c..fec501dce 100644 --- a/src/sampletones_player/driver/assembly/source/channels.s +++ b/src/sampletones_player/driver/assembly/source/channels.s @@ -125,7 +125,7 @@ channels_advance: ; ; The values a tick plays come from wherever the token put them: a phrase body, the bytes spelled ; out behind a literal, or the value the plane already reached. A body played out holds its last -; value onwards, which is what carries a note whose envelope has finished. +; value onward, which is what carries a note whose envelope has finished. plane_advance: lda plane_state + PLANE_TOKEN_TICKS,x bne @within diff --git a/src/sampletones_player/specification/channels.py b/src/sampletones_player/specification/channels.py index bcbeaa53d..8c7c00cbd 100644 --- a/src/sampletones_player/specification/channels.py +++ b/src/sampletones_player/specification/channels.py @@ -1,4 +1,4 @@ -from typing import Dict, Final, FrozenSet, Tuple +from typing import Dict, Final, Tuple from sampletones_core.constants.enums import ChannelName from sampletones_player.specification.registers import ( @@ -21,11 +21,3 @@ ChannelName.TRIANGLE: (TRIANGLE_LINEAR_COUNTER, TRIANGLE_TIMER_LOW, TRIANGLE_TIMER_HIGH), ChannelName.NOISE: (NOISE_CONTROL, NOISE_PERIOD), } - -TONE_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset( - { - ChannelName.PULSE1, - ChannelName.PULSE2, - ChannelName.TRIANGLE, - } -) diff --git a/src/sampletones_player/specification/compression.py b/src/sampletones_player/specification/compression.py index ea582df37..ee1e6c557 100644 --- a/src/sampletones_player/specification/compression.py +++ b/src/sampletones_player/specification/compression.py @@ -2,13 +2,12 @@ from math import ceil from typing import Final -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_player.specification.binary import ( BYTE_VALUES, MAX_BYTE_VALUE, WORD_SIZE, ) -from sampletones_player.specification.channels import TONE_CHANNELS from sampletones_shared.constants.general import BITS_PER_BYTE diff --git a/src/sampletones_player/trace/trace.py b/src/sampletones_player/trace/trace.py index be9779270..cd9bd185b 100644 --- a/src/sampletones_player/trace/trace.py +++ b/src/sampletones_player/trace/trace.py @@ -31,7 +31,7 @@ class RegisterTrace: """Every APU register write a run of the driver makes, grouped by the call that makes it. This is the contract the assembly is written against: initialization clears the channels, - enables them and sounds the song's first tick, and each play call afterwards either advances + enables them and sounds the song's first tick, and each play call afterward either advances the streams and writes the tick it lands on, or leaves the console alone. The three registers that reset a running channel are written only where their value changes, which is what keeps a pulse waveform's phase running across a rest the way a rendered channel does. diff --git a/src/sampletones_shared/types/callback.py b/src/sampletones_shared/types/callback.py index 61f75b918..668d11a6f 100644 --- a/src/sampletones_shared/types/callback.py +++ b/src/sampletones_shared/types/callback.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import Any, Callable, TypeVar +from typing import Any, Callable, Tuple, TypeVar Callback = Callable[..., Any] VoidCallback = Callable[[], None] PathCallback = Callable[[Path], None] +PathsCallback = Callable[[Tuple[Path, ...]], None] StringCallback = Callable[[str], None] MessageCallback = Callable[..., str] CallbackT = TypeVar("CallbackT", bound=Callback) diff --git a/src/sampletones_shared/utils/callbacks.py b/src/sampletones_shared/utils/callbacks.py index 71a8f6b19..445484e19 100644 --- a/src/sampletones_shared/utils/callbacks.py +++ b/src/sampletones_shared/utils/callbacks.py @@ -12,14 +12,15 @@ class CallbackMixin: them: optional hook attributes are the sole channel outward. ``call`` announces an event and ``query`` asks a hook for a value; both are deliberately lenient about missing hooks — partial wiring during - construction is expected and logged rather than raised. + construction is expected, so an unset hook is noted at debug and the + caller carries on. """ def call(self, callback: Optional[Callback], *args: Any, **kwargs: Any) -> Any: """ Safely invokes a callback with provided arguments. - Handles None callbacks gracefully by logging a warning and returning None. + Handles None callbacks gracefully by noting them at debug and returning None. Validates that the callback is callable before invocation. Args: @@ -34,7 +35,7 @@ def call(self, callback: Optional[Callback], *args: Any, **kwargs: Any) -> Any: TypeError: If callback is not None but is not callable. """ if callback is None: - logger.warning(f"No callback for {self.__class__.__name__} to call.") + logger.debug(f"No callback for {self.__class__.__name__} to call.") return None if not callable(callback): @@ -71,7 +72,7 @@ def query( TypeError: If callback is not None but is not callable. """ if callback is None: - logger.warning(f"No callback for {self.__class__.__name__} to query.") + logger.debug(f"No callback for {self.__class__.__name__} to query.") return default if not callable(callback): diff --git a/src/sampletones_shared/utils/hashing.py b/src/sampletones_shared/utils/hashing.py new file mode 100644 index 000000000..6a2f37134 --- /dev/null +++ b/src/sampletones_shared/utils/hashing.py @@ -0,0 +1,124 @@ +import hashlib +from collections.abc import Hashable +from typing import Final + +from pydantic import BaseModel + +from sampletones_shared.types.data import ModelHashable +from sampletones_shared.utils.serialization import dump + +HASH_LENGTH: Final[int] = 32 +HASH_PATTERN: Final[str] = rf"^[0-9a-f]{{{HASH_LENGTH}}}$" +IDENTITY_SEPARATOR: Final[str] = "\x00" + + +def get_hash_bytes(data: Hashable) -> bytes: + """ + Converts hashable data types to signed bytes. + + Args: + data (Hashable): The data to convert. Must be hashable. + + Returns: + bytes: Byte representation of the data. + + Raises: + TypeError: If the data is not hashable. + """ + if not isinstance(data, Hashable): + raise TypeError("Data must be hashable to convert to hash bytes") + + signed = hash(data) + unsigned = signed & ((1 << 64) - 1) + return unsigned.to_bytes(8, byteorder="big", signed=False) + + +def calculate_hash(data: ModelHashable, *, length: int = HASH_LENGTH) -> str: + """ + Calculates a SHA-256 hash for hashable data types. + + Supports BaseModel instances, primitive types (bool, int, float, bytes, str), + and other hashable objects. BaseModel instances are serialized to JSON before hashing. + + Args: + data (ModelHashable): The data to hash. Can be BaseModel, bool, int, float, + bytes, str, or any hashable object. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string truncated to the specified length. + + Raises: + ValueError: If the length lies outside 1 to 64. + """ + if length <= 0 or length > 64: + raise ValueError("Hash length must be between 1 and 64") + + raw: bytes + if isinstance(data, BaseModel): + raw = dump(data.model_dump()).encode("utf-8") + elif isinstance(data, (bool, int, float, bytes, str)): + if not data: + data = "" + + if isinstance(data, (bool, int, float)): + data = str(float(data)) + + if isinstance(data, str): + data = data.encode("utf-8") + + raw = data + else: + raw = get_hash_bytes(data) + + return hashlib.sha256(raw).hexdigest()[:length] + + +def hash_models(*models: BaseModel, length: int = HASH_LENGTH) -> str: + """ + Calculates a combined hash for multiple BaseModel instances. + + Models are serialized to JSON as a list and hashed together, ensuring + the hash depends on both the models' content and their order. + + Args: + *models (BaseModel): One or more BaseModel instances to hash. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing all models combined. + """ + combined = [model.model_dump() for model in models] + json_string = dump(combined) + return calculate_hash(json_string, length=length) + + +def hash_model(model: BaseModel, *, length: int = HASH_LENGTH) -> str: + """ + Calculates a hash for a single BaseModel instance. + + Args: + model (BaseModel): The BaseModel instance to hash. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing the model. + """ + return hash_models(model, length=length) + + +def identity_digest(*parts: str, length: int = HASH_LENGTH) -> str: + """ + Calculates a hash for an identity spelled out in parts. + + The parts are joined on a separator that names carry nowhere, so one identity reaches + one digest and two identities spelled in different parts reach different digests. + + Args: + *parts (str): The pieces the identity is spelled in, in the order they belong. + length (int): The length of the hash string to return. Defaults to 32. + + Returns: + str: Hexadecimal hash string representing the identity. + """ + return calculate_hash(IDENTITY_SEPARATOR.join(parts), length=length) diff --git a/src/sampletones_shared/utils/serialization.py b/src/sampletones_shared/utils/serialization.py index e127ebc0f..73ab6b45e 100644 --- a/src/sampletones_shared/utils/serialization.py +++ b/src/sampletones_shared/utils/serialization.py @@ -1,7 +1,5 @@ import base64 -import hashlib import json -from collections.abc import Hashable from contextlib import suppress from pathlib import Path from typing import Any, Dict, Final, List, Mapping, Optional, Type, TypeVar, Union @@ -11,13 +9,11 @@ from pydantic import BaseModel from sampletones_shared.types.array import Array -from sampletones_shared.types.data import ModelHashable, SerializedData +from sampletones_shared.types.data import SerializedData from sampletones_shared.types.path import Pathlike JSON_INDENT: Final[int] = 2 YAML_ROOT_STEM: Final[str] = "root" -HASH_LENGTH: Final[int] = 32 -HASH_PATTERN: Final[str] = rf"^[0-9a-f]{{{HASH_LENGTH}}}$" ModelTypeT = TypeVar("ModelTypeT", bound=BaseModel) @@ -254,98 +250,6 @@ def deserialize_array(data: SerializedData) -> np.ndarray: return array.reshape(data["shape"]) -def get_hash_bytes(data: Hashable) -> bytes: - """ - Converts hashable data types to signed bytes. - - Args: - data (Hashable): The data to convert. Must be hashable. - - Returns: - bytes: Byte representation of the data. - - Raises: - TypeError: If the data is not hashable. - """ - if not isinstance(data, Hashable): - raise TypeError("Data must be hashable to convert to hash bytes") - - signed = hash(data) - unsigned = signed & ((1 << 64) - 1) - return unsigned.to_bytes(8, byteorder="big", signed=False) - - -def calculate_hash(data: ModelHashable, *, length: int = HASH_LENGTH) -> str: - """ - Calculates a SHA-256 hash for hashable data types. - - Supports BaseModel instances, primitive types (bool, int, float, bytes, str), - and other hashable objects. BaseModel instances are serialized to JSON before hashing. - - Args: - data (ModelHashable): The data to hash. Can be BaseModel, bool, int, float, - bytes, str, or any hashable object. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string truncated to the specified length. - """ - if length <= 0 or length > 64: - raise ValueError("Hash length must be between 1 and 64") - - raw: bytes - if isinstance(data, BaseModel): - raw = dump(data.model_dump()).encode("utf-8") - elif isinstance(data, (bool, int, float, bytes, str)): - if not data: - data = "" - - if isinstance(data, (bool, int, float)): - data = str(float(data)) - - if isinstance(data, str): - data = data.encode("utf-8") - - raw = data - else: - raw = get_hash_bytes(data) - - return hashlib.sha256(raw).hexdigest()[:length] - - -def hash_models(*models: BaseModel, length: int = HASH_LENGTH) -> str: - """ - Calculates a combined hash for multiple BaseModel instances. - - Models are serialized to JSON as a list and hashed together, ensuring - the hash depends on both the models' content and their order. - - Args: - *models (BaseModel): One or more BaseModel instances to hash. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string representing all models combined. - """ - combined = [model.model_dump() for model in models] - json_string = dump(combined) - return calculate_hash(json_string, length=length) - - -def hash_model(model: BaseModel, *, length: int = HASH_LENGTH) -> str: - """ - Calculates a hash for a single BaseModel instance. - - Args: - model (BaseModel): The BaseModel instance to hash. - length (int): The length of the hash string to return. Defaults to 32. - - Returns: - str: Hexadecimal hash string representing the model. - """ - return hash_models(model, length=length) - - def snake_to_camel(snake_str: str) -> str: """ Converts a snake_case string to CamelCase. diff --git a/src/sampletones_synthesis/envelopes/linear_attack.py b/src/sampletones_synthesis/envelopes/linear_attack.py index 9a620aea4..68474e2c4 100644 --- a/src/sampletones_synthesis/envelopes/linear_attack.py +++ b/src/sampletones_synthesis/envelopes/linear_attack.py @@ -5,7 +5,7 @@ class LinearAttackEnvelope(BaseModel): - """Linear rise from 0 to full level over the attack, holding 1 afterwards.""" + """Linear rise from 0 to full level over the attack, holding 1 afterward.""" model_config = ConfigDict(frozen=True, extra="forbid") diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py new file mode 100644 index 000000000..ad60c12ab --- /dev/null +++ b/tests/benchmarks/test_converter_load.py @@ -0,0 +1,352 @@ +from itertools import count +from pathlib import Path +from typing import Callable, Final, Iterator, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import batch_entries +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import stem_rows +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.general import SUF_TEXT +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.gestures import ROW_WIDGET_KINDS +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.ui.elements.stems.tags import StemsTags +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 +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.timing import seconds + +SMALL_FOLDER: Final[int] = 1_000 +LARGE_FOLDER: Final[int] = 10_000 +GROWTH_ALLOWANCE: Final[float] = 2.0 +UNREADABLE: Final[float] = float("inf") +SETTINGS: Final[StemSettings] = StemSettings(channels=[ChannelName.PULSE1], bends=[]) +SMALL_ROOT: Final[Path] = Path("/gathered/small") +LARGE_ROOT: Final[Path] = Path("/gathered/large") +ROOT_TAG: Final[str] = "load_root" + + +def folder_of(root: Path, count: int) -> Folder: + """A gathered folder of ``count`` recordings, each settled the way a fresh one arrives.""" + return Folder( + root=root, + recordings=tuple(Recording(path=root / f"{index:06d}.wav", settings=SETTINGS) for index in range(count)), + ) + + +def gathering_of(root: Path, count: int) -> Gathering: + """The setup a reader is left with after gathering one folder of ``count`` recordings.""" + return Gathering.empty().listing_folder(folder_of(root, count)) + + +def state_of(root: Path, count: int) -> ConverterState: + """The setup a per-recording run derives its entries from, one folder gathered into it.""" + return ConverterState( + settings=RunSettings( + joining=SETTINGS, + output=OutputKind.PER_RECORDING, + channel_cap=len(ChannelName), + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ), + gathering=gathering_of(root, count), + destination=Destination.unset(), + selected=None, + ) + + +def growth(small: Callable[[], object], large: Callable[[], object]) -> Tuple[float, float, str]: + """What each size costs, and a line naming both readings and the growth between them. + + A clock coarse enough to read the smaller run as nothing at all reports the growth as + unbounded, so the line prints and the bound fails on the reading rather than on the division. + """ + one = seconds(small) + many = seconds(large) + ratio = many / one if one > 0 else UNREADABLE + report = ( + f"{SMALL_FOLDER} recordings {one * 1000:.1f} ms, " + f"{LARGE_FOLDER} recordings {many * 1000:.1f} ms, " + f"{ratio:.1f}x for {LARGE_FOLDER // SMALL_FOLDER}x the recordings" + ) + print(report) + return one, many, report + + +def linear(one: float) -> float: + """The most a reading may cost while the work it does still follows the list's length. + + ``GROWTH_ALLOWANCE`` is the room the reading itself takes. The smaller run is the divisor and + is warmed by whatever ran before it, so the two readings differ in more than the work between + them. The shapes these bounds are here to catch — the list read again for each row, or once + per folder standing in it — read fifty times over at ten thousand, so the allowance is wide + enough for the warmth and narrow enough for those. + """ + return one * (LARGE_FOLDER / SMALL_FOLDER) * GROWTH_ALLOWANCE + + +class TestGatheringAFolder(BaseTestSuite): + """A folder joins the list at the cost of the recordings it brought in. + + The list takes over the loose recordings a folder covers, which asks what already stands + against what is arriving. Answering that recording by recording would make gathering cost the + square of what a folder holds, which is the shape this bound catches: at ten thousand it is + the difference between a moment and a minute. + """ + + def test_it_costs_what_the_recordings_it_holds_cost(self) -> None: + small = folder_of(SMALL_ROOT, SMALL_FOLDER) + large = folder_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth(lambda: SourceList().add_folder(small), lambda: SourceList().add_folder(large)) + + assert many < linear(one), report + + def test_a_folder_joining_a_list_that_holds_one_costs_the_same(self) -> None: + """Gathering a second folder reads what the first holds, which is the O(n²) door.""" + standing = SourceList().add_folder(folder_of(SMALL_ROOT, LARGE_FOLDER)) + small = folder_of(Path("/gathered/second/small"), SMALL_FOLDER) + large = folder_of(Path("/gathered/second/large"), LARGE_FOLDER) + one, many, report = growth(lambda: standing.add_folder(small), lambda: standing.add_folder(large)) + + assert many < linear(one), report + + +class TestReadingTheRowsAGestureLeaves(BaseTestSuite): + """Every gesture reads the gathered sources into the rows the list draws. + + A folder is read down to the recordings it holds, each becoming a row of its own and each + answered for on the disk, so this is the reading a reader waits through on every click. The + bound holds it to the length of the list rather than to the list times its channels. + """ + + def test_it_costs_what_the_list_holds(self) -> None: + small = gathering_of(SMALL_ROOT, SMALL_FOLDER) + large = gathering_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth( + lambda: stem_rows(small, mixes=False), + lambda: stem_rows(large, mixes=False), + ) + + assert many < linear(one), report + + def test_it_reads_a_row_for_every_recording_a_folder_holds(self) -> None: + """What the reading costs is what it builds, which is a row apiece and the folder's own.""" + rows = stem_rows(gathering_of(LARGE_ROOT, LARGE_FOLDER), mixes=False) + + assert len(rows) == 1 + assert rows[0].holds == LARGE_FOLDER + + +class TestReadingWhereEachRecordingIsWritten(BaseTestSuite): + """Every gesture derives the entries a per-recording run would write. + + Settling the setup follows it to the destination, which builds one entry per gathered recording + holding a channel and asks the list which folder each was gathered from. So this runs on every + click beside the row reading, and the bound holds it to the length of the list rather than to + the list times the folders standing in it. + """ + + def test_it_costs_what_the_list_holds(self) -> None: + small = state_of(SMALL_ROOT, SMALL_FOLDER) + large = state_of(LARGE_ROOT, LARGE_FOLDER) + one, many, report = growth(lambda: batch_entries(small), lambda: batch_entries(large)) + + assert many < linear(one), report + + def test_it_writes_an_entry_for_every_recording_a_folder_holds(self) -> None: + """What the derivation costs is what it builds, which is one entry per recording.""" + entries = batch_entries(state_of(LARGE_ROOT, LARGE_FOLDER)) + + assert len(entries) == LARGE_FOLDER + assert entries[0].base_directory == LARGE_ROOT + + +class TestSettlingAChannel(BaseTestSuite): + """One box on a folder settles every recording it stands for. + + A folder answers as one group, so the gesture writes the whole of what it holds. That is work + the length of the list by design; what the bound catches is a settle that reads the list again + for each recording it writes. + """ + + def test_it_costs_what_the_folder_holds(self) -> None: + small = gathering_of(SMALL_ROOT, SMALL_FOLDER).sources + large = gathering_of(LARGE_ROOT, LARGE_FOLDER).sources + one, many, report = growth( + lambda: small.toggled(SourceKey.folder(SMALL_ROOT), CHANNEL_SLOT, ChannelName.TRIANGLE), + lambda: large.toggled(SourceKey.folder(LARGE_ROOT), CHANNEL_SLOT, ChannelName.TRIANGLE), + ) + + assert many < linear(one), report + + def test_settling_one_recording_inside_a_folder_costs_the_same(self) -> None: + """A reader who opens a folder answers for one recording in it, and pays for that one.""" + small = gathering_of(SMALL_ROOT, SMALL_FOLDER).sources + large = gathering_of(LARGE_ROOT, LARGE_FOLDER).sources + one, many, report = growth( + lambda: small.toggled(SourceKey.recording(SMALL_ROOT / "000500.wav"), CHANNEL_SLOT, ChannelName.NOISE), + lambda: large.toggled(SourceKey.recording(LARGE_ROOT / "005000.wav"), CHANNEL_SLOT, ChannelName.NOISE), + ) + + assert many < linear(one), report + + +@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 the list binds while it draws.""" + 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)) + with dpg.window(tag=ROOT_TAG): + yield + + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture(scope="module") +def small_listing() -> StemsListViewModel: + return listing_of(SMALL_ROOT, SMALL_FOLDER) + + +@pytest.fixture(scope="module") +def large_listing() -> StemsListViewModel: + return listing_of(LARGE_ROOT, LARGE_FOLDER) + + +def listing_of(root: Path, count: int) -> StemsListViewModel: + """The reading the panel hands the list after a folder of ``count`` recordings is gathered.""" + return StemsListViewModel( + rows=stem_rows(Gathering.empty().listing_folder(folder_of(root, count)), mixes=False), + channels_in_play=tuple(ChannelName.items()), + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=True, + selected_key=None, + ) + + +def list_drawn_as(prefix: str, layout_config: LayoutConfig) -> GUIStemsList: + """A converter's list of gathered sources, drawn into the window standing open.""" + built = GUIStemsList( + prefix=prefix, + layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=GATHERED_SOURCES, + ) + built.create(ROOT_TAG) + return built + + +def cleared(prefix: str, stems_list: GUIStemsList) -> None: + """Take down everything one drawn list stands as: its own well, and the registries it made.""" + tags = StemsTags(prefix=prefix) + dpg.delete_item(stems_list.tag) + for kind in ROW_WIDGET_KINDS: + dpg.delete_item(tags.handlers(kind)) + + +def rows_on_screen(prefix: str, listing: StemsListViewModel) -> int: + """How many of a folder's recordings the list put widgets on screen for.""" + folder_row = listing.rows[0] + tags = StemsTags(prefix=prefix) + return sum(1 for held in folder_row.held if dpg.does_item_exist(tags.row(held.key, SUF_TEXT))) + + +class TestDrawingAGatheredFolder(BaseTestSuite): + """What an open folder builds is what a reader can see, however many recordings it holds. + + The rows outside the window stand as reserved room rather than as widgets, so the interface a + folder of ten thousand costs is the interface a folder of ten costs. Before a row has been + measured the region is generous, which is the window this counts against. + """ + + def test_it_builds_what_a_reader_can_see( + self, + dpg_context: None, + layout_config: LayoutConfig, + small_listing: StemsListViewModel, + large_listing: StemsListViewModel, + ) -> None: + layout = layout_config.general.stems + window = RowGeometry.opening_at(overscan=layout.window_overscan, opening=float(layout.name_height)).size( + float(layout.folder_ceiling) + ) + drawn = tuple( + self._opened(prefix, layout_config, listing) + for prefix, listing in (("load.small", small_listing), ("load.large", large_listing)) + ) + + assert drawn == (window, window) + + def test_opening_it_costs_what_a_reader_can_see( + self, + dpg_context: None, + layout_config: LayoutConfig, + small_listing: StemsListViewModel, + large_listing: StemsListViewModel, + ) -> None: + """The whole gesture, from the reading the panel hands over to the widgets on screen.""" + runs = count() + one = seconds(lambda: self._opened(f"load.timed.{next(runs)}", layout_config, small_listing)) + many = seconds(lambda: self._opened(f"load.timed.{next(runs)}", layout_config, large_listing)) + report = f"{SMALL_FOLDER} recordings {one * 1000:.1f} ms, {LARGE_FOLDER} recordings {many * 1000:.1f} ms" + print(report) + + assert many < linear(one), report + + @staticmethod + def _opened(prefix: str, layout_config: LayoutConfig, listing: StemsListViewModel) -> int: + """Draw the listing, open the folder standing in it, and count the rows that reached screen. + + What the gesture built is taken down once it has been counted, so a batch of them costs + what one costs: the window each reading is taken in holds the widgets of that reading + alone, and the small run is measured on the same window as the large one. + """ + stems_list = list_drawn_as(prefix, layout_config) + stems_list.update_view(listing) + stems_list.toggle_folder(listing.rows[0].key) + reached = rows_on_screen(prefix, listing) + cleared(prefix, stems_list) + return reached diff --git a/tests/benchmarks/test_pitch_bend.py b/tests/benchmarks/test_pitch_bend.py index e53e73a15..fd1887d61 100644 --- a/tests/benchmarks/test_pitch_bend.py +++ b/tests/benchmarks/test_pitch_bend.py @@ -1,4 +1,3 @@ -from time import process_time from typing import Final, List import pytest @@ -7,11 +6,11 @@ from sampletones_core.constants.enums import ChannelName from sampletones_core.generators.render import render_instructions from sampletones_core.instructions import PulseInstruction +from tests.suite.timing import seconds FRAMES: Final[int] = 6000 PITCH: Final[int] = 60 VOLUME: Final[int] = 12 -REPEATS: Final[int] = 3 BEND_OVERHEAD_LIMIT: Final[float] = 1.25 @@ -30,14 +29,8 @@ def _stream(bent: bool) -> List[PulseInstruction]: def _render_seconds(config: Config, instructions: List[PulseInstruction]) -> float: - """The best of several renders, which is the reading least disturbed by other load.""" - readings: List[float] = [] - for _ in range(REPEATS): - started = process_time() - render_instructions(instructions, ChannelName.PULSE1, config) - readings.append(process_time() - started) - - return min(readings) + """What one render of the stream costs, the reading least disturbed by other load.""" + return seconds(lambda: render_instructions(instructions, ChannelName.PULSE1, config)) @pytest.fixture(scope="module") diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 0ec1ed42d..a03457c13 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -7,7 +7,13 @@ from sampletones_core.audio.processing import normalize from sampletones_core.configs import Config, InstructionsLibraryConfig from sampletones_core.configs.generation import GenerationConfig -from sampletones_core.constants.enums import ChannelName, HierarchyMode, SpectrumMethod +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + HierarchyMode, + SpectrumMethod, + bending_channels, +) from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor from sampletones_core.generators import get_generators_by_channels @@ -22,6 +28,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_yaml from tests.integration.assets.synth_config import SynthConfig @@ -57,7 +64,10 @@ def three_stem_config() -> StemsConfig: hierarchy level, stem c (pulse 1, noise) on the second. """ return StemsConfig( - entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in THREE_STEM_ENTRY_CHANNELS.items()], + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id, channels in THREE_STEM_ENTRY_CHANNELS.items() + ], hierarchy=StemsHierarchy( levels=[[STEM_A_ID, STEM_B_ID], [STEM_C_ID]], mode=HierarchyMode.STRICT, @@ -67,8 +77,8 @@ def three_stem_config() -> StemsConfig: def three_stem_reconstruction_config() -> Config: - """Builds a reconstruction config with both pulses enabled for the three-stem example.""" - return Config(generation=GenerationConfig(channels=THREE_STEM_CHANNELS)) + """Builds a reconstruction config for the three-stem example.""" + return Config() def write_three_stem_recordings( @@ -124,6 +134,7 @@ def reconstruct_sample( audio: np.ndarray, config: Config, library: InstructionLibrary, + channels: FrozenSet[ChannelName], *, tmp_dir: Pathlike, name: str, @@ -131,7 +142,7 @@ def reconstruct_sample( """Runs the real reconstruction pipeline on ``audio`` via a temp WAV.""" path = Path(tmp_dir) / f"{name}.wav" write_wave(path, config.library.sample_rate, audio) - reconstruction = Reconstructor(config, library=library)(path) + reconstruction = Reconstructor(config, channels, library=library)(path) if reconstruction is None: raise AssertionError(f"Reconstruction of '{name}' produced no result") @@ -149,7 +160,14 @@ def make_sample( loop: bool = False, ) -> Sample: """Reconstructs ``audio`` into a `Sample`, asserting the channels it plays.""" - reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name) + reconstruction = reconstruct_sample( + audio, + config, + library, + expected_slices, + tmp_dir=tmp_dir, + name=name, + ) played = frozenset(reconstruction.playing_channels) if played != expected_slices: raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") @@ -185,7 +203,7 @@ def load_instrument_catalog( catalog: Dict[str, Sample] = {} for entry in spec["instruments"]: channels = [ChannelName(name) for name in entry["channels"]] - config = Config(library=library_config, generation=GenerationConfig(channels=channels)) + config = Config(library=library_config) audio = _render_instrument(synth_config, entry["synth"], sample_rate=sample_rate) catalog[entry["name"]] = make_sample( entry["name"], diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py index c004b35e7..488621ee4 100644 --- a/tests/integration/reconstruction/test_conversion_jobs.py +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -3,7 +3,11 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + bending_channels, +) from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.converter import ( DirectoryConversion, @@ -18,6 +22,7 @@ from sampletones_shared.exceptions import OperationCanceled from sampletones_shared.utils.progress import silent_reporter from tests.integration.assets.reconstruction import ( + THREE_STEM_CHANNELS, build_mini_library, three_stem_config, three_stem_reconstruction_config, @@ -36,7 +41,7 @@ class TestGroupConversionEndToEnd: def test_three_stems_convert_into_one_reconstruction_file(self, tmp_path: Path) -> None: config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=build_mini_library(config)) sources = write_three_stem_recordings(config, tmp_path) jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config) @@ -52,9 +57,10 @@ def test_three_stems_convert_into_one_reconstruction_file(self, tmp_path: Path) def test_one_source_converts_the_classic_way(self, tmp_path: Path) -> None: config = _writing_to(Config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) written = reconstruct_job((reconstructor, jobs[0], silent_reporter)) @@ -70,11 +76,11 @@ class TestDirectoryConversionEndToEnd: def test_each_recording_is_written_on_its_own(self, tmp_path: Path) -> None: config = _writing_to(Config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) recordings = tmp_path / "recordings" recordings.mkdir() sources = write_three_stem_recordings(config, recordings) - stems = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1) + stems = StemsConfig.single_entry([ChannelName.PULSE1], bending_channels([ChannelName.PULSE1]), channel_cap=1) jobs = DirectoryConversion(directory=recordings, stems=stems).jobs(config) @@ -96,7 +102,7 @@ class TestAJobReportsItselfAsItRuns: def test_the_run_passes_through_its_stages_in_order(self, tmp_path: Path) -> None: config = _writing_to(three_stem_reconstruction_config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=build_mini_library(config)) sources = write_three_stem_recordings(config, tmp_path) jobs = GroupConversion(sources=sources, stems=three_stem_config()).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() @@ -107,9 +113,10 @@ def test_the_run_passes_through_its_stages_in_order(self, tmp_path: Path) -> Non def test_the_reading_climbs_from_nothing_to_the_whole_run(self, tmp_path: Path) -> None: config = _writing_to(Config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() @@ -122,9 +129,10 @@ def test_the_reading_climbs_from_nothing_to_the_whole_run(self, tmp_path: Path) def test_the_matching_stage_counts_the_frames_the_recording_holds(self, tmp_path: Path) -> None: config = _writing_to(Config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() @@ -135,9 +143,10 @@ def test_the_matching_stage_counts_the_frames_the_recording_holds(self, tmp_path def test_a_withdrawn_job_unwinds_and_writes_nothing(self, tmp_path: Path) -> None: config = _writing_to(Config(), tmp_path / "out") - reconstructor = Reconstructor(config, library=build_mini_library(config)) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter(withdraw_at=FIRST_REPORT) diff --git a/tests/integration/reconstruction/test_conversion_progress.py b/tests/integration/reconstruction/test_conversion_progress.py index a48df5471..ee58841ce 100644 --- a/tests/integration/reconstruction/test_conversion_progress.py +++ b/tests/integration/reconstruction/test_conversion_progress.py @@ -7,6 +7,10 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + bending_channels, +) from sampletones_core.reconstructions.converter import GroupConversion, ReconstructionConverter from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.stage import ReconstructionStage @@ -40,7 +44,8 @@ def conversion_run(tmp_path: Path) -> Iterator[Tuple[ReconstructionConverter, Pr config = _config(tmp_path) source = write_silent_recording(tmp_path / "kick.wav") release_path = tmp_path / RELEASE_NAME - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) plan = GroupConversion(sources=(source,), stems=stems) recorder = ProgressRecorder() diff --git a/tests/integration/reconstruction/test_decoding.py b/tests/integration/reconstruction/test_decoding.py index 62a772a1c..cda43bd2a 100644 --- a/tests/integration/reconstruction/test_decoding.py +++ b/tests/integration/reconstruction/test_decoding.py @@ -6,7 +6,11 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.configs.generation import GenerationConfig -from sampletones_core.constants.enums import ChannelName, SelectorName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + SelectorName, +) from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction, Reconstructor from tests.integration.assets.reconstruction import build_mini_library @@ -33,7 +37,7 @@ def _flickering_path(tmp_path: Path, config: Config) -> Path: def _reconstruct(selector_name: SelectorName, audio_path: Path) -> Reconstruction: config = Config(generation=GenerationConfig(decoder={"selector": selector_name})) - reconstruction = Reconstructor(config, library=build_mini_library(config))(audio_path) + reconstruction = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=build_mini_library(config))(audio_path) assert reconstruction is not None return reconstruction diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py index 22c1fb87b..bccaeab6c 100644 --- a/tests/integration/reconstruction/test_pitch_refinement.py +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -7,8 +7,12 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.configs.generation import GenerationConfig, RefinementConfig -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.configs.generation import GenerationConfig +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, +) from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.fft import Window from sampletones_core.fft.features import get_feature_extractor @@ -22,6 +26,7 @@ ) from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstructor.refinement import refiner +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig PITCH: Final[int] = 60 NEIGHBORHOOD: Final[range] = range(PITCH - 2, PITCH + 3) @@ -33,10 +38,12 @@ NOISE_SEED: Final[int] = 11 EDGE_FRAMES: Final[int] = 1 PULSE_ONLY: Final[List[ChannelName]] = [ChannelName.PULSE1] +BENDING: Final[List[ChannelName]] = [ChannelName.PULSE1] +UNBENT: Final[List[ChannelName]] = [] -def _config(*, refinement: RefinementConfig) -> Config: - return Config(generation=GenerationConfig(channels=PULSE_ONLY, refinement=refinement)) +def _config() -> Config: + return Config() def _library(config: Config) -> InstructionLibrary: @@ -87,8 +94,13 @@ def _noise_path(path: Path, config: Config) -> Path: return path -def _reconstruct(config: Config, audio_path: Path) -> Reconstruction: - reconstruction = Reconstructor(config, library=_library(config))(audio_path) +def _reconstruct(config: Config, audio_path: Path, bends: List[ChannelName]) -> Reconstruction: + """The conversion of one recording by a stem carrying the channels ``bends`` names.""" + reconstructor = Reconstructor(config, frozenset(PULSE_ONLY), library=_library(config)) + reconstruction = reconstructor.reconstruct( + [audio_path], + StemsConfig.single_entry(PULSE_ONLY, bends), + ) assert reconstruction is not None return reconstruction @@ -110,13 +122,8 @@ def _cents_off(config: Config, instruction: PulseInstruction) -> float: @pytest.fixture(scope="module") -def refining() -> Config: - return _config(refinement=RefinementConfig(enabled=True)) - - -@pytest.fixture(scope="module") -def plain() -> Config: - return _config(refinement=RefinementConfig(enabled=False)) +def config() -> Config: + return _config() class TestAConversionLandsOnTheNoteTheSourceSounds: @@ -126,40 +133,40 @@ class TestAConversionLandsOnTheNoteTheSourceSounds: hardware's divider grid is finer than that, and the refinement is what spends the difference. """ - def test_a_detuned_tone_comes_back_bent_towards_its_own_pitch( + def test_a_detuned_tone_comes_back_bent_toward_its_own_pitch( self, - refining: Config, + config: Config, tmp_path: Path, ) -> None: - reconstruction = _reconstruct(refining, _square_path(tmp_path / "sharp.wav", refining, DETUNE_CENTS)) + reconstruction = _reconstruct(config, _square_path(tmp_path / "sharp.wav", config, DETUNE_CENTS), BENDING) sounding = _sounding(reconstruction) assert sounding - measured = float(np.median([_cents_off(refining, frame) for frame in sounding])) + measured = float(np.median([_cents_off(config, frame) for frame in sounding])) assert abs(measured - DETUNE_CENTS) < CENTS_TOLERANCE def test_a_tone_already_on_the_grid_is_left_where_it_is( self, - refining: Config, + config: Config, tmp_path: Path, ) -> None: - reconstruction = _reconstruct(refining, _square_path(tmp_path / "flat.wav", refining, 0.0)) + reconstruction = _reconstruct(config, _square_path(tmp_path / "flat.wav", config, 0.0), BENDING) sounding = _sounding(reconstruction) assert sounding - measured = float(np.median([_cents_off(refining, frame) for frame in sounding])) + measured = float(np.median([_cents_off(config, frame) for frame in sounding])) assert abs(measured) < CENTS_TOLERANCE - def test_the_bend_holds_rather_than_wandering(self, refining: Config, tmp_path: Path) -> None: + def test_the_bend_holds_rather_than_wandering(self, config: Config, tmp_path: Path) -> None: """A steady tone is one tuning, so the stream settles on one bend and keeps it. The opening and closing frames are read across the edge of the recording, where the constant-Q window reaches past what was recorded, so the interior is what a held tuning shows in. """ - reconstruction = _reconstruct(refining, _square_path(tmp_path / "steady.wav", refining, DETUNE_CENTS)) + reconstruction = _reconstruct(config, _square_path(tmp_path / "steady.wav", config, DETUNE_CENTS), BENDING) bends = [frame.timer_offset for frame in _sounding(reconstruction)] interior = bends[EDGE_FRAMES:-EDGE_FRAMES] @@ -169,22 +176,22 @@ def test_the_bend_holds_rather_than_wandering(self, refining: Config, tmp_path: class TestWhatTheRefinementLeavesAlone: - def test_a_conversion_with_the_refinement_off_bends_nothing( + def test_a_stem_carrying_no_channel_bends_nothing( self, - plain: Config, + config: Config, tmp_path: Path, ) -> None: - reconstruction = _reconstruct(plain, _square_path(tmp_path / "unrefined.wav", plain, DETUNE_CENTS)) + reconstruction = _reconstruct(config, _square_path(tmp_path / "unrefined.wav", config, DETUNE_CENTS), UNBENT) assert all(not frame.bent for frame in _sounding(reconstruction)) - def test_a_conversion_with_the_refinement_off_records_the_bend_as_the_channels( + def test_a_stem_carrying_no_channel_records_the_bend_as_the_channels( self, - plain: Config, + config: Config, tmp_path: Path, ) -> None: """Nothing bent means nothing chosen, so both dimensions stay the channel's own.""" - reconstruction = _reconstruct(plain, _square_path(tmp_path / "held.wav", plain, DETUNE_CENTS)) + reconstruction = _reconstruct(config, _square_path(tmp_path / "held.wav", config, DETUNE_CENTS), UNBENT) held = reconstruction.held_features[ChannelName.PULSE1] assert FeatureKey.PITCH in held @@ -192,11 +199,11 @@ def test_a_conversion_with_the_refinement_off_records_the_bend_as_the_channels( def test_a_source_with_no_pitch_to_read_is_left_unbent( self, - refining: Config, + config: Config, tmp_path: Path, ) -> None: """Noise states no fundamental, so no frame of it earns a bend.""" - reconstruction = _reconstruct(refining, _noise_path(tmp_path / "noise.wav", refining)) + reconstruction = _reconstruct(config, _noise_path(tmp_path / "noise.wav", config), BENDING) assert all(not frame.bent for frame in _sounding(reconstruction)) @@ -225,38 +232,38 @@ def counting(recording: np.ndarray, sample_rate: int, hop_length: int) -> Instan def test_a_refined_conversion_reads_each_recording_once( self, - refining: Config, + config: Config, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: readings = self._counted(monkeypatch) - _reconstruct(refining, _square_path(tmp_path / "read.wav", refining, DETUNE_CENTS)) + _reconstruct(config, _square_path(tmp_path / "read.wav", config, DETUNE_CENTS), BENDING) assert len(readings) == 1 def test_a_longer_source_is_read_no_more_often( self, - refining: Config, + config: Config, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: """A reading per stem is what keeps the cost off the frame count and off the catalog.""" readings = self._counted(monkeypatch) - _reconstruct(refining, _square_path(tmp_path / "short.wav", refining, DETUNE_CENTS)) + _reconstruct(config, _square_path(tmp_path / "short.wav", config, DETUNE_CENTS), BENDING) short = len(readings) readings.clear() - _reconstruct(refining, _square_path(tmp_path / "long.wav", refining, DETUNE_CENTS, seconds=SECONDS * 3)) + _reconstruct(config, _square_path(tmp_path / "long.wav", config, DETUNE_CENTS, seconds=SECONDS * 3), BENDING) assert len(readings) == short - def test_a_conversion_with_the_refinement_off_reads_nothing( + def test_a_stem_carrying_no_channel_reads_nothing( self, - plain: Config, + config: Config, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: readings = self._counted(monkeypatch) - _reconstruct(plain, _square_path(tmp_path / "unread.wav", plain, DETUNE_CENTS)) + _reconstruct(config, _square_path(tmp_path / "unread.wav", config, DETUNE_CENTS), UNBENT) assert not readings diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 89e5fc1a5..ef8df05ba 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -8,18 +8,25 @@ from sampletones_core.audio import mix, write_wave from sampletones_core.configs import Config from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP, RESTING_STEM_ID -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + HierarchyMode, + bending_channels, +) from sampletones_core.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.reconstruction.stems.removal import without_stem from sampletones_core.reconstructions.reconstruction.stems.selection import StemSelection from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from tests.integration.assets.reconstruction import ( STEM_A_ID, STEM_B_ID, STEM_C_ID, STEM_RECORDING_DURATION_SECONDS, + THREE_STEM_CHANNELS, build_mini_library, three_stem_config, three_stem_reconstruction_config, @@ -34,6 +41,12 @@ _DISJOINT_AMPLITUDE: Final[float] = 0.5 +def _classic_stems(config: Config, *, channel_cap: int) -> StemsConfig: + """One stem over every configured channel, carrying each of them that reads a bend.""" + channels = list(DEFAULT_CHANNELS) + return StemsConfig.single_entry(channels, bending_channels(channels), channel_cap=channel_cap) + + def _frame_count(config: Config, duration_seconds: float) -> int: return int(config.library.sample_rate * duration_seconds) // config.library.frame_length @@ -41,8 +54,12 @@ def _frame_count(config: Config, duration_seconds: float) -> int: def _stems_config() -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.NOISE]), + StemEntry( + id=0, settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])) + ), + StemEntry( + id=1, settings=StemSettings(channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])) + ), ], hierarchy=StemsHierarchy( levels=[[0], [1]], @@ -56,7 +73,7 @@ class TestReconstructStems: def test_assigns_disjoint_stems_to_their_channels(self, tmp_path: Path) -> None: config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) sample_rate = config.library.sample_rate count = int(sample_rate * _DURATION_SECONDS) @@ -99,7 +116,7 @@ def test_assigns_disjoint_stems_to_their_channels(self, tmp_path: Path) -> None: def test_requires_one_path_per_entry(self, tmp_path: Path) -> None: config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) with pytest.raises(ValueError, match="stem paths"): reconstructor.reconstruct( @@ -115,7 +132,7 @@ class TestThreeStemHierarchy: def test_builds_a_reconstruction_over_the_three_stems(self, tmp_path: Path) -> None: config = three_stem_reconstruction_config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) @@ -149,7 +166,7 @@ def test_every_channel_in_play_carries_one_entry_per_frame(self, tmp_path: Path) """ config = three_stem_reconstruction_config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) @@ -173,7 +190,7 @@ def test_every_channel_in_play_carries_one_entry_per_frame(self, tmp_path: Path) def test_round_trips_through_the_file(self, tmp_path: Path) -> None: config = three_stem_reconstruction_config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) reconstruction = reconstructor.reconstruct(list(paths), stems_config) @@ -198,7 +215,7 @@ def test_selection_filters_the_waveform_and_partials(self, tmp_path: Path) -> No """ config = three_stem_reconstruction_config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) stems_config = three_stem_config() paths = write_three_stem_recordings(config, tmp_path) reconstruction = reconstructor.reconstruct(list(paths), stems_config) @@ -282,7 +299,7 @@ class TestRemovingAStem: def _three_stems(self, tmp_path: Path) -> Tuple[Reconstruction, Tuple[Path, Path, Path], Config]: config = three_stem_reconstruction_config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(THREE_STEM_CHANNELS), library=library) paths = write_three_stem_recordings(config, tmp_path) reconstruction = reconstructor.reconstruct(list(paths), three_stem_config()) assert reconstruction is not None @@ -379,7 +396,7 @@ def test_mixes_the_recorded_stems_at_the_balance_they_were_captured_in(self, tmp """ config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) sample_rate = config.library.sample_rate count = int(sample_rate * _DURATION_SECONDS) @@ -432,7 +449,7 @@ def _tone_path(self, tmp_path: Path, config: Config) -> Path: def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tmp_path: Path) -> None: config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) tone_path = self._tone_path(tmp_path, config) reconstruction = reconstructor(tone_path) @@ -441,7 +458,7 @@ def test_classic_conversion_records_one_stem_over_every_enabled_channel(self, tm assert reconstruction.audio_filepath == (tone_path,) stems_data = reconstruction.stems_data assert stems_data.config.entries[0].id == 0 - assert stems_data.config.entries[0].channels == list(config.generation.channels) + assert stems_data.config.entries[0].settings.channels == list(DEFAULT_CHANNELS) assert stems_data.config.channel_cap == DEFAULT_STEMS_CHANNEL_CAP for channel, stem_ids in stems_data.assignments_by_channel.items(): assert set(stem_ids) <= {0} @@ -455,7 +472,7 @@ def test_a_silent_stretch_rests_and_states_silence(self, tmp_path: Path) -> None """ config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) sample_rate = config.library.sample_rate frame_length = config.library.frame_length @@ -485,12 +502,12 @@ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> """One channel sounds per frame while the others rest, each keeping its place in the frame.""" config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) tone_path = self._tone_path(tmp_path, config) reconstruction = reconstructor.reconstruct( [tone_path], - StemsConfig.single_entry(list(config.generation.channels), channel_cap=1), + _classic_stems(config, channel_cap=1), ) assert reconstruction is not None @@ -539,7 +556,12 @@ def _recordings(self, config: Config, tmp_path: Path) -> Tuple[Tuple[Path, ...], def _stems_config(self, channels: Sequence[ChannelName]) -> StemsConfig: """Every stem may take every channel, each on a level of its own.""" return StemsConfig( - entries=[StemEntry(id=index, channels=list(channels)) for index in range(len(_DISJOINT_TONES))], + entries=[ + StemEntry( + id=index, settings=StemSettings(channels=list(channels), bends=bending_channels(list(channels))) + ) + for index in range(len(_DISJOINT_TONES)) + ], hierarchy=StemsHierarchy( levels=[[index] for index in range(len(_DISJOINT_TONES))], mode=HierarchyMode.ROUND_ROBIN, @@ -550,10 +572,10 @@ def _stems_config(self, channels: Sequence[ChannelName]) -> StemsConfig: def _reconstruct(self, tmp_path: Path) -> Tuple[Reconstruction, List[range], Config]: config = Config() library = build_mini_library(config) - reconstructor = Reconstructor(config, library=library) + reconstructor = Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=library) paths, spans = self._recordings(config, tmp_path) - reconstruction = reconstructor.reconstruct(list(paths), self._stems_config(config.generation.channels)) + reconstruction = reconstructor.reconstruct(list(paths), self._stems_config(DEFAULT_CHANNELS)) assert reconstruction is not None return reconstruction, spans, config diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index ec6ee4b19..eab87d093 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -6,7 +6,10 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_application.logic.reconstruction.feature import FeatureData from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) from sampletones_core.constants.general import MIN_PITCH from sampletones_core.exporters import Features, PulseExporter from sampletones_core.instructions import PulseInstruction @@ -52,7 +55,7 @@ def minimal_reconstruction(default_config, pulse_instructions) -> Reconstruction coefficient=1.0, audio_filepath=(Path("/dev/null"),), stems_data=single_entry_stems_data( - list(default_config.generation.channels), + list(DEFAULT_CHANNELS), {ChannelName.PULSE1: pulse_instructions}, ), ) diff --git a/tests/integration/sampletones_application/services/test_conversion.py b/tests/integration/sampletones_application/services/test_conversion.py index fb03df1b6..007993364 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -4,13 +4,18 @@ from sampletones_application.services.conversion.service import ConversionService from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + bending_channels, +) from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion from sampletones_core.reconstructions.converter.plan.protocol import ConversionPlan from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig def _stems(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + return StemsConfig.single_entry(channels, bending_channels(channels)) class TestConversionServiceArgumentRouting: diff --git a/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py index 4fae8b94b..a79892ae1 100644 --- a/tests/integration/sampletones_core/parallelization/test_progress_channel.py +++ b/tests/integration/sampletones_core/parallelization/test_progress_channel.py @@ -37,7 +37,7 @@ def counting_run( release_path: Path, workers: int, ) -> Iterator[Tuple[CountingProcessor, ProgressRecorder]]: - """Starts a counting run, hands the test its recorder, and reaps the pool afterwards. + """Starts a counting run, hands the test its recorder, and reaps the pool afterward. The release file is written on the way out whatever the test did, so a run whose assertion failed before releasing its tasks still ends rather than holding a worker at its halfway mark. @@ -125,7 +125,7 @@ class TestAWithdrawalReachesTheTasks: The tasks are held at their halfway mark, so the withdrawal below is delivered to workers that are provably still running: what ends the run is the answer the reporter gave them, and the - pool being torn down afterwards is the backstop rather than the mechanism. + pool being torn down afterward is the backstop rather than the mechanism. """ def test_a_withdrawn_run_ends_canceled(self, release_path: Path) -> None: @@ -136,5 +136,5 @@ def test_a_withdrawn_run_ends_canceled(self, release_path: Path) -> None: release_path.touch() processor.wait(POOL_TIMEOUT) - assert recorder.last_of(TaskStatus.CANCELLING) is not None + assert recorder.last_of(TaskStatus.CANCELING) is not None assert not processor.is_running() diff --git a/tests/suite/conversion.py b/tests/suite/conversion.py index 0c29d667e..1be862c48 100644 --- a/tests/suite/conversion.py +++ b/tests/suite/conversion.py @@ -1,7 +1,8 @@ from pathlib import Path -from typing import Final, FrozenSet, Sequence +from typing import AbstractSet, Final, FrozenSet, Sequence from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.progress import ( STAGE_BEGUN, WHOLE_STAGE, @@ -34,8 +35,16 @@ class FakeReconstructor: reconstruction under way is made while that reconstruction is provably under way. """ - def __init__(self, config: Config, release_path: Path, *, frames: int = FAKE_FRAMES) -> None: + def __init__( + self, + config: Config, + channels: AbstractSet[ChannelName], + release_path: Path, + *, + frames: int = FAKE_FRAMES, + ) -> None: self.config = config + self.channels = channels self.release_path = release_path self.frames = frames diff --git a/tests/suite/frames.py b/tests/suite/frames.py new file mode 100644 index 000000000..2000545d2 --- /dev/null +++ b/tests/suite/frames.py @@ -0,0 +1,41 @@ +from typing import Final, List, Tuple + +from sampletones_shared.types.callback import VoidCallback + +ONE_FRAME: Final[int] = 1 + + +class Frames: + """The frames a widget defers work to, carried out when a case says one was rendered. + + DearPyGui runs a frame callback from the render loop, which a suite never starts, so work a + widget holds back until its rows are placed waits here. Holding it rather than running it as + it arrives is what lets a case say how many frames passed: a region asking for another pass + gets one per rendered frame, the way it would on screen, and a case reads how much work still + stands waiting. + """ + + def __init__(self) -> None: + self._held: List[Tuple[int, VoidCallback]] = [] + + def hold(self, callback: VoidCallback, frame_count: int = ONE_FRAME) -> None: + """Take work a widget hands over, standing in for ``FrameCallbackManager``. + + ``frame_count`` is how many frames the work waits through, which is what the manager + counts from the frame the widget handed it over on. + """ + self._held.append((max(ONE_FRAME, frame_count), callback)) + + @property + def pending(self) -> int: + """How many pieces of work stand waiting on a frame.""" + return len(self._held) + + def render(self, frames: int = ONE_FRAME) -> None: + """Carry out the work each of this many frames would, in the order it was handed over.""" + for _ in range(frames): + counted = [(waiting - 1, callback) for waiting, callback in self._held] + due = [callback for waiting, callback in counted if not waiting] + self._held = [(waiting, callback) for waiting, callback in counted if waiting] + for callback in due: + callback() diff --git a/tests/suite/gestures.py b/tests/suite/gestures.py new file mode 100644 index 000000000..4f17b0e27 --- /dev/null +++ b/tests/suite/gestures.py @@ -0,0 +1,30 @@ +from typing import Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import SUF_TEXT +from sampletones_application.ui.elements.stems.tags import StemsTags + +CLICKED: Final[str] = "mvAppItemType::mvClickedHandler" +DOUBLE_CLICKED: Final[str] = "mvAppItemType::mvDoubleClickedHandler" +HOVERED: Final[str] = "mvAppItemType::mvHoverHandler" + + +def handler_of(registry: str, kind: str) -> int: + """The handler answering one kind of gesture in a registry, found by what it is. + + A registry holds its handlers in the order they were added, so reading one by its position + names a different gesture as soon as another is registered beside it. + """ + for handler in dpg.get_item_children(registry, 1): + if dpg.get_item_info(handler)["type"] == kind: + return int(handler) + + raise AssertionError(f"{registry} registers no {kind}") + + +def click_row_name(tags: StemsTags, key: str, *, kind: str, button: int) -> None: + """Land a mouse gesture on one row's name the way DearPyGui reports one.""" + name_tag = tags.row(key, SUF_TEXT) + callback = dpg.get_item_callback(handler_of(tags.handlers(SUF_TEXT), kind)) + callback(name_tag, (button, dpg.get_alias_id(name_tag))) diff --git a/tests/suite/performance.py b/tests/suite/performance.py index 78bf607cb..e89e2a7a0 100644 --- a/tests/suite/performance.py +++ b/tests/suite/performance.py @@ -4,7 +4,11 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, +) from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -38,7 +42,7 @@ def _reconstruction( coefficient=1.0, audio_filepath=(Path("/dev/null"),), stems_data=single_entry_stems_data( - list(Config().generation.channels), + list(DEFAULT_CHANNELS), channel_instructions, ), ) diff --git a/tests/suite/player.py b/tests/suite/player.py index b0fb0df5a..94947c7fe 100644 --- a/tests/suite/player.py +++ b/tests/suite/player.py @@ -5,7 +5,10 @@ import numpy as np from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) from sampletones_core.constants.general import DUTY_CYCLES from sampletones_core.exporters import Features from sampletones_core.exports.request import InstrumentExport, SampleExport @@ -243,7 +246,7 @@ def player_reconstruction( config=config, coefficient=1.0, audio_filepath=(Path(os.devnull),), - stems_data=single_entry_stems_data(list(config.generation.channels), instructions), + stems_data=single_entry_stems_data(list(DEFAULT_CHANNELS), instructions), ) diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 14c36bc4a..48e5a5d1a 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -18,7 +18,10 @@ 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 ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) from sampletones_core.instructions import ( InstructionUnion, NoiseInstruction, @@ -71,7 +74,7 @@ def sample_reconstruction(channels: Sequence[ChannelName]) -> Reconstruction: config=config, coefficient=1.0, audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data(list(config.generation.channels), instructions), + stems_data=single_entry_stems_data(list(DEFAULT_CHANNELS), instructions), ) @@ -94,7 +97,7 @@ def render_slots( ) -> 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 + A frame renders the same either way, so this is the reading that shows a write materializing a pattern the channel had not held before. """ frame = controller.project.song.order[frame_index] @@ -308,7 +311,7 @@ def _fill_cell( ) -> 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 + A cell is written whole where it carries anything, so the row it lands on materializes exactly once however many of its subcolumns hold a value. """ note = parse_note(tokens[0], voice_ids) diff --git a/tests/suite/shortcuts.py b/tests/suite/shortcuts.py index c7cf794cd..b63afe3ad 100644 --- a/tests/suite/shortcuts.py +++ b/tests/suite/shortcuts.py @@ -2,6 +2,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_application.utils.gui.shortcuts.scheme import ShortcutScheme from sampletones_application.utils.gui.shortcuts.source import ShortcutSource @@ -24,3 +25,16 @@ def shipped_source() -> ShortcutSource: changes what a press means shows up as a failure here. """ return ShortcutSource(shipped_scheme()) + + +def rebound_source(shortcut_id: ShortcutId, combination: str) -> ShortcutSource: + """A source over the shipped scheme with one action answering ``combination`` instead. + + A case reading the shipped keys on both sides passes whatever the production side does, so + what tells a combination read from the scheme apart from one written into the code is a + scheme that gives the action different keys. + """ + scheme = shipped_scheme() + rebound = dict(scheme.bindings) + rebound[shortcut_id] = rebound[shortcut_id].rebound(combination) + return ShortcutSource(ShortcutScheme(name=scheme.name, bindings=rebound)) diff --git a/tests/suite/stems.py b/tests/suite/stems.py index 3768392e2..3dd8ba7dc 100644 --- a/tests/suite/stems.py +++ b/tests/suite/stems.py @@ -1,7 +1,7 @@ from typing import List, Mapping, Sequence from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, bending_channels from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData @@ -11,7 +11,7 @@ def single_entry_stems_data( channels: List[ChannelName], instructions: Mapping[ChannelName, Sequence[InstructionUnion]], ) -> StemsData: - """The single-entry record for ``channels``, stem 0 owning each frame that plays.""" + """The single-entry record for ``channels``, stem 0 owning each frame that plays and bending them.""" assignments = [ ChannelAssignment(channel_name=channel_name, stem_ids=[0] * len(stream)) for channel_name, stream in instructions.items() @@ -19,5 +19,6 @@ def single_entry_stems_data( ] return StemsData.single_entry( channels, + bending_channels(channels), assignments, ) diff --git a/tests/suite/timing.py b/tests/suite/timing.py new file mode 100644 index 000000000..e7854ea88 --- /dev/null +++ b/tests/suite/timing.py @@ -0,0 +1,24 @@ +from time import process_time +from timeit import Timer +from typing import Callable, Final, List + +REPEATS: Final[int] = 3 + + +def seconds(work: Callable[[], object]) -> float: + """What one run of the work costs, taken as the best of several batches. + + A process's own time is counted in steps: Linux counts it in nanoseconds, Windows in about a + sixtieth of a second, so a run of a few milliseconds reads there as either nothing at all or + as a whole step. ``Timer.autorange`` grows a batch until it stands well clear of one step, and + what comes back is the batch divided by the runs in it — a reading the coarsest clock can see. + + The collector is held off for each batch, since it runs on how much is live rather than on + what the work does: a batch building ten times the objects meets it more often and reads as + more than ten times the cost. What is left is how the work itself follows its input. + """ + timer = Timer(work, timer=process_time) + runs, reached = timer.autorange() + readings: List[float] = [reached / runs] + readings.extend(reading / runs for reading in timer.repeat(REPEATS - 1, runs)) + return min(readings) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 92d020a78..940e65c2b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -1,18 +1,22 @@ from pathlib import Path -from typing import Final +from typing import Final, Tuple from unittest.mock import MagicMock import pytest from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.coordinators.tabs.main import MainTabCoordinator -from sampletones_application.logic.main.converter import ConversionSuccess +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.logic.main.sources.scan import FolderScan from sampletones_application.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, TAG_MAIN_CONVERTER_DIALOG_LOAD, TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET, TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, ) +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from tests.suite.language import FakeLanguageManager CONVERTER_RUNNING_MESSAGE_KEY: Final[str] = "main.explorer.message.converter_running_msg" @@ -23,19 +27,36 @@ CLOSE_BUTTON_KEY: Final[str] = "main.converter.label.close_button" STOP_BUTTON_KEY: Final[str] = "main.converter.label.stop_button" CONTINUE_BUTTON_KEY: Final[str] = "main.converter.label.continue_button" +NOTHING_BELOW_KEY: Final[str] = "main.converter.message.scan_nothing_below" + + +def _hooks(*, operation_active: bool) -> MainTabHooks: + """The tab's outward collaborator, every hook of which a test can read what reached it.""" + return MainTabHooks( + is_operation_active=lambda: operation_active, + on_busy_state_changed=MagicMock(), + on_reconstruct_file=MagicMock(), + on_reconstruct_directory=MagicMock(), + on_load_reconstruction=MagicMock(), + on_load_library=MagicMock(), + on_load_file=MagicMock(), + on_load_directory=MagicMock(), + on_canceled=MagicMock(), + on_refresh_trees=MagicMock(), + on_generate_library=MagicMock(), + ) def _coordinator(*, operation_active: bool) -> MainTabCoordinator: """A coordinator with only the state the reconstruct guards touch, bypassing the heavy constructor.""" coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._is_operation_active = lambda: operation_active + coordinator._hooks = _hooks(operation_active=operation_active) coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() - coordinator._on_reconstruct_file = MagicMock() - coordinator._on_reconstruct_directory = MagicMock() coordinator._converter_logic = MagicMock() - coordinator._converter_logic.stems_mode = False + coordinator._converter_logic.mixes = False + coordinator._converter_logic.gathered_paths = () return coordinator @@ -72,7 +93,7 @@ def test_file_request_declines_while_an_operation_is_active(self) -> None: coordinator._request_reconstruct_file(Path("/audio/sample.wav")) - coordinator._on_reconstruct_file.assert_not_called() + coordinator._hooks.on_reconstruct_file.assert_not_called() coordinator._dialogs.show_info.assert_called_once() def test_file_request_delegates_when_idle(self) -> None: @@ -81,7 +102,7 @@ def test_file_request_delegates_when_idle(self) -> None: coordinator._request_reconstruct_file(filepath) - coordinator._on_reconstruct_file.assert_called_once_with(filepath) + coordinator._hooks.on_reconstruct_file.assert_called_once_with(filepath) coordinator._dialogs.show_info.assert_not_called() def test_directory_request_declines_while_an_operation_is_active(self) -> None: @@ -89,7 +110,7 @@ def test_directory_request_declines_while_an_operation_is_active(self) -> None: coordinator._request_reconstruct_directory(Path("/audio")) - coordinator._on_reconstruct_directory.assert_not_called() + coordinator._hooks.on_reconstruct_directory.assert_not_called() coordinator._dialogs.show_info.assert_called_once() def test_directory_request_delegates_when_idle(self) -> None: @@ -98,14 +119,14 @@ def test_directory_request_delegates_when_idle(self) -> None: coordinator._request_reconstruct_directory(directory) - coordinator._on_reconstruct_directory.assert_called_once_with(directory) + coordinator._hooks.on_reconstruct_directory.assert_called_once_with(directory) coordinator._dialogs.show_info.assert_not_called() def _success_coordinator() -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._dialogs = MagicMock() - coordinator._on_refresh_trees = MagicMock() + coordinator._hooks = _hooks(operation_active=False) coordinator._converter_logic = MagicMock() coordinator._language_manager = FakeLanguageManager() return coordinator @@ -121,7 +142,7 @@ def test_file_success_refreshes_and_offers_to_load(self) -> None: coordinator._on_conversion_success(ConversionSuccess(written=(output_path,))) - coordinator._on_refresh_trees.assert_called_once_with() + coordinator._hooks.on_refresh_trees.assert_called_once_with() coordinator._dialogs.show_confirmation.assert_called_once() args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_MAIN_CONVERTER_DIALOG_LOAD @@ -144,7 +165,7 @@ def test_a_batch_offers_to_open_the_folder(self) -> None: class TestCancelConfirmation: - """Cancelling is destructive, so the panel's cancel intent asks for confirmation before the + """Canceling is destructive, so the panel's cancel intent asks for confirmation before the conversion is actually stopped.""" def test_cancel_request_confirms_before_stopping(self) -> None: @@ -172,130 +193,129 @@ def test_cancel_request_confirms_before_stopping(self) -> None: def _stems_coordinator( *, operation_active: bool = False, - stems_mode: bool = True, - source_count: int = 0, + mixes: bool = True, + gathered: Tuple[Path, ...] = (), + folder_rows: Tuple[MagicMock, ...] = (), + gathered_rows: Tuple[MagicMock, ...] = (), room: int = MAX_STEM_SOURCES, + ceiling: int = MAX_STEM_SOURCES, ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) - coordinator._is_operation_active = lambda: operation_active + coordinator._hooks = _hooks(operation_active=operation_active) coordinator._notify_converter_running = lambda: operation_active coordinator._dialogs = MagicMock() coordinator._language_manager = FakeLanguageManager() coordinator._converter_logic = MagicMock() - coordinator._converter_logic.stems_mode = stems_mode - coordinator._converter_logic.source_count = source_count + coordinator._converter_logic.mixes = mixes + coordinator._converter_logic.gathered_paths = gathered + coordinator._converter_logic.source_count = len(gathered) coordinator._converter_logic.room_for_sources = room + coordinator._converter_logic.mix_ceiling = ceiling + coordinator._converter_logic.list_fits_a_mix = len(gathered) <= ceiling + coordinator._converter_logic.rows_offered.return_value = folder_rows + coordinator._converter_logic.gathered_rows = gathered_rows coordinator._stem_selection_window = MagicMock() + coordinator._scan_window = MagicMock() + coordinator._repaint_priority = 0 + coordinator._folder_scan = FolderScan() + coordinator._folder_scan.on_started = coordinator._on_scan_started + coordinator._folder_scan.on_progress = coordinator._on_scan_progress + coordinator._folder_scan.on_stopped = coordinator._on_scan_stopped return coordinator -class TestStemsModeSwitch: - """Leaving stems mode drops every recording but the first, so a list of several asks first.""" +def _folder_of(tmp_path: Path, count: int) -> Path: + """A folder holding that many recordings, which the reading below it finds.""" + root = tmp_path / "takes" + root.mkdir(exist_ok=True) + for index in range(count): + (root / f"take_{index:02d}.wav").touch() - def test_entering_stems_mode_takes_effect_at_once(self) -> None: - coordinator = _stems_coordinator(stems_mode=False) + return root - coordinator._request_stems_mode(True) - coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) - coordinator._dialogs.show_confirmation.assert_not_called() - - def test_leaving_with_one_recording_takes_effect_at_once(self) -> None: - coordinator = _stems_coordinator(source_count=1) - - coordinator._request_stems_mode(False) - - coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) - coordinator._dialogs.show_confirmation.assert_not_called() +def _add_folder(coordinator: MainTabCoordinator, root: Path) -> None: + """Asks for the folder and waits for the reading, the way a reader does.""" + coordinator._on_directory_add_requested(root) + SingleThreadExecutor.join_all() - def test_leaving_with_several_recordings_asks_first(self) -> None: - coordinator = _stems_coordinator(source_count=3) - coordinator._request_stems_mode(False) +class TestOutputSwitch: + """A mix reaches a fixed number of recordings, so a longer list is put to the reader first.""" - coordinator._converter_logic.set_stems_mode.assert_not_called() - args, kwargs = coordinator._dialogs.show_confirmation.call_args - assert args[1] == DISCARD_STEMS_PROMPT_KEY - assert kwargs["ok_label"] == DISCARD_STEMS_BUTTON_KEY - assert kwargs["cancel_label"] == KEEP_STEMS_BUTTON_KEY + def test_turning_to_a_mix_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(mixes=False) - def test_confirming_the_prompt_leaves_stems_mode(self) -> None: - coordinator = _stems_coordinator(source_count=3) + coordinator._request_output(OutputKind.MIXED) - coordinator._request_stems_mode(False) - args, _ = coordinator._dialogs.show_confirmation.call_args - args[3]() + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.MIXED) + coordinator._dialogs.show_confirmation.assert_not_called() - coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) + def test_turning_away_from_a_mix_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(gathered=tuple(Path(f"/audio/{index}.wav") for index in range(20))) - def test_declining_the_prompt_repaints_the_checkbox(self) -> None: - """The checkbox already moved when it was clicked, so declining restores what stands.""" - coordinator = _stems_coordinator(source_count=3) + coordinator._request_output(OutputKind.PER_RECORDING) - coordinator._request_stems_mode(False) - _, kwargs = coordinator._dialogs.show_confirmation.call_args - kwargs["on_cancel"]() + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.PER_RECORDING) + coordinator._dialogs.show_confirmation.assert_not_called() - coordinator._converter_logic.set_stems_mode.assert_not_called() - coordinator._converter_logic.refresh_view.assert_called_once_with() + def test_a_list_longer_than_a_mix_holds_asks_which_to_mix(self) -> None: + gathered = tuple(Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 2)) + listed = _rows_holding(*[1] * len(gathered)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered, gathered_rows=listed) + coordinator._request_output(OutputKind.MIXED) -class TestDirectoryAdd: - """Ctrl-clicking a folder offers its recordings; a folder that overflows the list asks which.""" + coordinator._converter_logic.set_output.assert_not_called() + rows, room, answer = coordinator._stem_selection_window.open.call_args.args + assert rows == listed + assert room == MAX_STEM_SOURCES + assert answer == coordinator._converter_logic.mix_only - def test_a_folder_that_fits_is_added_whole(self, tmp_path: Path) -> None: - (tmp_path / "a.wav").touch() - (tmp_path / "b.wav").touch() - coordinator = _stems_coordinator(room=MAX_STEM_SOURCES) + def test_a_list_a_mix_holds_takes_effect_at_once(self) -> None: + gathered = tuple(Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered) - coordinator._on_directory_add_requested(tmp_path) + coordinator._request_output(OutputKind.MIXED) - added = coordinator._converter_logic.add_sources.call_args.args[0] - assert {path.name for path in added} == {"a.wav", "b.wav"} + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.MIXED) coordinator._stem_selection_window.open.assert_not_called() - def test_a_folder_that_overflows_raises_the_selection(self, tmp_path: Path) -> None: - for index in range(3): - (tmp_path / f"{index}.wav").touch() - coordinator = _stems_coordinator(room=2) - coordinator._on_directory_add_requested(tmp_path) +class TestDirectoryAdd: + """Ctrl-clicking a folder gathers it, standing for the recordings found below it.""" - coordinator._converter_logic.add_sources.assert_not_called() - candidates, room = coordinator._stem_selection_window.open.call_args.args - assert len(candidates) == 3 - assert room == 2 + def test_a_folder_joins_the_setup(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) - def test_a_folder_holding_no_recordings_is_left_alone(self, tmp_path: Path) -> None: - (tmp_path / "notes.txt").write_text("not audio") - coordinator = _stems_coordinator() + _add_folder(coordinator, root) - coordinator._on_directory_add_requested(tmp_path) + gathered, found = coordinator._converter_logic.gather_folder.call_args.args + assert gathered == root + assert {path.name for path in found} == {"take_00.wav", "take_01.wav"} - coordinator._converter_logic.add_sources.assert_not_called() - coordinator._stem_selection_window.open.assert_not_called() - - def test_a_classic_conversion_starts_gathering(self, tmp_path: Path) -> None: - """The gesture is what starts a stems conversion, so it turns the mode on to answer.""" - (tmp_path / "a.wav").touch() - coordinator = _stems_coordinator(stems_mode=False) + def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(operation_active=True) coordinator._on_directory_add_requested(tmp_path) - coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) - assert coordinator._converter_logic.add_sources.call_args.args[0][0].name == "a.wav" + coordinator._converter_logic.gather_folder.assert_not_called() - def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: - (tmp_path / "a.wav").touch() - coordinator = _stems_coordinator(operation_active=True) + def test_a_folder_holding_no_recordings_says_so(self, tmp_path: Path) -> None: + """The reading is what knows what a folder holds, so the answer arrives when it comes back + and the setup stands as it was.""" + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 0) - coordinator._on_directory_add_requested(tmp_path) + _add_folder(coordinator, root) - coordinator._converter_logic.add_sources.assert_not_called() + coordinator._converter_logic.gather_folder.assert_not_called() + assert coordinator._dialogs.show_info.call_args.args[1] == NOTHING_BELOW_KEY class TestFileAdd: - """A recording added from the browser's menu joins a stems list, opening one where none stands.""" + """A recording added from the browser's menu joins the setup, whichever run it names.""" def test_a_recording_joins_the_list(self, tmp_path: Path) -> None: recording = tmp_path / "bass.wav" @@ -304,23 +324,14 @@ def test_a_recording_joins_the_list(self, tmp_path: Path) -> None: coordinator._on_file_add_requested(recording) - coordinator._converter_logic.add_sources.assert_called_once_with([recording]) - - def test_adding_from_a_classic_conversion_turns_stems_mode_on(self, tmp_path: Path) -> None: - recording = tmp_path / "bass.wav" - recording.touch() - coordinator = _stems_coordinator(stems_mode=False) - - coordinator._on_file_add_requested(recording) - - coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) + coordinator._converter_logic.gather_recordings.assert_called_once_with([recording]) def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: coordinator = _stems_coordinator(operation_active=True) coordinator._on_file_add_requested(tmp_path / "bass.wav") - coordinator._converter_logic.add_sources.assert_not_called() + coordinator._converter_logic.gather_recordings.assert_not_called() class TestModifierAddAvailability: @@ -330,7 +341,7 @@ def test_a_gathered_list_takes_the_click(self) -> None: assert _stems_coordinator()._can_add_stems() is True def test_a_classic_conversion_takes_the_click_and_opens_a_list(self) -> None: - assert _stems_coordinator(stems_mode=False)._can_add_stems() is True + assert _stems_coordinator(mixes=False)._can_add_stems() is True def test_a_busy_application_leaves_the_click_alone(self) -> None: assert _stems_coordinator(operation_active=True)._can_add_stems() is False @@ -338,6 +349,8 @@ def test_a_busy_application_leaves_the_click_alone(self) -> None: OVERWRITE_TARGET_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_target_prompt" OVERWRITE_TARGET_BUTTON_KEY: Final[str] = "main.converter.label.overwrite_target_button" +OVERWRITE_TARGETS_PROMPT_KEY: Final[str] = "main.converter.message.overwrite_targets_prompt" +OVERWRITE_TARGETS_TITLE_KEY: Final[str] = "main.converter.title.overwrite_targets_dialog" class TestOverwritePrompt: @@ -347,7 +360,7 @@ def test_the_prompt_names_the_file_it_would_replace(self, tmp_path: Path) -> Non coordinator = _stems_coordinator() target = tmp_path / "song.stn" - coordinator._confirm_overwriting_target(target) + coordinator._confirm_overwriting_target((target,)) args, kwargs = coordinator._dialogs.show_confirmation.call_args assert args[0] == TAG_MAIN_CONVERTER_DIALOG_OVERWRITE_TARGET @@ -355,10 +368,22 @@ def test_the_prompt_names_the_file_it_would_replace(self, tmp_path: Path) -> Non assert kwargs["ok_label"] == OVERWRITE_TARGET_BUTTON_KEY assert kwargs["path"] == target + def test_several_standing_reconstructions_are_all_put_to_the_reader(self, tmp_path: Path) -> None: + """Confirming writes over every one of them, so the prompt speaks for all of them.""" + coordinator = _stems_coordinator() + targets = tuple(tmp_path / name for name in ("one.stn", "two.stn", "three.stn")) + + coordinator._confirm_overwriting_target(targets) + + args, kwargs = coordinator._dialogs.show_confirmation.call_args + assert args[1] == OVERWRITE_TARGETS_PROMPT_KEY + assert args[2] == OVERWRITE_TARGETS_TITLE_KEY + assert kwargs["path"] is None + def test_confirming_runs_the_conversion_it_asked_about(self, tmp_path: Path) -> None: coordinator = _stems_coordinator() - coordinator._confirm_overwriting_target(tmp_path / "song.stn") + coordinator._confirm_overwriting_target((tmp_path / "song.stn",)) coordinator._dialogs.show_confirmation.call_args.args[3]() coordinator._converter_logic.start_conversion.assert_called_once_with(confirmed=True) @@ -366,52 +391,118 @@ def test_confirming_runs_the_conversion_it_asked_about(self, tmp_path: Path) -> def test_declining_converts_nothing(self, tmp_path: Path) -> None: coordinator = _stems_coordinator() - coordinator._confirm_overwriting_target(tmp_path / "song.stn") + coordinator._confirm_overwriting_target((tmp_path / "song.stn",)) coordinator._converter_logic.start_conversion.assert_not_called() -class TestReconstructLeavesStemsMode: - """A Reconstruct names what a classic conversion converts, so a gathered list is asked about.""" +class TestReconstructReplacesTheSetup: + """A Reconstruct converts what it names alone, so a setup already holding sources is asked about.""" - def _coordinator(self, *, stems_mode: bool) -> MainTabCoordinator: - coordinator = _stems_coordinator(stems_mode=stems_mode) - coordinator._on_reconstruct_file = MagicMock() - coordinator._on_reconstruct_directory = MagicMock() - return coordinator + def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: + return _stems_coordinator(mixes=mixes, gathered=gathered) - def test_a_classic_conversion_reconstructs_straight_away(self, tmp_path: Path) -> None: - coordinator = self._coordinator(stems_mode=False) + def test_an_empty_setup_reconstructs_straight_away(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=False) coordinator._request_reconstruct_file(tmp_path / "a.wav") - coordinator._on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") + coordinator._hooks.on_reconstruct_file.assert_called_once_with(tmp_path / "a.wav") coordinator._dialogs.show_confirmation.assert_not_called() @pytest.mark.parametrize("gesture", ["_request_reconstruct_file", "_request_reconstruct_directory"]) def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str) -> None: - coordinator = self._coordinator(stems_mode=True) + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) getattr(coordinator, gesture)(tmp_path) - coordinator._on_reconstruct_file.assert_not_called() - coordinator._on_reconstruct_directory.assert_not_called() + coordinator._hooks.on_reconstruct_file.assert_not_called() + coordinator._hooks.on_reconstruct_directory.assert_not_called() assert coordinator._dialogs.show_confirmation.call_args.args[1] == DISCARD_STEMS_PROMPT_KEY - def test_confirming_leaves_stems_mode_and_converts(self, tmp_path: Path) -> None: - coordinator = self._coordinator(stems_mode=True) + def test_confirming_converts_what_was_named(self, tmp_path: Path) -> None: + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) coordinator._request_reconstruct_directory(tmp_path) coordinator._dialogs.show_confirmation.call_args.args[3]() - coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) - coordinator._on_reconstruct_directory.assert_called_once_with(tmp_path) + coordinator._hooks.on_reconstruct_directory.assert_called_once_with(tmp_path) def test_declining_converts_nothing(self, tmp_path: Path) -> None: - coordinator = self._coordinator(stems_mode=True) + coordinator = self._coordinator(mixes=True, gathered=(Path("/audio/a.wav"),)) coordinator._request_reconstruct_directory(tmp_path) coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]() - coordinator._converter_logic.set_stems_mode.assert_not_called() - coordinator._on_reconstruct_directory.assert_not_called() + coordinator._converter_logic.set_output.assert_not_called() + coordinator._hooks.on_reconstruct_directory.assert_not_called() + + +def _rows_holding(*counts: int) -> Tuple[MagicMock, ...]: + """Rows standing for that many recordings each, which is what the room is counted against.""" + return tuple(MagicMock(recordings=tuple(MagicMock() for _ in range(count))) for count in counts) + + +class TestGatheringAFolder: + """A mix reaches a fixed number of recordings, so a folder overflowing it asks rather than gathers.""" + + def test_a_run_writing_one_apiece_gathers_whatever_it_holds(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=False, folder_rows=_rows_holding(MAX_STEM_SOURCES + 5)) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES + 5)) + + coordinator._converter_logic.gather_folder.assert_called_once() + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_folder_a_mix_still_holds_is_gathered(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(mixes=True, folder_rows=_rows_holding(MAX_STEM_SOURCES)) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES)) + + coordinator._converter_logic.gather_folder.assert_called_once() + coordinator._stem_selection_window.open.assert_not_called() + + def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> None: + rows = _rows_holding(MAX_STEM_SOURCES, 1) + coordinator = _stems_coordinator(mixes=True, folder_rows=rows) + + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES + 1)) + + coordinator._converter_logic.gather_folder.assert_not_called() + offered, room, answer = coordinator._stem_selection_window.open.call_args.args + assert offered == coordinator._converter_logic.gathered_rows + rows + assert room == MAX_STEM_SOURCES + assert answer == coordinator._converter_logic.mix_only + + def test_a_full_mix_is_offered_beside_what_the_folder_holds(self, tmp_path: Path) -> None: + """A mix with no room left is answerable: letting one go is what makes room for another.""" + rows = _rows_holding(1) + coordinator = _stems_coordinator( + mixes=True, + folder_rows=rows, + gathered_rows=_rows_holding(*[1] * MAX_STEM_SOURCES), + room=0, + ) + + _add_folder(coordinator, _folder_of(tmp_path, 1)) + + offered, room, _answer = coordinator._stem_selection_window.open.call_args.args + assert offered == coordinator._converter_logic.gathered_rows + rows + assert room == MAX_STEM_SOURCES + + def test_the_reading_is_put_on_screen(self, tmp_path: Path) -> None: + """A folder of thousands takes seconds to read, so the reader is shown what they wait for.""" + coordinator = _stems_coordinator(mixes=False, folder_rows=_rows_holding(1)) + root = _folder_of(tmp_path, 1) + + _add_folder(coordinator, root) + + coordinator._scan_window.open.assert_called_once_with(root) + + def test_a_busy_application_leaves_the_folder_alone(self, tmp_path: Path) -> None: + coordinator = _stems_coordinator(operation_active=True, folder_rows=_rows_holding(1)) + + _add_folder(coordinator, _folder_of(tmp_path, 1)) + + coordinator._converter_logic.gather_folder.assert_not_called() + coordinator._stem_selection_window.open.assert_not_called() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 41b9c095e..d0a7c1c1e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -117,7 +117,7 @@ def _reconstructions( def coordinator() -> SequencerReconstructions: """The reconstruction gestures with only the collaborators an import touches. - The tab's full constructor builds the sequencer's GUI subtree (themes, fonts, synthesiser), + The tab's full constructor builds the sequencer's GUI subtree (themes, fonts, synthesizer), which is out of scope here; only the import orchestration is under test. Defaults to an open project with samples and a matching reconstruction frequency (60 Hz); individual tests override. diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index 3776d0c2f..ce968e408 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -351,13 +351,13 @@ def test_confirming_while_the_clock_runs_keeps_the_change_and_stops_the_clock( class TestCancel: - def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: + def test_canceling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: harness.cancel() assert harness.dialogs.confirmations == [] assert not harness.window.visible - def test_cancelling_a_changed_dialog_asks_first(self, harness: Harness) -> None: + def test_canceling_a_changed_dialog_asks_first(self, harness: Harness) -> None: harness.change(harness.settings.with_palette(DARK)) harness.cancel() @@ -504,7 +504,7 @@ def test_keeping_stops_the_clock_and_leaves_the_change_standing(self, harness: H 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: + def test_a_kept_change_is_still_undone_by_canceling(self, harness: Harness) -> None: harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) harness.keep() harness.cancel() diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py index bf69dd41d..c9434fbee 100644 --- a/tests/unit/sampletones_application/coordinators/test_keybindings.py +++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py @@ -416,13 +416,13 @@ def test_a_stored_preference_reopens_on_the_keys_it_stored(self, harness: Harnes class TestCancel: - def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None: + def test_canceling_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: + def test_canceling_an_edited_dialog_asks_first(self, harness: Harness) -> None: harness.select(ABOUT_DIALOG) harness.type_combination(FREE_COMBINATION) harness.cancel() diff --git a/tests/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index 0f456cc32..e71ada124 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -20,7 +20,7 @@ COMPRESSING_LABEL: Final[str] = "Compressing the song" WRITING_LABEL: Final[str] = "Writing the file" SIZE_TEMPLATE: Final[str] = "{completed} of {total} bytes" -CANCELLING_LABEL: Final[str] = "Stopping the export..." +CANCELING_LABEL: Final[str] = "Stopping the export..." NOTHING_MEASURED: Final[int] = 0 PROGRAM_AREA: Final[int] = 32429 SONG_TICKS: Final[int] = 14400 @@ -77,7 +77,7 @@ def logic_fixture(service: FakeExportService) -> SongExportLogic: ExportStage.WRITING: WRITING_LABEL, }, size_template=SIZE_TEMPLATE, - cancelling_label=CANCELLING_LABEL, + canceling_label=CANCELING_LABEL, ) @@ -150,7 +150,7 @@ def test_a_second_run_starts_the_list_over( class TestHowEachStageReads: """A stage traveling to an end is a fraction; one measured against a limit is a figure.""" - def test_a_travelling_stage_carries_its_share( + def test_a_traveling_stage_carries_its_share( self, logic: SongExportLogic, service: FakeExportService, @@ -160,7 +160,7 @@ def test_a_travelling_stage_carries_its_share( service.deliver(progress(ExportStage.WALKING, WALKED_TICKS, SONG_TICKS)) assert views[-1].progress == pytest.approx(WALKED_TICKS / SONG_TICKS) - def test_a_travelling_stage_states_no_figure( + def test_a_traveling_stage_states_no_figure( self, logic: SongExportLogic, service: FakeExportService, @@ -221,7 +221,7 @@ def test_a_stop_says_so( ) -> None: service.deliver(ServiceStarted(total=NOTHING_MEASURED)) logic.cancel() - assert views[-1].figure == CANCELLING_LABEL + assert views[-1].figure == CANCELING_LABEL def test_a_report_arriving_after_a_stop_leaves_the_message_standing( self, @@ -232,7 +232,7 @@ def test_a_report_arriving_after_a_stop_leaves_the_message_standing( service.deliver(ServiceStarted(total=NOTHING_MEASURED)) logic.cancel() service.deliver(progress(ExportStage.COMPRESSING, REACHED_SIZE, PROGRAM_AREA)) - assert views[-1].figure == CANCELLING_LABEL + assert views[-1].figure == CANCELING_LABEL def test_a_stop_asked_of_nothing_reaches_no_service( self, diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index 637ccdf17..0dcdda9cf 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -11,7 +11,7 @@ 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 sampletones_shared.utils.hashing import hash_model from tests.conftest import ReconstructionFactory from tests.unit.sampletones_application.logic.history.conftest import HistoryFactory diff --git a/tests/unit/sampletones_application/logic/main/converter/__init__.py b/tests/unit/sampletones_application/logic/main/converter/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py new file mode 100644 index 000000000..98757f3d4 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import Tuple + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_core.configs import Config +from tests.suite.base import BaseTestSuite + + +class TestTheDocumentARunIsMaking(BaseTestSuite): + """A run of one names itself after the reconstruction it writes.""" + + def test_the_output_names_the_reconstruction(self) -> None: + destination = Destination( + input_path=Path("/audio/kick.wav"), + output_path=Path("/reconstructions/track.stn"), + is_file=True, + ) + + assert destination.reconstruction_name == "track" + + def test_a_run_yet_to_resolve_its_output_names_the_recording_picked(self) -> None: + destination = Destination(input_path=Path("/audio/kick.wav"), output_path=None, is_file=True) + + assert destination.reconstruction_name == "kick" + + def test_a_converter_aimed_at_nothing_names_nothing(self) -> None: + assert Destination.unset().reconstruction_name == "" + + def test_a_completed_run_names_what_it_wrote(self) -> None: + written = Path("/reconstructions/mixed.stn") + destination = Destination.unset().writing_to(written) + + assert (destination.output_path, destination.reconstruction_name) == (written, "mixed") + + +class TestWhereAMixWrites(BaseTestSuite): + def test_a_mix_with_nobody_taking_part_stands_where_it_was(self) -> None: + destination = Destination.unset() + sources: Tuple[Path, ...] = () + + assert destination.aimed_at_mix(Config(), sources, frozenset()) == destination diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py new file mode 100644 index 000000000..2daad7abc --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -0,0 +1,178 @@ +from pathlib import Path +from typing import FrozenSet, List + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + + +def _mixed(*names: str) -> Gathering: + """A setup a mix converts: the recordings gathered, and the order they pick in.""" + gathering = Gathering.empty() + for name in names: + gathering = gathering.mixing(recording(f"/audio/{name}.wav")) + + return gathering + + +def _listed(*names: str) -> Gathering: + """A setup a per-recording run converts: the recordings gathered, no order among them.""" + gathering = Gathering.empty() + for name in names: + gathering = gathering.listing(recording(f"/audio/{name}.wav")) + + return gathering + + +def _names(gathering: Gathering) -> List[str]: + return [path.stem for path in gathering.paths] + + +def _mixed_names(gathering: Gathering) -> List[str]: + return [path.stem for path in gathering.levels.paths] + + +class TestGatheringRecordings(BaseTestSuite): + def test_a_recording_stands_in_the_list_and_on_a_level(self) -> None: + gathering = _mixed("bass") + + assert _names(gathering) == ["bass"] + assert gathering.recording(Path("/audio/bass.wav")) is not None + + def test_recordings_pick_in_the_order_they_were_mixed(self) -> None: + assert _names(_mixed("bass", "lead")) == ["bass", "lead"] + + def test_a_recording_already_gathered_keeps_what_it_holds(self) -> None: + gathering = _mixed("bass").written( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.NOISE}), + ) + + gathering = gathering.mixing(recording("/audio/bass.wav", [ChannelName.PULSE1])) + + settled = gathering.recording(Path("/audio/bass.wav")) + assert settled is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_leaves_both_sides_of_the_setup(self) -> None: + gathering = _mixed("bass", "lead").remove(SourceKey.recording(Path("/audio/bass.wav"))) + + assert _names(gathering) == ["lead"] + assert _mixed_names(gathering) == ["lead"] + assert gathering.recording(Path("/audio/bass.wav")) is None + + +class TestTheCeilingAMixHoldsTo(BaseTestSuite): + """A mix reaches as many recordings as the assignment has room to mix, whatever gathers them.""" + + def test_an_empty_setup_has_room_for_the_whole_ceiling(self) -> None: + assert Gathering.empty().room == MAX_STEM_SOURCES + + def test_a_recording_arriving_at_a_full_setup_reaches_neither_side(self) -> None: + gathering = _mixed(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]) + + gathering = gathering.mixing(recording("/audio/one_more.wav")) + + assert gathering.count == MAX_STEM_SOURCES + assert gathering.recording(Path("/audio/one_more.wav")) is None + + def test_a_list_within_the_ceiling_fits_one_mix(self) -> None: + assert _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]).fits_a_mix is True + + def test_a_list_past_the_ceiling_does_not(self) -> None: + """Turning to a mix is what asks this, so a longer list is what the reader is asked about.""" + assert _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES + 1)]).fits_a_mix is False + + +class TestSettlingOneRecording(BaseTestSuite): + def test_a_slot_settles_on_the_recording_named(self) -> None: + gathering = _mixed("bass", "lead").written( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.NOISE}), + ) + + settled = gathering.recording(Path("/audio/bass.wav")) + untouched = gathering.recording(Path("/audio/lead.wav")) + assert settled is not None and untouched is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + assert untouched.settings.channel_set == {ChannelName.PULSE1} + + def test_settling_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: + gathering = _mixed("bass") + + assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering + + +class TestTheListAPerRecordingRunConverts(BaseTestSuite): + """A run writing one reconstruction apiece converts whatever the list holds, unbounded.""" + + def test_a_recording_joins_the_list_without_joining_a_mix(self) -> None: + gathering = _listed("bass") + + assert _names(gathering) == ["bass"] + assert _mixed_names(gathering) == [] + + def test_the_list_takes_more_than_a_mix_could_hold(self) -> None: + gathering = _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES + 3)]) + + assert gathering.count == MAX_STEM_SOURCES + 3 + + def test_a_folder_stands_for_the_recordings_below_it(self) -> None: + gathering = Gathering.empty().listing_folder( + folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + ) + + assert gathering.count == 2 + assert gathering.row_count == 1 + assert gathering.folder_root_of(Path("/audio/a.wav")) == Path("/audio") + + def test_a_folder_goes_with_everything_it_stands_for(self) -> None: + gathering = Gathering.empty().listing_folder(folder("/audio", [recording("/audio/a.wav")])) + + gathering = gathering.remove(SourceKey.folder(Path("/audio"))) + + assert gathering.count == 0 + + +class TestTurningToAMix(BaseTestSuite): + """A mix converts loose recordings and holds a fixed number of them.""" + + def test_the_recordings_picked_stand_alone_and_in_order(self) -> None: + held = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + gathering = Gathering.empty().listing_folder(held) + + gathering = gathering.mixing_only((recording("/audio/b.wav"),)) + + assert _names(gathering) == ["b"] + assert _mixed_names(gathering) == ["b"] + assert gathering.folder_root_of(Path("/audio/b.wav")) is None + + def test_a_recording_keeps_the_settings_it_is_named_with(self) -> None: + gathering = Gathering.empty().listing(recording("/audio/a.wav")) + + named = recording("/audio/a.wav", [ChannelName.NOISE]) + settled = gathering.mixing_only((named,)).recording(Path("/audio/a.wav")) + + assert settled is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_the_list_never_gathered_joins_it(self) -> None: + """The mix and the list are one thing, so naming a recording is what brings it in.""" + gathering = _listed("a").mixing_only((recording("/audio/a.wav"), recording("/audio/stranger.wav"))) + + assert _names(gathering) == ["a", "stranger"] + assert _mixed_names(gathering) == ["a", "stranger"] + + +class TestTurningAwayFromAMix(BaseTestSuite): + def test_the_list_stands_and_the_picking_order_goes(self) -> None: + gathering = _mixed("bass", "lead").unmixed() + + assert _names(gathering) == ["bass", "lead"] + assert _mixed_names(gathering) == [] diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py new file mode 100644 index 000000000..7af0ce9ae --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -0,0 +1,1017 @@ +from pathlib import Path +from typing import Callable, FrozenSet, List +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.config.profile import UserProfile +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind +from sampletones_application.logic.main.converter.logic import ConverterLogic +from sampletones_application.logic.main.converter.run import ConversionSuccess +from sampletones_application.services.conversion.result import ConversionResult +from sampletones_application.services.result import ServiceError, ServiceSuccess +from sampletones_application.view_model.main.converter import ( + ACTIVE_PHASES, + ConversionPhase, + ConverterViewModel, +) +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.reconstructions.converter import GroupConversion +from sampletones_core.reconstructions.converter.paths import get_audio_files +from tests.suite.base import BaseTestSuite +from tests.suite.language import FakeLanguageManager +from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS + +SCHEDULING: str = "sampletones_application.logic.main.converter.logic.CallbackQueue.add" + + +def _config_writing_under(reconstructions_directory: Path) -> Config: + """A configuration whose reconstructions are written under ``reconstructions_directory``.""" + config = Config() + general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)}) + return config.model_copy(update={"general": general}) + + +def _config_manager_writing_under(reconstructions_directory: Path) -> MagicMock: + """The configuration manager a converter reads where a run writes from.""" + config_manager = MagicMock() + config_manager.config = _config_writing_under(reconstructions_directory) + config_manager.get_reconstructions_directory.return_value = reconstructions_directory + return config_manager + + +@pytest.fixture +def session_manager(tmp_path: Path) -> SessionManager: + """A session writing under the test's own directory, so the joining settings round-trip.""" + return SessionManager(UserProfile(config=tmp_path / "config.json", state=tmp_path / "state.yaml")) + + +@pytest.fixture +def service() -> MagicMock: + """The conversion service the converter drives, which reports back through its subscription.""" + service = MagicMock() + service.is_running.return_value = False + return service + + +@pytest.fixture +def converter_logic( + tmp_path: Path, + session_manager: SessionManager, + service: MagicMock, +) -> ConverterLogic: + """A converter reading a real configuration, so resolving where a run writes answers as it does live. + + The configuration writes under the test's own directory, which keeps a target this converter + resolves within the test rather than in the reconstructions the developer holds. + """ + reconstructions_directory = tmp_path / "reconstructions" + config_manager = _config_manager_writing_under(reconstructions_directory) + scheduling = MagicMock( + priorities=MagicMock(schedule=0), + delays=MagicMock(schedule=0, cancel=0), + ) + logic = ConverterLogic( + config_manager, + session_manager, + service, + scheduling=scheduling, + language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] + is_operation_active=lambda: False, + ) + logic.on_view_changed = MagicMock() + logic.generate_library = MagicMock() + logic.is_library_available = lambda: False + return logic + + +def _view(converter_logic: ConverterLogic) -> ConverterViewModel: + """The panel's last reading of the converter.""" + view_model: ConverterViewModel = converter_logic.on_view_changed.call_args.args[0] + return view_model + + +def _phase(converter_logic: ConverterLogic) -> ConversionPhase: + return _view(converter_logic).phase + + +def _reports(service: MagicMock, result: ConversionResult) -> None: + """Hands the converter a result the conversion service would report to it.""" + handler: Callable[[ConversionResult], None] = service.subscribe.call_args.args[0] + handler(result) + + +def _mixing(converter_logic: ConverterLogic, *names: str) -> None: + """Gathers recordings into a mix, which is the run several recordings amount to.""" + converter_logic.set_output(OutputKind.MIXED) + converter_logic.gather_recordings([Path(f"/audio/{name}.wav") for name in names]) + + +def _listed(converter_logic: ConverterLogic, *names: str) -> None: + """Gathers recordings into a run writing one reconstruction apiece.""" + converter_logic.gather_recordings([Path(f"/audio/{name}.wav") for name in names]) + + +def _started_plan(converter_logic: ConverterLogic, service: MagicMock) -> GroupConversion: + """The plan the converter hands the service once the library it waits for is ready.""" + converter_logic.is_library_available = lambda: True + with patch(SCHEDULING): + converter_logic.start_conversion(confirmed=True) + + plan: GroupConversion = service.start.call_args.args[1] + return plan + + +class TestCancelDuringLibraryGeneration(BaseTestSuite): + """The converter requests a library when none exists and waits for it. Canceling during that + wait must abort the pending conversion and stop the in-flight generation.""" + + def test_cancel_while_waiting_cancels_generation_and_finishes( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + cancel_generation = MagicMock() + on_canceled = MagicMock() + converter_logic.cancel_library_generation = cancel_generation + converter_logic.on_canceled = on_canceled + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + assert _phase(converter_logic) == ConversionPhase.WAITING + + converter_logic.cancel() + + cancel_generation.assert_called_once() + on_canceled.assert_called_once() + assert _phase(converter_logic) == ConversionPhase.CANCELED + + def test_wait_loop_aborts_once_no_longer_waiting( + self, + converter_logic: ConverterLogic, + service: MagicMock, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING) as scheduled: + converter_logic.start_conversion() + converter_logic.cancel() + scheduled.reset_mock() + + converter_logic._wait_for_library_and_start() + + service.start.assert_not_called() + scheduled.assert_not_called() + + def test_wait_poll_does_not_emit_a_zero_progress_view( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """While waiting, the bar reflects the library-generation progress. A re-poll that finds the + library still missing must only re-queue itself, never emit its own view: emitting one would + carry ``progress=0.0`` and momentarily reset the bar.""" + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING) as scheduled: + converter_logic.start_conversion() + converter_logic.on_view_changed.reset_mock() + scheduled.reset_mock() + + converter_logic._wait_for_library_and_start() + + converter_logic.on_view_changed.assert_not_called() + scheduled.assert_called_once() + + +class TestNoChannelsGuard(BaseTestSuite): + """A gathered recording holding no channel reconstructs nothing, so the run must not start.""" + + def test_no_generators_notifies_and_does_not_start( + self, + converter_logic: ConverterLogic, + ) -> None: + _listed(converter_logic, "a") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) + on_no_generators = MagicMock() + converter_logic.on_no_generators = on_no_generators + + converter_logic.start_conversion() + + on_no_generators.assert_called_once() + converter_logic.generate_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + +class TestNothingToConvertGuard(BaseTestSuite): + """A converter aimed at nothing has no plan to run, so a request leaves it where it stands.""" + + def test_a_request_with_nothing_picked_starts_nothing( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic.emit_initial_view() + + converter_logic.start_conversion() + + converter_logic.generate_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def test_a_mix_runs_without_a_recording_ever_being_picked( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + """A mix converts the recordings it gathered, so nothing about the browser's selection gates it.""" + _mixing(converter_logic, "a", "b") + + plan = _started_plan(converter_logic, service) + + assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + +class TestOverwriteGuard(BaseTestSuite): + """A single conversion writes one named file, so a run that would replace one asks first. + + A batch settles the question itself — it converts what is still to be written — so the + prompt reaches the reader for the single-file and stems runs alone. + """ + + @staticmethod + def _aimed_at(converter_logic: ConverterLogic, path: Path) -> Path: + """Gathers ``path`` and answers where its run would write.""" + converter_logic.gather_recordings([path]) + return _view(converter_logic).output_path + + @staticmethod + def _standing(target: Path) -> None: + target.parent.mkdir(parents=True, exist_ok=True) + target.touch() + + def test_a_standing_target_is_put_to_the_reader_and_nothing_starts( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + target = self._aimed_at(converter_logic, source) + self._standing(target) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_called_once_with((target,)) + converter_logic.generate_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def _target_alone(self, converter_logic: ConverterLogic, source: Path) -> Path: + """Where a run over this recording alone would write, leaving the setup as it was found.""" + target = self._aimed_at(converter_logic, source) + converter_logic.remove_source(source) + return target + + def test_every_standing_target_is_named_to_the_reader( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """A recording the reader named is written whenever the run goes, so all of them are named. + + The answer covers each one it is given, which is what makes the prompt worth reading. + """ + sources = [] + targets = [] + for name in ("one.wav", "two.wav", "three.wav"): + source = tmp_path / name + source.touch() + sources.append(source) + targets.append(self._target_alone(converter_logic, source)) + + for target in targets: + self._standing(target) + + converter_logic.gather_recordings(sources) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_called_once_with(tuple(targets)) + assert _phase(converter_logic) == ConversionPhase.IDLE + + def test_a_confirmed_run_goes_ahead( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + self._standing(self._aimed_at(converter_logic, source)) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion(confirmed=True) + + on_target_exists.assert_not_called() + converter_logic.generate_library.assert_called_once() + assert _phase(converter_logic) == ConversionPhase.WAITING + + def test_a_target_still_to_be_written_starts_straight_away( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = tmp_path / "song.wav" + source.touch() + self._aimed_at(converter_logic, source) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.WAITING + + def test_a_folder_starts_without_asking( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """A recording gathered from a folder is never written over, so a standing file stops nothing.""" + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + converter_logic.gather_folder(sources, get_audio_files(sources, sort=True)) + on_target_exists = MagicMock() + converter_logic.on_target_exists = on_target_exists + + with patch(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.WAITING + + +class TestStartConversionGate(BaseTestSuite): + """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, + service: MagicMock, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + converter_logic._is_operation_active = lambda: True + + converter_logic.start_conversion() + + service.start.assert_not_called() + converter_logic.generate_library.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.IDLE + + def test_proceeds_when_nothing_is_active( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + + converter_logic.generate_library.assert_called_once() + assert _phase(converter_logic) == ConversionPhase.WAITING + + +class TestWhatTheSetupNamesItselfBy(BaseTestSuite): + """A setup holding one row is that row, which is what a reader converting one file reads.""" + + def test_one_recording_names_itself( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = _aimed_at_a_recording(converter_logic, tmp_path) + + view_model = _view(converter_logic) + + assert (view_model.input_path, view_model.is_file) == (source, True) + + def test_one_folder_names_the_tree_it_mirrors( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + + converter_logic.gather_folder(sources, get_audio_files(sources, sort=True)) + + view_model = _view(converter_logic) + assert (view_model.input_path, view_model.is_file) == (sources, False) + + def test_several_rows_name_none_of_them(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "a", "b") + + assert _view(converter_logic).input_path is None + + +class TestWhatACompletedConversionLeaves(BaseTestSuite): + """A completed conversion tells its listener what it wrote, so the follow-up offer can target + the single reconstruction or the folder holding a batch.""" + + def test_success_carries_the_reconstructions_that_were_written( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + on_success = MagicMock() + converter_logic.on_success = on_success + written = (Path("/reconstructions/kick.stn"),) + + _reports(service, ServiceSuccess(value=written)) + + assert _phase(converter_logic) == ConversionPhase.COMPLETED + on_success.assert_called_once_with(ConversionSuccess(written=written)) + + def test_one_written_reconstruction_becomes_the_displayed_output( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + written = (Path("/reconstructions/kick.stn"),) + + _reports(service, ServiceSuccess(value=written)) + + assert _view(converter_logic).output_path == written[0] + + def test_a_batch_loads_the_folder_and_one_file_loads_itself( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + converter_logic.on_load_file = MagicMock() + converter_logic.on_load_directory = MagicMock() + + _reports(service, ServiceSuccess(value=(Path("/reconstructions/kick.stn"),))) + converter_logic.handle_load_request() + converter_logic.on_load_file.assert_called_once_with(Path("/reconstructions/kick.stn")) + + _reports( + service, + ServiceSuccess(value=(Path("/reconstructions/kick.stn"), Path("/reconstructions/snare.stn"))), + ) + converter_logic.handle_load_request() + converter_logic.on_load_directory.assert_called_once_with() + + +class TestFailureReturnsToIdle(BaseTestSuite): + """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, + service: MagicMock, + ) -> None: + converter_logic.on_error = MagicMock() + + with patch(SCHEDULING) as scheduled: + _reports(service, ServiceError(exception=RuntimeError("boom"))) + + assert _phase(converter_logic) == ConversionPhase.FAILED + scheduled.assert_called_once() + assert scheduled.call_args.args[0] == converter_logic.close + converter_logic.on_error.assert_called_once() + + +class TestActivePhases(BaseTestSuite): + """``is_active`` reports a conversion occupying resources for every non-idle, non-terminal phase — + covering the WAITING preparation that runs before the service starts.""" + + def test_a_requested_conversion_is_active_before_the_service_takes_it( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + _aimed_at_a_recording(converter_logic, tmp_path) + + with patch(SCHEDULING): + converter_logic.start_conversion() + + assert _phase(converter_logic) in ACTIVE_PHASES + assert converter_logic.is_active is True + + def test_a_settled_conversion_holds_nothing( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _reports(service, ServiceSuccess(value=(Path("/reconstructions/kick.stn"),))) + + assert converter_logic.is_active is False + + def test_a_converter_that_has_run_nothing_is_idle(self, converter_logic: ConverterLogic) -> None: + converter_logic.emit_initial_view() + + assert (_phase(converter_logic), converter_logic.is_active) == (ConversionPhase.IDLE, False) + + +class TestGatheringRecordings(BaseTestSuite): + """The rows a reader gathers, as the panel reads them back.""" + + def _names(self, converter_logic: ConverterLogic) -> List[str]: + return [row.name for row in _view(converter_logic).stem_sources] + + def test_a_gathered_recording_becomes_a_row(self, converter_logic: ConverterLogic) -> None: + converter_logic.gather_recordings([Path("/audio/bass.wav")]) + converter_logic.gather_recordings([Path("/audio/lead.wav")]) + + assert self._names(converter_logic) == ["bass", "lead"] + + def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "bass", "lead") + converter_logic.isolate_source(Path("/audio/lead.wav")) + + converter_logic.gather_recordings([Path("/audio/lead.wav")]) + + rows = _view(converter_logic).stem_sources + assert [row.name for row in rows] == ["bass", "lead"] + assert [row.level for row in rows] == [0, 1] + + def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES + 3)]) + + assert converter_logic.source_count == MAX_STEM_SOURCES + assert converter_logic.room_for_sources == 0 + + def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.remove_source(Path("/audio/a.wav")) + + assert self._names(converter_logic) == ["b"] + + def test_turning_to_a_mix_carries_the_picked_file_in( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + source = _aimed_at_a_recording(converter_logic, tmp_path) + + converter_logic.set_output(OutputKind.MIXED) + + assert self._names(converter_logic) == [source.stem] + + def test_turning_away_from_a_mix_keeps_every_recording( + self, + converter_logic: ConverterLogic, + ) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.set_output(OutputKind.PER_RECORDING) + + assert converter_logic.source_count == 2 + + def test_a_per_recording_run_takes_more_than_a_mix_could_hold( + self, + converter_logic: ConverterLogic, + ) -> None: + _listed(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES + 3)]) + + assert converter_logic.source_count == MAX_STEM_SOURCES + 3 + + def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0) + + rows = _view(converter_logic).stem_sources + assert [(row.path.name, row.level, row.level_count) for row in rows] == [ + ("b.wav", 0, 2), + ("a.wav", 1, 2), + ] + + +class TestAnsweringWhichRecordingsToMix(BaseTestSuite): + """A mix reaching a fixed number of recordings is put to the reader, and the answer is what + the mix is then built from: what it names joins, and what it leaves out goes.""" + + def _names(self, converter_logic: ConverterLogic) -> List[str]: + return [row.name for row in _view(converter_logic).stem_sources] + + def test_the_answer_names_the_whole_mix(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b", "c") + + converter_logic.mix_only([Path("/audio/a.wav"), Path("/audio/c.wav")]) + + assert self._names(converter_logic) == ["a", "c"] + + def test_a_recording_the_list_never_held_joins_it(self, converter_logic: ConverterLogic) -> None: + """A full mix is answered by letting one go for one a folder offered, in the one gesture.""" + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) + + standing = [Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES - 1)] + converter_logic.mix_only([*standing, Path("/audio/late.wav")]) + + assert self._names(converter_logic)[-1] == "late" + assert converter_logic.source_count == MAX_STEM_SOURCES + + def test_a_recording_standing_keeps_the_channels_it_was_given( + self, + converter_logic: ConverterLogic, + ) -> None: + _mixing(converter_logic, "a", "b") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) + + converter_logic.mix_only([Path("/audio/a.wav")]) + + assert _view(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) + + def test_the_run_it_leaves_is_a_mix(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "a", "b") + + converter_logic.mix_only([Path("/audio/a.wav")]) + + assert converter_logic.mixes + + +class TestWhatTheGatheredRecordingsRun(BaseTestSuite): + """What the converter asks the service to run, once a reader has set the mix up.""" + + def test_the_rows_channels_and_levels_reach_the_setup( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a", "b") + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1})) + converter_logic.isolate_source(Path("/audio/b.wav")) + + plan = _started_plan(converter_logic, service) + + assert plan.stems.entries[0].settings.channels == [ChannelName.PULSE1] + assert plan.stems.hierarchy.levels == [[0], [1]] + + def test_a_recording_left_with_no_channel_takes_no_part( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a", "b") + + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) + plan = _started_plan(converter_logic, service) + + assert converter_logic.source_count == 2 + assert plan.sources == (Path("/audio/b.wav"),) + assert [entry.id for entry in plan.stems.entries] == [0] + + def test_the_hierarchy_mode_reaches_the_setup( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + assert _started_plan(converter_logic, service).stems.hierarchy.mode == HierarchyMode.STRICT + + def test_the_cap_the_reader_asked_for_reaches_the_setup( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + + converter_logic.set_channel_cap(1) + + assert _started_plan(converter_logic, service).stems.channel_cap == 1 + + def test_the_configuration_reaches_the_service_with_the_plan( + self, + converter_logic: ConverterLogic, + service: MagicMock, + ) -> None: + _mixing(converter_logic, "a") + + _started_plan(converter_logic, service) + + started_config = service.start.call_args.args[0] + assert started_config == converter_logic._config_manager.config + + +class TestAFolderInTheList(BaseTestSuite): + """A folder stands as one row, answering for every recording gathered below it.""" + + def _folder(self, converter_logic: ConverterLogic, tmp_path: Path, names: List[str]) -> Path: + root = tmp_path / "sources" + root.mkdir() + for name in names: + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return root + + def test_a_folder_draws_one_row_naming_what_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + rows = _view(converter_logic).stem_sources + + assert len(rows) == 1 + assert (rows[0].path, rows[0].holds, rows[0].stands_for_a_folder) == (root, 2, True) + + def test_a_folder_its_recordings_agree_on_reads_as_held( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + """The list and the settings card read one folder the same way, since both fold its rows.""" + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + converter_logic.select_row(root, SourceKind.FOLDER) + + row = _view(converter_logic).stem_sources[0] + + assert row.channels == converter_logic.settings_slots[0].held_channels + assert row.partial_channels == frozenset() + + def test_a_folder_its_recordings_differ_on_reads_as_half_held( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + row = _view(converter_logic).stem_sources[0] + assert row.channels == frozenset({ChannelName.PULSE1}) + assert ChannelName.TRIANGLE in row.partial_channels + + def test_one_gesture_settles_the_whole_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + converter_logic.set_source_channels(root / "a.wav", frozenset({ChannelName.PULSE1})) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + row = _view(converter_logic).stem_sources[0] + assert ChannelName.TRIANGLE in row.channels + assert ChannelName.TRIANGLE not in row.partial_channels + + def test_a_folder_every_recording_of_which_holds_it_lets_it_go( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.toggle_folder_channel(root, ChannelName.TRIANGLE) + + row = _view(converter_logic).stem_sources[0] + assert ChannelName.TRIANGLE not in row.channels + assert ChannelName.TRIANGLE not in row.partial_channels + + def test_removing_a_folder_takes_everything_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.remove_folder(root) + + assert _view(converter_logic).stem_sources == () + assert converter_logic.source_count == 0 + + def test_turning_to_a_mix_gives_up_the_folder( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + converter_logic.set_output(OutputKind.MIXED) + + rows = _view(converter_logic).stem_sources + assert [row.stands_for_a_folder for row in rows] == [False, False] + + +class TestTheChannelAKeyReaches(BaseTestSuite): + """A channel's key settles the row a reader picked out, which is the box beside that row.""" + + @staticmethod + def _channels_of(converter_logic: ConverterLogic, path: Path) -> FrozenSet[ChannelName]: + """The channels the row standing for ``path`` reads as holding.""" + held = [row.channels for row in _view(converter_logic).stem_sources if row.path == path] + assert len(held) == 1 + return held[0] + + def test_it_settles_the_recording_picked_out(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + kick = Path("/audio/kick.wav") + converter_logic.select_row(kick, SourceKind.RECORDING) + held = ChannelName.TRIANGLE in self._channels_of(converter_logic, kick) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert (ChannelName.TRIANGLE in self._channels_of(converter_logic, kick)) is not held + + def test_it_leaves_the_rows_it_was_not_pointed_at(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + snare = Path("/audio/snare.wav") + converter_logic.select_row(Path("/audio/kick.wav"), SourceKind.RECORDING) + standing = self._channels_of(converter_logic, snare) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert self._channels_of(converter_logic, snare) == standing + + def test_a_folder_picked_out_carries_it_to_what_it_holds( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = tmp_path / "sources" + root.mkdir() + for name in ("a.wav", "b.wav"): + (root / name).touch() + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + converter_logic.select_row(root, SourceKind.FOLDER) + held = ChannelName.TRIANGLE in self._channels_of(converter_logic, root) + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert (ChannelName.TRIANGLE in self._channels_of(converter_logic, root)) is not held + + def test_nothing_picked_out_leaves_the_list_as_it_stands(self, converter_logic: ConverterLogic) -> None: + _listed(converter_logic, "kick", "snare") + standing = [row.channels for row in _view(converter_logic).stem_sources] + + converter_logic.toggle_channel(ChannelName.TRIANGLE) + + assert [row.channels for row in _view(converter_logic).stem_sources] == standing + + +class TestTheStemsView(BaseTestSuite): + """What the panel is told about the setup being built.""" + + def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a", "b") + + view_model = _view(converter_logic) + + assert [row.name for row in view_model.stem_sources] == ["a", "b"] + assert view_model.mixes is True + assert view_model.has_input is True + + def test_a_row_shows_the_channels_it_may_take(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, "a") + + converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) + + assert _view(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) + + def test_the_view_states_whether_another_recording_fits(self, converter_logic: ConverterLogic) -> None: + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) + + view_model = _view(converter_logic) + + assert view_model.source_count == MAX_STEM_SOURCES + assert view_model.can_add_source is False + + def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: ConverterLogic) -> None: + converter_logic.set_output(OutputKind.MIXED) + + view_model = _view(converter_logic) + + assert view_model.has_input is False + assert view_model.convert_button_enabled is False + + def test_the_cap_the_view_reports_holds_within_the_channels_there_are( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic.set_channel_cap(len(ChannelName) + 5) + + assert _view(converter_logic).channel_cap == len(ChannelName) + + +def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + """Gathers a recording standing on disk, the way a click in the browser does.""" + source = tmp_path / "song.wav" + source.touch() + converter_logic.gather_recordings([source]) + return source + + +class TestAFolderOfFolders(BaseTestSuite): + """A gathered folder stands for every recording below it, however deep the tree goes.""" + + @staticmethod + def _tree(tmp_path: Path) -> Path: + root = tmp_path / "library" + nested = root / "loops" / "drums" + nested.mkdir(parents=True) + (root / "top.wav").touch() + (nested / "deep.wav").touch() + (nested / "notes.txt").write_text("not audio") + return root + + def test_every_recording_below_it_is_gathered( + self, + converter_logic: ConverterLogic, + tmp_path: Path, + ) -> None: + root = self._tree(tmp_path) + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + + assert {path.name for path in converter_logic.gathered_paths} == {"top.wav", "deep.wav"} + + def test_it_still_stands_as_one_row(self, converter_logic: ConverterLogic, tmp_path: Path) -> None: + root = self._tree(tmp_path) + + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + + rows = _view(converter_logic).stem_sources + assert len(rows) == 1 + assert (rows[0].path, rows[0].holds) == (root, 2) + + +class TestTheRunTheSessionCarries(BaseTestSuite): + """The shape of a run is carried between launches, so the converter opens where it was left.""" + + def test_the_output_switch_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + converter_logic.set_output(OutputKind.MIXED) + + assert session_manager.converter_output is OutputKind.MIXED + + def test_the_channel_cap_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + converter_logic.set_channel_cap(2) + + assert session_manager.converter_channel_cap == 2 + + def test_the_order_is_written_down( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + assert session_manager.converter_hierarchy_mode is HierarchyMode.STRICT + + def test_a_converter_opens_on_what_the_session_carries( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + service: MagicMock, + tmp_path: Path, + ) -> None: + """The whole point of writing them down: the next launch reads them back.""" + converter_logic.set_output(OutputKind.MIXED) + converter_logic.set_channel_cap(2) + converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) + + reopened = ConverterLogic( + _config_manager_writing_under(tmp_path / "reconstructions"), + session_manager, + service, + scheduling=MagicMock(priorities=MagicMock(schedule=0), delays=MagicMock(schedule=0, cancel=0)), + language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] + is_operation_active=lambda: False, + ) + + assert reopened.mixes is True diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py new file mode 100644 index 000000000..fb84a37cc --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -0,0 +1,141 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_application.services.conversion.result import ( + ConversionItem, + ReconstructionStep, +) +from sampletones_application.services.result import ServiceProgress +from sampletones_application.view_model.main.converter import ConversionPhase +from sampletones_core.reconstructions.stage import ReconstructionStage +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS, messages + +FRAMES: Final[int] = 1100 + + +def _progress( + completed: int, + total: int, + item: Optional[ConversionItem] = None, + partial: float = 0.0, +) -> ServiceProgress[ConversionItem]: + return ServiceProgress( + completed=completed, + total=total, + eta_seconds=None, + current_item=item, + partial=partial, + ) + + +def _item(stage: ReconstructionStage, completed: int) -> ConversionItem: + return ConversionItem( + source=Path("/audio/kick.wav"), + step=ReconstructionStep(stage=stage, completed=completed, total=FRAMES), + ) + + +class TestProgressText(BaseTestSuite): + """A batch counts the files it has written; a single job names the reconstruction it is making.""" + + def test_a_batch_counts_its_files(self) -> None: + assert messages().progress_text(_progress(2, 5), "track") == "Progress: 2/5 files" + + def test_a_single_job_names_the_reconstruction_it_writes(self) -> None: + assert messages().progress_text(_progress(0, 1), "track") == "Reconstructing track..." + + def test_the_status_names_the_stage_and_its_counts(self) -> None: + progress = _progress(0, 1, item=_item(ReconstructionStage.MATCHING, 412), partial=0.35) + + assert messages().progress_text(progress, "kick") == "Reconstructing kick... - matching 412/1100" + + def test_a_run_yet_to_say_anything_still_names_its_recording(self) -> None: + progress = _progress(0, 1, item=ConversionItem(source=Path("/audio/kick.wav"))) + + assert messages().progress_text(progress, "kick") == "Reconstructing kick..." + + def test_a_batch_counts_the_reconstruction_under_way_toward_its_files(self) -> None: + progress = _progress(2, 5, item=_item(ReconstructionStage.RENDERING, FRAMES), partial=0.5) + + assert messages().progress_text(progress, "kick") == "Progress: 2/5 files - rendering 1100/1100" + + +class TestActionLabel(BaseTestSuite): + """The button says what the run writes. + + One recording names its document, several are counted, and the count reads as a mix or as a run + of its own depending on the output switch, so the switch and the button read as one sentence. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + phase: ConversionPhase + mixes: bool + converted: Tuple[Path, ...] + expected: str + + test_cases = ( + TestCase( + label="nothing_gathered_offers_the_bare_label", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(), + expected=TEXTS["main.converter.label.convert_button"], + ), + TestCase( + label="one_recording_names_its_document", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(Path("/audio/kick.wav"),), + expected=TEXTS["main.converter.template.convert_recording"].format(name="kick"), + ), + TestCase( + label="a_mix_of_one_names_it_too", + phase=ConversionPhase.IDLE, + mixes=True, + converted=(Path("/audio/kick.wav"),), + expected=TEXTS["main.converter.template.convert_recording"].format(name="kick"), + ), + TestCase( + label="several_recordings_are_counted", + phase=ConversionPhase.IDLE, + mixes=False, + converted=(Path("/audio/kick.wav"), Path("/audio/snare.wav"), Path("/audio/hat.wav")), + expected=TEXTS["main.converter.template.convert_recordings"].format(count=3), + ), + TestCase( + label="several_mixed_recordings_read_as_a_mix", + phase=ConversionPhase.IDLE, + mixes=True, + converted=(Path("/audio/kick.wav"), Path("/audio/snare.wav"), Path("/audio/hat.wav")), + expected=TEXTS["main.converter.template.mix_recordings"].format(count=3), + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_label_says_what_the_run_writes(self, test_case: TestCase) -> None: + label = messages().action_label( + phase=test_case.phase, + mixes=test_case.mixes, + converted=test_case.converted, + ) + + assert label == test_case.expected + + @pytest.mark.parametrize( + "phase", + [ConversionPhase.WAITING, ConversionPhase.RUNNING, ConversionPhase.CANCELING], + ) + def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: ConversionPhase) -> None: + label = messages().action_label( + phase=phase, + mixes=False, + converted=(Path("/audio/kick.wav"),), + ) + + assert label == TEXTS["main.converter.label.cancel_button"] diff --git a/tests/unit/sampletones_application/logic/main/converter/test_run.py b/tests/unit/sampletones_application/logic/main/converter/test_run.py new file mode 100644 index 000000000..cbd89bf9e --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -0,0 +1,213 @@ +from pathlib import Path +from typing import Callable, List, Tuple +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.main.converter.run import ( + ConversionRun, + ConversionSuccess, + RunReport, +) +from sampletones_application.services.conversion.result import ConversionItem, ConversionResult +from sampletones_application.services.result import ( + ServiceCanceled, + ServiceError, + ServiceIntermediate, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.view_model.main.converter import ConversionPhase +from sampletones_core.configs import Config +from sampletones_core.parallelization import TaskProgress +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.converter.texts import messages + +WRITTEN: Tuple[Path, ...] = (Path("/reconstructions/kick.stn"),) + + +def _library_progress(completed: int, total: int) -> ConversionResult: + """The intermediate result the service reports while the library is being generated.""" + return ServiceIntermediate(data=TaskProgress(completed=completed, total=total)) + + +class Driver: + """A run and the service seam a test reports results through, as the real service would.""" + + def __init__(self) -> None: + self.service = MagicMock() + self.service.is_running.return_value = False + self.reports: List[RunReport] = [] + self.run = ConversionRun(self.service, messages=messages()) + self.run.on_report = self.reports.append + self.run.on_success = MagicMock() + self.run.on_error = MagicMock() + self.run.on_canceled = MagicMock() + + @property + def _handler(self) -> Callable[[ConversionResult], None]: + handler: Callable[[ConversionResult], None] = self.service.subscribe.call_args.args[0] + return handler + + def reports_from_service(self, result: ConversionResult) -> None: + """Hands the run a result the conversion service would report to it.""" + self._handler(result) + + def begin(self, reconstruction_name: str = "kick") -> None: + self.run.wait() + self.run.begin(Config(), MagicMock(), reconstruction_name) + + +@pytest.fixture +def driver() -> Driver: + return Driver() + + +class TestWhereARunStands(BaseTestSuite): + """A run occupies resources from the moment it is requested until it settles.""" + + def test_a_fresh_run_is_idle(self, driver: Driver) -> None: + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.IDLE, False) + + def test_a_request_waits_for_the_library_it_converts_against(self, driver: Driver) -> None: + driver.run.wait() + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.WAITING, True) + + def test_the_first_progress_puts_the_run_under_way(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.RUNNING, True) + + def test_canceling_holds_resources_until_the_service_answers(self, driver: Driver) -> None: + driver.begin() + + driver.run.cancel() + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.CANCELING, True) + driver.service.cancel.assert_called_once() + + @pytest.mark.parametrize( + ("result", "phase"), + [ + (ServiceSuccess(value=WRITTEN), ConversionPhase.COMPLETED), + (ServiceError(exception=RuntimeError("boom")), ConversionPhase.FAILED), + (ServiceCanceled(), ConversionPhase.CANCELED), + ], + ids=["completed", "failed", "canceled"], + ) + def test_a_settled_run_holds_nothing( + self, + driver: Driver, + result: ConversionResult, + phase: ConversionPhase, + ) -> None: + driver.begin() + + driver.reports_from_service(result) + + assert (driver.run.phase, driver.run.is_active) == (phase, False) + + +class TestWhatARunReports(BaseTestSuite): + def test_a_request_says_it_is_waiting(self, driver: Driver) -> None: + driver.run.wait() + + assert driver.reports[-1].status_text == "main.converter.message.status_waiting" + + def test_progress_names_the_document_being_written(self, driver: Driver) -> None: + driver.begin(reconstruction_name="track") + + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + + assert driver.reports[-1].status_text == "Reconstructing track..." + + def test_progress_names_the_recording_under_way(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service( + ServiceProgress(completed=0, total=2, current_item=ConversionItem(source=Path("/audio/snare.wav"))) + ) + + assert driver.reports[-1].input_path == Path("/audio/snare.wav") + + def test_a_run_naming_no_recording_leaves_the_reader_looking_at_their_own(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + + assert driver.reports[-1].input_path is None + + def test_a_canceled_run_keeps_reporting_the_canceling_line(self, driver: Driver) -> None: + driver.begin() + driver.run.cancel() + + driver.reports_from_service(ServiceProgress(completed=1, total=2)) + + assert driver.reports[-1].status_text == "main.converter.message.status_canceling" + assert driver.run.phase == ConversionPhase.CANCELING + + def test_library_progress_moves_the_bar_while_waiting(self, driver: Driver) -> None: + driver.run.wait() + + driver.reports_from_service(ServiceStarted(total=1)) + driver.reports_from_service(_library_progress(completed=3, total=4)) + + assert driver.reports[-1].progress == pytest.approx(0.75) + + def test_library_progress_once_the_run_is_under_way_reports_nothing(self, driver: Driver) -> None: + driver.begin() + driver.reports_from_service(ServiceProgress(completed=0, total=1)) + reported = len(driver.reports) + + driver.reports_from_service(_library_progress(completed=3, total=4)) + + assert len(driver.reports) == reported + + +class TestWhatACompletedRunHandsOver(BaseTestSuite): + """A completed conversion tells its listener what it wrote, so the follow-up offer can target + the single reconstruction or the folder holding a batch.""" + + def test_success_carries_the_reconstructions_that_were_written(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceSuccess(value=WRITTEN)) + + driver.run.on_success.assert_called_once_with(ConversionSuccess(written=WRITTEN)) + assert driver.run.written == WRITTEN + + def test_a_failure_hands_over_the_exception(self, driver: Driver) -> None: + error = RuntimeError("boom") + driver.begin() + + driver.reports_from_service(ServiceError(exception=error)) + + driver.run.on_error.assert_called_once_with(error) + + def test_a_cancellation_says_so(self, driver: Driver) -> None: + driver.begin() + + driver.reports_from_service(ServiceCanceled()) + + driver.run.on_canceled.assert_called_once_with() + + def test_a_request_given_up_before_the_service_took_it_cancels_all_the_same(self, driver: Driver) -> None: + driver.run.wait() + + driver.run.abandon() + + driver.run.on_canceled.assert_called_once_with() + assert driver.run.phase == ConversionPhase.CANCELED + + def test_closing_lets_the_written_reconstructions_go(self, driver: Driver) -> None: + driver.begin() + driver.reports_from_service(ServiceSuccess(value=WRITTEN)) + + driver.run.close() + + assert (driver.run.written, driver.run.phase) == ((), ConversionPhase.IDLE) + driver.service.cleanup.assert_called_once() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py new file mode 100644 index 000000000..e2cca879d --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -0,0 +1,50 @@ +from typing import List + +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +from sampletones_application.constants.output import OutputKind +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite + +TONES: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _settings(channels: List[ChannelName], channel_cap: int = 4) -> RunSettings: + return RunSettings( + joining=_joining(channels), + output=OutputKind.PER_RECORDING, + channel_cap=channel_cap, + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ) + + +def _joining(channels: List[ChannelName]) -> StemSettings: + return StemSettings(channels=channels, bends=bending_channels(channels)) + + +class TestTheCapARunHoldsTo(BaseTestSuite): + def test_a_cap_beyond_the_channels_there_are_is_held_to_them(self) -> None: + settings = _settings(TONES).with_channel_cap(len(ChannelName) + 5) + + assert settings.effective_channel_cap == len(ChannelName) + + def test_a_cap_below_one_channel_is_refused(self) -> None: + assert _settings(TONES).with_channel_cap(0).effective_channel_cap == MIN_CHANNEL_CAP + + def test_the_cap_stands_whatever_a_row_holds(self) -> None: + """The cap bounds a frame, so it answers to the hardware rather than to one row.""" + settings = _settings([ChannelName.PULSE1]).with_channel_cap(3) + + assert settings.effective_channel_cap == 3 + + +class TestTheShapeOfTheRun(BaseTestSuite): + def test_the_run_is_named_as_a_mix(self) -> None: + assert _settings(TONES).with_output(OutputKind.MIXED).mixes is True + + def test_the_levels_take_turns_as_the_reader_asked(self) -> None: + settings = _settings(TONES).with_hierarchy_mode(HierarchyMode.STRICT) + + assert settings.hierarchy_mode == HierarchyMode.STRICT diff --git a/tests/unit/sampletones_application/logic/main/converter/test_setup.py b/tests/unit/sampletones_application/logic/main/converter/test_setup.py new file mode 100644 index 000000000..794fd292c --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -0,0 +1,126 @@ +from pathlib import Path +from typing import List, Optional + +from sampletones_application.constants.output import OutputKind +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_application.logic.main.converter.gathering import Gathering +from sampletones_application.logic.main.converter.settings import RunSettings +from sampletones_application.logic.main.converter.setup import ( + batch_entries, + conversion_plan, + conversion_setup, + playing_sources, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels +from sampletones_core.reconstructions.converter import BatchConversion, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + +JOINING: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _state( + *, + output: OutputKind, + listed: Optional[List[str]] = None, + mixed: Optional[List[str]] = None, + gathered_folder: Optional[str] = None, + channel_cap: int = len(JOINING), + hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE, +) -> ConverterState: + gathering = Gathering.empty() + for name in listed or []: + gathering = gathering.listing(recording(name, JOINING)) + + for name in mixed or []: + gathering = gathering.mixing(recording(name, JOINING)) + + if gathered_folder is not None: + gathering = gathering.listing_folder( + folder( + gathered_folder, + [recording(f"{gathered_folder}/a.wav", JOINING), recording(f"{gathered_folder}/b.wav", JOINING)], + ) + ) + + return ConverterState( + settings=RunSettings( + joining=StemSettings(channels=JOINING, bends=bending_channels(JOINING)), + output=output, + channel_cap=channel_cap, + hierarchy_mode=hierarchy_mode, + ), + gathering=gathering, + destination=Destination.unset(), + selected=None, + ) + + +class TestWhatAPerRecordingRunConverts(BaseTestSuite): + """One reconstruction per gathered recording, each under the settings its own row holds.""" + + def test_a_recording_becomes_an_entry_of_its_own(self) -> None: + plan = conversion_plan(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert isinstance(plan, BatchConversion) + assert [entry.source for entry in plan.entries] == [Path("/audio/kick.wav")] + + def test_a_recording_the_reader_named_carries_no_folder(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert entries[0].base_directory is None + assert entries[0].is_named is True + + def test_a_recording_gathered_from_a_folder_carries_the_folder_its_tree_mirrors(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, gathered_folder="/audio")) + + assert [entry.base_directory for entry in entries] == [Path("/audio"), Path("/audio")] + + def test_an_entry_holds_the_channels_the_recording_joins_with(self) -> None: + entries = batch_entries(_state(output=OutputKind.PER_RECORDING, listed=["/audio/kick.wav"])) + + assert entries[0].stems == StemsConfig.single_entry(JOINING, [], channel_cap=len(JOINING)) + + def test_the_cap_reaches_every_entry(self) -> None: + """One recording per frame is a choice a reader makes for every conversion, batch included.""" + state = _state(output=OutputKind.PER_RECORDING, listed=["/audio/a.wav", "/audio/b.wav"], channel_cap=1) + + assert [entry.stems.channel_cap for entry in batch_entries(state)] == [1, 1] + + def test_a_setup_holding_nothing_names_no_plan(self) -> None: + assert conversion_plan(_state(output=OutputKind.PER_RECORDING)) is None + + +class TestWhatAMixRuns(BaseTestSuite): + def test_a_mix_groups_every_gathered_recording(self) -> None: + plan = conversion_plan(_state(output=OutputKind.MIXED, mixed=["/audio/a.wav", "/audio/b.wav"])) + + assert isinstance(plan, GroupConversion) + assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) + assert [entry.id for entry in plan.stems.entries] == [0, 1] + + def test_a_mix_with_nobody_taking_part_names_no_plan(self) -> None: + assert conversion_plan(_state(output=OutputKind.MIXED)) is None + + def test_the_levels_take_turns_as_the_reader_asked(self) -> None: + state = _state( + output=OutputKind.MIXED, + mixed=["/audio/a.wav"], + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert conversion_setup(state).stems.hierarchy.mode == HierarchyMode.STRICT + + def test_the_recordings_that_take_part_stand_in_mixing_order(self) -> None: + state = _state(output=OutputKind.MIXED, mixed=["/audio/a.wav", "/audio/b.wav"]) + + assert playing_sources(state) == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + def test_a_per_recording_run_reaches_the_recordings_it_writes(self) -> None: + state = _state(output=OutputKind.PER_RECORDING, listed=["/audio/a.wav", "/audio/b.wav"]) + + assert playing_sources(state) == (Path("/audio/a.wav"), Path("/audio/b.wav")) diff --git a/tests/unit/sampletones_application/logic/main/converter/texts.py b/tests/unit/sampletones_application/logic/main/converter/texts.py new file mode 100644 index 000000000..cc59c2017 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/texts.py @@ -0,0 +1,25 @@ +from typing import Dict, Final + +from sampletones_application.logic.main.converter.messages import ConverterMessages +from tests.suite.language import FakeLanguageManager + +TEXTS: Final[Dict[str, str]] = { + "main.converter.label.convert_button": "Convert", + "main.converter.label.cancel_button": "Cancel", + "main.converter.template.convert_recording": "Convert {name}", + "main.converter.template.convert_recordings": "Convert {count} recordings", + "main.converter.template.mix_recordings": "Mix {count} recordings", + "main.converter.template.progress_template": "Progress: {}/{} files", + "main.converter.template.single_progress_template": "Reconstructing {}...", + "main.converter.template.stage_template": " - {stage} {completed}/{total}", + "main.converter.message.stage_loading": "reading", + "main.converter.message.stage_matching": "matching", + "main.converter.message.stage_decoding": "decoding", + "main.converter.message.stage_rendering": "rendering", + "global.dialog.template.time_estimation": "", +} + + +def messages() -> ConverterMessages: + """The converter's phrases over a language manager stating the ones a test reads.""" + return ConverterMessages(FakeLanguageManager(TEXTS)) # type: ignore[arg-type] diff --git a/tests/unit/sampletones_application/logic/main/sources/__init__.py b/tests/unit/sampletones_application/logic/main/sources/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/main/sources/factories.py b/tests/unit/sampletones_application/logic/main/sources/factories.py new file mode 100644 index 000000000..8f67350e6 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/factories.py @@ -0,0 +1,26 @@ +from pathlib import Path +from typing import Iterable, Sequence + +from sampletones_application.logic.main.sources.folder import Folder +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings + + +def settings( + channels: Sequence[ChannelName] = (ChannelName.PULSE1,), + bends: Sequence[ChannelName] = (), +) -> StemSettings: + return StemSettings(channels=list(channels), bends=list(bends)) + + +def recording( + path: str, + channels: Sequence[ChannelName] = (ChannelName.PULSE1,), + bends: Sequence[ChannelName] = (), +) -> Recording: + return Recording(path=Path(path), settings=settings(channels, bends)) + + +def folder(root: str, recordings: Iterable[Recording]) -> Folder: + return Folder(root=Path(root), recordings=tuple(recordings)) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_derive.py b/tests/unit/sampletones_application/logic/main/sources/test_derive.py new file mode 100644 index 000000000..144969185 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_derive.py @@ -0,0 +1,159 @@ +from pathlib import Path +from typing import List, Sequence, Tuple + +from sampletones_application.logic.main.sources.derive import derive_conversion_setup +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_core.constants.enums import ChannelName, HierarchyMode +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import recording + + +def _path(name: str) -> Path: + return Path(f"/audio/{name}.wav") + + +def _gathered( + *levels: Sequence[str], + holding: Sequence[ChannelName] = (ChannelName.PULSE1,), +) -> Tuple[SourceList, MixLevels]: + """A list and the levels naming it, every recording holding the same channels.""" + sources = SourceList() + for level in levels: + for name in level: + sources = sources.add_recording(recording(str(_path(name)), holding)) + + return sources, MixLevels.of([[_path(name) for name in level] for level in levels]) + + +class TestWhatEachRecordingBringsToTheSetup(BaseTestSuite): + def test_a_recording_carries_the_channels_its_own_row_holds(self) -> None: + sources, levels = _gathered(["lead"], holding=[ChannelName.PULSE1, ChannelName.PULSE2]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.channels == [ChannelName.PULSE1, ChannelName.PULSE2] + + def test_a_bend_the_recording_carries_reaches_the_entry(self) -> None: + sources = SourceList().add_recording( + recording(str(_path("lead")), [ChannelName.TRIANGLE], [ChannelName.TRIANGLE]) + ) + levels = MixLevels.of([[_path("lead")]]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.bends == [ChannelName.TRIANGLE] + + +class TestTheSetupTheLevelsAmountTo(BaseTestSuite): + def test_a_recordings_position_is_its_stem_id(self) -> None: + sources, levels = _gathered(["a", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert [entry.id for entry in setup.stems.entries] == [0, 1] + + def test_recordings_sharing_a_level_pick_together(self) -> None: + sources, levels = _gathered(["a", "c"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.hierarchy.levels == [[0, 1], [2]] + + def test_the_mix_lists_the_recordings_in_entry_order(self) -> None: + sources, levels = _gathered(["a"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert setup.sources == (_path("a"), _path("b")) + + def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None: + sources, levels = _gathered(["a"]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=2, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN) + + +class TestARecordingThatTakesNoPart(BaseTestSuite): + """A recording left holding no channel the run enables reaches neither the mix nor the entries.""" + + def _silent_beside(self, names: List[str]) -> Tuple[SourceList, MixLevels]: + sources = SourceList() + for name in names: + channels = [] if name == "silent" else [ChannelName.PULSE1] + sources = sources.add_recording(recording(str(_path(name)), channels)) + + return sources, MixLevels.of([[_path(name)] for name in names]) + + def test_it_reaches_neither_the_mix_nor_the_entries(self) -> None: + sources = SourceList() + sources = sources.add_recording(recording(str(_path("a")), [ChannelName.PULSE1])) + sources = sources.add_recording(recording(str(_path("silent")), [])) + sources = sources.add_recording(recording(str(_path("b")), [ChannelName.PULSE1])) + levels = MixLevels.of([[_path("a"), _path("silent")], [_path("b")]]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.sources == (_path("a"), _path("b")) + assert setup.stems.hierarchy.levels == [[0], [1]] + + def test_a_level_left_with_nobody_taking_part_drops_out(self) -> None: + sources, levels = self._silent_beside(["silent", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.hierarchy.levels == [[0]] + + def test_a_path_the_list_never_gathered_takes_no_part(self) -> None: + sources, levels = _gathered(["a"]) + levels = MixLevels.of([[_path("a"), _path("stranger")]]) + + setup = derive_conversion_setup( + sources, + levels, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.sources == (_path("a"),) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_levels.py b/tests/unit/sampletones_application/logic/main/sources/test_levels.py new file mode 100644 index 000000000..7a8c88718 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_levels.py @@ -0,0 +1,145 @@ +from pathlib import Path +from typing import List, Sequence + +import pytest + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.logic.main.sources.levels import MixLevels +from tests.suite.base import BaseTestSuite + + +def _path(name: str) -> Path: + return Path(f"/audio/{name}.wav") + + +def _levels(*names: Sequence[str]) -> MixLevels: + return MixLevels.of([[_path(name) for name in level] for level in names]) + + +def _shape(levels: MixLevels) -> List[List[str]]: + return [[path.stem for path in level] for level in levels.levels] + + +class TestGathering(BaseTestSuite): + """The mix a reader builds: recordings arrive on the first level and leave without a trace.""" + + def test_the_first_recording_opens_a_level(self) -> None: + assert _shape(MixLevels().add(_path("bass"))) == [["bass"]] + + def test_further_recordings_join_the_first_level(self) -> None: + levels = MixLevels().add(_path("bass")).add(_path("lead")) + assert _shape(levels) == [["bass", "lead"]] + + def test_a_recording_already_gathered_changes_nothing(self) -> None: + assert _shape(_levels(["bass"]).add(_path("bass"))) == [["bass"]] + + def test_removing_the_last_of_a_level_takes_the_level_with_it(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).remove(_path("bass"))) == [["lead"]] + + def test_a_row_states_where_it_stands(self) -> None: + levels = _levels(["bass", "lead"], ["pad"]) + assert (levels.level_of(_path("lead")), levels.position_of(_path("lead"))) == (0, 1) + + def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None: + with pytest.raises(KeyError): + _levels(["bass"]).level_of(_path("lead")) + + +class TestTheCeilingAMixHolds(BaseTestSuite): + """A mix reaches as many recordings as the assignment has room to mix.""" + + def test_an_empty_mix_has_room_for_the_whole_ceiling(self) -> None: + assert MixLevels().room == MAX_STEM_SOURCES + + def test_room_falls_as_recordings_are_gathered(self) -> None: + assert _levels(["a", "b"]).room == MAX_STEM_SOURCES - 2 + + def test_a_recording_arriving_at_a_full_mix_leaves_it_as_it_stands(self) -> None: + levels = MixLevels() + for index in range(MAX_STEM_SOURCES): + levels = levels.add(_path(f"source{index}")) + + assert levels.count == MAX_STEM_SOURCES + assert levels.add(_path("one_more")).count == MAX_STEM_SOURCES + + +class TestMovesWithinALevel(BaseTestSuite): + """Position among peers settles which of two equal-cost choices picks first.""" + + def test_a_recording_moves_past_its_neighbor(self) -> None: + assert _shape(_levels(["bass", "lead"]).move_within_level(_path("lead"), -1)) == [["lead", "bass"]] + + def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None: + levels = _levels(["bass", "lead"]) + assert _shape(levels.move_within_level(_path("bass"), -1)) == _shape(levels) + + +class TestMovesBetweenLevels(BaseTestSuite): + def test_a_recording_joins_the_level_below(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).join_level(_path("bass"), 1)) == [["lead", "bass"]] + + def test_a_recording_joins_the_level_above(self) -> None: + assert _shape(_levels(["bass"], ["lead"]).join_level(_path("lead"), -1)) == [["bass", "lead"]] + + def test_joining_past_the_last_level_changes_nothing(self) -> None: + levels = _levels(["bass"], ["lead"]) + assert _shape(levels.join_level(_path("lead"), 1)) == _shape(levels) + + def test_a_recording_takes_a_level_of_its_own_after_the_one_it_shared(self) -> None: + assert _shape(_levels(["bass", "lead"], ["pad"]).isolate(_path("bass"))) == [ + ["lead"], + ["bass"], + ["pad"], + ] + + def test_a_recording_already_alone_stays_where_it_is(self) -> None: + levels = _levels(["bass"], ["lead"]) + assert _shape(levels.isolate(_path("bass"))) == _shape(levels) + + +class TestDropOntoARow(BaseTestSuite): + def test_the_dragged_recording_takes_the_place_it_was_dropped_on(self) -> None: + assert _shape(_levels(["bass"], ["lead", "pad"]).move_onto(_path("bass"), _path("pad"))) == [ + ["lead", "bass", "pad"] + ] + + def test_dropping_a_recording_on_itself_changes_nothing(self) -> None: + levels = _levels(["bass", "lead"]) + assert _shape(levels.move_onto(_path("bass"), _path("bass"))) == _shape(levels) + + +class TestDropOntoAStrip(BaseTestSuite): + """A strip is the gap between two bands, counted from the one above the first level.""" + + @pytest.mark.parametrize( + ("position", "expected"), + [ + (0, [["bass"], ["lead"], ["pad"]]), + (1, [["bass"], ["lead"], ["pad"]]), + (2, [["lead"], ["bass"], ["pad"]]), + (3, [["lead"], ["pad"], ["bass"]]), + ], + ) + def test_a_lone_recording_lands_in_the_slot_it_was_dropped_in( + self, + position: int, + expected: List[List[str]], + ) -> None: + levels = _levels(["bass"], ["lead"], ["pad"]) + assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected + + @pytest.mark.parametrize( + ("position", "expected"), + [ + (0, [["bass"], ["lead"], ["pad"]]), + (1, [["lead"], ["bass"], ["pad"]]), + (2, [["lead"], ["pad"], ["bass"]]), + ], + ) + def test_a_recording_leaving_its_peers_opens_a_level( + self, + position: int, + expected: List[List[str]], + ) -> None: + levels = _levels(["bass", "lead"], ["pad"]) + assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py new file mode 100644 index 000000000..4f4e5bd38 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -0,0 +1,188 @@ +from pathlib import Path + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.list import SourceList +from sampletones_application.logic.main.sources.slots import BEND_SLOT, CHANNEL_SLOT +from sampletones_application.view_model.shared.agreement import Agreement +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + + +class TestGatheringRecordings(BaseTestSuite): + def test_a_recording_named_stands_as_a_row_of_its_own(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + assert sources.paths == (Path("/audio/a.wav"),) + assert sources.row_count == 1 + + def test_rows_stand_in_the_order_they_were_added(self) -> None: + sources = SourceList().add_recording(recording("/audio/b.wav")).add_recording(recording("/audio/a.wav")) + assert sources.paths == (Path("/audio/b.wav"), Path("/audio/a.wav")) + + def test_a_path_already_standing_leaves_the_list_as_it_is(self) -> None: + first = recording("/audio/a.wav", [ChannelName.PULSE1]) + again = recording("/audio/a.wav", [ChannelName.NOISE]) + sources = SourceList().add_recording(first).add_recording(again) + assert sources.rows == (first,) + + +class TestGatheringAFolder(BaseTestSuite): + def test_a_folder_stands_as_one_row_holding_its_recordings(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered) + + assert sources.row_count == 1 + assert sources.count == 2 + assert sources.paths == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + def test_a_root_already_standing_leaves_the_list_as_it_is(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_folder(gathered).add_folder(folder("/audio", [recording("/audio/b.wav")])) + assert sources.count == 1 + + def test_a_loose_recording_the_folder_covers_joins_it(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + sources = sources.add_folder(folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")])) + + assert sources.row_count == 1 + assert sources.folder_root_of(Path("/audio/a.wav")) == Path("/audio") + + def test_a_loose_recording_joins_holding_the_settings_it_stood_with(self) -> None: + settled = recording("/audio/a.wav", [ChannelName.NOISE]) + sources = SourceList().add_recording(settled) + sources = sources.add_folder(folder("/audio", [recording("/audio/a.wav", [ChannelName.PULSE1])])) + + gathered = sources.recording(Path("/audio/a.wav")) + assert gathered is not None + assert gathered.settings.channel_set == {ChannelName.NOISE} + + def test_a_recording_another_folder_holds_stays_where_it_is(self) -> None: + sources = SourceList().add_folder(folder("/audio/inner", [recording("/audio/inner/a.wav")])) + sources = sources.add_folder(folder("/audio", [recording("/audio/inner/a.wav"), recording("/audio/b.wav")])) + + assert sources.folder_root_of(Path("/audio/inner/a.wav")) == Path("/audio/inner") + assert sources.folder_root_of(Path("/audio/b.wav")) == Path("/audio") + assert sources.count == 2 + + def test_a_recording_the_reader_named_belongs_to_no_folder(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")) + assert sources.folder_root_of(Path("/audio/a.wav")) is None + + +class TestAFolderStandingForNothing(BaseTestSuite): + """A row names the recordings a run writes, so a folder naming none stays out of the list.""" + + def test_an_empty_folder_leaves_the_list_as_it_is(self) -> None: + sources = SourceList().add_folder(folder("/audio", [])) + + assert sources.rows == () + + def test_a_folder_whose_recordings_another_holds_leaves_it_as_it_is(self) -> None: + held = recording("/audio/takes/a.wav") + sources = SourceList().add_folder(folder("/audio/takes", [held])) + + sources = sources.add_folder(folder("/audio/takes/again", [held])) + + assert sources.row_count == 1 + + +class TestLettingSourcesGo(BaseTestSuite): + def test_a_folder_goes_with_everything_it_holds(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).remove(gathered.key) + assert sources.rows == () + + def test_a_recording_inside_a_folder_goes_from_that_folder(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).remove(SourceKey.recording(Path("/audio/a.wav"))) + + assert sources.row_count == 1 + assert sources.paths == (Path("/audio/b.wav"),) + + def test_a_folder_left_holding_nothing_goes_along_with_its_last_recording(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_folder(gathered).remove(SourceKey.recording(Path("/audio/a.wav"))) + assert sources.rows == () + + def test_a_recording_the_reader_named_goes_on_its_own(self) -> None: + sources = SourceList().add_recording(recording("/audio/a.wav")).add_recording(recording("/audio/b.wav")) + sources = sources.remove(SourceKey.recording(Path("/audio/a.wav"))) + assert sources.paths == (Path("/audio/b.wav"),) + + +class TestSettlingWhatARowStandsFor(BaseTestSuite): + def test_a_recording_settles_on_its_own(self) -> None: + row = recording("/audio/a.wav") + sources = SourceList().add_recording(row).settled(row.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + settled = sources.recording(Path("/audio/a.wav")) + assert settled is not None + assert settled.settings.channel_set == {ChannelName.PULSE1, ChannelName.NOISE} + + def test_a_folder_settles_every_recording_it_holds(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).settled(gathered.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + assert all(ChannelName.NOISE in source.settings.channel_set for source in sources.recordings) + + def test_settling_a_folder_leaves_the_rows_beside_it_alone(self) -> None: + loose = recording("/other/a.wav") + gathered = folder("/audio", [recording("/audio/a.wav")]) + sources = SourceList().add_recording(loose).add_folder(gathered) + sources = sources.settled(gathered.key, CHANNEL_SLOT, ChannelName.NOISE, True) + + untouched = sources.recording(Path("/other/a.wav")) + assert untouched is not None + assert untouched.settings.channel_set == {ChannelName.PULSE1} + + +class TestHowAFolderReads(BaseTestSuite): + def test_a_folder_every_recording_of_which_holds_it_reads_as_all(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.ALL + + def test_a_folder_whose_recordings_differ_reads_as_some(self) -> None: + gathered = folder( + "/audio", + [recording("/audio/a.wav", [ChannelName.PULSE1]), recording("/audio/b.wav", [ChannelName.NOISE])], + ) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.SOME + + def test_a_folder_no_recording_of_which_holds_it_reads_as_none(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav", [ChannelName.PULSE1])]) + sources = SourceList().add_folder(gathered) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.NOISE) == Agreement.NONE + + def test_a_row_the_list_has_none_of_reads_as_none(self) -> None: + assert ( + SourceList().agreement(SourceKey.recording(Path("/audio/a.wav")), CHANNEL_SLOT, ChannelName.PULSE1) + == Agreement.NONE + ) + + +class TestOneGestureOnARow(BaseTestSuite): + def test_a_folder_its_recordings_disagree_on_settles_on_all_of_them(self) -> None: + gathered = folder( + "/audio", + [recording("/audio/a.wav", [ChannelName.PULSE1]), recording("/audio/b.wav", [ChannelName.NOISE])], + ) + sources = SourceList().add_folder(gathered).toggled(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.ALL + + def test_a_folder_every_recording_of_which_holds_it_lets_it_go(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).toggled(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) + assert sources.agreement(gathered.key, CHANNEL_SLOT, ChannelName.PULSE1) == Agreement.NONE + + def test_a_bend_settles_the_same_way_a_channel_does(self) -> None: + gathered = folder( + "/audio", + [ + recording("/audio/a.wav", [ChannelName.TRIANGLE]), + recording("/audio/b.wav", [ChannelName.TRIANGLE]), + ], + ) + sources = SourceList().add_folder(gathered).toggled(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) + assert sources.agreement(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) == Agreement.ALL diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py new file mode 100644 index 000000000..4464866cb --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -0,0 +1,214 @@ +from pathlib import Path +from typing import List, Tuple + +import pytest + +from sampletones_application.logic.main.sources import scan as scan_module +from sampletones_application.logic.main.sources.scan import REPORT_EVERY, FolderScan +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from tests.suite.base import BaseTestSuite + + +@pytest.fixture(name="scan") +def scan_fixture() -> FolderScan: + return FolderScan() + + +def tree(root: Path, count: int, *, deep: int = 0) -> Path: + """A folder holding ``count`` recordings, and ``deep`` more in a folder below it.""" + root.mkdir(parents=True, exist_ok=True) + for index in range(count): + (root / f"take_{index:04d}.wav").touch() + + if deep: + tree(root / "below", deep) + + return root + + +def read(scan: FolderScan, root: Path) -> List[Tuple[Path, Tuple[Path, ...]]]: + """Reads the folder and waits for the walk, reporting what the answer was handed.""" + answered: List[Tuple[Path, Tuple[Path, ...]]] = [] + scan.start(root, lambda found_root, found: answered.append((found_root, found))) + SingleThreadExecutor.join_all() + return answered + + +class TestWhatAWalkFinds(BaseTestSuite): + """The walk goes as deep as the folder does and hands what it found to whoever asked.""" + + def test_every_recording_below_the_folder(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", 3, deep=2) + + answered = read(scan, root) + + assert len(answered[0][1]) == 5 + + def test_the_folder_it_was_asked_about(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", 1) + + answered = read(scan, root) + + assert answered[0][0] == root + + def test_they_arrive_in_name_order(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", 4) + + found = read(scan, root)[0][1] + + assert list(found) == sorted(found) + + def test_a_folder_holding_none_answers_with_none(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", 0) + + assert read(scan, root)[0][1] == () + + +class TestWhatTheReaderIsTold(BaseTestSuite): + """The reader hears which folder is being read and how far the walk has got.""" + + def test_the_folder_is_named_before_the_walk(self, scan: FolderScan, tmp_path: Path) -> None: + named: List[Path] = [] + scan.on_started = named.append + root = tree(tmp_path / "takes", 1) + + read(scan, root) + + assert named == [root] + + def test_the_count_rises_while_it_walks(self, scan: FolderScan, tmp_path: Path) -> None: + counted: List[int] = [] + scan.on_progress = counted.append + root = tree(tmp_path / "takes", REPORT_EVERY * 2) + + read(scan, root) + + assert counted == [REPORT_EVERY, REPORT_EVERY * 2] + + +class TestGivingUp(BaseTestSuite): + """A reader who asked for the wrong folder stops the walk rather than waiting it out.""" + + def test_a_stopped_walk_answers_nobody(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", REPORT_EVERY * 4) + answered: List[Tuple[Path, Tuple[Path, ...]]] = [] + scan.on_progress = lambda _count: scan.stop() + + scan.start(root, lambda found_root, found: answered.append((found_root, found))) + SingleThreadExecutor.join_all() + + assert answered == [] + + def test_it_says_that_it_stopped(self, scan: FolderScan, tmp_path: Path) -> None: + root = tree(tmp_path / "takes", REPORT_EVERY * 4) + stopped: List[bool] = [] + scan.on_stopped = lambda: stopped.append(True) + scan.on_progress = lambda _count: scan.stop() + + scan.start(root, lambda _root, _found: None) + SingleThreadExecutor.join_all() + + assert stopped == [True] + + def test_a_walk_that_ended_leaves_the_next_free_to_start( + self, + scan: FolderScan, + tmp_path: Path, + ) -> None: + root = tree(tmp_path / "takes", 1) + read(scan, root) + + assert scan.running is False + assert len(read(scan, root)) == 1 + + +class TestOneWalkAtATime(BaseTestSuite): + """The answer travels with the walk that earns it, and a walk is let go however it ends.""" + + def test_each_walk_answers_the_caller_that_asked_for_it( + self, + scan: FolderScan, + tmp_path: Path, + ) -> None: + """Two callers ask this one scan — a gathering and a conversion — so a walk that reported + to the other one would convert a folder nobody asked about.""" + first = tree(tmp_path / "first", 2) + second = tree(tmp_path / "second", 3) + gathered: List[Tuple[Path, Tuple[Path, ...]]] = [] + converted: List[Tuple[Path, Tuple[Path, ...]]] = [] + + scan.start(first, lambda root, found: gathered.append((root, found))) + SingleThreadExecutor.join_all() + scan.start(second, lambda root, found: converted.append((root, found))) + SingleThreadExecutor.join_all() + + assert [root for root, _ in gathered] == [first] + assert [root for root, _ in converted] == [second] + + def test_a_walk_that_fails_leaves_the_next_free_to_start( + self, + scan: FolderScan, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A reading that dies partway holds nothing back, so the reader may ask again.""" + root = tree(tmp_path / "takes", 2) + + def raising(_root: Path) -> List[Path]: + raise OSError("the tree went away") + + monkeypatch.setattr(scan_module, "walk_entries", raising) + scan.start(root, lambda _root, _found: None) + SingleThreadExecutor.join_all() + + assert scan.running is False + + monkeypatch.undo() + assert len(read(scan, root)) == 1 + + def test_a_folder_asked_for_while_one_is_read_is_turned_away( + self, + scan: FolderScan, + tmp_path: Path, + ) -> None: + """One walk runs at a time, so the second answer hears nothing until it is asked again.""" + root = tree(tmp_path / "takes", 2) + answered: List[Path] = [] + scan._running.set() + + scan.start(root, lambda found_root, _found: answered.append(found_root)) + SingleThreadExecutor.join_all() + + assert answered == [] + + def test_a_walk_stands_as_running_while_it_hands_its_answer_over( + self, + scan: FolderScan, + tmp_path: Path, + ) -> None: + """The worker outlives its own answer, and a folder asked for in that moment reaches an + executor still holding the last one, so the scan reads as running for as long as it does.""" + root = tree(tmp_path / "takes", 2) + standing: List[bool] = [] + + scan.start(root, lambda _root, _found: standing.append(scan.running)) + SingleThreadExecutor.join_all() + + assert standing == [True] + + def test_a_walk_stands_as_running_while_it_says_it_stopped( + self, + scan: FolderScan, + tmp_path: Path, + ) -> None: + """A walk the reader gave up on reports the same way its answer does, so both leave the + scan free at the one moment the worker does.""" + root = tree(tmp_path / "takes", REPORT_EVERY * 4) + standing: List[bool] = [] + scan.on_progress = lambda _count: scan.stop() + scan.on_stopped = lambda: standing.append(scan.running) + + scan.start(root, lambda _root, _found: None) + SingleThreadExecutor.join_all() + + assert standing == [True] diff --git a/tests/unit/sampletones_application/logic/main/sources/test_slots.py b/tests/unit/sampletones_application/logic/main/sources/test_slots.py new file mode 100644 index 000000000..c1e692043 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_slots.py @@ -0,0 +1,76 @@ +from sampletones_application.logic.main.sources.slots import ( + BEND_SLOT, + CHANNEL_SLOT, + SETTINGS_SLOTS, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.main.sources.factories import settings + + +class TestWhichChannelsASlotIsOfferedOn(BaseTestSuite): + """A choice reaches a reader on the channels the settings it edits put it to.""" + + def test_a_channel_is_offered_on_every_channel(self) -> None: + held = settings(list(ChannelName.items())) + assert all(CHANNEL_SLOT.offers(held, channel_name) for channel_name in ChannelName.items()) + + def test_a_bend_is_offered_on_the_occupied_channels_that_read_one(self) -> None: + held = settings(list(ChannelName.items())) + assert BEND_SLOT.offered(held) == TONE_CHANNELS + assert not BEND_SLOT.offers(held, ChannelName.NOISE) + + def test_a_bend_reaches_no_channel_the_recording_leaves_alone(self) -> None: + """A bend belongs to a channel the recording occupies, so an empty one offers none.""" + assert BEND_SLOT.offered(settings([])) == frozenset() + + +class TestSettlingTheChannelsARecordingOccupies(BaseTestSuite): + def test_a_channel_settled_on_joins_the_ones_held(self) -> None: + settled = CHANNEL_SLOT.settled(settings(), ChannelName.NOISE, True) + assert settled.channel_set == {ChannelName.PULSE1, ChannelName.NOISE} + + def test_a_channel_settled_off_leaves_the_ones_held(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.NOISE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.NOISE, False) + assert settled.channel_set == {ChannelName.PULSE1} + + def test_the_channels_stand_in_the_order_the_application_names_them(self) -> None: + held = settings([ChannelName.NOISE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.PULSE1, True) + assert settled.channels == [ChannelName.PULSE1, ChannelName.NOISE] + + def test_a_channel_settled_off_takes_the_bend_it_carried(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE], [ChannelName.TRIANGLE]) + settled = CHANNEL_SLOT.settled(held, ChannelName.TRIANGLE, False) + assert settled.bends == [] + + +class TestSettlingTheChannelsARecordingBends(BaseTestSuite): + def test_a_bend_settled_on_a_channel_held_stands(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE]) + settled = BEND_SLOT.settled(held, ChannelName.TRIANGLE, True) + assert settled.bend_set == {ChannelName.TRIANGLE} + + def test_a_bend_reaches_only_a_channel_the_recording_occupies(self) -> None: + settled = BEND_SLOT.settled(settings([ChannelName.PULSE1]), ChannelName.TRIANGLE, True) + assert settled.bends == [] + + def test_a_bend_reaches_only_a_channel_whose_hardware_reads_one(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.NOISE]) + settled = BEND_SLOT.settled(held, ChannelName.NOISE, True) + assert settled.bends == [] + + def test_settling_a_bend_leaves_the_channels_as_they_stand(self) -> None: + held = settings([ChannelName.PULSE1, ChannelName.TRIANGLE]) + settled = BEND_SLOT.settled(held, ChannelName.TRIANGLE, True) + assert settled.channels == held.channels + + +class TestTheSlotsARecordingOffers(BaseTestSuite): + def test_every_slot_reads_and_settles_the_choice_it_names(self) -> None: + for slot in SETTINGS_SLOTS: + held = settings(list(TONE_CHANNELS)) + settled = slot.settled(held, ChannelName.TRIANGLE, True) + assert slot.holds(settled, ChannelName.TRIANGLE) + assert not slot.holds(slot.settled(settled, ChannelName.TRIANGLE, False), ChannelName.TRIANGLE) diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py deleted file mode 100644 index f01e9aa72..000000000 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ /dev/null @@ -1,822 +0,0 @@ -from pathlib import Path -from typing import Dict, Final, Optional -from unittest.mock import MagicMock, patch - -import pytest - -from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP -from sampletones_application.logic.main.converter import ( - ConversionSuccess, - ConverterLogic, -) -from sampletones_application.services.conversion.result import ( - ConversionItem, - ReconstructionStep, -) -from sampletones_application.services.result import ServiceProgress -from sampletones_application.view_model.main.converter import ( - ACTIVE_PHASES, - ConversionPhase, - ConverterViewModel, -) -from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, HierarchyMode -from sampletones_core.reconstructions.converter import DirectoryConversion, GroupConversion -from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig -from sampletones_core.reconstructions.stage import ReconstructionStage -from tests.suite.language import FakeLanguageManager - -TEXTS: Final[Dict[str, str]] = { - "main.converter.label.convert_sample_button": "Convert sample", - "main.converter.label.convert_directory_button": "Convert directory", - "main.converter.label.cancel_button": "Cancel", - "main.converter.template.convert_label_template": "{}: {}", - "main.converter.template.progress_template": "Progress: {}/{} files", - "main.converter.template.single_progress_template": "Reconstructing {}...", - "main.converter.template.stage_template": " - {stage} {completed}/{total}", - "main.converter.message.stage_loading": "reading", - "main.converter.message.stage_matching": "matching", - "main.converter.message.stage_decoding": "decoding", - "main.converter.message.stage_rendering": "rendering", - "global.dialog.template.time_estimation": "", -} - -FRAMES: Final[int] = 1100 - - -def _config_writing_under(reconstructions_directory: Path) -> Config: - """A configuration whose reconstructions are written under ``reconstructions_directory``.""" - config = Config() - general = config.general.model_copy(update={"reconstructions_directory": str(reconstructions_directory)}) - return config.model_copy(update={"general": general}) - - -@pytest.fixture -def converter_logic(tmp_path: Path) -> ConverterLogic: - """A converter reading a real configuration, so resolving where a run writes answers as it does live. - - The configuration writes under the test's own directory, which keeps a target this converter - resolves within the test rather than in the reconstructions the developer holds. - """ - reconstructions_directory = tmp_path / "reconstructions" - config_manager = MagicMock() - config_manager.config = _config_writing_under(reconstructions_directory) - config_manager.get_reconstructions_directory.return_value = reconstructions_directory - service = MagicMock() - service.is_running.return_value = False - scheduling = MagicMock( - priorities=MagicMock(schedule=0), - delays=MagicMock(schedule=0, cancel=0), - ) - logic = ConverterLogic( - config_manager, - service, - scheduling=scheduling, - language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] - is_operation_active=lambda: False, - ) - logic.on_view_changed = MagicMock() - logic.generate_library = MagicMock() - logic.is_library_available = lambda: False - return logic - - -class TestCancelDuringLibraryGeneration: - """The converter requests a library when none exists and waits for it. Cancelling during that - wait must abort the pending conversion and stop the in-flight generation.""" - - def test_cancel_while_waiting_cancels_generation_and_finishes( - self, - converter_logic: ConverterLogic, - ) -> None: - cancel_generation = MagicMock() - on_canceled = MagicMock() - converter_logic.cancel_library_generation = cancel_generation - converter_logic.on_canceled = on_canceled - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - assert converter_logic._phase == ConversionPhase.WAITING - - converter_logic.cancel() - - cancel_generation.assert_called_once() - on_canceled.assert_called_once() - assert converter_logic._phase == ConversionPhase.CANCELED - - def test_wait_loop_aborts_once_no_longer_waiting( - self, - converter_logic: ConverterLogic, - ) -> None: - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic.start_conversion() - converter_logic.cancel() - scheduled.reset_mock() - - converter_logic._wait_for_library_and_start() - - converter_logic._service.start.assert_not_called() - scheduled.assert_not_called() - - def test_wait_poll_does_not_emit_a_zero_progress_view( - self, - converter_logic: ConverterLogic, - ) -> None: - """While waiting, the bar reflects the library-generation progress. A re-poll that finds the - library still missing must only re-queue itself, never emit its own view: emitting one would - carry ``progress=0.0`` and momentarily reset the bar.""" - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic.start_conversion() - converter_logic.on_view_changed.reset_mock() - scheduled.reset_mock() - - converter_logic._wait_for_library_and_start() - - converter_logic.on_view_changed.assert_not_called() - scheduled.assert_called_once() - - -class TestNoChannelsGuard: - """With no channels enabled there is nothing to reconstruct, so the conversion must not start.""" - - def test_no_generators_notifies_and_does_not_start( - self, - converter_logic: ConverterLogic, - ) -> None: - config = converter_logic._config_manager.config - converter_logic._config_manager.config = config.model_copy( - update={"generation": config.generation.model_copy(update={"channels": []})} - ) - on_no_generators = MagicMock() - converter_logic.on_no_generators = on_no_generators - - converter_logic.start_conversion() - - on_no_generators.assert_called_once() - converter_logic.generate_library.assert_not_called() - assert converter_logic._phase == ConversionPhase.IDLE - - -class TestOverwriteGuard: - """A single conversion writes one named file, so a run that would replace one asks first. - - A batch settles the question itself — it converts what is still to be written — so the - prompt reaches the reader for the single-file and stems runs alone. - """ - - @staticmethod - def _aimed_at(converter_logic: ConverterLogic, path: Path) -> Path: - """Points the converter at ``path`` and answers where its run would write.""" - converter_logic.set_input_path(path) - config = converter_logic._config_manager.config - return GroupConversion(sources=(path,), stems=StemsConfig()).jobs(config)[0].output_path - - @staticmethod - def _standing(target: Path) -> None: - target.parent.mkdir(parents=True, exist_ok=True) - target.touch() - - def test_a_standing_target_is_put_to_the_reader_and_nothing_starts( - self, - converter_logic: ConverterLogic, - tmp_path: Path, - ) -> None: - source = tmp_path / "song.wav" - source.touch() - target = self._aimed_at(converter_logic, source) - self._standing(target) - on_target_exists = MagicMock() - converter_logic.on_target_exists = on_target_exists - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - on_target_exists.assert_called_once_with(target) - converter_logic.generate_library.assert_not_called() - assert converter_logic._phase == ConversionPhase.IDLE - - def test_a_confirmed_run_goes_ahead( - self, - converter_logic: ConverterLogic, - tmp_path: Path, - ) -> None: - source = tmp_path / "song.wav" - source.touch() - self._standing(self._aimed_at(converter_logic, source)) - on_target_exists = MagicMock() - converter_logic.on_target_exists = on_target_exists - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion(confirmed=True) - - on_target_exists.assert_not_called() - converter_logic.generate_library.assert_called_once() - assert converter_logic._phase == ConversionPhase.WAITING - - def test_a_target_still_to_be_written_starts_straight_away( - self, - converter_logic: ConverterLogic, - tmp_path: Path, - ) -> None: - source = tmp_path / "song.wav" - source.touch() - self._aimed_at(converter_logic, source) - on_target_exists = MagicMock() - converter_logic.on_target_exists = on_target_exists - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - on_target_exists.assert_not_called() - assert converter_logic._phase == ConversionPhase.WAITING - - def test_a_batch_starts_without_asking( - self, - converter_logic: ConverterLogic, - tmp_path: Path, - ) -> None: - """The scan keeps every reconstruction already written, so a standing file stops nothing.""" - sources = tmp_path / "sources" - sources.mkdir() - (sources / "song.wav").touch() - converter_logic.set_input_path(sources) - on_target_exists = MagicMock() - converter_logic.on_target_exists = on_target_exists - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - on_target_exists.assert_not_called() - assert converter_logic._phase == ConversionPhase.WAITING - - -class TestActivePhases: - """``is_active`` reports a conversion occupying resources for every non-idle, non-terminal phase — - 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: - converter_logic._phase = phase - assert converter_logic.is_active is True - - @pytest.mark.parametrize( - "phase", - [ - ConversionPhase.IDLE, - ConversionPhase.COMPLETED, - ConversionPhase.CANCELED, - ConversionPhase.FAILED, - ], - ) - 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 - - -def _last_view_model(converter_logic: ConverterLogic) -> ConverterViewModel: - return converter_logic.on_view_changed.call_args.args[0] - - -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. - """ - - 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") - - converter_logic.emit_initial_view() - - 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: - converter_logic._is_file = False - converter_logic._input_path = Path("/audio/drums") - - converter_logic.emit_initial_view() - - 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: - 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: - converter_logic._input_path = Path("/audio/kick.wav") - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - assert _last_view_model(converter_logic).action_label == "Cancel" - - -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: - converter_logic._is_operation_active = lambda: True - - converter_logic.start_conversion() - - converter_logic._service.start.assert_not_called() - 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: - with patch("sampletones_application.logic.main.converter.CallbackQueue.add"): - converter_logic.start_conversion() - - converter_logic.generate_library.assert_called_once() - assert converter_logic._phase == ConversionPhase.WAITING - - -class TestAssignPaths: - """``get_output_path``'s contract is the ``OSError`` family: those failures abort the - conversion and report through ``on_error``; a failure outside the contract is a bug and - propagates.""" - - @pytest.mark.parametrize( - "error", - [FileNotFoundError("missing"), OSError("invalid path")], - ids=["missing", "invalid"], - ) - def test_path_failure_reports_error_and_aborts( - self, - converter_logic: ConverterLogic, - error: Exception, - ) -> None: - converter_logic.on_error = MagicMock() - - with patch( - "sampletones_application.logic.main.converter.get_output_path", - side_effect=error, - ): - 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: - converter_logic.on_error = MagicMock() - - with ( - patch( - "sampletones_application.logic.main.converter.get_output_path", - side_effect=KeyError("drive"), - ), - pytest.raises(KeyError), - ): - converter_logic._assign_paths(Path("/tmp/input.wav"), MagicMock()) - - converter_logic.on_error.assert_not_called() - - -class TestConversionCompleteHandsOverOutcome: - """A completed conversion tells its listener what it wrote, so the follow-up offer can target - the single reconstruction or the folder holding a batch.""" - - def test_success_carries_the_reconstructions_that_were_written( - self, - converter_logic: ConverterLogic, - ) -> None: - on_success = MagicMock() - converter_logic.on_success = on_success - written = (Path("/reconstructions/kick.rcn"),) - - converter_logic._on_conversion_complete(written) - - assert converter_logic._phase == ConversionPhase.COMPLETED - on_success.assert_called_once_with(ConversionSuccess(written=written)) - - def test_one_written_reconstruction_becomes_the_displayed_output( - self, - converter_logic: ConverterLogic, - ) -> None: - written = (Path("/reconstructions/kick.rcn"),) - - converter_logic._on_conversion_complete(written) - - assert converter_logic._output_path == written[0] - - def test_a_batch_loads_the_folder_and_one_file_loads_itself( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic.on_load_file = MagicMock() - converter_logic.on_load_directory = MagicMock() - - converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"),)) - converter_logic.handle_load_request() - converter_logic.on_load_file.assert_called_once_with(Path("/reconstructions/kick.rcn")) - - converter_logic._on_conversion_complete((Path("/reconstructions/kick.rcn"), Path("/reconstructions/snare.rcn"))) - converter_logic.handle_load_request() - converter_logic.on_load_directory.assert_called_once_with() - - -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: - converter_logic.on_error = MagicMock() - - with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled: - converter_logic._on_conversion_error(RuntimeError("boom")) - - assert converter_logic._phase == ConversionPhase.FAILED - scheduled.assert_called_once() - assert scheduled.call_args.args[0] == converter_logic.close - converter_logic.on_error.assert_called_once() - - -class TestConversionPlan: - """What the converter asks the service to run: one reconstruction for a file, one per audio - file for a directory.""" - - def _prepare(self, converter_logic: ConverterLogic, input_path: Path, is_file: bool) -> Config: - config = Config() - converter_logic._config_manager.config = config - converter_logic._input_path = input_path - converter_logic._is_file = is_file - return config - - def test_a_file_becomes_one_group_over_that_file(self, converter_logic: ConverterLogic) -> None: - config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) - - plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - - assert isinstance(plan, GroupConversion) - assert plan.sources == (Path("/audio/kick.wav"),) - - def test_a_directory_becomes_a_directory_conversion(self, converter_logic: ConverterLogic) -> None: - config = self._prepare(converter_logic, Path("/audio"), is_file=False) - - plan = converter_logic._conversion_plan(config, Path("/audio")) - - assert isinstance(plan, DirectoryConversion) - assert plan.directory == Path("/audio") - - def test_the_setup_covers_every_enabled_channel(self, converter_logic: ConverterLogic) -> None: - """With no stems listed, one stem holds every channel the configuration enables.""" - config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) - channels = list(config.generation.channels) - - plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - - assert plan.stems == StemsConfig.single_entry(channels, channel_cap=len(channels)) - assert plan.stems.covered_channels == frozenset(channels) - - def test_starting_hands_the_plan_to_the_service(self, converter_logic: ConverterLogic) -> None: - config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) - converter_logic._config_manager.config = config - - converter_logic._start_conversion() - - started_config, started_plan = converter_logic._service.start.call_args.args - assert started_config == config - assert isinstance(started_plan, GroupConversion) - - -class TestStemsSetup: - """The rows a reader gathers, and the setup they turn into.""" - - def _with_config(self, converter_logic: ConverterLogic) -> Config: - config = Config() - converter_logic._config_manager.config = config - return config - - def test_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - - converter_logic.select_source(Path("/audio/bass.wav")) - converter_logic.select_source(Path("/audio/lead.wav")) - - assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) - - def test_adding_a_listed_recording_leaves_the_list_as_it_is(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/bass.wav"), Path("/audio/lead.wav")]) - converter_logic.isolate_source(Path("/audio/lead.wav")) - - converter_logic.add_sources([Path("/audio/lead.wav")]) - - assert converter_logic._source_paths == (Path("/audio/bass.wav"), Path("/audio/lead.wav")) - assert converter_logic._levels.level_of(Path("/audio/lead.wav")) == 1 - - def test_the_list_stops_at_the_room_it_has(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - - converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES + 3)]) - - assert converter_logic.source_count == MAX_STEM_SOURCES - - def test_removing_a_recording_takes_it_out(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - converter_logic.remove_source(Path("/audio/a.wav")) - - assert converter_logic._source_paths == (Path("/audio/b.wav"),) - - def test_entering_stems_mode_carries_the_selected_file_in(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic._input_path = Path("/audio/kick.wav") - converter_logic._is_file = True - - converter_logic.set_stems_mode(True) - - assert converter_logic._source_paths == (Path("/audio/kick.wav"),) - - def test_leaving_stems_mode_keeps_the_first_recording(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - converter_logic.set_stems_mode(False) - - assert converter_logic._levels.paths == (Path("/audio/a.wav"),) - - def test_a_stems_conversion_groups_every_listed_recording(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) - - assert isinstance(plan, GroupConversion) - assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) - assert [entry.id for entry in plan.stems.entries] == [0, 1] - - def test_the_rows_channels_and_levels_reach_the_setup(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.PULSE1})) - converter_logic.isolate_source(Path("/audio/b.wav")) - - plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) - - assert plan.stems.entries[0].channels == [ChannelName.PULSE1] - assert plan.stems.hierarchy.levels == [[0], [1]] - - def test_a_recording_left_with_no_channel_takes_no_part(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) - plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) - - assert converter_logic.source_count == 2 - assert plan.sources == (Path("/audio/b.wav"),) - assert [entry.id for entry in plan.stems.entries] == [0] - - def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0) - rows = converter_logic._stem_rows(config) - - assert [(row.path.name, row.level, row.level_count) for row in rows] == [ - ("b.wav", 0, 2), - ("a.wav", 1, 2), - ] - - def test_the_cap_holds_within_the_channels_enabled(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - channels = list(config.generation.channels) - - converter_logic.set_channel_cap(len(channels) + 5) - - assert converter_logic._effective_channel_cap == len(channels) - - def test_a_cap_below_one_is_refused(self, converter_logic: ConverterLogic) -> None: - self._with_config(converter_logic) - - converter_logic.set_channel_cap(0) - - assert converter_logic._effective_channel_cap == MIN_CHANNEL_CAP - - def test_the_cap_reaches_a_classic_conversion_too(self, converter_logic: ConverterLogic) -> None: - """One recording per frame is a choice a reader makes for every conversion, batch included.""" - config = self._with_config(converter_logic) - converter_logic._input_path = Path("/audio/kick.wav") - converter_logic._is_file = True - converter_logic.set_channel_cap(1) - - plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - - assert plan.stems.channel_cap == 1 - - def test_the_hierarchy_mode_reaches_the_setup(self, converter_logic: ConverterLogic) -> None: - config = self._with_config(converter_logic) - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav")]) - - converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) - - plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) - assert plan.stems.hierarchy.mode == HierarchyMode.STRICT - - -class TestStemsView: - """What the panel is told about the setup being built.""" - - def _emitted(self, converter_logic: ConverterLogic) -> ConverterViewModel: - return converter_logic.on_view_changed.call_args.args[0] - - def test_the_rows_reach_the_view_in_list_order(self, converter_logic: ConverterLogic) -> None: - converter_logic._config_manager.config = Config() - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav"), Path("/audio/b.wav")]) - - view_model = self._emitted(converter_logic) - - assert [row.name for row in view_model.stem_sources] == ["a", "b"] - assert view_model.stems_mode is True - assert view_model.has_input is True - - def test_a_row_shows_the_channels_it_may_take(self, converter_logic: ConverterLogic) -> None: - converter_logic._config_manager.config = Config() - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path("/audio/a.wav")]) - converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset({ChannelName.NOISE})) - - assert self._emitted(converter_logic).stem_sources[0].channels == frozenset({ChannelName.NOISE}) - - def test_the_view_states_whether_another_recording_fits(self, converter_logic: ConverterLogic) -> None: - converter_logic._config_manager.config = Config() - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path(f"/audio/{index}.wav") for index in range(MAX_STEM_SOURCES)]) - - view_model = self._emitted(converter_logic) - - assert view_model.source_count == MAX_STEM_SOURCES - assert view_model.can_add_source is False - - def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: ConverterLogic) -> None: - converter_logic._config_manager.config = Config() - converter_logic.set_stems_mode(True) - - view_model = self._emitted(converter_logic) - - assert view_model.has_input is False - assert view_model.convert_button_enabled is False - - -class TestProgressText: - """A batch counts the files it has written; a single job names the reconstruction it is making.""" - - @staticmethod - def _progress( - completed: int, - total: int, - item: Optional[ConversionItem] = None, - partial: float = 0.0, - ) -> ServiceProgress[ConversionItem]: - return ServiceProgress( - completed=completed, - total=total, - eta_seconds=None, - current_item=item, - partial=partial, - ) - - @staticmethod - def _status(converter_logic: ConverterLogic) -> str: - view_model = converter_logic.on_view_changed.call_args.args[0] - return str(view_model.status_text) - - @staticmethod - def _bar(converter_logic: ConverterLogic) -> float: - view_model = converter_logic.on_view_changed.call_args.args[0] - return float(view_model.progress) - - def test_a_batch_counts_its_files(self, converter_logic: ConverterLogic) -> None: - converter_logic._handle_progress_result(self._progress(2, 5)) - - assert self._status(converter_logic) == "Progress: 2/5 files" - - def test_a_single_job_names_the_reconstruction_it_writes(self, converter_logic: ConverterLogic) -> None: - converter_logic._output_path = Path("/reconstructions/track.stn") - - converter_logic._handle_progress_result(self._progress(0, 1)) - - assert self._status(converter_logic) == "Reconstructing track..." - - def test_a_single_job_falls_back_to_the_selected_input(self, converter_logic: ConverterLogic) -> None: - converter_logic._output_path = None - converter_logic._input_path = Path("/audio/kick.wav") - - converter_logic._handle_progress_result(self._progress(0, 1)) - - assert self._status(converter_logic) == "Reconstructing kick..." - - -class TestASingleJobShowsItsProgress: - """One reconstruction is one job, so what moves its bar is the reconstruction's own account. - - Without this a whole conversion reads as nothing done out of one file until the moment it is - written, which tells a reader watching it nothing at all. - """ - - @staticmethod - def _item(stage: ReconstructionStage, completed: int) -> ConversionItem: - return ConversionItem( - source=Path("/audio/kick.wav"), - step=ReconstructionStep(stage=stage, completed=completed, total=FRAMES), - ) - - def test_the_bar_reads_the_work_under_way(self, converter_logic: ConverterLogic) -> None: - converter_logic._handle_progress_result( - TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35) - ) - - assert TestProgressText._bar(converter_logic) == pytest.approx(0.35) - - def test_the_status_names_the_stage_and_its_counts(self, converter_logic: ConverterLogic) -> None: - converter_logic._input_path = Path("/audio/kick.wav") - converter_logic._output_path = None - - converter_logic._handle_progress_result( - TestProgressText._progress(0, 1, item=self._item(ReconstructionStage.MATCHING, 412), partial=0.35) - ) - - assert TestProgressText._status(converter_logic) == "Reconstructing kick... - matching 412/1100" - - def test_a_run_yet_to_say_anything_still_names_its_recording( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._output_path = None - converter_logic._input_path = Path("/audio/kick.wav") - item = ConversionItem(source=Path("/audio/kick.wav")) - - converter_logic._handle_progress_result(TestProgressText._progress(0, 1, item=item)) - - assert TestProgressText._status(converter_logic) == "Reconstructing kick..." - assert TestProgressText._bar(converter_logic) == pytest.approx(0.0) - - def test_a_batch_counts_the_reconstruction_under_way_toward_its_files( - self, - converter_logic: ConverterLogic, - ) -> None: - converter_logic._handle_progress_result( - TestProgressText._progress( - 2, - 5, - item=self._item(ReconstructionStage.RENDERING, FRAMES), - partial=0.5, - ) - ) - - assert TestProgressText._bar(converter_logic) == pytest.approx(0.5) - assert TestProgressText._status(converter_logic) == "Progress: 2/5 files - rendering 1100/1100" diff --git a/tests/unit/sampletones_application/logic/main/test_stems.py b/tests/unit/sampletones_application/logic/main/test_stems.py deleted file mode 100644 index 3a44902cb..000000000 --- a/tests/unit/sampletones_application/logic/main/test_stems.py +++ /dev/null @@ -1,218 +0,0 @@ -from pathlib import Path -from typing import FrozenSet, List, Sequence - -import pytest - -from sampletones_application.logic.main.stems import ( - StemLevels, - StemSource, - derive_conversion_setup, - effective_channels, -) -from sampletones_core.constants.enums import ChannelName, HierarchyMode - -ENABLED: List[ChannelName] = [ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE] - - -def _path(name: str) -> Path: - return Path(f"/audio/{name}.wav") - - -def _source(name: str, channels: FrozenSet[ChannelName] = frozenset(ENABLED)) -> StemSource: - return StemSource(path=_path(name), channels=channels) - - -def _levels(*names: Sequence[str]) -> StemLevels: - return StemLevels.of([[_source(name) for name in level] for level in names]) - - -def _shape(levels: StemLevels) -> List[List[str]]: - return [[source.path.stem for source in level] for level in levels.levels] - - -class TestEffectiveChannels: - def test_a_source_keeps_the_channels_still_enabled(self) -> None: - source = _source("lead", frozenset({ChannelName.PULSE1, ChannelName.PULSE2})) - assert effective_channels(source, ENABLED) == [ChannelName.PULSE1] - - def test_a_source_holding_no_enabled_channel_takes_no_part(self) -> None: - source = _source("lead", frozenset({ChannelName.PULSE2})) - assert effective_channels(source, ENABLED) == [] - - def test_channels_follow_the_order_the_run_enables_them(self) -> None: - source = _source("lead", frozenset({ChannelName.NOISE, ChannelName.PULSE1})) - assert effective_channels(source, ENABLED) == [ChannelName.PULSE1, ChannelName.NOISE] - - -class TestGathering: - """The list a reader builds: recordings arrive on the first level and leave without a trace.""" - - def test_the_first_recording_opens_a_level(self) -> None: - assert _shape(StemLevels().add(_source("bass"))) == [["bass"]] - - def test_further_recordings_join_the_first_level(self) -> None: - levels = StemLevels().add(_source("bass")).add(_source("lead")) - assert _shape(levels) == [["bass", "lead"]] - - def test_a_recording_already_gathered_changes_nothing(self) -> None: - levels = _levels(["bass"]).add(_source("bass")) - assert _shape(levels) == [["bass"]] - - def test_removing_the_last_of_a_level_takes_the_level_with_it(self) -> None: - assert _shape(_levels(["bass"], ["lead"]).remove(_path("bass"))) == [["lead"]] - - def test_leaving_stems_mode_keeps_the_recording_that_picks_first(self) -> None: - assert _shape(_levels(["bass", "lead"], ["pad"]).keep_first()) == [["bass"]] - - def test_a_row_states_where_it_stands(self) -> None: - levels = _levels(["bass", "lead"], ["pad"]) - assert (levels.level_of(_path("lead")), levels.position_of(_path("lead"))) == (0, 1) - - def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None: - with pytest.raises(KeyError): - _levels(["bass"]).level_of(_path("lead")) - - -class TestMovesWithinALevel: - """Position among peers settles which of two equal-cost choices picks first.""" - - def test_a_recording_moves_past_its_neighbor(self) -> None: - assert _shape(_levels(["bass", "lead"]).move_within_level(_path("lead"), -1)) == [["lead", "bass"]] - - def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None: - levels = _levels(["bass", "lead"]) - assert _shape(levels.move_within_level(_path("bass"), -1)) == _shape(levels) - - -class TestMovesBetweenLevels: - def test_a_recording_joins_the_level_below(self) -> None: - assert _shape(_levels(["bass"], ["lead"]).join_level(_path("bass"), 1)) == [["lead", "bass"]] - - def test_a_recording_joins_the_level_above(self) -> None: - assert _shape(_levels(["bass"], ["lead"]).join_level(_path("lead"), -1)) == [["bass", "lead"]] - - def test_joining_past_the_last_level_changes_nothing(self) -> None: - levels = _levels(["bass"], ["lead"]) - assert _shape(levels.join_level(_path("lead"), 1)) == _shape(levels) - - def test_a_recording_takes_a_level_of_its_own_after_the_one_it_shared(self) -> None: - assert _shape(_levels(["bass", "lead"], ["pad"]).isolate(_path("bass"))) == [["lead"], ["bass"], ["pad"]] - - def test_a_recording_already_alone_stays_where_it_is(self) -> None: - levels = _levels(["bass"], ["lead"]) - assert _shape(levels.isolate(_path("bass"))) == _shape(levels) - - -class TestDropOntoARow: - def test_the_dragged_recording_takes_the_place_it_was_dropped_on(self) -> None: - assert _shape(_levels(["bass"], ["lead", "pad"]).move_onto(_path("bass"), _path("pad"))) == [ - ["lead", "bass", "pad"] - ] - - def test_dropping_a_recording_on_itself_changes_nothing(self) -> None: - levels = _levels(["bass", "lead"]) - assert _shape(levels.move_onto(_path("bass"), _path("bass"))) == _shape(levels) - - -class TestDropOntoAStrip: - """A strip is the gap between two bands, counted from the one above the first level.""" - - @pytest.mark.parametrize( - ("position", "expected"), - [ - (0, [["bass"], ["lead"], ["pad"]]), - (1, [["bass"], ["lead"], ["pad"]]), - (2, [["lead"], ["bass"], ["pad"]]), - (3, [["lead"], ["pad"], ["bass"]]), - ], - ) - def test_a_lone_recording_lands_in_the_slot_it_was_dropped_in( - self, - position: int, - expected: List[List[str]], - ) -> None: - levels = _levels(["bass"], ["lead"], ["pad"]) - assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected - - @pytest.mark.parametrize( - ("position", "expected"), - [ - (0, [["bass"], ["lead"], ["pad"]]), - (1, [["lead"], ["bass"], ["pad"]]), - (2, [["lead"], ["pad"], ["bass"]]), - ], - ) - def test_a_recording_leaving_its_peers_opens_a_level( - self, - position: int, - expected: List[List[str]], - ) -> None: - levels = _levels(["bass", "lead"], ["pad"]) - assert _shape(levels.move_to_new_level(_path("bass"), position)) == expected - - -class TestDeriveConversionSetup: - def test_a_recordings_position_is_its_stem_id(self) -> None: - setup = derive_conversion_setup( - _levels(["a", "b"]), - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert [entry.id for entry in setup.stems.entries] == [0, 1] - - def test_recordings_sharing_a_level_pick_together(self) -> None: - setup = derive_conversion_setup( - _levels(["a", "c"], ["b"]), - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert setup.stems.hierarchy.levels == [[0, 1], [2]] - - def test_the_mix_lists_the_recordings_in_entry_order(self) -> None: - setup = derive_conversion_setup( - _levels(["a"], ["b"]), - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.ROUND_ROBIN, - ) - - assert setup.sources == (_path("a"), _path("b")) - - def test_a_recording_holding_no_enabled_channel_reaches_neither_the_mix_nor_the_entries(self) -> None: - levels = StemLevels.of([[_source("a"), _source("silent", frozenset())], [_source("b")]]) - - setup = derive_conversion_setup( - levels, - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert setup.sources == (_path("a"), _path("b")) - assert setup.stems.hierarchy.levels == [[0], [1]] - - def test_a_level_left_with_nobody_taking_part_drops_out(self) -> None: - levels = StemLevels.of([[_source("silent", frozenset())], [_source("b")]]) - - setup = derive_conversion_setup( - levels, - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert setup.stems.hierarchy.levels == [[0]] - - def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None: - setup = derive_conversion_setup( - _levels(["a"]), - ENABLED, - channel_cap=2, - hierarchy_mode=HierarchyMode.ROUND_ROBIN, - ) - - assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 146b98806..54a49f14a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -6,7 +6,7 @@ from sampletones_application.logic.reconstruction.data import ReconstructionData from sampletones_core.audio import mix, write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, bending_channels from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -16,6 +16,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings def _heard(*stem_ids: int) -> StemSelection: @@ -289,8 +290,14 @@ def _stems_data( approximation = np.arange(length, dtype=np.float32) stems_config = StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) @@ -342,8 +349,14 @@ def test_original_mix_mixes_the_selected_recordings( write_wave(second, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.25) stems_config = StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) @@ -419,7 +432,15 @@ def _three_recordings( "audio_filepath": tuple(paths), "stems_data": StemsData( config=StemsConfig( - entries=[StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)], + entries=[ + StemEntry( + id=stem_id, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ) + for stem_id in range(3) + ], hierarchy=StemsHierarchy(levels=[[0], [1], [2]]), ), assignments=[ diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index a9b168cbb..cec36b9a6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -8,13 +8,14 @@ from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import LoadReconstructionError from tests.suite.errors import DIRECTORY_READ_ERRORS from tests.suite.stems import single_entry_stems_data @@ -24,8 +25,14 @@ def _two_entry_stems_data() -> StemsData: return StemsData( config=StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, @@ -404,7 +411,7 @@ def test_locate_audio_raises_file_not_found_when_audio_missing( coefficient=1.0, audio_filepath=(missing_path,), stems_data=single_entry_stems_data( - list(Config().generation.channels), + [ChannelName.PULSE1], {ChannelName.PULSE1: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]}, ), ) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index a5e1a0065..23720b65b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -20,7 +20,7 @@ ) from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.constants.enums import AudioSourceType, ChannelName +from sampletones_core.constants.enums import AudioSourceType, ChannelName, bending_channels from sampletones_core.exports.format import ExportFormat from sampletones_core.instructions import TriangleInstruction from sampletones_core.reconstructions import Reconstruction @@ -29,6 +29,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.constants.nes import PAL_FREQUENCY from sampletones_shared.music import Tuning from sampletones_shared.paths.extensions import ( @@ -921,8 +922,14 @@ def stems_data_fixture( frame_count = len(reconstruction.approximations[ChannelName.PULSE1]) // reconstruction.config.frame_length stems_config = StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=1, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index 633526a31..fcaac3bbf 100644 --- a/tests/unit/sampletones_application/logic/render/test_logic.py +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -247,8 +247,8 @@ def test_a_stop_holds_its_message_over_the_reports_still_arriving( ) ) - assert render.view.phase == RenderPhase.CANCELLING - assert render.view.status_text == "settings.render.message.status_cancelling" + assert render.view.phase == RenderPhase.CANCELING + assert render.view.status_text == "settings.render.message.status_canceling" def test_a_stop_reaches_the_service(self, render: RenderFixture) -> None: render.configure() diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 04fbfd77e..36c5c8c7a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -26,7 +26,7 @@ def make_controller() -> ProjectController: def all_channels() -> FrozenSet[ChannelName]: - """The fully audible mask a synthesiser renders under unless a test moves it.""" + """The fully audible mask a synthesizer renders under unless a test moves it.""" return ALL_CHANNELS @@ -37,7 +37,7 @@ def make_synthesizer( sample_rate: int = DEFAULT_SAMPLE_RATE, active_channels: Callable[[], FrozenSet[ChannelName]] = all_channels, ) -> RowSynthesizer: - """A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies.""" + """A synthesizer rendering at ``sample_rate``, standing in for the output a caller supplies.""" return RowSynthesizer( controller, config, 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 6308e4726..630b93a2a 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 @@ -126,7 +126,7 @@ 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. + synthesizer 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)) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py index 917c4666b..4fb4c125e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -423,7 +423,7 @@ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) assert render_frame(grid.logic) == before -class TestMaterialisation: +class TestMaterialization: """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: diff --git a/tests/unit/sampletones_application/logic/shared/test_file_playback.py b/tests/unit/sampletones_application/logic/shared/test_file_playback.py new file mode 100644 index 000000000..c858bdfdb --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_file_playback.py @@ -0,0 +1,89 @@ +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from sampletones_application.logic.shared.file_playback import FilePlayback +from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_shared.exceptions import InvalidReconstructionError +from sampletones_shared.paths import extensions + +RECONSTRUCTION_LOAD = "sampletones_application.logic.shared.file_playback.Reconstruction.load" + + +class TestWhatItSounds: + """A file the player knows how to sound is a reconstruction or an audio file.""" + + def test_a_reconstruction_sounds(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / f"song{extensions.EXT_FILE_RECONSTRUCTION}") is True + + def test_an_audio_file_sounds(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / "audio.wav") is True + + def test_anything_else_stays_quiet(self, tmp_path: Path) -> None: + assert FilePlayback.plays(tmp_path / "notes.txt") is False + + +class TestHowItSounds: + """A file named on demand outranks the preview a selection sounds on its own.""" + + def test_an_audio_file_asked_for_by_name_plays_at_normal_priority(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play(tmp_path / "audio.wav") + + audio_device_manager.play_file.assert_called_once_with( + tmp_path / "audio.wav", + update=False, + priority=PlaybackPriority.NORMAL, + ) + + def test_a_priority_the_caller_names_is_the_one_it_plays_at(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play_at(tmp_path / "audio.wav", PlaybackPriority.PREVIEW) + + audio_device_manager.play_file.assert_called_once_with( + tmp_path / "audio.wav", + update=False, + priority=PlaybackPriority.PREVIEW, + ) + + def test_a_file_of_another_kind_sounds_nothing(self, tmp_path: Path) -> None: + audio_device_manager = MagicMock() + + FilePlayback(audio_device_manager).play(tmp_path / "notes.txt") + + audio_device_manager.play_file.assert_not_called() + audio_device_manager.play.assert_not_called() + + +class TestAReconstructionThatWillNotRead: + """A reconstruction file under the cursor is untrusted input: any load or playback failure in + the domain (``SampleToNESError``) or I/O (``OSError``) families reports through ``on_error``; + a failure outside those families is a bug and propagates.""" + + @pytest.mark.parametrize( + "error", + [InvalidReconstructionError("corrupt"), PermissionError("denied")], + ids=["domain", "io"], + ) + def test_a_load_failure_is_reported(self, tmp_path: Path, error: Exception) -> None: + audio_device_manager = MagicMock() + playback = FilePlayback(audio_device_manager) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD, side_effect=error): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_called_once_with(error) + audio_device_manager.play.assert_not_called() + + def test_an_unexpected_failure_propagates(self, tmp_path: Path) -> None: + playback = FilePlayback(MagicMock()) + playback.on_error = MagicMock() + + with patch(RECONSTRUCTION_LOAD, side_effect=RuntimeError("bug")), pytest.raises(RuntimeError): + playback.play(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") + + playback.on_error.assert_not_called() diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py index ddea9bf89..2331d1073 100644 --- a/tests/unit/sampletones_application/logic/shared/test_project_source.py +++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py @@ -41,7 +41,7 @@ def test_reconstruction_audio_is_shared( class TestASnapshotIsASource(BaseTestSuite): - """A captured document reads as the source a synthesiser takes.""" + """A captured document reads as the source a synthesizer takes.""" def test_the_live_controller_is_a_source(self, project_controller: ProjectController) -> None: source: ProjectSource = project_controller diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index afc741b38..67c725c6a 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -8,6 +8,7 @@ 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.file_playback import FilePlayback from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode @@ -46,7 +47,7 @@ def _tree( return TreeLogic( session_manager, - audio_device_manager, + FilePlayback(audio_device_manager), scheduling=scheduling, ) @@ -340,49 +341,3 @@ def test_schedule_search_update_clears_pending_query_after_execution(self) -> No tree.on_search_update_needed = lambda: None tree.schedule_search_update("test") assert tree._pending_search_query is None - - -class TestReconstructionAutoplayFailure: - """A reconstruction file under the cursor is untrusted input: any load or playback failure in - the domain (``SampleToNESError``) or I/O (``OSError``) families reports through - ``on_autoplay_error``; a failure outside those families is a bug and propagates.""" - - @pytest.mark.parametrize( - "error", - [InvalidReconstructionError("corrupt"), PermissionError("denied")], - ids=["domain", "io"], - ) - def test_load_failure_reports_autoplay_error( - self, - tmp_path: Path, - error: Exception, - ) -> None: - audio_device_manager = MagicMock() - tree = _tree(audio_device_manager=audio_device_manager) - tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") - - with patch( - "sampletones_application.logic.shared.tree.Reconstruction.load", - side_effect=error, - ): - tree.request_autoplay(node) - - tree.on_autoplay_error.assert_called_once_with(error) - audio_device_manager.play.assert_not_called() - - def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: - tree = _tree() - tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") - - with ( - patch( - "sampletones_application.logic.shared.tree.Reconstruction.load", - side_effect=RuntimeError("bug"), - ), - pytest.raises(RuntimeError), - ): - tree.request_autoplay(node) - - tree.on_autoplay_error.assert_not_called() diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 25be24ed6..d2e1bf8da 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -46,7 +46,7 @@ def outcome(results: List[Any]) -> Any: class StubBackend: - """Records what the service asked for and returns a prepared artefact. + """Records what the service asked for and returns a prepared artifact. The service under test owns the thread boundary and the result contract; what lands on disk belongs to the real backends and is exercised in their own tests. @@ -531,7 +531,7 @@ def on_result(result: Any) -> None: assert call_count == 0 -class CancellingBackend: +class CancelingBackend: """Withdraws the run from inside it, the way a user pressing Cancel does.""" def __init__(self, service: ExportService) -> None: @@ -607,7 +607,7 @@ def test_a_canceled_run_ends_canceled(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", - CancellingBackend(export_service), + CancelingBackend(export_service), build_instrument(), ) assert isinstance(outcome(results), ServiceCanceled) @@ -616,14 +616,14 @@ def test_a_canceled_run_reports_no_failure(self, service, tmp_path) -> None: export_service, results = service export_service.export_instrument( tmp_path / "instrument.nsf", - CancellingBackend(export_service), + CancelingBackend(export_service), build_instrument(), ) assert not any(isinstance(result, (ExportSuccess, ExportError)) for result in results) def test_the_format_stops_where_it_was_told(self, service, tmp_path) -> None: export_service, _ = service - backend = CancellingBackend(export_service) + backend = CancelingBackend(export_service) export_service.export_instrument(tmp_path / "instrument.nsf", backend, build_instrument()) assert backend.stages == [ExportStage.WALKING] diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py index bc1edf8f3..a2fbe6f4a 100644 --- a/tests/unit/sampletones_application/services/render/conftest.py +++ b/tests/unit/sampletones_application/services/render/conftest.py @@ -22,7 +22,7 @@ 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 normalizing pass has a peak to find and a written file - can be checked sample by sample without modelling a generator. + can be checked sample by sample without modeling a generator. """ def __init__( diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py index f40183fff..b8c20f836 100644 --- a/tests/unit/sampletones_application/services/render/test_service.py +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -147,17 +147,17 @@ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: assert not (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() -class TestCancelling(BaseTestSuite): +class TestCanceling(BaseTestSuite): """A canceled render reports itself canceled and names no file.""" - def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: + def _canceling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None) def test_a_canceled_render_leaves_no_file(self, tmp_path: Path) -> None: destination = tmp_path / "song.wav" service = SongRenderService() service.start( - synthesizer=self._cancelling_synthesizer(service), + synthesizer=self._canceling_synthesizer(service), destination=destination, spec=wave_spec(), normalize=False, @@ -171,7 +171,7 @@ def test_a_canceled_render_reports_itself_canceled(self, tmp_path: Path) -> None results: List[RenderResult] = [] service.subscribe(results.append) service.start( - synthesizer=self._cancelling_synthesizer(service), + synthesizer=self._canceling_synthesizer(service), destination=tmp_path / "song.wav", spec=wave_spec(), normalize=False, @@ -183,7 +183,7 @@ def test_a_canceled_render_reports_itself_canceled(self, tmp_path: Path) -> None def test_a_canceled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: service = SongRenderService() service.start( - synthesizer=self._cancelling_synthesizer(service), + synthesizer=self._canceling_synthesizer(service), destination=tmp_path / "song.wav", spec=wave_spec(), normalize=True, diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index 403ff0a0e..e018a3599 100644 --- a/tests/unit/sampletones_application/services/test_conversion.py +++ b/tests/unit/sampletones_application/services/test_conversion.py @@ -125,7 +125,7 @@ def test_on_progress_running_emits_service_progress( assert result.current_item is not None assert result.current_item.source == Path("/some/file.wav") - def test_on_progress_cancelling_emits_service_progress( + def test_on_progress_canceling_emits_service_progress( self, service: Service, ) -> None: @@ -134,7 +134,7 @@ def test_on_progress_cancelling_emits_service_progress( results.clear() progress = TaskProgress(total=5, completed=3) - callbacks["on_progress"](TaskStatus.CANCELLING, progress) + callbacks["on_progress"](TaskStatus.CANCELING, progress) assert len(results) == 1 assert isinstance(results[0], ServiceProgress) diff --git a/tests/unit/sampletones_application/services/test_progress.py b/tests/unit/sampletones_application/services/test_progress.py index d44b94f8e..02fb188ba 100644 --- a/tests/unit/sampletones_application/services/test_progress.py +++ b/tests/unit/sampletones_application/services/test_progress.py @@ -64,7 +64,7 @@ def test_a_fall_of_a_step_is_reported(self) -> None: class TestWhereAnEstimateStands: """A remaining time is stated only where the count travels toward the total.""" - def test_a_stage_travelling_to_its_total_estimates(self) -> None: + def test_a_stage_traveling_to_its_total_estimates(self) -> None: reports: List[ServiceProgress[RenderStage]] = [] progress = StageProgress(RenderStage.SYNTHESIS, TOTAL_SAMPLES, emit=reports.append, estimates=True) progress.advance(TOTAL_SAMPLES) diff --git a/tests/unit/sampletones_application/tags/test_compose.py b/tests/unit/sampletones_application/tags/test_compose.py index 75d3eda0f..62648979d 100644 --- a/tests/unit/sampletones_application/tags/test_compose.py +++ b/tests/unit/sampletones_application/tags/test_compose.py @@ -4,7 +4,12 @@ import pytest -from sampletones_application.tags.compose import TAG_SEPARATOR, compose_tag +from sampletones_application.tags.compose import ( + TAG_DIGEST_LENGTH, + TAG_SEPARATOR, + compose_tag, + identity_part, +) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error @@ -110,3 +115,27 @@ def test_every_segment_is_separated(self) -> None: def test_casing_and_spacing_of_a_runtime_name_do_not_change_the_tag(self) -> None: assert compose_tag("base", "My Layer") == compose_tag("base", "my_layer") + + +class TestIdentityPart: + """A tag built from a name a user gave carries the identity of that name beside it, since the + composer reads two names differing only in case or spacing as one segment.""" + + def test_names_the_composer_reads_alike_carry_parts_of_their_own(self) -> None: + assert identity_part("Kick.wav") != identity_part("kick.wav") + assert compose_tag("base", "Kick.wav") == compose_tag("base", "kick.wav") + + def test_one_name_carries_one_part(self) -> None: + assert identity_part("/audio/kick.wav") == identity_part("/audio/kick.wav") + + def test_a_part_composes_into_a_tag_as_one_segment(self) -> None: + """A name carrying separators of its own contributes them, and the part stands beside it.""" + part = identity_part("kick.wav") + + composed = compose_tag("base", "kick.wav", part, "text") + + assert composed.split(TAG_SEPARATOR).count(part) == 1 + assert composed.endswith(f"{TAG_SEPARATOR}text") + + def test_a_part_is_the_length_a_tag_holds(self) -> None: + assert len(identity_part("kick.wav")) == TAG_DIGEST_LENGTH diff --git a/tests/unit/sampletones_application/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py index 3f8111785..7c098124d 100644 --- a/tests/unit/sampletones_application/test_application_channels.py +++ b/tests/unit/sampletones_application/test_application_channels.py @@ -51,7 +51,7 @@ class TestCase(BaseRegularTestCase): test_cases = ( TestCase( - label="the main tab switches a channel of the reconstructor", + label="the main tab switches a channel of the source settings", tab=Tab.MAIN, expected=Surface.MAIN, ), diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index deed263c4..b7d26e5b6 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,6 +1,6 @@ from contextlib import ExitStack, contextmanager from pathlib import Path -from typing import Any, Callable, Dict, Final, Generator, List +from typing import Any, Callable, Dict, Final, FrozenSet, Generator, List, Tuple, Union from unittest.mock import PropertyMock, patch import dearpygui.dearpygui as dpg @@ -10,22 +10,45 @@ 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.conversion import MAX_STEM_SOURCES from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, SUF_GROUP, SUF_STRIP, SUF_TABLE, + SUF_TABLE_COLUMN, SUF_TEXT, TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + PRE_MAIN_SOURCE_SLOT, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONFIG_PANEL_CONFIG_CELL, + TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, + TAG_MAIN_CONVERTER_GROUP_CONTROLS, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_PANEL, + TAG_MAIN_CONVERTER_RADIO_MODE, TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, + TAG_MAIN_SOURCE_GROUP_GRID, + TAG_MAIN_SOURCE_PANEL, + TAG_MAIN_SOURCE_TEXT_INSPECTING, + TAG_MAIN_SOURCE_TEXT_UNPICKED, ) from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.panels.main import explorer as explorer_module from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.utils.gui.keyboard.modifiers import Modifier from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, TAB_SHORTCUT_IDS, @@ -35,12 +58,17 @@ stop_background_workers, ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor -from sampletones_application.view_model.main.converter import ConversionPhase +from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.shared.stems import StemRowViewModel from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.converter.paths import get_audio_files +from sampletones_core.structures.tree import FileSystemNode, NodeType +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} DRAG_PAYLOAD_SLOT: Final[int] = 3 +UNBUILT_ROW: Final[str] = "browser.row.unbuilt" _DPG_DISPLAY_FUNCTIONS = [ "create_context", @@ -366,12 +394,55 @@ def _press(app: Application, channel: ChannelName, tab: Tab) -> None: with patch.object(app._shell, "get_current_tab", return_value=tab): _press_shortcut(app, CHANNEL_SHORTCUT_IDS[channel]) - def test_the_main_tab_switches_the_generator_a_reconstruction_is_built_from(self, app: Application) -> None: - selected = frozenset(app.config_manager.config.generation.channels) + @staticmethod + def _gathered(app: Application, tmp_path: Path) -> List[Path]: + paths = [] + for name in ["a.wav", "b.wav"]: + path = tmp_path / name + path.touch() + paths.append(path) + + app._main_tab._converter_logic.gather_recordings(paths) + return paths + + def test_the_main_tab_settles_the_channel_on_the_row_picked_out( + self, + app: Application, + tmp_path: Path, + ) -> None: + """The key answers for the row the settings card is pointed at, which is the box beside it.""" + paths = self._gathered(app, tmp_path) + app._main_tab._converter_logic.select_row(paths[0], SourceKind.RECORDING) + held = ChannelName.TRIANGLE in _row_of(app, paths[0]).channels self._press(app, ChannelName.TRIANGLE, Tab.MAIN) - assert frozenset(app.config_manager.config.generation.channels) == selected ^ {ChannelName.TRIANGLE} + assert (ChannelName.TRIANGLE in _row_of(app, paths[0]).channels) is not held + + def test_the_rows_it_was_not_pointed_at_stand_as_they_were( + self, + app: Application, + tmp_path: Path, + ) -> None: + paths = self._gathered(app, tmp_path) + app._main_tab._converter_logic.select_row(paths[0], SourceKind.RECORDING) + held = ChannelName.TRIANGLE in _row_of(app, paths[1]).channels + + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) + + assert (ChannelName.TRIANGLE in _row_of(app, paths[1]).channels) is held + + def test_the_main_tab_holding_nothing_picked_out_leaves_the_list_alone( + self, + app: Application, + tmp_path: Path, + ) -> None: + paths = self._gathered(app, tmp_path) + standing = [_row_of(app, path).channels for path in paths] + + self._press(app, ChannelName.TRIANGLE, Tab.MAIN) + + assert [_row_of(app, path).channels for path in paths] == standing def test_the_sequencer_switches_its_mix(self, app: Application) -> None: self._press(app, ChannelName.NOISE, Tab.SEQUENCER) @@ -396,6 +467,45 @@ def test_the_main_tab_leaves_the_sequencer_mix_alone(self, app: Application) -> assert not app._sequencer_tab.channels.any_muted +class TestTheRemovalKey: + """The key that takes a recording off the list reaches the row the reader picked out. + + 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 in front decides whether + the converter's list is what the press reaches. + """ + + @staticmethod + def _picked(app: Application, tmp_path: Path) -> Path: + """One gathered recording, clicked so the list holds it picked out.""" + path = tmp_path / "a.wav" + path.touch() + app._main_tab._converter_logic.gather_recordings([path]) + _click_row(app, path) + return path + + @staticmethod + def _press(app: Application, tab: Tab) -> None: + with patch.object(app._shell, "get_current_tab", return_value=tab): + _press_shortcut(app, ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_the_main_tab_takes_the_picked_row_off_the_list(self, app: Application, tmp_path: Path) -> None: + path = self._picked(app, tmp_path) + + self._press(app, Tab.MAIN) + + assert not dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + + def test_another_tab_in_front_leaves_the_row_where_it_is(self, app: Application, tmp_path: Path) -> None: + """A picked row outlives a move to another tab, so which tab stands in front is read at + the moment the press lands.""" + path = self._picked(app, tmp_path) + + self._press(app, Tab.SEQUENCER) + + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + + class TestTabKeys: """One key per tab, bringing it to the front from wherever the reader stands. @@ -426,6 +536,304 @@ def drop(tag: str, payload: str) -> None: dpg.get_item_configuration(tag)["drop_callback"](dpg.get_alias_id(tag), payload) +def _click_row(app: Application, path: Path) -> None: + """Clicks a row the way DearPyGui reports a selectable being picked.""" + name_tag = stems_list(app).tags.row(str(path), SUF_TEXT) + dpg.get_item_callback(name_tag)(name_tag, True, str(path)) + + +def _click_slot_box(field: SettingsField, channel_name: ChannelName) -> None: + """Clicks one of the settings card's boxes, the way DearPyGui reports a checkbox.""" + box = compose_tag(PRE_MAIN_SOURCE_SLOT, field.value, channel_name.value) + dpg.get_item_callback(box)(box, True, dpg.get_item_user_data(box)) + + +def _row_of(app: Application, path: Path) -> StemRowViewModel: + """The row the converter last drew for ``path``.""" + row = stems_list(app).row(str(path)) + assert row is not None + return row + + +def _level_of(app: Application, path: Path) -> str: + """The level band the row for ``path`` is drawn in.""" + return str(dpg.get_item_parent(stems_list(app).tags.row(str(path), SUF_GROUP))) + + +def _reports_running(app: Application, status_text: str, progress: float) -> None: + """Puts the panel in front of a conversion under way, the way the converter reports one.""" + converter_logic = app._main_tab._converter_logic + emitted: List[ConverterViewModel] = [] + listener = converter_logic.on_view_changed + converter_logic.on_view_changed = emitted.append + converter_logic.emit_initial_view() + converter_logic.on_view_changed = listener + + running = emitted[0].model_copy( + update={"phase": ConversionPhase.RUNNING, "status_text": status_text, "progress": progress} + ) + app._main_tab._on_converter_view_changed(running) + + +def _ctrl_click_folder(app: Application, directory: Path) -> None: + """Reports a Ctrl-click on a folder's row, the way the browser does.""" + panel = app._main_tab._explorer_panel + node = FileSystemNode(directory.name, node_type=NodeType.DIRECTORY, filepath=directory) + with patch.object(explorer_module, "capture_modifiers", return_value=frozenset({Modifier.CTRL})): + panel._directory_node_clicked(node, UNBUILT_ROW) + + +def _double_click_name(prefix: str, key: str) -> None: + """Double-click one row's name in a stems list, the way DearPyGui reports the gesture.""" + click_row_name(StemsTags(prefix=prefix), key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +class TestGatheringAFolderIntoAMix: + """A folder bringing in more than a mix holds is a question, and the answer reaches the mix. + + The question names the recordings to gather, so what the reader picks is what the setup takes + up — the whole chain from the browser gesture to the rows the card ends up drawing. + """ + + @staticmethod + def _folder(tmp_path: Path, count: int) -> Path: + directory = tmp_path / "takes" + directory.mkdir() + for index in range(count): + (directory / f"take_{index:02d}.wav").touch() + + return directory + + @staticmethod + def _ask(app: Application, directory: Path) -> None: + """Ctrl-clicks the folder and waits for the reading, the way a reader does.""" + _ctrl_click_folder(app, directory) + SingleThreadExecutor.join_all() + + def test_it_asks_rather_than_gathers(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + opened.assert_called_once() + assert app._main_tab._converter_logic.gathered_paths == () + + def test_what_the_reader_picks_is_what_the_mix_takes(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + offered, _room, answer = opened.call_args.args + picked = [row.path for row in offered[:MAX_STEM_SOURCES]] + answer(picked) + + assert set(app._main_tab._converter_logic.gathered_paths) == set(picked) + + def test_the_switch_reads_the_output_the_setup_holds(self, app: Application, tmp_path: Path) -> None: + """A question about a mix leaves the run as it is until the reader answers it.""" + converter_logic = app._main_tab._converter_logic + paths = [] + for index in range(MAX_STEM_SOURCES + 2): + path = tmp_path / f"take_{index:02d}.wav" + path.touch() + paths.append(path) + + converter_logic.gather_recordings(paths) + standing = dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) + + with patch.object(app._main_tab._stem_selection_window, "open"): + app._main_tab._request_output(OutputKind.MIXED) + + assert dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) == standing + + def test_a_folder_the_mix_still_holds_is_gathered(self, app: Application, tmp_path: Path) -> None: + directory = self._folder(tmp_path, MAX_STEM_SOURCES - 1) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + opened.assert_not_called() + assert len(app._main_tab._converter_logic.gathered_paths) == MAX_STEM_SOURCES - 1 + + def test_a_recording_in_the_question_sounds_where_the_reader_asks_for_it( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A reader decides by ear, so a double-click in the question reaches the player the + converter's list reaches, the whole way from the gesture to the device.""" + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + with patch.object(app._main_tab._stem_selection_window, "open") as opened: + self._ask(app, directory) + + offered, room, answer = opened.call_args.args + window = app._main_tab._stem_selection_window + window.open(offered, room, answer) + recording = offered[0] + + with patch.object(app.audio_device_manager, "play_file") as sounded: + _double_click_name(PRE_MAIN_CONVERTER_CANDIDATE, recording.key) + + assert sounded.call_args.args[0] == recording.path + + +class TestMainTabReadingOrder: + """The tab reads in one direction: what a run is set up with, what it gathers, what a row takes. + + The reconstruction card names whichever row the converter's list stands on, so it follows the + list it reads rather than standing above it. + """ + + @staticmethod + def _identity(item: Union[int, str]) -> int: + """One reading of an item, since DearPyGui answers with an alias where a tag names one.""" + return dpg.get_alias_id(item) if isinstance(item, str) else item + + @classmethod + def _place(cls, tag: str) -> Tuple[int, int]: + """Where a card stands: the parent holding it, and its place among that parent's children.""" + item = cls._identity(tag) + parent = cls._identity(dpg.get_item_parent(item)) + children = [cls._identity(child) for child in dpg.get_item_children(parent)[1]] + return parent, children.index(item) + + @classmethod + def _stands_within(cls, tag: str, ancestor: str) -> bool: + item = cls._identity(tag) + wanted = cls._identity(ancestor) + while item: + if item == wanted: + return True + item = cls._identity(dpg.get_item_parent(item)) + + return False + + def test_the_reconstruction_card_follows_the_converter(self, app: Application) -> None: + converter_parent, converter_place = self._place(TAG_MAIN_CONVERTER_PANEL) + card_parent, card_place = self._place(TAG_MAIN_SOURCE_PANEL) + + assert card_parent == converter_parent + assert card_place > converter_place + + def test_the_settings_cards_share_the_row_above(self, app: Application) -> None: + assert self._stands_within(TAG_MAIN_CONFIG_PANEL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW) + assert self._stands_within(TAG_MAIN_ADVANCED_PANEL, TAG_MAIN_CONFIG_TABLE_CONFIG_ROW) + + def test_the_row_holds_its_height_until_both_cards_collapse(self, app: Application) -> None: + """The row is the two settings cards' own, so it is theirs to give up.""" + coordinator = app._main_tab + with ( + patch.object(type(coordinator._config_panel), "collapsed", PropertyMock(return_value=True)), + patch.object(type(coordinator._advanced_settings_panel), "collapsed", PropertyMock(return_value=True)), + ): + coordinator._sync_config_row_height() + + assert dpg.get_item_configuration(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW)["height"] == 0 + + @staticmethod + def _share(cell_tag: str) -> float: + """The share of the settings row the column behind a cell holds.""" + column = compose_tag(cell_tag, SUF_TABLE_COLUMN) + return float(dpg.get_item_configuration(column)["init_width_or_weight"]) + + def test_the_advanced_card_leaves_the_row_and_comes_back_to_its_half(self, app: Application) -> None: + """One toggle leaves the row to the general card, the other gives the advanced one its half. + + Which way the first toggle goes is whatever the session was left at, so the pair of shares + is what the rule states: nothing while the card is put away, and the general card's own + share once it stands again. + """ + coordinator = app._main_tab + general = self._share(TAG_MAIN_CONFIG_PANEL_CONFIG_CELL) + + coordinator.toggle_advanced_settings() + first = self._share(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + coordinator.toggle_advanced_settings() + second = self._share(TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL) + + assert general > 0 + assert {first, second} == {0.0, general} + + +class TestBrowserGathering: + """What a gesture in the browser gathers: a plain click walks it, and gathering is asked for. + + Reading every recording below a folder is work a reader asks for, so it answers the gathering + gesture alone. A plain click walks the browser and leaves the conversion as it is — opening a + folder, playing a recording — which is what keeps navigating a large tree from gathering it. + Ctrl brings in whatever the row names, and a double-click brings in a recording. + """ + + @staticmethod + def _folder(directory: Path) -> FileSystemNode: + return FileSystemNode(directory.name, node_type=NodeType.DIRECTORY, filepath=directory) + + @staticmethod + def _tree(tmp_path: Path) -> Path: + directory = tmp_path / "takes" + directory.mkdir() + (directory / "one.wav").touch() + (directory / "deeper").mkdir() + (directory / "deeper" / "two.wav").touch() + return directory + + def _click(self, app: Application, directory: Path, *, modifiers: FrozenSet[Modifier]) -> None: + """Clicks a folder's row, with whatever the reader was holding down, and lets it settle.""" + panel = app._main_tab._explorer_panel + with patch.object(explorer_module, "capture_modifiers", return_value=modifiers): + panel._directory_node_clicked(self._folder(directory), UNBUILT_ROW) + + SingleThreadExecutor.join_all() + + def test_a_plain_click_gathers_nothing(self, app: Application, tmp_path: Path) -> None: + directory = self._tree(tmp_path) + + self._click(app, directory, modifiers=frozenset()) + + assert app._main_tab._converter_logic.gathered_paths == () + + def test_ctrl_gathers_the_whole_tree_below_it(self, app: Application, tmp_path: Path) -> None: + directory = self._tree(tmp_path) + + self._click(app, directory, modifiers=frozenset({Modifier.CTRL})) + + assert set(app._main_tab._converter_logic.gathered_paths) == { + directory / "one.wav", + directory / "deeper" / "two.wav", + } + + def test_a_plain_click_on_a_recording_gathers_nothing(self, app: Application, tmp_path: Path) -> None: + """A plain click previews a recording, so listening through a folder leaves the run alone.""" + recording = self._recording(tmp_path) + panel = app._main_tab._explorer_panel + + with patch.object(explorer_module, "capture_modifiers", return_value=frozenset()): + panel._audio_node_clicked(self._node(recording)) + + assert app._main_tab._converter_logic.gathered_paths == () + + def test_a_double_click_on_a_recording_gathers_it(self, app: Application, tmp_path: Path) -> None: + """A recording is one path, so naming it costs nothing and one gesture brings it in.""" + recording = self._recording(tmp_path) + panel = app._main_tab._explorer_panel + + panel._on_file_node_double_clicked(0, (dpg.mvMouseButton_Left, 0), (self._node(recording), 0)) + + assert app._main_tab._converter_logic.gathered_paths == (recording,) + + def _recording(self, tmp_path: Path) -> Path: + return self._tree(tmp_path) / "one.wav" + + @staticmethod + def _node(recording: Path) -> FileSystemNode: + return FileSystemNode(recording.name, node_type=NodeType.FILE, filepath=recording) + + class TestConverterStemsCard: """Gathering recordings paints the converter card: a row each, carrying what the reader set.""" @@ -437,56 +845,55 @@ def _gather(self, app: Application, tmp_path: Path, names: List[str]) -> List[Pa paths.append(path) converter_logic = app._main_tab._converter_logic - converter_logic.set_stems_mode(True) - converter_logic.add_sources(paths) + converter_logic.set_output(OutputKind.MIXED) + converter_logic.gather_recordings(paths) return paths def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Path) -> None: paths = self._gather(app, tmp_path, ["a.wav", "b.wav"]) for path in paths: - assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP)) - assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_BUTTON)) + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_BUTTON)) def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Path) -> None: """The row offers a checkbox per channel the configuration enables, ticked as the row holds it.""" path = self._gather(app, tmp_path, ["a.wav"])[0] converter_logic = app._main_tab._converter_logic - enabled = list(converter_logic._config_manager.config.generation.channels) + enabled = app.session_manager.converter_settings.channels kept, cleared = enabled[-1], enabled[0] converter_logic.set_source_channels(path, frozenset({kept})) - assert dpg.get_value(stems_list(app).channel_tag(str(path), kept)) is True - assert dpg.get_value(stems_list(app).channel_tag(str(path), cleared)) is False + assert dpg.get_value(stems_list(app).tags.channel(str(path), kept)) is True + assert dpg.get_value(stems_list(app).tags.channel(str(path), cleared)) is False def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) app._main_tab._converter_logic.remove_source(first) - assert not dpg.does_item_exist(stems_list(app).row_tag(str(first), SUF_GROUP)) - assert dpg.does_item_exist(stems_list(app).row_tag(str(second), SUF_GROUP)) + assert not dpg.does_item_exist(stems_list(app).tags.row(str(first), SUF_GROUP)) + assert dpg.does_item_exist(stems_list(app).tags.row(str(second), SUF_GROUP)) - def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Path) -> None: - self._gather(app, tmp_path, ["a.wav"]) + def test_the_list_stands_whichever_run_the_switch_names(self, app: Application, tmp_path: Path) -> None: + """The gathered sources are what a run converts either way, so the list is always on screen.""" + path = self._gather(app, tmp_path, ["a.wav"])[0] assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True - app._main_tab._converter_logic.set_stems_mode(False) + app._main_tab._converter_logic.set_output(OutputKind.PER_RECORDING) - assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is False + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) def test_the_list_stays_on_screen_while_a_conversion_runs(self, app: Application, tmp_path: Path) -> None: """The setup is what a running conversion is making, so it keeps saying what that is.""" path = self._gather(app, tmp_path, ["a.wav"])[0] - converter_logic = app._main_tab._converter_logic - converter_logic._phase = ConversionPhase.RUNNING - converter_logic.refresh_view() - converter_logic._emit_view_model("running", 0.5) + _reports_running(app, "running", 0.5) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_WINDOW_STEMS)["show"] is True - assert dpg.get_item_configuration(stems_list(app).row_tag(str(path), SUF_BUTTON))["enabled"] is False + assert dpg.get_item_configuration(stems_list(app).tags.row(str(path), SUF_BUTTON))["enabled"] is False def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) @@ -494,47 +901,150 @@ def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> N converter_logic.isolate_source(second) - assert dpg.does_item_exist(stems_list(app).level_tag(0, SUF_TABLE)) - assert dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE)) - assert dpg.does_item_exist(stems_list(app).level_tag(2, SUF_STRIP)) - assert dpg.get_item_parent(stems_list(app).row_tag(str(first), SUF_GROUP)) == stems_list(app).level_tag( + assert dpg.does_item_exist(stems_list(app).tags.level(0, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) + assert dpg.does_item_exist(stems_list(app).tags.level(2, SUF_STRIP)) + assert dpg.get_item_parent(stems_list(app).tags.row(str(first), SUF_GROUP)) == stems_list(app).tags.level( 0, SUF_TABLE ) - assert dpg.get_item_parent(stems_list(app).row_tag(str(second), SUF_GROUP)) == stems_list(app).level_tag( + assert dpg.get_item_parent(stems_list(app).tags.row(str(second), SUF_GROUP)) == stems_list(app).tags.level( 1, SUF_TABLE ) def test_a_row_is_the_thing_you_drag_it_by(self, app: Application, tmp_path: Path) -> None: + """A level is a turn to choose, so the drag that rearranges them arrives with the second.""" + first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + + assert dpg.get_item_children(stems_list(app).tags.row(str(first), SUF_TEXT), DRAG_PAYLOAD_SLOT) + + def test_one_recording_in_a_mix_is_its_own_order(self, app: Application, tmp_path: Path) -> None: path = self._gather(app, tmp_path, ["a.wav"])[0] - assert dpg.get_item_children(stems_list(app).row_tag(str(path), SUF_TEXT), DRAG_PAYLOAD_SLOT) + assert not dpg.does_item_exist(stems_list(app).tags.level(0, SUF_TEXT)) + assert not dpg.get_item_children(stems_list(app).tags.row(str(path), SUF_TEXT), DRAG_PAYLOAD_SLOT) def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Application, tmp_path: Path) -> None: first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) converter_logic = app._main_tab._converter_logic converter_logic.isolate_source(second) - drop(stems_list(app).row_tag(str(second), SUF_TEXT), str(first)) + drop(stems_list(app).tags.row(str(second), SUF_TEXT), str(first)) - assert converter_logic._levels.level_count == 1 + assert _level_of(app, first) == _level_of(app, second) + assert not dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) def test_dropping_a_recording_in_a_gap_opens_a_level(self, app: Application, tmp_path: Path) -> None: first, _second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) - converter_logic = app._main_tab._converter_logic - drop(stems_list(app).level_tag(1, SUF_STRIP), str(first)) + drop(stems_list(app).tags.level(1, SUF_STRIP), str(first)) - assert converter_logic._levels.level_count == 2 - assert converter_logic._levels.level_of(first) == 1 + assert dpg.does_item_exist(stems_list(app).tags.level(1, SUF_TABLE)) + assert _level_of(app, first) == stems_list(app).tags.level(1, SUF_TABLE) + + def test_a_clicked_row_is_what_the_settings_card_edits(self, app: Application, tmp_path: Path) -> None: + """The whole wiring chain: a click on a row, a box on the card, and the row it settles. + + A recording joins holding the channels a run hands out, so the box the case ticks is one + of the two it starts without. + """ + first, second = self._gather(app, tmp_path, ["a.wav", "b.wav"]) + assert ChannelName.PULSE2 not in _row_of(app, second).channels + + _click_row(app, second) + _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) + + assert ChannelName.PULSE2 in _row_of(app, second).channels + assert ChannelName.PULSE2 not in _row_of(app, first).channels + + def _gather_folder(self, app: Application, tmp_path: Path, names: List[str]) -> List[Path]: + """Gathers a folder of recordings as one row, and answers what it holds.""" + root = tmp_path / "takes" + root.mkdir() + paths = [] + for name in names: + path = root / name + path.touch() + paths.append(path) - def test_the_order_explanation_leaves_with_the_control_it_belongs_to(self, app: Application) -> None: - """A tooltip left live over a hidden widget's rectangle explains whatever moved into it.""" converter_logic = app._main_tab._converter_logic + converter_logic.set_output(OutputKind.PER_RECORDING) + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) + return paths + + def test_a_folder_arrives_closed_and_opens_onto_what_it_holds( + self, + app: Application, + tmp_path: Path, + ) -> None: + held = self._gather_folder(app, tmp_path, ["a.wav", "b.wav"]) + root = held[0].parent + name_tag = stems_list(app).tags.row(str(held[0]), SUF_TEXT) + assert not dpg.does_item_exist(name_tag) + + stems_list(app).toggle_folder(str(root)) + + assert dpg.does_item_exist(name_tag) + assert dpg.does_item_exist(stems_list(app).tags.region(str(root))) + + def test_a_recording_inside_an_open_folder_is_what_the_card_edits( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A reader who opens a folder answers for one of its recordings without breaking it up.""" + first, second = self._gather_folder(app, tmp_path, ["a.wav", "b.wav"]) + stems_list(app).toggle_folder(str(first.parent)) + assert ChannelName.PULSE2 not in _row_of(app, second).channels - converter_logic.set_stems_mode(True) + _click_row(app, second) + _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) + + assert ChannelName.PULSE2 in _row_of(app, second).channels + assert ChannelName.PULSE2 not in _row_of(app, first).channels + + def test_the_card_names_the_gesture_that_gives_it_a_row(self, app: Application, tmp_path: Path) -> None: + """The card answers for a picked row, so with none picked it says which gesture picks one.""" + self._gather(app, tmp_path, ["a.wav"]) + + assert dpg.get_item_configuration(TAG_MAIN_SOURCE_TEXT_UNPICKED)["show"] is True + assert dpg.get_item_configuration(TAG_MAIN_SOURCE_GROUP_GRID)["show"] is False + + def test_a_picked_row_brings_the_grid_with_it(self, app: Application, tmp_path: Path) -> None: + path = self._gather(app, tmp_path, ["a.wav"])[0] + + _click_row(app, path) + + assert dpg.get_item_configuration(TAG_MAIN_SOURCE_GROUP_GRID)["show"] is True + assert dpg.get_value(TAG_MAIN_SOURCE_TEXT_INSPECTING) == path.stem + + def test_the_run_controls_arrive_with_the_first_recording(self, app: Application, tmp_path: Path) -> None: + """The choices answer for what is listed, so they stand once there is something to answer for.""" + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_CONTROLS)["show"] is False + + self._gather(app, tmp_path, ["a.wav"]) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_CONTROLS)["show"] is True + + def test_the_order_arrives_with_the_second_recording_in_a_mix(self, app: Application, tmp_path: Path) -> None: + """One recording is its own order, so the choice of how levels take turns arrives with the second.""" + self._gather(app, tmp_path, ["a.wav"]) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_ORDER)["show"] is False + + self._gather(app, tmp_path, ["b.wav"]) + + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_GROUP_ORDER)["show"] is True + + def test_the_order_explanation_leaves_with_the_control_it_belongs_to( + self, + app: Application, + tmp_path: Path, + ) -> None: + """A tooltip left live over a hidden widget's rectangle explains whatever moved into it.""" + self._gather(app, tmp_path, ["a.wav", "b.wav"]) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is True - converter_logic.set_stems_mode(False) + app._main_tab._converter_logic.set_output(OutputKind.PER_RECORDING) + assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is False def test_a_recording_holding_no_channel_grays_out_but_stays_listed( @@ -546,7 +1056,7 @@ def test_a_recording_holding_no_channel_grays_out_but_stays_listed( app._main_tab._converter_logic.set_source_channels(path, frozenset()) - name_tag = stems_list(app).row_tag(str(path), SUF_TEXT) - assert dpg.does_item_exist(stems_list(app).row_tag(str(path), SUF_GROUP)) + name_tag = stems_list(app).tags.row(str(path), SUF_TEXT) + assert dpg.does_item_exist(stems_list(app).tags.row(str(path), SUF_GROUP)) assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT assert dpg.get_item_configuration(name_tag)["enabled"] 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 02af2b2b7..4e8789782 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py @@ -22,6 +22,8 @@ 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.frame import FrameCallbackManager +from tests.suite.frames import Frames _EXPANDED_GLYPH = "v" _COLLAPSED_GLYPH = ">" @@ -34,6 +36,7 @@ _STRIP_PADDING = 8 +HOVER_RECHECK_FRAMES = 2 @pytest.fixture @@ -250,6 +253,28 @@ def test_hover_highlighting_covers_the_rail_as_well_as_the_strip( assert controller.strip_tag in probed assert controller.rail_tag in probed + def test_a_hovered_bar_asks_for_the_frame_that_catches_the_pointer_leaving( + self, + dpg_context: None, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An item hover handler fires only while the pointer is over the item, so the re-check + that restores the idle background waits the couple of frames the hover takes to end.""" + controller = _controller(CollapseAxis.VERTICAL) + _build_card(controller) + held = Frames() + probed: List[str] = [] + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", held.hold) + monkeypatch.setattr(dpg, "is_item_hovered", lambda tag: bool(probed.append(tag)) or True) + controller._on_bar_hover() + probed.clear() + + held.render(HOVER_RECHECK_FRAMES - 1) + assert probed == [] + + held.render() + assert probed == [controller.strip_tag] + def test_toggle_announces_the_new_state(self, dpg_context: None) -> None: controller = _controller(CollapseAxis.HORIZONTAL_LEFT) _build_card(controller) diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_columns.py b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py new file mode 100644 index 000000000..33fa0892c --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_columns.py @@ -0,0 +1,144 @@ +from dataclasses import dataclass +from typing import Iterator, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_TABLE_COLUMN, SUF_TABLE_GAP +from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +_PANEL_GAP = 12 +_LEFT = "test.left" +_MIDDLE = "test.middle" +_RIGHT = "test.right" + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def _fill(parent: str) -> None: + dpg.add_text("card", parent=parent) + + +def _columns(*tags: str) -> Tuple[ColumnSpec, ...]: + return tuple(ColumnSpec(tag=tag, build=_fill) for tag in tags) + + +def _weight(tag: str) -> float: + return float(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_COLUMN))["init_width_or_weight"]) + + +def _gap(tag: str) -> float: + return float(dpg.get_item_configuration(compose_tag(tag, SUF_TABLE_GAP))["init_width_or_weight"]) + + +class TestARowDeclaresWhatItsColumnsTake(BaseTestSuite): + """A row states each column's share rather than reading it back from the card inside it, so the + proportions hold whatever its cards draw and a column put away can come back at the share it + was declared with.""" + + def test_a_stretching_column_takes_a_share_of_its_own(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=_columns(_LEFT, _RIGHT)) + + assert _weight(_LEFT) == _weight(_RIGHT) + assert _weight(_LEFT) > 0 + + def test_a_fixed_column_takes_the_width_it_names(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row( + panel_gap=_PANEL_GAP, + columns=( + ColumnSpec(tag=_LEFT, build=_fill, width=240), + ColumnSpec(tag=_RIGHT, build=_fill), + ), + ) + + assert _weight(_LEFT) == 240 + + def test_a_gap_stands_between_neighbors(self, dpg_context: None) -> None: + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=_columns(_LEFT, _RIGHT)) + + assert _gap(_RIGHT) == _PANEL_GAP + + +class TestARowDividesItselfAmongTheColumnsStanding(BaseTestSuite): + """A card the reader puts away leaves its column nothing to hold, so the row gives its share to + the columns still standing and keeps one gap between each of them.""" + + @dataclass(frozen=True, kw_only=True) + class StandingCase(BaseRegularTestCase): + declared: Tuple[str, ...] + standing: Tuple[str, ...] + expected_weights: Tuple[float, ...] + expected_gaps: Tuple[float, ...] + + test_cases = ( + StandingCase( + label="both_stand", + declared=(_LEFT, _RIGHT), + standing=(_LEFT, _RIGHT), + expected_weights=(1.0, 1.0), + expected_gaps=(_PANEL_GAP,), + ), + StandingCase( + label="the_last_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(_LEFT,), + expected_weights=(1.0, 0.0), + expected_gaps=(0.0,), + ), + StandingCase( + label="the_first_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(_RIGHT,), + expected_weights=(0.0, 1.0), + expected_gaps=(0.0,), + ), + StandingCase( + label="the_middle_is_put_away", + declared=(_LEFT, _MIDDLE, _RIGHT), + standing=(_LEFT, _RIGHT), + expected_weights=(1.0, 0.0, 1.0), + expected_gaps=(0.0, _PANEL_GAP), + ), + StandingCase( + label="every_column_is_put_away", + declared=(_LEFT, _RIGHT), + standing=(), + expected_weights=(0.0, 0.0), + expected_gaps=(0.0,), + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_row_holds_what_stands(self, case: StandingCase, dpg_context: None) -> None: + columns = _columns(*case.declared) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + + TabColumns.stand_columns(columns, frozenset(case.standing), _PANEL_GAP) + + assert tuple(_weight(tag) for tag in case.declared) == case.expected_weights + assert tuple(_gap(tag) for tag in case.declared[1:]) == case.expected_gaps + + def test_a_column_comes_back_at_the_share_it_was_declared_with(self, dpg_context: None) -> None: + columns = _columns(_LEFT, _RIGHT) + with dpg.window(): + TabColumns.row(panel_gap=_PANEL_GAP, columns=columns) + + TabColumns.stand_columns(columns, frozenset({_LEFT}), _PANEL_GAP) + TabColumns.stand_columns(columns, frozenset({_LEFT, _RIGHT}), _PANEL_GAP) + + assert _weight(_LEFT) == _weight(_RIGHT) + assert _gap(_RIGHT) == _PANEL_GAP diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py new file mode 100644 index 000000000..a3dab1e4b --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -0,0 +1,260 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.ui.elements.layout.geometry import ( + GEOMETRY_TOLERANCE, + MINIMUM_ROW_PITCH, + RowGeometry, + Window, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +OVERSCAN = 2 +PITCH = 20.0 +OPENING = 16.0 +REGION_HEIGHT = 100.0 +TOTAL_ROWS = 100 +READING_STEPS = 40 + + +def measured(*, overscan: int = OVERSCAN, pitch: float = PITCH) -> RowGeometry: + """A reading of the room one row takes, as a region that has drawn rows would hold it.""" + return RowGeometry(overscan=overscan, pitch=pitch, opening=OPENING) + + +class TestAnUnmeasuredGeometry(BaseTestSuite): + """A geometry that has yet to read a row works from the height its layout gives one, so a + first draw builds about the rows the region shows.""" + + def test_it_reports_no_reading(self) -> None: + assert not RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).measured + + def test_it_works_from_the_opening_it_was_given(self) -> None: + assert RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).room == OPENING + + def test_an_opening_under_the_floor_is_held_to_it(self) -> None: + """No theme draws a row that small, so a figure below the floor opens at the floor.""" + opened = RowGeometry.opening_at(overscan=OVERSCAN, opening=MINIMUM_ROW_PITCH / 2) + assert opened.room == MINIMUM_ROW_PITCH + + def test_its_window_covers_what_a_measured_one_covers(self) -> None: + """The opening stands close to what a row takes, so the first slice reaches the rows the + region shows rather than several times as many.""" + unmeasured = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert unmeasured.size(REGION_HEIGHT) >= measured().size(REGION_HEIGHT) + assert unmeasured.size(REGION_HEIGHT) < 2 * measured().size(REGION_HEIGHT) + + def test_it_still_holds_a_long_list_back(self) -> None: + assert RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).windows(height=REGION_HEIGHT, total=10_000) + + def test_a_short_list_is_drawn_whole(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=3) == (0, 3) + + +class TestAMeasuredGeometry(BaseTestSuite): + """A reading taken from drawn rows is what every room the region reserves is worked out from.""" + + def test_the_room_is_the_reading(self) -> None: + assert measured().room == PITCH + + def test_it_reports_a_reading(self) -> None: + assert measured().measured + + +class TestWindowSize(BaseTestSuite): + """The window holds the rows the region shows and an overscan beyond each edge, so a scroll + in either direction meets rows already standing.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + height: float + pitch: float + overscan: int + expected: int + + test_cases = ( + TestCase(label="five_rows_fit", height=100.0, pitch=20.0, overscan=2, expected=10), + TestCase(label="a_partial_row_counts", height=110.0, pitch=20.0, overscan=2, expected=10), + TestCase(label="no_overscan_shows_what_fits", height=100.0, pitch=20.0, overscan=0, expected=6), + TestCase(label="a_region_shorter_than_a_row_holds_one", height=5.0, pitch=20.0, overscan=0, expected=1), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_size_counts_what_shows_and_the_overscan(self, test_case: TestCase) -> None: + geometry = measured(overscan=test_case.overscan, pitch=test_case.pitch) + assert geometry.size(test_case.height) == test_case.expected + + +class TestSliceOf(BaseTestSuite): + """Where a window opens is where the offset stands counted in the rooms the region reserves + by, so the top of the list is reachable at the top and the end of it at the end.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + offset: float + total: int + expected: Window + + test_cases = ( + TestCase(label="a_short_list_is_drawn_whole", offset=0.0, total=8, expected=(0, 8)), + TestCase(label="a_list_the_size_of_the_window_is_drawn_whole", offset=0.0, total=10, expected=(0, 10)), + TestCase(label="the_top_opens_at_the_first_row", offset=0.0, total=TOTAL_ROWS, expected=(0, 10)), + TestCase(label="a_row_down_carries_the_overscan", offset=PITCH, total=TOTAL_ROWS, expected=(0, 10)), + TestCase(label="the_middle_opens_halfway_down", offset=950.0, total=TOTAL_ROWS, expected=(45, 10)), + TestCase(label="the_end_opens_at_the_last_rows", offset=1900.0, total=TOTAL_ROWS, expected=(90, 10)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_slice_follows_the_rooms_the_offset_counts_out(self, test_case: TestCase) -> None: + window = measured().slice_of( + offset=test_case.offset, + height=REGION_HEIGHT, + total=test_case.total, + ) + assert window == test_case.expected + + def test_a_window_holds_the_same_rows_however_long_the_list(self) -> None: + """This is the claim a folder rests on: opening ten thousand costs what opening ten costs, + because the rows outside the window stand as reserved room rather than as widgets.""" + geometry = measured() + + _, few = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=TOTAL_ROWS) + _, many = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=100 * TOTAL_ROWS) + + assert few == many + + def test_the_end_of_the_list_is_reachable(self) -> None: + """A region scrolled to the end of its rows builds the rows at the end of its list.""" + total = 5_000 + geometry = measured() + start, count = geometry.slice_of(offset=total * PITCH, height=REGION_HEIGHT, total=total) + assert start + count == total + + @pytest.mark.parametrize("offset", range(0, 2001, 37)) + def test_a_window_stays_inside_the_list_at_any_offset(self, offset: int) -> None: + start, count = measured().slice_of( + offset=float(offset), + height=REGION_HEIGHT, + total=TOTAL_ROWS, + ) + assert start >= 0 + assert start + count <= TOTAL_ROWS + + def test_the_window_only_moves_forward_as_the_region_scrolls(self) -> None: + geometry = measured() + previous = 0 + for offset in range(0, 10_001, 13): + start, _ = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=500) + assert start >= previous + previous = start + + +class TestTheWindowCoversTheRegion(BaseTestSuite): + """The rows a window names stand across the region the offset it was chosen for is looking at. + + A window is placed by the room reserved above it and filled with rows of whatever height the + theme draws them at, so the two agree in length only while the reading is exact. The rows + have to reach across the region either way: a reading over the height a row really takes is + what a list whose rows are drawn in tables of their own leaves behind, and a window chosen + against it left the foot of an open folder blank and its last rows out of reach. + """ + + @pytest.mark.parametrize("drawn", (PITCH * 0.8, PITCH, PITCH * 1.3)) + def test_the_rows_it_names_reach_across_the_region(self, drawn: float) -> None: + geometry = measured() + for offset in self._offsets(geometry, drawn): + start, count = geometry.slice_of(offset=offset, height=REGION_HEIGHT, total=TOTAL_ROWS) + head = float(geometry.reserve(start)) + assert head <= offset + assert head + count * drawn >= offset + REGION_HEIGHT + + @pytest.mark.parametrize("drawn", (PITCH * 0.8, PITCH, PITCH * 1.3)) + def test_the_last_row_stands_at_the_foot(self, drawn: float) -> None: + geometry = measured() + start, count = geometry.slice_of( + offset=self._offsets(geometry, drawn)[-1], + height=REGION_HEIGHT, + total=TOTAL_ROWS, + ) + assert start + count == TOTAL_ROWS + + @staticmethod + def _offsets(geometry: RowGeometry, drawn: float) -> Tuple[float, ...]: + """Every position the reader can scroll a region whose rows are drawn ``drawn`` tall. + + What the region holds is the two reserves and the block of drawn rows, so a row drawn at + a height the reading overstates makes the content shorter than the rooms it was reserved + in, and the reader stops short of the travel the reserves alone would give. + """ + count = geometry.size(REGION_HEIGHT) + content = geometry.reserve(TOTAL_ROWS) - geometry.reserve(count) + count * drawn + travel = content - REGION_HEIGHT + return tuple(travel * step / READING_STEPS for step in range(READING_STEPS + 1)) + + +class TestReserve(BaseTestSuite): + """The rows a window passed over stand as the room they would have taken, which is what keeps + the scrollbar proportional to the whole list.""" + + @pytest.mark.parametrize("rows", (0, 1, 7, 5_000)) + def test_reserve_is_the_room_those_rows_take(self, rows: int) -> None: + assert measured().reserve(rows) == int(rows * PITCH) + + def test_an_unmeasured_geometry_reserves_by_its_opening(self) -> None: + rows = 100 + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.reserve(rows) == int(rows * OPENING) + + def test_the_reserves_and_the_drawn_rows_span_the_list(self) -> None: + geometry = measured() + total = 100 + start, count = geometry.slice_of(offset=400.0, height=REGION_HEIGHT, total=total) + spanned = geometry.reserve(start) + geometry.reserve(count) + geometry.reserve(total - start - count) + assert spanned == geometry.reserve(total) + + +class TestTake(BaseTestSuite): + """A reading is taken from the block of rows a region drew, so whatever a table lays around + its rows is carried by the same number that reserves room for them.""" + + def test_a_reading_gives_the_room_one_row_takes(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + geometry.take(block=200.0, rows=10) + assert geometry.pitch == pytest.approx(20.0) + assert geometry.measured + + def test_a_first_reading_is_worth_redrawing(self) -> None: + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + assert geometry.take(block=200.0, rows=10) + + def test_a_reading_that_holds_asks_for_no_redraw(self) -> None: + geometry = measured() + assert not geometry.take(block=PITCH * 10, rows=10) + + def test_a_move_within_the_tolerance_asks_for_no_redraw(self) -> None: + geometry = measured() + drift = GEOMETRY_TOLERANCE / 2 + assert not geometry.take(block=(PITCH + drift) * 10, rows=10) + + def test_a_move_past_the_tolerance_is_worth_redrawing(self) -> None: + geometry = measured() + drift = GEOMETRY_TOLERANCE * 2 + assert geometry.take(block=(PITCH + drift) * 10, rows=10) + + @pytest.mark.parametrize("rows", (0, -1)) + def test_a_block_of_no_rows_leaves_the_reading_alone(self, rows: int) -> None: + geometry = measured() + assert not geometry.take(block=200.0, rows=rows) + assert geometry.pitch == PITCH + + @pytest.mark.parametrize("block", (0.0, -50.0, MINIMUM_ROW_PITCH * 10 - 1)) + def test_a_block_measured_while_the_rows_were_clipped_is_let_be(self, block: float) -> None: + """A block giving a row less than the floor is a region measured before its rows were + placed, and taking it would put every region sharing the reading out of step.""" + geometry = measured() + assert not geometry.take(block=block, rows=10) + assert geometry.pitch == PITCH diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py new file mode 100644 index 000000000..4f8db0e6a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -0,0 +1,620 @@ +from typing import Any, Iterator, List, Optional, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_LEAD +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import ( + NO_GUTTER, + NO_MARGIN, + NO_SCROLL, + LeadBuilder, + WindowedRegion, +) +from sampletones_application.ui.elements.layout.well import well +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 +from tests.suite.base import BaseTestSuite + +ROOT_TAG = "test_root" +REGION_TAG = "test.region" +PITCH = 20.0 +OPENING = 8.0 +OVERSCAN = 2 +CEILING = 100 +HEADING_TEXT = "channels" +STANDING_OFFSET = 300.0 +PADDING = 8 +GUTTER = 13 +MARGIN = 6 +LEAD_TAG = compose_tag(REGION_TAG, SUF_LEAD) +HEADING_HEIGHT = 50.0 +NO_HEADING = 0.0 +FITTING_ROWS = 4 +MEASURING_ROWS = 8 + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + """Stands up the context and themes a recessed region binds while it draws.""" + dpg.create_context() + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture +def region(dpg_context: None) -> WindowedRegion: + """A region whose reading of a row is already taken, as a list that has drawn rows leaves it.""" + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + +def heading(parent: str) -> None: + """A heading of the kind a list stands above its rows.""" + dpg.add_text(HEADING_TEXT, parent=parent) + + +def draw(region: WindowedRegion, total: int, *, lead: Optional[LeadBuilder] = None) -> List[Tuple[int, int]]: + """Draw a list of ``total`` rows, reporting the slice the region asked to be built.""" + asked: List[Tuple[int, int]] = [] + + def build(start: int, count: int) -> None: + asked.append((start, count)) + for index in range(start, start + count): + dpg.add_text(f"row {index}", parent=region.body) + + region.draw(total, build, lead=lead) + return asked + + +def block_of(height: float, *, lead: float = NO_HEADING) -> Any: + """Stands in for the rows a frame placed, and for the heading standing above them. + + A block is measured whole, so the heading's own room is part of what a region reads and comes + out of it again before a row is counted from what is left. + """ + + def measured(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + return [0.0, lead if item == LEAD_TAG else height] + + return patch.object(dpg, "get_item_rect_size", side_effect=measured) + + +def reserves(region: WindowedRegion) -> Tuple[int, int]: + """The room standing above and below the rows the region drew.""" + spacers = [ + child + for child in dpg.get_item_children(region.body, 1) + if dpg.get_item_type(child) == "mvAppItemType::mvSpacer" + ] + return ( + int(dpg.get_item_configuration(spacers[0])["height"]), + int(dpg.get_item_configuration(spacers[-1])["height"]), + ) + + +class TestAShortList(BaseTestSuite): + """A list the region can show whole is built whole, with no room reserved either side.""" + + def test_every_row_is_built(self, region: WindowedRegion) -> None: + assert draw(region, 4) == [(0, 4)] + + def test_nothing_is_reserved(self, region: WindowedRegion) -> None: + draw(region, 4) + assert reserves(region) == (0, 0) + + def test_the_window_names_the_whole_list(self, region: WindowedRegion) -> None: + draw(region, 4) + assert region.window == (0, 4) + + +class TestALongList(BaseTestSuite): + """A list outgrowing the region is built a slice at a time, the rest standing as room.""" + + def test_only_the_slice_is_built(self, region: WindowedRegion) -> None: + assert draw(region, 500) == [(0, 10)] + + def test_the_rows_still_to_come_are_reserved(self, region: WindowedRegion) -> None: + draw(region, 500) + above, below = reserves(region) + assert above == 0 + assert below == int((500 - 10) * PITCH) + + def test_the_reserves_and_the_slice_span_the_whole_list(self, region: WindowedRegion) -> None: + draw(region, 500) + above, below = reserves(region) + _, count = region.window + assert above + int(count * PITCH) + below == int(500 * PITCH) + + +class TestAnUnmeasuredRegion(BaseTestSuite): + """A region with no reading of a row yet builds a first slice at its natural height and + reserves nothing, which is the run of rows a reading is then taken from.""" + + @pytest.fixture + def unmeasured(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_it_builds_a_bounded_slice_of_a_long_list(self, unmeasured: WindowedRegion) -> None: + asked = draw(unmeasured, 5_000) + assert asked[0][0] == 0 + assert asked[0][1] < 5_000 + + def test_it_reserves_nothing_while_it_has_no_reading(self, unmeasured: WindowedRegion) -> None: + draw(unmeasured, 5_000) + assert reserves(unmeasured) == (0, 0) + + def test_a_short_list_is_still_built_whole(self, unmeasured: WindowedRegion) -> None: + assert draw(unmeasured, 4) == [(0, 4)] + + +class TestAReadingTheHeightHasYetToFollow(BaseTestSuite): + """A region reads what a row takes from the rows it drew, which is a frame after it was sized. + + The reading is what says how tall the whole list stands, so a region holding it is standing at + a height decided before it knew: :attr:`settling` is what asks for the pass that puts it right, + and without one a long list stands as tall as every row it drew. + """ + + @pytest.fixture + def unmeasured(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_a_reading_asks_for_the_pass_that_holds_the_region_to_it(self, unmeasured: WindowedRegion) -> None: + drawn = draw(unmeasured, 500) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + assert unmeasured.settling + + def test_that_pass_holds_the_region_to_its_ceiling(self, unmeasured: WindowedRegion) -> None: + drawn = draw(unmeasured, 500) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + assert unmeasured.settle() + assert not unmeasured.natural + assert draw(unmeasured, 500)[0][1] < drawn[0][1] + + def test_it_comes_to_rest_once_the_height_follows_the_reading(self, unmeasured: WindowedRegion) -> None: + """A list the region shows whole holds nothing back, so the reading is the last thing due.""" + drawn = draw(unmeasured, 4) + with block_of(drawn[0][1] * PITCH): + unmeasured.settle() + + unmeasured.settle() + + assert not unmeasured.settling + + def test_a_region_taking_the_height_it_measures_asks_to_be_read_back( + self, + unmeasured: WindowedRegion, + ) -> None: + """The height a region opens at is not the one it measures, and a move is a move. + + Whatever the region is drawn inside measures it as it stands, so the height it takes to + read a row by asks for the same further pass a height taken from a reading does. + """ + draw(unmeasured, 4) + + unmeasured.settle() + + assert unmeasured.settling + + def test_a_region_with_nothing_to_read_comes_to_rest_where_it_stands( + self, + unmeasured: WindowedRegion, + ) -> None: + """A region drawn where no frame has placed its rows measures nothing, and settles anyway. + + The first pass moves it off the height it opened at, which is a move like any other and + asks to be read back. The pass that follows finds it standing where it already stood, so a + region with nothing to measure comes to rest rather than asking on every frame. + """ + draw(unmeasured, 4) + unmeasured.settle() + + unmeasured.settle() + + assert not unmeasured.settling + + +class TestAHeightTheFrameHasYetToShow(BaseTestSuite): + """A region that takes a new height stands at its old one until the frame after. + + Whatever the region is drawn inside measures it as it stands, so a region reporting itself at + rest the moment it resizes leaves the one around it holding a scrollbar over content that fits. + """ + + @pytest.fixture + def sizing(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + def test_a_region_that_shrank_below_its_ceiling_asks_for_another_pass( + self, + sizing: WindowedRegion, + ) -> None: + draw(sizing, 500) + sizing.settle() + + draw(sizing, 2) + sizing.settle() + + assert sizing.natural + assert sizing.settling + + def test_a_region_standing_where_it_stood_comes_to_rest(self, sizing: WindowedRegion) -> None: + draw(sizing, 2) + sizing.settle() + + sizing.settle() + + assert not sizing.settling + + +class TestRedrawing(BaseTestSuite): + """A region drawn again replaces what it held, so its rows stand once however often it is + rebuilt.""" + + def test_a_second_draw_builds_the_slice_once(self, region: WindowedRegion) -> None: + draw(region, 500) + draw(region, 500) + _, count = region.window + assert len(dpg.get_item_children(region.body, 1)) == count + 2 + + def test_a_shorter_list_reserves_less(self, region: WindowedRegion) -> None: + draw(region, 500) + draw(region, 20) + _, below = reserves(region) + assert below == int((20 - 10) * PITCH) + + +class TestALead(BaseTestSuite): + """A heading standing above the rows is built with them and scrolls with them.""" + + def test_the_heading_stands_before_the_rows(self, region: WindowedRegion) -> None: + draw(region, 4, lead=heading) + first = dpg.get_item_children(region.body, 1)[0] + + assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" + + def test_the_rows_are_reserved_around_as_they_are_without_one(self, region: WindowedRegion) -> None: + draw(region, 500, lead=heading) + + assert reserves(region) == (0, int((500 - 10) * PITCH)) + + def test_a_region_drawn_again_holds_one_heading(self, region: WindowedRegion) -> None: + draw(region, 4, lead=heading) + draw(region, 4, lead=heading) + groups = [ + child + for child in dpg.get_item_children(region.body, 1) + if dpg.get_item_type(child) == "mvAppItemType::mvGroup" + ] + + assert len(groups) == 1 + + def test_the_room_the_rows_ask_for_counts_the_heading(self, region: WindowedRegion) -> None: + """Rows that stand inside the region alone outgrow it once a heading stands above them.""" + draw(region, FITTING_ROWS, lead=heading) + + with block_of(NO_HEADING, lead=HEADING_HEIGHT): + region.settle() + + assert not region.natural + + def test_the_same_rows_without_one_stand_inside_it(self, region: WindowedRegion) -> None: + """What puts the rows past the ceiling is the heading, so without one they fit.""" + draw(region, FITTING_ROWS, lead=heading) + + with block_of(NO_HEADING, lead=NO_HEADING): + region.settle() + + assert region.natural + + def test_the_reading_of_a_row_leaves_the_heading_out(self, dpg_context: None) -> None: + """A block is measured with the heading in it, so a row is counted from what is left.""" + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) + built = WindowedRegion( + tag=REGION_TAG, + geometry=geometry, + ceiling=CEILING, + padding=0, + margin=0, + gutter=NO_GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + drawn = draw(built, MEASURING_ROWS, lead=heading) + with block_of(drawn[0][1] * PITCH + HEADING_HEIGHT, lead=HEADING_HEIGHT): + built.settle() + + assert geometry.pitch == PITCH + + +class TestAWholeDraw(BaseTestSuite): + """Content that is more than a run of rows is built entire, and the region holds it to its + ceiling from there on.""" + + def test_everything_it_is_given_is_built(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: [dpg.add_text(f"row {index}", parent=region.body) for index in range(30)], lead=None) + + assert len(dpg.get_item_children(region.body, 1)) == 30 + + def test_it_holds_back_no_rows(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + + assert not region.windowing + + def test_it_carries_its_heading_too(self, region: WindowedRegion) -> None: + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=heading) + first = dpg.get_item_children(region.body, 1)[0] + + assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" + + def test_content_past_the_ceiling_is_held_at_it(self, region: WindowedRegion) -> None: + """What a region holds is measured rather than counted, since it is more than a run of rows.""" + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + + with block_of(CEILING + PITCH): + region.settle() + + assert not region.natural + assert dpg.get_item_configuration(REGION_TAG)["height"] == CEILING + assert dpg.get_item_configuration(REGION_TAG)["auto_resize_y"] is False + + def test_content_inside_the_ceiling_sizes_the_region_to_itself(self, region: WindowedRegion) -> None: + """A region held at its ceiling follows what it holds back down once that fits again.""" + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) + with block_of(CEILING + PITCH): + region.settle() + + with block_of(CEILING - PITCH): + region.settle() + + assert region.natural + assert dpg.get_item_configuration(REGION_TAG)["auto_resize_y"] is True + assert dpg.get_item_configuration(REGION_TAG)["no_scrollbar"] is True + + +class TestWhereTheReaderStands(BaseTestSuite): + """A region redrawn as the reader scrolls leaves the scroll where they put it, and one built + in place of another opens where that one stood. + + The rows a region shows are replaced a frame after the wheel asked for them, by which time the + reader has scrolled on. A position written back then lands against the wheel and takes them + somewhere they never scrolled, which asks for the rows to be replaced again — a region that + never settles while a hand is on the wheel. + """ + + def test_a_draw_writes_no_scroll(self, region: WindowedRegion) -> None: + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + draw(region, 40) + + set_y_scroll.assert_not_called() + + def test_the_window_follows_where_the_reader_scrolled_to(self, region: WindowedRegion) -> None: + draw(region, 40) + region.settle() + + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + asked = draw(region, 40) + + assert asked[0][0] > 0 + + def test_a_redraw_the_scroll_asked_for_hands_nothing_back(self, region: WindowedRegion) -> None: + """By the frame the rows land the reader has scrolled on, so the region leaves them there.""" + draw(region, 40) + region.settle() + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + draw(region, 40) + + with ( + patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET + PITCH), + patch.object(dpg, "set_y_scroll") as set_y_scroll, + ): + region.settle() + + set_y_scroll.assert_not_called() + + +class TestARegionOpeningInPlaceOfAnother(BaseTestSuite): + """A region built where one a rebuild took down stood opens on the rows that one showed.""" + + def test_its_window_opens_where_the_one_before_it_stood(self, region: WindowedRegion) -> None: + region.opens_at(STANDING_OFFSET) + + asked = draw(region, 40) + + assert asked[0][0] > 0 + + def test_the_reader_is_put_back_once_the_rows_are_placed(self, region: WindowedRegion) -> None: + region.opens_at(STANDING_OFFSET) + draw(region, 40) + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + region.settle() + + set_y_scroll.assert_called_once_with(REGION_TAG, STANDING_OFFSET) + + def test_a_region_opening_where_it_already_stands_writes_nothing(self, region: WindowedRegion) -> None: + """A folder opens at the top by default, which is where the region stands, so no scroll is written.""" + region.opens_at(NO_SCROLL) + draw(region, 40) + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + settled = region.settle() + + set_y_scroll.assert_not_called() + assert settled is False + + def test_it_is_handed_back_once(self, region: WindowedRegion) -> None: + """A restored position is where the reader stands, so the next frame writes nothing.""" + region.opens_at(STANDING_OFFSET) + draw(region, 40) + with patch.object(dpg, "set_y_scroll"): + region.settle() + + with patch.object(dpg, "set_y_scroll") as set_y_scroll: + region.settle() + + set_y_scroll.assert_not_called() + + +class TestARegionWhoseBodyHasGone(BaseTestSuite): + """A window closing takes the region's whole subtree down while the pass that settles it is + still armed, so the region answers for a body that is no longer there.""" + + def test_it_asks_for_nothing_more(self, region: WindowedRegion) -> None: + draw(region, 40) + dpg.delete_item(ROOT_TAG, children_only=True) + + assert region.settle() is False + assert region.settling is False + + def test_a_draw_reaching_it_builds_nothing(self, region: WindowedRegion) -> None: + """Building into a parent that has been freed is what a closed dialog would otherwise do.""" + draw(region, 40) + dpg.delete_item(ROOT_TAG, children_only=True) + + assert draw(region, 40) == [] + + +class TestTheGutterAScrollbarWillTake(BaseTestSuite): + """A region holds the scrollbar's room clear until the scrollbar itself takes it. + + A child window's scrollbar comes out of the room its content stands in, so a grid inside a + region that scrolls would stand narrower than the same grid inside one that fits. Holding the + room clear while no scrollbar stands keeps the content one width across the moment it starts + scrolling, which is what lines a folder's columns up with the columns around it. + """ + + @pytest.fixture(name="gutted") + def gutted_fixture(self, dpg_context: None) -> WindowedRegion: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), + ceiling=CEILING, + padding=PADDING, + margin=0, + gutter=GUTTER, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + @staticmethod + def _inset(region: WindowedRegion) -> int: + """The room the body holds clear at its right, which a negative width states.""" + return -int(dpg.get_item_configuration(region.body)["width"]) + + def test_a_region_standing_whole_holds_the_room_clear(self, gutted: WindowedRegion) -> None: + draw(gutted, 4) + gutted.settle() + + assert self._inset(gutted) == PADDING + GUTTER + + def test_a_region_that_scrolls_gives_the_room_up(self, gutted: WindowedRegion) -> None: + """The scrollbar stands in that room itself, so the body would otherwise pay for it twice.""" + with block_of(PITCH * 500): + draw(gutted, 500) + gutted.settle() + + assert self._inset(gutted) == PADDING + + def test_a_region_falling_back_under_its_ceiling_holds_it_again(self, gutted: WindowedRegion) -> None: + with block_of(PITCH * 500): + draw(gutted, 500) + gutted.settle() + + draw(gutted, 4) + gutted.settle() + + assert self._inset(gutted) == PADDING + GUTTER + + +class TestTheGapAWellOpens(BaseTestSuite): + """A well opens a gap above its first row and below its last, or lays its rows flush. + + A well standing on a card of its own opens the gap so its body reads apart from the card's + edge; one nested inside a list lays its rows flush, since the list's own rhythm carries them. + """ + + @staticmethod + def _built(dpg_context: None, *, margin: int) -> str: + with dpg.window(tag=ROOT_TAG): + body = well(ROOT_TAG, REGION_TAG, padding=PADDING, margin=margin, width=-PADDING) + + return body + + def test_a_well_asked_for_a_gap_lays_a_spacer_either_side(self, dpg_context: None) -> None: + body = self._built(dpg_context, margin=MARGIN) + + laid = dpg.get_item_children(REGION_TAG, 1) + + assert len(laid) == 3 + assert laid[1] == dpg.get_alias_id(body) + assert dpg.get_item_configuration(laid[0])["height"] == MARGIN + assert dpg.get_item_configuration(laid[2])["height"] == MARGIN + + def test_a_well_asked_for_none_opens_its_rows_where_it_opens(self, dpg_context: None) -> None: + body = self._built(dpg_context, margin=NO_MARGIN) + + laid = dpg.get_item_children(REGION_TAG, 1) + + assert laid == [dpg.get_alias_id(body)] diff --git a/tests/unit/sampletones_application/ui/elements/stems/conftest.py b/tests/unit/sampletones_application/ui/elements/stems/conftest.py new file mode 100644 index 000000000..d3ed4854c --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/conftest.py @@ -0,0 +1,12 @@ +import pytest + +from sampletones_application.utils.gui.frame import FrameCallbackManager +from tests.suite.frames import Frames + + +@pytest.fixture +def frames(monkeypatch: pytest.MonkeyPatch) -> Frames: + """The frames a stems list holds its settle back to, which a case renders for itself.""" + held = Frames() + monkeypatch.setattr(FrameCallbackManager, "set_frame_callback", held.hold) + return held diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py new file mode 100644 index 000000000..6a2ff4a66 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -0,0 +1,299 @@ +from typing import Any, Dict, Iterator, List, Tuple +from unittest.mock import patch + +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 +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.columns import StemsColumns +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite + +ROOT_TAG = "test_root" +TABLE_TAG = "test_table" +GLYPH = "▸" +GLYPH_WIDTH = 9.0 +GLYPH_SIZE = [GLYPH_WIDTH, 20.0] +LABEL = "PULSE 1" +LABEL_WIDTH = 41.0 +LABEL_SIZE = [LABEL_WIDTH, 14.0] +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) + + +@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 and the faces a measurement is taken in.""" + dpg.create_context() + FontRegistry.setup(layout_config.fonts) + FontRegistry.register_fonts(layout_config.fonts.scale) + try: + yield + finally: + dpg.destroy_context() + + +def columns( + layout_config: LayoutConfig, + *, + folders: bool, + master: bool = False, + bends: bool = False, +) -> StemsColumns: + """The grid a list of gathered recordings declares.""" + return StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=master, + removable=True, + bends=bends, + folders=folders, + ) + + +def measured(size: object) -> object: + """What DearPyGui answers a text measurement with, which needs a drawn frame to be a size.""" + return patch.object(dpg, "get_text_size", return_value=size) + + +class TestWhereARowWithoutAMarkerOpens(BaseTestSuite): + """A folder's marker glyph stands in the middle of the room the marker is given, and a row + carrying no marker opens its name there, so the names read as one column.""" + + def test_the_name_opens_where_the_glyph_stands( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + with measured(GLYPH_SIZE): + indent = columns(layout_config, folders=True).marker_indent(GLYPH, Font.ICON) + + assert indent == (stems.twisty_width - int(GLYPH_WIDTH)) // 2 + + def test_a_grid_holding_no_folder_opens_its_names_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """No marker leads any row there, so there is nothing for a name to line up with.""" + with measured(GLYPH_SIZE): + indent = columns(layout_config, folders=False).marker_indent(GLYPH, Font.ICON) + + assert indent == 0 + + def test_a_glyph_no_frame_has_measured_yet_opens_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A measurement waits on a drawn frame, and the next reading of the list settles it.""" + with measured(None): + indent = columns(layout_config, folders=True).marker_indent(GLYPH, Font.ICON) + + assert indent == 0 + + +class TestWhereAChannelNameStands(BaseTestSuite): + """The heading names each channel over the middle of the column its boxes stand in, so the + name reads as that column's however long the channel is called.""" + + def test_the_name_stands_over_the_middle_of_its_column( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + with measured(LABEL_SIZE): + indent = columns(layout_config, folders=True).name_indent(LABEL, Font.BOLD_SMALL) + + assert indent == (stems.channel_solo_width - int(LABEL_WIDTH)) // 2 + + def test_a_name_over_a_column_carrying_bends_stands_in_the_wider_room( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A cell holding the channel and the bend on it takes a column of its own width, and the + name above it is centered in that.""" + stems = layout_config.general.stems + + with measured(LABEL_SIZE): + indent = columns(layout_config, folders=True, bends=True).name_indent(LABEL, Font.BOLD_SMALL) + + assert indent == (stems.channel_column_width - int(LABEL_WIDTH)) // 2 + + def test_a_name_no_frame_has_measured_yet_opens_at_the_edge( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A measurement waits on a drawn frame, and the next reading of the heading settles it.""" + with measured(None): + indent = columns(layout_config, folders=True).name_indent(LABEL, Font.BOLD_SMALL) + + assert indent == 0 + + +class TestWhereABoxStands(BaseTestSuite): + """Every box stands in the middle of the column it belongs to, whichever column that is.""" + + def test_a_channel_box_is_centered_in_its_column( + self, + layout_config: LayoutConfig, + ) -> None: + """One box stands in a column the width the layout gives a channel standing on its own.""" + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True).box_indent(ChannelName.PULSE1) + + assert indent == (stems.channel_solo_width - stems.channel_box_width) // 2 + + def test_a_channel_carrying_its_bend_centers_both_boxes_together( + self, + layout_config: LayoutConfig, + ) -> None: + """A tone channel's cell holds the channel and the bend on it, in a column of its own width.""" + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True, bends=True).box_indent(ChannelName.PULSE1) + + assert indent == (stems.channel_column_width - 2 * stems.channel_box_width) // 2 + + def test_a_channel_taking_no_bend_keeps_the_one_box( + self, + layout_config: LayoutConfig, + ) -> None: + """A bend moves a note within its divider, so noise holds the first slot alone.""" + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True, bends=True).box_indent(ChannelName.NOISE) + + assert indent == (stems.channel_column_width - stems.channel_box_width) // 2 + + def test_the_box_beside_a_row_is_centered_in_its_own_column( + self, + layout_config: LayoutConfig, + ) -> None: + """The master column is narrower than a channel's, so it takes an indent of its own.""" + stems = layout_config.general.stems + + indent = columns(layout_config, folders=True, master=True).master_indent + + assert indent == (stems.master_column_width - stems.channel_box_width) // 2 + + def test_a_box_wider_than_its_column_opens_at_the_edge( + self, + layout_config: LayoutConfig, + ) -> None: + grid = columns(layout_config, folders=True) + wide = grid.layout.model_copy(update={"master_column_width": 1}) + + indent = StemsColumns( + layout=wide, + channels=CHANNELS, + master=True, + removable=True, + bends=False, + folders=True, + ).master_indent + + assert indent == 0 + + +class TestTheRoomAFolderSpends(BaseTestSuite): + """A folder draws its recordings inside a region of its own, and the room that region spends + at its right is held clear across every table outside it, so the columns stand in one grid.""" + + def test_a_grid_holding_folders_spends_what_a_region_takes( + self, + layout_config: LayoutConfig, + ) -> None: + """The room is the layout's own figure, which is what a folder's region spends at its right.""" + assert columns(layout_config, folders=True).reserve == layout_config.general.stems.folder_reserve + + def test_a_grid_holding_none_spends_nothing( + self, + layout_config: LayoutConfig, + ) -> None: + assert columns(layout_config, folders=False).reserve == 0 + + +class TestTheColumnsAGridDeclares(BaseTestSuite): + """A grid declares its columns in one order at one set of widths, which is what stands every + table of a stems list — and the settings card's single row — in the same grid.""" + + @staticmethod + def _declared(grid: StemsColumns) -> List[Dict[str, Any]]: + """The columns one table comes out holding, as a reader would measure them.""" + with dpg.window(tag=ROOT_TAG): + with dpg.table(tag=TABLE_TAG): + grid.declare() + + return [dpg.get_item_configuration(column) for column in dpg.get_item_children(TABLE_TAG, 0)] + + def test_a_grid_holding_folders_ends_on_the_reserved_strip( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The strip stands where a folder's region spends its room, so the columns line up.""" + grid = columns(layout_config, folders=True) + + declared = self._declared(grid) + + assert declared[-1]["init_width_or_weight"] == grid.reserve_width + assert len(declared) == len(CHANNELS) + 3 + + def test_a_grid_holding_none_ends_on_the_removal_column( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A card drawing one row opens no region, so a strip held clear there would be negative.""" + stems = layout_config.general.stems + grid = columns(layout_config, folders=False) + + declared = self._declared(grid) + + assert grid.reserve_width < 0 + assert declared[-1]["init_width_or_weight"] == stems.remove_button_width + assert len(declared) == len(CHANNELS) + 2 + + def test_the_name_column_is_the_one_that_stretches( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A wider card spends its room on the recordings rather than on the boxes beside them.""" + declared = self._declared(columns(layout_config, folders=True, master=True)) + + assert [column["width_stretch"] for column in declared].count(True) == 1 + assert declared[1]["width_stretch"] is True + + def test_each_channel_takes_the_width_its_boxes_ask_for( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + grid = columns(layout_config, folders=True, bends=True) + + declared = self._declared(grid) + + assert [column["init_width_or_weight"] for column in declared[1 : 1 + len(CHANNELS)]] == [ + stems.channel_column_width + ] * len(CHANNELS) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py b/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py new file mode 100644 index 000000000..a675debc8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_expansion.py @@ -0,0 +1,50 @@ +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from tests.suite.base import BaseTestSuite + +FIRST = "/music/loops" +SECOND = "/music/drums" + + +class TestOpenFolders(BaseTestSuite): + """A folder stands as the reader last left it, and a folder that has left the list is + forgotten with it.""" + + def test_a_folder_arrives_closed(self) -> None: + assert not OpenFolders().stands_open(FIRST) + + def test_a_toggle_opens_it(self) -> None: + folders = OpenFolders() + assert folders.toggle(FIRST) + assert folders.stands_open(FIRST) + + def test_a_second_toggle_closes_it(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + assert not folders.toggle(FIRST) + assert not folders.stands_open(FIRST) + + def test_folders_stand_apart(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + assert folders.stands_open(FIRST) + assert not folders.stands_open(SECOND) + + def test_the_keys_name_what_stands_open(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + folders.toggle(SECOND) + assert folders.keys == {FIRST, SECOND} + + def test_a_memory_with_nothing_open_is_falsy(self) -> None: + folders = OpenFolders() + assert not folders + folders.toggle(FIRST) + assert folders + + def test_a_folder_that_left_the_list_is_forgotten(self) -> None: + folders = OpenFolders() + folders.toggle(FIRST) + folders.toggle(SECOND) + folders.hold_to({SECOND}) + assert not folders.stands_open(FIRST) + assert folders.stands_open(SECOND) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py new file mode 100644 index 000000000..f9bdc1199 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -0,0 +1,874 @@ +from pathlib import Path +from typing import Any, Callable, Final, FrozenSet, Iterator, List, Optional, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_GROUP, + SUF_LEAD, + SUF_TEXT, + SUF_TWISTY, + TAG_GLOBAL_THEME_STEMS_GRID, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_MARKER, +) +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.stems.columns import COLUMN_BORDER, StemsColumns +from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +from sampletones_application.ui.elements.stems.tags import StemsTags +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 +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.frames import Frames +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name + +ROOT_TAG = "test_root" +PREFIX = "test.stems" +TAGS: Final[StemsTags] = StemsTags(prefix=PREFIX) +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +DEEP_FOLDER: Final[int] = 200 +STANDING_OFFSET: Final[float] = 700.0 +GLYPH_SIZE: Final[List[float]] = [9.0, 20.0] +NO_OFFSET: Final[float] = 0.0 +ROW_PITCH: Final[float] = 20.0 +HEADING_HEIGHT: Final[float] = 24.0 +REACHED_HELD: Final[int] = 40 +FRAMES_TO_FOLLOW: Final[int] = 2 +FRAMES_TO_SETTLE: Final[int] = 3 +FIRST_HELD: Final[int] = 0 +HOLDS_ONE_PAST_THE_CEILING: Final[int] = 13 + + +@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 the list binds while it draws.""" + 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() + + +@pytest.fixture +def stems_list(dpg_context: None, layout_config: LayoutConfig) -> GUIStemsList: + """A converter's list of gathered sources, drawn into a window of its own.""" + built = GUIStemsList( + prefix=PREFIX, + layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=GATHERED_SOURCES, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + +def recording(path: Path, *, channels: FrozenSet[ChannelName] = frozenset(CHANNELS)) -> StemRowViewModel: + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + path=path, + held=(), + channels=channels, + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def folder(name: str, *, holds: int) -> StemRowViewModel: + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + path=root, + held=tuple(recording(root / f"take_{index}.wav") for index in range(holds)), + channels=frozenset(CHANNELS), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def view(*rows: StemRowViewModel, selected_key: Optional[str] = None) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=True, + selected_key=selected_key, + ) + + +def named(name: str, *, holds: int) -> str: + """How a folder's row reads: its own name, and how many recordings it holds.""" + return str(LanguageManager(LANG_EN)["global.stems.template.folder_row"].format(name=name, count=holds)) + + +def measured(size: List[float]) -> object: + """What DearPyGui answers a text measurement with, which needs a drawn frame to be a size.""" + return patch.object(dpg, "get_text_size", return_value=size) + + +def placed(rows: int) -> Any: + """Stands in for a frame having placed this many rows of one height under a region's heading. + + A region reads what a row takes from the block its rows were drawn into, which no suite + renders, so every region answers as the frame would have left it. + """ + + def sized(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + block = rows * ROW_PITCH + HEADING_HEIGHT + return [0.0, HEADING_HEIGHT if str(item).endswith(f".{SUF_LEAD}") else block] + + return patch.object(dpg, "get_item_rect_size", side_effect=sized) + + +def press(tag: str) -> None: + """Press a widget the way DearPyGui would, with the user data it carries.""" + dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag)) + + +def twisty_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TWISTY) + + +def region_of(row: StemRowViewModel) -> str: + return TAGS.region(row.key) + + +def name_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TEXT) + + +def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: + return TAGS.channel(row.key, channel_name) + + +def table_of(row: StemRowViewModel) -> int: + """The grid one row stands in, which is what says whether two rows share a rhythm.""" + return dpg.get_item_parent(TAGS.row(row.key, SUF_GROUP)) + + +def theme_on(row: StemRowViewModel) -> str: + """The theme one row's line carries, which is what bands a group apart from its neighbors.""" + return dpg.get_item_alias(dpg.get_item_theme(TAGS.row(row.key, SUF_GROUP))) + + +def folder_without(row: StemRowViewModel, leaving: StemRowViewModel) -> StemRowViewModel: + """The folder as the model leaves it once one of its recordings is taken out.""" + held = tuple(standing for standing in row.held if standing.key != leaving.key) + return row.model_copy(update={"held": held}) + + +class TestAClosedFolder(BaseTestSuite): + """A folder arrives closed, standing as one row that names how many recordings it brought in.""" + + def test_it_draws_no_region(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + assert not dpg.does_item_exist(region_of(sources)) + + def test_it_draws_none_of_the_recordings_it_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + for held in sources.held: + assert not dpg.does_item_exist(name_of(held)) + + def test_it_carries_a_marker_to_open_it_by(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + assert dpg.does_item_exist(twisty_of(sources)) + + def test_a_recording_carries_no_marker(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(folder("sources", holds=1), bass)) + assert not dpg.does_item_exist(twisty_of(bass)) + + +class TestOpeningAFolder(BaseTestSuite): + """The marker beside a folder's name puts its recordings in view, and puts them away again.""" + + def test_the_marker_opens_a_region(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + assert dpg.does_item_exist(region_of(sources)) + + def test_an_open_folder_draws_the_recordings_it_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for held in sources.held: + assert dpg.does_item_exist(name_of(held)) + + def test_the_marker_closes_it_again(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + press(twisty_of(sources)) + assert not dpg.does_item_exist(region_of(sources)) + assert not dpg.does_item_exist(name_of(sources.held[0])) + + def test_the_folder_row_stands_through_it(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + assert dpg.does_item_exist(name_of(sources)) + + def test_folders_open_apart(self, stems_list: GUIStemsList) -> None: + first = folder("loops", holds=2) + second = folder("drums", holds=2) + stems_list.update_view(view(first, second)) + press(twisty_of(first)) + assert dpg.does_item_exist(region_of(first)) + assert not dpg.does_item_exist(region_of(second)) + + def test_a_folder_stays_open_across_a_new_reading(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + stems_list.update_view(view(sources)) + assert dpg.does_item_exist(region_of(sources)) + + +class TestARecordingInsideAFolder(BaseTestSuite): + """A reader who opened a folder answers for one of its recordings without leaving the list.""" + + def test_it_draws_a_box_on_every_channel_it_offers(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for channel_name in CHANNELS: + assert dpg.does_item_exist(box_of(sources.held[0], channel_name)) + + def test_its_box_reports_the_recording_it_belongs_to(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + settled: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list.on_channels_changed = lambda key, channels: settled.append((key, channels)) + + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + held = sources.held[0] + box = box_of(held, ChannelName.PULSE1) + dpg.set_value(box, False) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert settled == [(held.key, frozenset({ChannelName.TRIANGLE}))] + + +class TestThePickInsideAFolder(BaseTestSuite): + """A folder closing over the row picked out reports the pick as gone, which is what keeps the + list, the settings card and the keys naming one row. + + The reading holds the pick, so every other way the rows are drawn leaves it where it is; this + is the one gesture that takes the picked row off the list without landing on it. + """ + + def test_a_recording_the_folder_shows_is_the_row_a_key_acts_on(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + held = sources.held[FIRST_HELD] + + stems_list.update_view(view(sources, selected_key=held.key)) + press(twisty_of(sources)) + + assert stems_list.picked_key == held.key + + def test_closing_the_folder_over_the_picked_row_reports_the_pick_as_gone( + self, + stems_list: GUIStemsList, + ) -> None: + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + stems_list.update_view(view(sources, selected_key=sources.held[FIRST_HELD].key)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert cleared == [True] + + def test_closing_a_folder_the_pick_stands_outside_leaves_it_alone( + self, + stems_list: GUIStemsList, + ) -> None: + """The recordings the folder takes away are its own, so a pick elsewhere is untouched.""" + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, bass, selected_key=bass.key)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert cleared == [] + assert stems_list.picked_key == bass.key + + def test_opening_a_folder_over_the_picked_row_leaves_the_pick_alone( + self, + stems_list: GUIStemsList, + ) -> None: + """Opening puts recordings on the list rather than taking them off it.""" + cleared: List[bool] = [] + stems_list.on_selection_cleared = lambda: cleared.append(True) + sources = folder("sources", holds=2) + held = sources.held[FIRST_HELD] + stems_list.update_view(view(sources, selected_key=held.key)) + + press(twisty_of(sources)) + + assert cleared == [] + assert stems_list.picked_key == held.key + + +class TestDoubleClick(BaseTestSuite): + """A double-click opens what it landed on: a folder shows what it holds, a recording sounds.""" + + def test_a_double_clicked_folder_opens(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + double_click(sources) + assert dpg.does_item_exist(region_of(sources)) + + def test_a_double_clicked_recording_is_reported(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + opened: List[str] = [] + stems_list.on_row_opened = opened.append + + stems_list.update_view(view(bass)) + double_click(bass) + + assert opened == [bass.key] + + def test_a_double_clicked_folder_sounds_nothing(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + opened: List[str] = [] + stems_list.on_row_opened = opened.append + + stems_list.update_view(view(sources)) + double_click(sources) + + assert opened == [] + + +class TestAFolderThatLeaves(BaseTestSuite): + """A folder taken out of the list is forgotten with it, so its name arriving again is closed.""" + + def test_a_folder_that_left_the_list_comes_back_closed(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=2) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + stems_list.update_view(view()) + stems_list.update_view(view(sources)) + assert not dpg.does_item_exist(region_of(sources)) + + +def double_click(row: StemRowViewModel) -> None: + """Double-click one row's name the way DearPyGui reports the gesture.""" + click_row_name(TAGS, row.key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +class TestARecordingThatLeavesAFolder(BaseTestSuite): + """A recording taken out from inside an open folder leaves it the way a loose one leaves.""" + + @staticmethod + def _opened(stems_list: GUIStemsList, sources: StemRowViewModel) -> None: + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + + def test_its_remove_button_is_live(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + + button = TAGS.row(sources.held[0].key, SUF_BUTTON) + + assert dpg.get_item_configuration(button)["enabled"] is True + + def test_one_of_them_leaving_draws_the_folder_again(self, stems_list: GUIStemsList) -> None: + """The region holds a row apiece, so it is built afresh once the folder holds one fewer.""" + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + leaving = sources.held[0] + + stems_list.update_view(view(folder_without(sources, leaving))) + + assert not dpg.does_item_exist(name_of(leaving)) + + def test_the_ones_that_stay_are_still_drawn(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + leaving = sources.held[0] + + stems_list.update_view(view(folder_without(sources, leaving))) + + for held in sources.held[1:]: + assert dpg.does_item_exist(name_of(held)) + + def test_the_rows_around_the_folder_keep_the_widgets_they_stand_as(self, stems_list: GUIStemsList) -> None: + """A recording leaving a folder is answered inside it, so the list around it stands.""" + sources = folder("sources", holds=3) + bass = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(bass, sources)) + press(twisty_of(sources)) + standing = dpg.get_alias_id(name_of(bass)) + + stems_list.update_view(view(bass, folder_without(sources, sources.held[0]))) + + assert dpg.get_alias_id(name_of(bass)) == standing + + def test_the_folder_row_keeps_the_widget_it_stands_as(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + standing = dpg.get_alias_id(name_of(sources)) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_alias_id(name_of(sources)) == standing + + def test_the_folder_reads_out_how_many_it_now_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + self._opened(stems_list, sources) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_item_label(name_of(sources)) == named("sources", holds=2) + + def test_a_closed_folder_reads_out_how_many_it_now_holds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + stems_list.update_view(view(folder_without(sources, sources.held[0]))) + + assert dpg.get_item_label(name_of(sources)) == named("sources", holds=2) + + @staticmethod + def _settled(stems_list: GUIStemsList, frames: Frames, *, holds: int) -> StemRowViewModel: + """An open folder standing at the height its recordings ask for, as a run of frames leaves it. + + A region reads what it holds back the frame after the rows are placed and sizes itself to + that, so the readings settle over the first few frames and the folder stands still from + there on. + """ + sources = folder("sources", holds=holds) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + for _ in range(FRAMES_TO_SETTLE): + with placed(holds): + frames.render() + + assert frames.pending == 0 + return sources + + def test_one_of_them_leaving_asks_for_the_frame_that_reads_the_folder_back( + self, + stems_list: GUIStemsList, + frames: Frames, + ) -> None: + """The region is filled again where the recording stood, so what room its rows now ask for + is read back once the frame that placed them has been rendered.""" + sources = self._settled(stems_list, frames, holds=HOLDS_ONE_PAST_THE_CEILING) + + stems_list.update_view(view(folder_without(sources, sources.held[FIRST_HELD]))) + + assert frames.pending == 1 + + def test_the_folder_comes_down_to_the_room_its_recordings_now_ask_for( + self, + stems_list: GUIStemsList, + frames: Frames, + ) -> None: + """A folder whose recordings outgrow its region stands at its ceiling and scrolls them; + with one fewer they fit, and the frame that reads them back is what stands it at their + own height again.""" + sources = self._settled(stems_list, frames, holds=HOLDS_ONE_PAST_THE_CEILING) + assert dpg.get_item_configuration(region_of(sources))["auto_resize_y"] is False + + stems_list.update_view(view(folder_without(sources, sources.held[FIRST_HELD]))) + with placed(HOLDS_ONE_PAST_THE_CEILING - 1): + frames.render() + + assert dpg.get_item_configuration(region_of(sources))["auto_resize_y"] is True + + def test_a_row_arriving_draws_the_list_again(self, stems_list: GUIStemsList) -> None: + """A row the list did not hold is met by the tables, so those are what is built again.""" + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + standing = dpg.get_alias_id(name_of(sources)) + + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert dpg.get_alias_id(name_of(sources)) != standing + + +def taken_down_at(offset: float) -> Callable[[str], float]: + """How DearPyGui reads a region a rebuild replaces: the one standing reports where the reader + scrolled it to, and the one built in its place stands at its top.""" + standing = [offset] + + def read(_tag: str) -> float: + return standing.pop() if standing else NO_OFFSET + + return read + + +class TestWhereAnOpenFolderStands(BaseTestSuite): + """A rebuild takes an open folder's region down, and the one built in its place opens on the + rows the reader had scrolled to.""" + + @staticmethod + def _deep(stems_list: GUIStemsList) -> StemRowViewModel: + sources = folder("sources", holds=DEEP_FOLDER) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + return sources + + def test_a_folder_at_its_top_comes_back_at_its_top(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert dpg.does_item_exist(name_of(sources.held[0])) + + def test_a_folder_scrolled_into_comes_back_where_it_stood(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + with patch.object(dpg, "get_y_scroll", taken_down_at(STANDING_OFFSET)): + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert not dpg.does_item_exist(name_of(sources.held[0])) + + def test_it_draws_the_recordings_that_position_reaches(self, stems_list: GUIStemsList) -> None: + sources = self._deep(stems_list) + + with patch.object(dpg, "get_y_scroll", taken_down_at(STANDING_OFFSET)): + stems_list.update_view(view(sources, recording(Path("/audio/bass.wav")))) + + assert any(dpg.does_item_exist(name_of(held)) for held in sources.held) + + +class TestTheRhythmAFolderStandsIn(BaseTestSuite): + """A folder's own row is a row like any other, so the list keeps one rhythm down its length. + + A grid gives every row it holds the same height, and a table of its own would give a folder a + chrome of its own on top of it. So the folder's row stands in the grid of the rows around it, + and only the region an open folder opens onto breaks the run. + """ + + def test_a_closed_folder_stands_in_the_grid_of_the_rows_around_it(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + + stems_list.update_view(view(bass, sources, lead)) + + assert table_of(sources) == table_of(bass) == table_of(lead) + + def test_the_marker_carries_the_theme_that_centers_its_glyph(self, stems_list: GUIStemsList) -> None: + """The marker's own theme is what spends no padding around the glyph and centers it, which + is what holds a folder's row to the height of the rows around it.""" + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_alias(dpg.get_item_theme(twisty_of(sources))) == TAG_GLOBAL_THEME_STEMS_MARKER + + def test_the_marker_stands_as_tall_as_the_name_it_leads( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + marker = dpg.get_item_configuration(twisty_of(sources)) + assert marker["height"] == layout_config.general.stems.name_height + assert marker["width"] == layout_config.general.stems.twisty_width + + def test_an_open_folder_breaks_the_run_so_its_region_stands_between(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + stems_list.update_view(view(bass, sources, lead)) + + press(twisty_of(sources)) + + assert table_of(sources) == table_of(bass) + assert table_of(lead) != table_of(sources) + + def test_a_folder_closed_again_rejoins_the_run(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + sources = folder("sources", holds=3) + lead = recording(Path("/audio/lead.wav")) + stems_list.update_view(view(bass, sources, lead)) + press(twisty_of(sources)) + + press(twisty_of(sources)) + + assert table_of(sources) == table_of(bass) == table_of(lead) + + +class TestWhereTheNamesOpen(BaseTestSuite): + """A folder opens at its marker and a recording at that marker's glyph, so they read as one + column of names however the two kinds of row stand beside each other.""" + + @staticmethod + def _grid(layout_config: LayoutConfig) -> StemsColumns: + """The columns the list declares for a run of gathered sources holding folders.""" + return StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=GATHERED_SOURCES.master_box, + removable=GATHERED_SOURCES.removal, + bends=GATHERED_SOURCES.bends, + folders=True, + ) + + @staticmethod + def _indent(row: StemRowViewModel) -> int: + return int(dpg.get_item_configuration(name_of(row))["indent"]) + + def test_a_folder_opens_at_its_marker(self, stems_list: GUIStemsList) -> None: + """The marker leads the row, so the name that follows it opens where the marker ends.""" + sources = folder("sources", holds=3) + + with measured(GLYPH_SIZE): + stems_list.update_view(view(sources)) + + assert self._indent(sources) == 0 + + def test_a_recording_beside_it_opens_at_the_marker_s_glyph( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + """A measured glyph puts the name in from the edge, which is what lines the column up.""" + bass = recording(Path("/audio/bass.wav")) + + with measured(GLYPH_SIZE): + stems_list.update_view(view(folder("sources", holds=1), bass)) + expected = self._grid(layout_config).marker_indent( + layout_config.glyphs.common.collapsed, + Font.ICON, + ) + + assert expected > 0 + assert self._indent(bass) == expected + + +class TestTheOneGridAFolderStandsIn(BaseTestSuite): + """The room a table outside a folder holds clear is the room that folder's region spends. + + The two are computed apart — one as a column at the end of every table, one as the inset a + region draws its body at — so the columns line up only while both read one figure. + """ + + def test_the_reserve_is_the_room_the_open_folder_s_region_takes( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + press(twisty_of(sources)) + + body = compose_tag(region_of(sources), SUF_GROUP) + inset = -int(dpg.get_item_configuration(body)["width"]) + assert inset == layout_config.general.stems.folder_reserve + + def test_every_row_opens_a_cell_for_the_strip_its_table_declares( + self, + stems_list: GUIStemsList, + ) -> None: + """A row short of a cell would let the columns after it slide left, so the row opens one + wherever the grid holds the strip clear.""" + sources = folder("sources", holds=3) + loose = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, loose)) + + declared = len(dpg.get_item_children(table_of(loose), 0)) + assert len(dpg.get_item_children(TAGS.row(loose.key, SUF_GROUP), 1)) == declared + + def test_the_strip_a_table_declares_comes_out_the_width_of_that_room( + self, + stems_list: GUIStemsList, + layout_config: LayoutConfig, + ) -> None: + """The column ending every table and the inset a region draws at are one figure. + + A column takes its own width plus its cell padding and the rule beside it, so what the + strip holds clear is what the region beside it spends. + """ + stems = layout_config.general.stems + sources = folder("sources", holds=3) + loose = recording(Path("/audio/bass.wav")) + stems_list.update_view(view(sources, loose)) + press(twisty_of(sources)) + + declared = dpg.get_item_children(table_of(loose), 0) + strip = int(dpg.get_item_configuration(declared[-1])["init_width_or_weight"]) + body = compose_tag(region_of(sources), SUF_GROUP) + + held_clear = strip + 2 * stems.cell_padding + COLUMN_BORDER + assert held_clear == -int(dpg.get_item_configuration(body)["width"]) + + +class TestTheBandAGroupReadsBy(BaseTestSuite): + """A group takes a band of its own behind its row, which is what sets it apart from a recording. + + The band is drawn as the row's background rather than as space around it, so a folder reads + apart while the list keeps the one rhythm every row stands in. + """ + + def test_a_folder_carries_the_band(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert theme_on(sources) == TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def test_a_recording_carries_none(self, stems_list: GUIStemsList) -> None: + bass = recording(Path("/audio/bass.wav")) + + stems_list.update_view(view(folder("sources", holds=1), bass)) + + assert theme_on(bass) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW + + def test_a_recording_inside_a_folder_carries_none(self, stems_list: GUIStemsList) -> None: + """What a folder holds are recordings, so the band names the folder alone.""" + sources = folder("sources", holds=3) + stems_list.update_view(view(sources)) + + press(twisty_of(sources)) + + assert all(theme_on(held) != TAG_GLOBAL_THEME_STEMS_GROUP_ROW for held in sources.held) + + +class TestWhatMakesTheBandVisible(BaseTestSuite): + """A band is a row background, so the grid draws one and states its own rows clear. + + The theme on a folder's row paints nothing unless its table draws row backgrounds at all, and + every other row would take the default alternation if the grid left it unstated. + """ + + def test_the_grid_draws_row_backgrounds(self, stems_list: GUIStemsList) -> None: + sources = folder("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_configuration(table_of(sources))["row_background"] is True + + def test_the_grid_carries_the_theme_that_states_its_own_rows_clear(self, stems_list: GUIStemsList) -> None: + """Every row would otherwise take DearPyGui's own alternation behind the band.""" + bass = recording(Path("/audio/bass.wav")) + + stems_list.update_view(view(folder("sources", holds=1), bass)) + + assert dpg.get_item_alias(dpg.get_item_theme(table_of(bass))) == TAG_GLOBAL_THEME_STEMS_GRID + + +class TestAFolderFollowingItsReader(BaseTestSuite): + """An open folder reads its rows back the frame after they are placed, and refills itself. + + A folder answers for its own length inside its region, so a scroll into one is answered there: + the list settles the folder's region and redraws the rows that position now reaches, leaving + the list around it alone. + """ + + @staticmethod + def _opened(stems_list: GUIStemsList) -> StemRowViewModel: + sources = folder("sources", holds=DEEP_FOLDER) + stems_list.update_view(view(sources)) + press(twisty_of(sources)) + return sources + + @staticmethod + def _built(sources: StemRowViewModel) -> int: + """How many of the folder's recordings stand as widgets, which is the block a frame places.""" + return sum(1 for held in sources.held if dpg.does_item_exist(name_of(held))) + + def test_a_scroll_into_one_brings_the_recordings_it_reaches_in( + self, + stems_list: GUIStemsList, + frames: Frames, + ) -> None: + """The folder's own region follows the reader, so the list around it keeps its widgets.""" + sources = self._opened(stems_list) + with placed(self._built(sources)): + frames.render() + + with placed(self._built(sources)), patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + frames.render(FRAMES_TO_FOLLOW) + + assert dpg.does_item_exist(name_of(sources.held[REACHED_HELD])) + assert not dpg.does_item_exist(name_of(sources.held[FIRST_HELD])) + + def test_the_folder_s_own_row_stays_where_it_stood( + self, + stems_list: GUIStemsList, + frames: Frames, + ) -> None: + """A scroll inside a folder is answered inside it, so the rows around it are left be.""" + sources = self._opened(stems_list) + with placed(self._built(sources)): + frames.render() + standing = dpg.get_alias_id(name_of(sources)) + + with placed(self._built(sources)), patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + frames.render(FRAMES_TO_FOLLOW) + + assert dpg.get_alias_id(name_of(sources)) == standing diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 5b41f9e28..7ba50d352 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,10 +1,12 @@ from pathlib import Path -from typing import Final, FrozenSet, Iterator, List, Tuple +from typing import Any, Final, FrozenSet, Iterator, List, Optional, Tuple +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.loader import load_layout_config from sampletones_application.paths import ( @@ -17,20 +19,25 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, - SUF_CHANNELS, SUF_CHECKBOX, - SUF_HANDLER_REGISTRY, - SUF_LEVEL, - SUF_ROW, + SUF_HEADING, + SUF_LEAD, SUF_STRIP, + SUF_TABLE, SUF_TEXT, TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.layout.geometry import MINIMUM_ROW_PITCH from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.columns import StemsColumns from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS 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 @@ -41,11 +48,22 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.callback import Callback +from tests.suite.base import BaseTestSuite +from tests.suite.frames import Frames +from tests.suite.gestures import CLICKED, DOUBLE_CLICKED, HOVERED, click_row_name, handler_of ROOT_TAG = "test_root" PREFIX = "test.stems" +TAGS: Final[StemsTags] = StemsTags(prefix=PREFIX) CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DRAG_PAYLOAD_SLOT: Final[int] = 3 +LONG_LIST: Final[int] = 200 +ROW_PITCH: Final[float] = 20.0 +HEADING_HEIGHT: Final[float] = 24.0 +DEEP_SCROLL: Final[float] = 2000.0 +EARLY_ROW: Final[int] = 0 +REACHED_ROW: Final[int] = 100 +SETTLING_FRAMES: Final[int] = 2 @pytest.fixture @@ -71,20 +89,28 @@ def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: def build( layout_config: LayoutConfig, *, - draggable: bool = True, - removable: bool = True, - retain_last_row: bool = False, - master_checkbox: bool = False, + dragging: bool = True, + removal: bool = True, + keeps_last_row: bool = False, + master_box: bool = False, + bends: bool = False, + picking: bool = False, ) -> GUIStemsList: stems_list = GUIStemsList( prefix=PREFIX, layout=layout_config.general.stems, + ceiling=layout_config.general.stems.well_ceiling, + glyphs=layout_config.glyphs.common, language_manager=LanguageManager(LANG_EN), status_bar=GUIStatusBar(), - draggable=draggable, - removable=removable, - retain_last_row=retain_last_row, - master_checkbox=master_checkbox, + offer=StemsListOffer( + master_box=master_box, + removal=removal, + keeps_last_row=keeps_last_row, + dragging=dragging, + bends=bends, + picking=picking, + ), ) with dpg.window(tag=ROOT_TAG): stems_list.create(ROOT_TAG) @@ -105,6 +131,10 @@ def row( ) -> StemRowViewModel: path = Path(f"/audio/{name}.wav") return StemRowViewModel( + kind=SourceKind.RECORDING, + held=(), + partial_channels=frozenset(), + bends=frozenset(), key=str(path), path=path, channels=channels, @@ -121,33 +151,152 @@ def view( *rows: StemRowViewModel, live: bool = True, muted_channels: FrozenSet[ChannelName] = frozenset(), + picked_keys: FrozenSet[str] = frozenset(), collapse_levels: bool = False, + selected_key: Optional[str] = None, + channels_in_play: Tuple[ChannelName, ...] = CHANNELS, ) -> StemsListViewModel: return StemsListViewModel( + selected_key=selected_key, rows=rows, - channels_in_play=CHANNELS, + channels_in_play=channels_in_play, muted_channels=muted_channels, + picked_keys=picked_keys, + picking_room=None, live=live, collapse_levels=collapse_levels, ) +def placed(rows: int) -> Any: + """Stands in for a frame having placed this many rows of one height under the heading. + + A region reads what a row takes from the block its rows were drawn into, which no suite + renders, so the block answers as a frame would have left it. + """ + lead_tag = compose_tag(TAGS.well, SUF_LEAD) + + def sized(item: str, *_args: Any, **_kwargs: Any) -> List[float]: + return [0.0, HEADING_HEIGHT if item == lead_tag else rows * ROW_PITCH + HEADING_HEIGHT] + + return patch.object(dpg, "get_item_rect_size", side_effect=sized) + + def row_tag(entry: StemRowViewModel, suffix: str) -> str: - return compose_tag(PREFIX, SUF_ROW, entry.key, suffix) + return TAGS.row(entry.key, suffix) def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str: - return compose_tag(PREFIX, SUF_ROW, entry.key, SUF_CHANNELS, compose_tag(channel_name, SUF_CHECKBOX)) + return TAGS.channel(entry.key, channel_name) def hover_handler(suffix: str) -> Callback: """The hover callback a row widget of that kind shares, as DearPyGui would call it.""" - registry = compose_tag(PREFIX, suffix, SUF_HANDLER_REGISTRY) - return dpg.get_item_callback(dpg.get_item_children(registry, 1)[-1]) + return dpg.get_item_callback(handler_of(TAGS.handlers(suffix), HOVERED)) + + +def click_on(entry: StemRowViewModel, kind: str, button: int) -> None: + """Land a mouse gesture on a row's name the way DearPyGui reports one.""" + click_row_name(TAGS, entry.key, kind=kind, button=button) + + +def select_name(entry: StemRowViewModel, value: bool) -> None: + """Click a row's name the way DearPyGui does: the widget moves, then the callback runs.""" + name_tag = row_tag(entry, SUF_TEXT) + dpg.set_value(name_tag, value) + dpg.get_item_callback(name_tag)(name_tag, value, entry.key) + + +def folder_row( + name: str, + *, + holds: int = 2, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + partial_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemRowViewModel: + """A row standing for the recordings gathered below a folder.""" + path = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(path), + kind=SourceKind.FOLDER, + path=path, + held=tuple(row(f"{name}/held_{index}") for index in range(holds)), + channels=channels, + partial_channels=partial_channels, + bends=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +class TestFolderRows(BaseTestSuite): + """A folder is one row answering for the recordings below it.""" + + def test_a_folder_names_itself_and_how_many_it_holds(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources", holds=3) + named = LanguageManager(LANG_EN)["global.stems.template.folder_row"].format(name="sources", count=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_label(row_tag(sources, SUF_TEXT)) == named + + def test_a_channel_every_recording_holds_reads_ticked(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources") + + stems_list.update_view(view(sources)) + + assert dpg.get_value(channel_tag(sources, ChannelName.PULSE1)) is True + + def test_a_channel_they_differ_on_reads_clear_in_the_softer_tone( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A tick would state an answer the folder has yet to give, so a divided reading is clear.""" + stems_list = build(layout_config) + sources = folder_row( + "sources", + channels=frozenset(), + partial_channels=frozenset({ChannelName.PULSE1}), + ) + + stems_list.update_view(view(sources)) + + box = channel_tag(sources, ChannelName.PULSE1) + assert dpg.get_value(box) is False + assert dpg.get_item_alias(dpg.get_item_theme(box)) == TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL + + def test_a_channel_none_of_them_holds_reads_clear(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources", channels=frozenset()) + + stems_list.update_view(view(sources)) + + assert dpg.get_value(channel_tag(sources, ChannelName.PULSE1)) is False + + def test_a_folders_box_reports_the_channel_it_settles(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + sources = folder_row("sources") + toggled: List[Tuple[str, ChannelName]] = [] + stems_list.on_channel_toggled = lambda key, channel: toggled.append((key, channel)) + + stems_list.update_view(view(sources)) + box = channel_tag(sources, ChannelName.PULSE1) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert toggled == [(sources.key, ChannelName.PULSE1)] -class TestRows: - def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None: +class TestRows(BaseTestSuite): + def test_a_row_names_its_recording_and_offers_every_channel_in_play( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: stems_list = build(layout_config) bass = row("bass") @@ -157,7 +306,7 @@ def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_co for channel_name in CHANNELS: assert dpg.get_value(channel_tag(bass, channel_name)) - def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_config) -> None: + def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass", channels=frozenset({ChannelName.PULSE1})) @@ -169,7 +318,7 @@ def test_a_channel_the_row_lacks_reads_unticked(self, dpg_context: None, layout_ def test_a_row_holding_no_channel_grays_out_and_still_answers( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config) bass = row("bass", channels=frozenset()) @@ -180,7 +329,7 @@ def test_a_row_holding_no_channel_grays_out_and_still_answers( assert dpg.get_item_alias(dpg.get_item_theme(name_tag)) == TAG_GLOBAL_THEME_STEMS_ROW_INERT assert dpg.is_item_enabled(name_tag) - def test_a_row_taking_part_reads_in_full(self, dpg_context: None, layout_config) -> None: + def test_a_row_taking_part_reads_in_full(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") @@ -188,7 +337,7 @@ def test_a_row_taking_part_reads_in_full(self, dpg_context: None, layout_config) assert dpg.get_item_alias(dpg.get_item_theme(row_tag(bass, SUF_TEXT))) == TAG_GLOBAL_THEME_STEMS_ROW - def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config) -> None: + def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass, lead = row("bass"), row("lead") stems_list.update_view(view(bass, lead, live=True)) @@ -198,7 +347,7 @@ def test_rows_follow_a_changed_list(self, dpg_context: None, layout_config) -> N assert not dpg.does_item_exist(row_tag(bass, SUF_TEXT)) assert dpg.does_item_exist(row_tag(lead, SUF_TEXT)) - def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layout_config) -> None: + def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") @@ -207,9 +356,84 @@ def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layou assert stems_list.row(bass.key) == bass assert stems_list.row("nothing") is None + def test_the_reading_the_list_already_stands_at_keeps_the_widgets_it_drew( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The same reading twice asks for a repaint, so the reader keeps the row they are over.""" + stems_list = build(layout_config) + bass = row("bass") + reading = view(bass) + stems_list.update_view(reading) + standing = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + + stems_list.update_view(reading) + + assert dpg.get_alias_id(row_tag(bass, SUF_TEXT)) == standing + + +class TestRowsNamedAlike(BaseTestSuite): + """Two recordings a tag part spells the same way stand on widgets of their own. + + A tag part lowercases its name and collapses whitespace, so paths differing only in case or + spacing reach one segment. Each row carries the identity of its own key beside the name. + """ + + def test_two_paths_differing_in_case_carry_their_own_names( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config) + lower, upper = row("kick"), row("Kick") + + stems_list.update_view(view(lower, upper)) + + assert row_tag(lower, SUF_TEXT) != row_tag(upper, SUF_TEXT) + assert dpg.get_item_label(row_tag(lower, SUF_TEXT)) == "kick" + assert dpg.get_item_label(row_tag(upper, SUF_TEXT)) == "Kick" + + def test_a_channel_ticked_on_one_leaves_the_other_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config) + spaced, scored = row("my song"), row("my_song") + + stems_list.update_view( + view( + spaced, + scored, + selected_key=None, + ), + ) + dpg.set_value(channel_tag(spaced, ChannelName.PULSE1), False) + + assert dpg.get_value(channel_tag(scored, ChannelName.PULSE1)) is True + + def test_the_reading_reported_names_the_row_it_was_ticked_on( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The boxes report through the key their row carries, so one row's tick is its own.""" + reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] + stems_list = build(layout_config) + stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) + lower, upper = row("kick"), row("Kick") + stems_list.update_view(view(lower, upper)) + + box = channel_tag(upper, ChannelName.PULSE1) + dpg.set_value(box, False) + dpg.get_item_callback(box)(box, False, dpg.get_item_user_data(box)) + + assert reported == [(upper.key, frozenset({ChannelName.TRIANGLE}))] + -class TestLevels: - def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None: +class TestLevels(BaseTestSuite): + def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) rows = ( row("bass", level=0, level_count=2), @@ -218,8 +442,25 @@ def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) stems_list.update_view(view(*rows)) - assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) == "LEVEL 1" - assert dpg.get_value(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT)) == "LEVEL 2" + caption = LanguageManager(LANG_EN)["global.stems.template.level_caption"] + assert dpg.get_value(TAGS.level(0, SUF_TEXT)) == caption.format(1).upper() + assert dpg.get_value(TAGS.level(1, SUF_TEXT)) == caption.format(2).upper() + + def test_a_row_moving_to_another_band_is_built_into_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A band is a table of its own, so a row picking on another level stands as a widget of + that table rather than as the one it was drawn into.""" + stems_list = build(layout_config) + bass = row("bass", level=0, level_count=2) + stems_list.update_view(view(bass, row("drums", level=1, level_count=2))) + standing = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + + stems_list.update_view(view(row("bass", level=1, level_count=2), row("drums", level=1, level_count=2))) + + assert dpg.get_alias_id(row_tag(bass, SUF_TEXT)) != standing def test_a_draggable_list_opens_a_strip_above_each_level_and_below_the_last( self, dpg_context: None, layout_config @@ -233,16 +474,16 @@ def test_a_draggable_list_opens_a_strip_above_each_level_and_below_the_last( stems_list.update_view(view(*rows)) for position in range(3): - assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, str(position), SUF_STRIP)) + assert dpg.does_item_exist(TAGS.level(position, SUF_STRIP)) -class TestAffordances: +class TestAffordances(BaseTestSuite): def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: - stems_list = build(layout_config, draggable=True) + stems_list = build(layout_config, dragging=True) bass = row("bass") stems_list.update_view(view(bass)) @@ -252,26 +493,26 @@ def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag( def test_a_list_without_dragging_carries_no_payload_and_no_strip( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: - stems_list = build(layout_config, draggable=False) + stems_list = build(layout_config, dragging=False) bass = row("bass") stems_list.update_view(view(bass)) assert not dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT) - assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_STRIP)) + assert not dpg.does_item_exist(TAGS.level(0, SUF_STRIP)) - def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, removable=True) + def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, removal=True) bass = row("bass") stems_list.update_view(view(bass)) assert dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) - def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, removable=False) + def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, removal=False) bass = row("bass") stems_list.update_view(view(bass)) @@ -279,21 +520,23 @@ def test_a_list_without_removal_gives_no_button(self, dpg_context: None, layout_ assert not dpg.does_item_exist(row_tag(bass, SUF_BUTTON)) -class TestRetainedLastRow: +class TestRetainedLastRow(BaseTestSuite): def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: - stems_list = build(layout_config, retain_last_row=True) + stems_list = build(layout_config, keeps_last_row=True) bass = row("bass") stems_list.update_view(view(bass)) assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) - def test_a_row_may_leave_once_another_stands_beside_it(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, retain_last_row=True) + def test_a_row_may_leave_once_another_stands_beside_it( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, keeps_last_row=True) bass = row("bass") lead = row("lead") @@ -301,8 +544,8 @@ def test_a_row_may_leave_once_another_stands_beside_it(self, dpg_context: None, assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) - def test_the_last_row_left_standing_stops_answering(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, retain_last_row=True) + def test_the_last_row_left_standing_stops_answering(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, keeps_last_row=True) bass = row("bass") lead = row("lead") stems_list.update_view(view(bass, lead)) @@ -311,8 +554,26 @@ def test_the_last_row_left_standing_stops_answering(self, dpg_context: None, lay assert not dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) - def test_a_list_that_keeps_no_row_lets_the_last_one_go(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, retain_last_row=False) + def test_the_rule_the_button_reads_is_the_one_every_way_out_reads( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + """A key press and a menu item reach removal past the row's own button, and each asks the + list rather than the widget, so the last row a list holds on to stays wherever it is asked + from.""" + stems_list = build(layout_config, keeps_last_row=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + alone = stems_list.lets_a_row_go + stems_list.update_view(view(bass, row("lead"))) + + assert alone is False + assert stems_list.lets_a_row_go is True + + def test_a_list_that_keeps_no_row_lets_the_last_one_go( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, keeps_last_row=False) bass = row("bass") stems_list.update_view(view(bass)) @@ -320,8 +581,10 @@ def test_a_list_that_keeps_no_row_lets_the_last_one_go(self, dpg_context: None, assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) -class TestGestures: - def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None: +class TestGestures(BaseTestSuite): + def test_unticking_a_channel_reports_the_row_and_what_it_keeps( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] stems_list = build(layout_config) stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) @@ -334,7 +597,7 @@ def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))] - def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_config) -> None: + def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_config: LayoutConfig) -> None: removed: List[str] = [] stems_list = build(layout_config) stems_list.on_remove_requested = removed.append @@ -347,8 +610,10 @@ def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_confi assert removed == [bass.key] -class TestBusyState: - def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_context: None, layout_config) -> None: +class TestBusyState(BaseTestSuite): + def test_a_list_that_is_not_live_disables_every_control_it_drew( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: stems_list = build(layout_config) bass = row("bass") @@ -359,7 +624,7 @@ def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_contex for channel_name in CHANNELS: assert not dpg.is_item_enabled(channel_tag(bass, channel_name)) - def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> None: + def test_a_live_list_answers_again(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") stems_list.update_view(view(bass, live=False)) @@ -370,10 +635,10 @@ def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> No assert dpg.is_item_enabled(row_tag(bass, SUF_BUTTON)) -class TestVanishedWidgets: +class TestVanishedWidgets(BaseTestSuite): """DearPyGui reports a hover a frame after it happened, by which time the row may have gone.""" - def test_a_hover_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config) -> None: + def test_a_hover_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") stems_list.update_view(view(bass)) @@ -383,10 +648,47 @@ def test_a_hover_naming_a_row_that_went_is_let_be(self, dpg_context: None, layou hover_handler(SUF_TEXT)(0, hovered) + def test_a_click_naming_a_row_that_went_is_let_be(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A gesture is answered a frame after it landed, by which time a rebuild may have run.""" + stems_list = build(layout_config) + bass = row("bass") + reported: List[str] = [] + stems_list.on_menu_requested = reported.append + stems_list.on_row_picked = reported.append + stems_list.update_view(view(bass)) + clicked = dpg.get_alias_id(row_tag(bass, SUF_TEXT)) + stems_list.update_view(view()) + + callback = dpg.get_item_callback(handler_of(TAGS.handlers(SUF_TEXT), CLICKED)) + callback(row_tag(bass, SUF_TEXT), (dpg.mvMouseButton_Right, clicked)) + + assert reported == [] + + def test_a_channel_settled_while_a_box_is_gone_keeps_what_the_row_held( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A box that is no longer drawn stands for the reading it last showed, not for an empty one.""" + stems_list = build(layout_config) + bass = row("bass") + settled: List[FrozenSet[ChannelName]] = [] + stems_list.on_channels_changed = lambda _key, channels: settled.append(channels) + stems_list.update_view(view(bass)) + dpg.delete_item(channel_tag(bass, ChannelName.TRIANGLE)) + + dpg.get_item_callback(channel_tag(bass, ChannelName.PULSE1))( + channel_tag(bass, ChannelName.PULSE1), + True, + (bass.key, ChannelName.PULSE1), + ) + + assert settled == [frozenset(CHANNELS)] + def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: """Graying a row is drawn onto the widgets it stands as, so the pointer keeps its box.""" stems_list = build(layout_config) @@ -399,8 +701,10 @@ def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( assert dpg.get_alias_id(channel_tag(bass, ChannelName.PULSE1)) == standing -class TestOfferedChannels: - def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: None, layout_config) -> None: +class TestOfferedChannels(BaseTestSuite): + def test_a_row_draws_a_box_only_on_the_channels_it_offers( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: stems_list = build(layout_config) bass = row("bass", channels=frozenset({ChannelName.PULSE1}), offered_channels=frozenset({ChannelName.PULSE1})) @@ -409,7 +713,7 @@ def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: Non assert dpg.does_item_exist(channel_tag(bass, ChannelName.PULSE1)) assert not dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) - def test_a_recording_missing_from_disk_grays_out(self, dpg_context: None, layout_config) -> None: + def test_a_recording_missing_from_disk_grays_out(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass", available=False) @@ -417,7 +721,22 @@ def test_a_recording_missing_from_disk_grays_out(self, dpg_context: None, layout assert dpg.get_item_theme(row_tag(bass, SUF_TEXT)) == ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_ROW_INERT).tag - def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_config) -> None: + def test_a_channel_coming_into_play_draws_the_rows_again( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The columns every table declares are the channels in play, so one arriving is met by + the tables themselves rather than by the boxes already drawn.""" + stems_list = build(layout_config) + bass = row("bass") + stems_list.update_view(view(bass, channels_in_play=(ChannelName.PULSE1,))) + + stems_list.update_view(view(bass)) + + assert dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) + + def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) stems_list.update_view(view(row("bass", offered_channels=frozenset({ChannelName.PULSE1})))) @@ -427,9 +746,31 @@ def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_conf assert dpg.does_item_exist(channel_tag(bass, ChannelName.TRIANGLE)) -class TestMasterCheckbox: - def test_a_master_box_reads_whether_the_row_holds_a_channel(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, master_checkbox=True) +class TestMasterCheckbox(BaseTestSuite): + def test_a_master_box_opens_where_the_grid_puts_it(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The box is centered in its column, and the indent the grid works out is what draws it there.""" + stems_list = build(layout_config, master_box=True) + bass = row("bass") + + stems_list.update_view(view(bass)) + + indent = int(dpg.get_item_configuration(row_tag(bass, SUF_CHECKBOX))["indent"]) + assert ( + indent + == StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=True, + removable=True, + bends=False, + folders=False, + ).master_indent + ) + + def test_a_master_box_reads_whether_the_row_holds_a_channel( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, master_box=True) playing = row("bass") quiet = row("pad", channels=frozenset()) @@ -441,10 +782,10 @@ def test_a_master_box_reads_whether_the_row_holds_a_channel(self, dpg_context: N def test_ticking_the_master_box_hands_the_row_every_channel_it_offers( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] - stems_list = build(layout_config, master_checkbox=True) + stems_list = build(layout_config, master_box=True) stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) bass = row("bass", channels=frozenset(), offered_channels=frozenset({ChannelName.PULSE1})) stems_list.update_view(view(bass)) @@ -454,9 +795,11 @@ def test_ticking_the_master_box_hands_the_row_every_channel_it_offers( assert reported == [(bass.key, frozenset({ChannelName.PULSE1}))] - def test_unticking_the_master_box_takes_every_channel_away(self, dpg_context: None, layout_config) -> None: + def test_unticking_the_master_box_takes_every_channel_away( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] - stems_list = build(layout_config, master_checkbox=True) + stems_list = build(layout_config, master_box=True) stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) bass = row("bass") stems_list.update_view(view(bass)) @@ -469,16 +812,16 @@ def test_unticking_the_master_box_takes_every_channel_away(self, dpg_context: No def test_a_row_offering_no_channel_has_nothing_for_its_master_box_to_do( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: - stems_list = build(layout_config, master_checkbox=True) + stems_list = build(layout_config, master_box=True) silent = row("pad", channels=frozenset(), offered_channels=frozenset()) stems_list.update_view(view(silent)) assert not dpg.is_item_enabled(row_tag(silent, SUF_CHECKBOX)) - def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_config) -> None: + def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") @@ -487,8 +830,8 @@ def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_ assert not dpg.does_item_exist(row_tag(bass, SUF_CHECKBOX)) -class TestMutedChannels: - def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config) -> None: +class TestMutedChannels(BaseTestSuite): + def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config: LayoutConfig) -> None: stems_list = build(layout_config) bass = row("bass") @@ -498,7 +841,9 @@ def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_co assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) == muted assert dpg.get_item_theme(channel_tag(bass, ChannelName.PULSE1)) != muted - def test_a_muted_box_keeps_its_value_and_stays_clickable(self, dpg_context: None, layout_config) -> None: + def test_a_muted_box_keeps_its_value_and_stays_clickable( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: stems_list = build(layout_config) bass = row("bass") @@ -508,7 +853,9 @@ def test_a_muted_box_keeps_its_value_and_stays_clickable(self, dpg_context: None assert dpg.get_value(channel_tag(bass, channel_name)) assert dpg.is_item_enabled(channel_tag(bass, channel_name)) - def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: None, layout_config) -> None: + def test_a_channel_switched_back_on_takes_its_own_color_again( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: stems_list = build(layout_config) bass = row("bass") stems_list.update_view(view(bass, muted_channels=frozenset({ChannelName.TRIANGLE}))) @@ -519,9 +866,46 @@ def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) != muted -class TestCollapsedLevels: - def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, draggable=False) +class TestTheToneAChannelNameTakes(BaseTestSuite): + """The heading tones each channel's name the way its boxes are toned, so the name and the + column of boxes under it read as one thing.""" + + @staticmethod + def _name_tag(channel_name: ChannelName) -> str: + return compose_tag(PREFIX, SUF_HEADING, channel_name, SUF_TEXT) + + def test_a_muted_channel_is_named_in_the_muted_tone(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("bass"), muted_channels=frozenset({ChannelName.TRIANGLE}))) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.TRIANGLE)) == muted + assert dpg.get_item_theme(self._name_tag(ChannelName.PULSE1)) != muted + + def test_a_channel_in_play_is_named_in_its_own_color(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("bass"))) + + expected = ThemeRegistry.get(CHANNEL_THEME_TAGS[ChannelName.PULSE1]).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.PULSE1)) == expected + + def test_a_channel_switched_back_on_is_named_in_its_own_color_again( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config) + stems_list.update_view(view(row("bass"), muted_channels=frozenset({ChannelName.TRIANGLE}))) + + stems_list.update_view(view(row("bass"))) + + muted = ThemeRegistry.get(TAG_GLOBAL_THEME_CHANNEL_MUTED).tag + assert dpg.get_item_theme(self._name_tag(ChannelName.TRIANGLE)) != muted + + +class TestCollapsedLevels(BaseTestSuite): + def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=False) rows = ( row("bass", level=0, position=0, level_size=1, level_count=2), row("pad", level=1, position=0, level_size=1, level_count=2), @@ -529,13 +913,13 @@ def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout stems_list.update_view(view(*rows, collapse_levels=True)) - assert dpg.does_item_exist(stems_list.table_tag) - assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) + assert dpg.does_item_exist(stems_list.tags.table) + assert not dpg.does_item_exist(TAGS.level(0, SUF_TEXT)) for entry in rows: assert dpg.does_item_exist(row_tag(entry, SUF_TEXT)) - def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_config) -> None: - stems_list = build(layout_config, draggable=False) + def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=False) rows = ( row("bass", level=0, position=0, level_size=1, level_count=2), row("pad", level=1, position=0, level_size=1, level_count=2), @@ -544,22 +928,456 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf stems_list.update_view(view(*rows)) - assert not dpg.does_item_exist(stems_list.table_tag) - assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) - assert dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "1", SUF_TEXT)) + assert not dpg.does_item_exist(stems_list.tags.table) + assert dpg.does_item_exist(TAGS.level(0, SUF_TEXT)) + assert dpg.does_item_exist(TAGS.level(1, SUF_TEXT)) -class TestActivation: - def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: None, layout_config) -> None: +class TestActivation(BaseTestSuite): + def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config: LayoutConfig) -> None: activated: List[str] = [] - stems_list = build(layout_config, draggable=False) + stems_list = build(layout_config, dragging=False) stems_list.on_row_activated = activated.append bass = row("bass") stems_list.update_view(view(bass)) - name_tag = row_tag(bass, SUF_TEXT) - dpg.set_value(name_tag, True) - dpg.get_item_callback(name_tag)(name_tag, True, bass.key) + select_name(bass, True) assert activated == [bass.key] - assert not dpg.get_value(name_tag) + + def test_a_right_click_picks_the_row_its_menu_stands_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The menu prints the key that takes a row out, so both name the row the menu stands over.""" + activated: List[str] = [] + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = activated.append + stems_list.on_menu_requested = asked.append + bass = row("bass") + lead = row("lead") + stems_list.update_view(view(bass, lead, selected_key=bass.key)) + + click_on(lead, CLICKED, dpg.mvMouseButton_Right) + + assert activated == [lead.key] + assert asked == [lead.key] + + def test_a_row_no_reading_picks_out_reads_plain( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A list whose owner answers a click without recording one stands its rows as the view + holds them, so the click leaves the look the last reading wrote.""" + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = lambda _key: None + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + def test_the_second_click_of_a_double_click_names_the_same_row( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """DearPyGui reports a selectable once per click, so a double-click reports its row twice + rather than picking it out and letting it go again.""" + activated: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = activated.append + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + select_name(bass, False) + + assert activated == [bass.key, bass.key] + + def test_the_list_names_the_row_a_key_press_acts_on(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + lead = row("lead") + + stems_list.update_view(view(bass, lead, selected_key=lead.key)) + + assert stems_list.picked_key == lead.key + + def test_a_list_holding_nothing_picked_out_names_no_row( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config, dragging=False) + + stems_list.update_view(view(row("bass"))) + + assert stems_list.picked_key is None + + def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A click is answered by whoever owns the list, so the next view decides what is selected.""" + stems_list = build(layout_config, dragging=False) + bass = row("bass") + lead = row("lead") + + stems_list.update_view(view(bass, lead, selected_key=lead.key)) + + assert dpg.get_value(row_tag(lead, SUF_TEXT)) is True + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + +class TestGesturesTheOwnerLeavesUnanswered(BaseTestSuite): + """A list hands a gesture on where it has an owner for it, and lets the rest be. + + A click still moves the widget it lands on, so a list answering no click puts the row back the + way its view holds it — a reader picking through such a list would otherwise leave a trail of + rows reading as picked that no reading of the list ever wrote. + """ + + def test_a_click_leaves_the_row_reading_as_the_view_holds_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + select_name(bass, True) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + def test_the_row_the_view_holds_picked_out_keeps_reading_that_way( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A click that reaches nobody puts the row back where the view stands it, either way.""" + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass, selected_key=bass.key)) + + select_name(bass, False) + + assert dpg.get_value(row_tag(bass, SUF_TEXT)) is True + + def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + with patch.object(stems_list, "call") as handed_on: + click_on(bass, CLICKED, dpg.mvMouseButton_Right) + + assert stems_list.has_menu is False + handed_on.assert_not_called() + + def test_a_right_click_names_its_row_where_the_owner_puts_one_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_menu_requested = asked.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, CLICKED, dpg.mvMouseButton_Right) + + assert asked == [bass.key] + + def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + with patch.object(stems_list, "call") as handed_on: + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Left) + + assert stems_list.playable is False + handed_on.assert_not_called() + + def test_a_double_click_sounds_its_row_where_the_owner_answers( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + opened: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_opened = opened.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Left) + + assert opened == [bass.key] + + +class TestEachGestureAnswersItsOwnButton(BaseTestSuite): + """Both handlers report every button, so each reads the one its own gesture is made with.""" + + def test_a_left_click_puts_no_menu_up(self, dpg_context: None, layout_config: LayoutConfig) -> None: + asked: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_menu_requested = asked.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, CLICKED, dpg.mvMouseButton_Left) + + assert asked == [] + + def test_a_right_double_click_sounds_nothing(self, dpg_context: None, layout_config: LayoutConfig) -> None: + opened: List[str] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_opened = opened.append + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Right) + + assert opened == [] + + +class TestTheHeading(BaseTestSuite): + """The channels are named once above the rows, whatever shape the list takes below it.""" + + def test_a_plain_list_names_them(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) + + def test_a_banded_list_names_them_too(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"))) + + assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) + + +class TestThePickALongListHolds(BaseTestSuite): + """A long list builds the rows a window reaches, and the pick is the reading's rather than the + widgets', so scrolling away from the picked row leaves it the row a key acts on.""" + + def test_a_row_scrolled_out_of_the_window_is_still_the_row_a_key_acts_on( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + picked = rows[EARLY_ROW] + stems_list.update_view(view(*rows, collapse_levels=True, selected_key=picked.key)) + + with placed(LONG_LIST), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render(SETTLING_FRAMES) + + assert not dpg.does_item_exist(row_tag(picked, SUF_TEXT)) + assert stems_list.picked_key == picked.key + + +class TestTheRulesTheGridDraws(BaseTestSuite): + """A rule runs around the grid's own edges and between its rows, so the run of rows an open + folder breaks reads as one ruled list down its whole length.""" + + def test_the_grid_rules_the_rows_off_from_the_heading(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The heading draws no rule of its own, so the grid below it is what divides the two.""" + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + assert dpg.get_item_configuration(TAGS.table)["borders_outerH"] is True + + def test_the_heading_draws_no_rule_of_its_own(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """One line divides the names from the rows, so the heading leaves the drawing of it to + the grid rather than adding a second beside it.""" + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), collapse_levels=True)) + + heading = compose_tag(PREFIX, SUF_HEADING, SUF_TABLE) + assert dpg.get_item_configuration(heading)["borders_outerH"] is False + + def test_the_grid_rules_between_the_rows_it_holds(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + + stems_list.update_view(view(row("kick"), row("snare"), collapse_levels=True)) + + assert dpg.get_item_configuration(TAGS.table)["borders_innerH"] is True + + +class TestTheWell(BaseTestSuite): + """The well keeps the card's shape: where its rows are recordings alone it builds the ones it + shows and reserves the room for the rest, and it holds every row otherwise.""" + + def test_a_long_run_of_recordings_builds_the_rows_it_shows( + self, dpg_context: None, layout_config: LayoutConfig + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + built = sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + assert 0 < built < LONG_LIST + + def test_the_first_slice_is_sized_by_the_height_the_layout_gives_a_row( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """No frame has measured a row yet, so the well opens at the layout's own figure: it + builds about the rows the ceiling holds rather than the several times as many a floor of + eight pixels a row would reach.""" + stems = layout_config.general.stems + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + built = sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + assert built < stems.well_ceiling / MINIMUM_ROW_PITCH + assert built >= stems.well_ceiling / stems.name_height + + def test_a_short_run_of_recordings_builds_them_all(self, dpg_context: None, layout_config: LayoutConfig) -> None: + stems_list = build(layout_config) + rows = (row("kick"), row("snare")) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + def test_a_list_holding_a_folder_builds_every_row(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A folder answers for its own length inside its region, so the well holds the rest whole.""" + stems_list = build(layout_config) + rows = (folder_row("sources"), *(row(f"take_{index}") for index in range(LONG_LIST))) + + stems_list.update_view(view(*rows, collapse_levels=True)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + def test_a_banded_list_builds_every_row(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """Captions and strips stand among banded rows, so there is no one row to reserve room by.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows)) + + assert all(dpg.does_item_exist(row_tag(entry, SUF_TEXT)) for entry in rows) + + +class TestTheListSettlingItsWell(BaseTestSuite): + """A region reads its rows back a frame after they are placed, and refills itself from there. + + The reading a row is measured by, and the rows a scroll has moved on to, both belong to the + frame after a draw, so the list asks for that frame and keeps asking for as long as a region + still holds rows it has yet to build. + """ + + @staticmethod + def _built(rows: Tuple[StemRowViewModel, ...]) -> int: + """How many of the list's rows stand as widgets, which is the block a frame would place.""" + return sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) + + @classmethod + def _drawn(cls, rows: Tuple[StemRowViewModel, ...], index: int) -> bool: + return dpg.does_item_exist(row_tag(rows[index], SUF_TEXT)) + + def test_a_well_holding_rows_back_asks_for_another_frame( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + """The rows outside the window are built as the reader reaches them, so the pass goes on.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + + with placed(self._built(rows)): + frames.render() + + assert frames.pending == 1 + + def test_readings_arriving_before_the_frame_ask_for_the_one_frame( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + """One pass answers for whatever the list has drawn by the time it runs, so a run of + readings between two frames leaves one pass rather than one apiece.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + + stems_list.update_view(view(*rows, collapse_levels=True)) + stems_list.update_view(view(*rows[:-1], collapse_levels=True)) + stems_list.update_view(view(*rows[:-2], collapse_levels=True)) + + assert frames.pending == 1 + + def test_the_pass_renews_itself_rather_than_piling_up( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + + for _ in range(SETTLING_FRAMES): + with placed(self._built(rows)): + frames.render() + + assert frames.pending == 1 + + def test_a_well_holding_everything_it_drew_comes_to_rest( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + """A list short enough to stand whole has nothing left to build, so it stops watching.""" + stems_list = build(layout_config) + rows = (row("kick"), row("snare")) + stems_list.update_view(view(*rows, collapse_levels=True)) + + with placed(self._built(rows)): + frames.render(SETTLING_FRAMES) + + assert frames.pending == 0 + + def test_a_scroll_brings_the_rows_it_reaches_in( + self, + dpg_context: None, + layout_config: LayoutConfig, + frames: Frames, + ) -> None: + """The window follows the reader, so rows they scrolled to are built and the ones behind go.""" + stems_list = build(layout_config) + rows = tuple(row(f"take_{index}") for index in range(LONG_LIST)) + stems_list.update_view(view(*rows, collapse_levels=True)) + with placed(self._built(rows)): + frames.render() + + with placed(self._built(rows)), patch.object(dpg, "get_y_scroll", return_value=DEEP_SCROLL): + frames.render() + + assert self._drawn(rows, REACHED_ROW) + assert not self._drawn(rows, EARLY_ROW) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py new file mode 100644 index 000000000..899bf53c0 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py @@ -0,0 +1,373 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, FrozenSet, Tuple + +import pytest + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind +from sampletones_application.paths import LANG_EN +from sampletones_application.ui.elements.stems.expansion import OpenFolders +from sampletones_application.ui.elements.stems.messages import StemsMessages +from sampletones_application.ui.elements.stems.offer import StemsListOffer +from sampletones_application.view_model.shared.stems import ( + StemRowViewModel, + StemsListViewModel, +) +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +CHANNELS: Final[Tuple[ChannelName, ...]] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +NAME: Final[str] = "kick" +FOLDER_NAME: Final[str] = "sources" +HOLDS: Final[int] = 3 + + +@dataclass(frozen=True) +class Host: + """What the list answers about the gestures its owner takes, as ``StemsListHost`` states them.""" + + activatable: bool = False + playable: bool = False + has_menu: bool = False + + +def offer(*, dragging: bool = False) -> StemsListOffer: + return StemsListOffer( + master_box=False, + removal=True, + keeps_last_row=False, + dragging=dragging, + bends=False, + picking=False, + ) + + +def recording( + name: str = NAME, + *, + channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + available: bool = True, +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + path=path, + held=(), + channels=channels, + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=offered_channels, + available=available, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def folder(name: str = FOLDER_NAME, *, holds: int = HOLDS) -> StemRowViewModel: + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + path=root, + held=tuple(recording(f"{name}/take_{index}") for index in range(holds)), + channels=frozenset(CHANNELS), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def view( + *rows: StemRowViewModel, + collapse_levels: bool, + muted_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=muted_channels, + picked_keys=frozenset(), + picking_room=None, + live=True, + collapse_levels=collapse_levels, + selected_key=None, + ) + + +def messages( + *rows: StemRowViewModel, + collapse_levels: bool, + dragging: bool = False, + host: Host = Host(), + open_folders: OpenFolders = OpenFolders(), + muted_channels: FrozenSet[ChannelName] = frozenset(), +) -> StemsMessages: + """The answers one list would give, reading the view it is drawing.""" + answering = StemsMessages( + LANGUAGE_MANAGER, + offer=offer(dragging=dragging), + open_folders=open_folders, + host=host, + ) + answering.reads(view(*rows, collapse_levels=collapse_levels, muted_channels=muted_channels)) + return answering + + +def template(key: str) -> str: + return LANGUAGE_MANAGER[f"global.stems.message.{key}"] + + +class TestWhatARowSaysAboutDragging(BaseTestSuite): + """A list drags its rows where it bands them, since a drag moves a recording between levels. + + What the row offers and what it says it offers are one answer, so a list drawing its rows in + one run neither takes a drag nor speaks of one. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + dragging: bool + collapse_levels: bool + + test_cases = ( + TestCase(label="a_banded_list_that_drags_says_so", dragging=True, collapse_levels=False, expected=True), + TestCase(label="one_run_of_rows_takes_no_drag", dragging=True, collapse_levels=True, expected=False), + TestCase(label="a_list_that_never_drags_stays_quiet", dragging=False, collapse_levels=False, expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_hover_names_the_drag_where_the_row_takes_one(self, test_case: TestCase) -> None: + kick = recording() + + explanation = messages( + kick, + collapse_levels=test_case.collapse_levels, + dragging=test_case.dragging, + ).row_explanation(kick) + + assert (template("drag_tooltip") in explanation) is test_case.expected + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_status_line_names_the_drag_where_the_row_takes_one(self, test_case: TestCase) -> None: + kick = recording() + + line = messages( + kick, + collapse_levels=test_case.collapse_levels, + dragging=test_case.dragging, + ).name(user_data=kick.key) + + drag = template("status_row_drag").format(name=kick.name) + assert (drag in line) is test_case.expected + + +class TestWhatARowSaysAboutTheClickItTakes(BaseTestSuite): + """A click reaches the owner wherever one answers, so the line naming it stands beside the + drag rather than behind it.""" + + def test_a_list_drawing_one_run_of_rows_names_the_click(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True, dragging=True, host=Host(activatable=True)).name(user_data=kick.key) + + assert template("status_row_reveal").format(name=kick.name) in line + + def test_a_banded_list_names_the_drag_and_the_click_together(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=False, dragging=True, host=Host(activatable=True)).name( + user_data=kick.key + ) + + assert template("status_row_drag").format(name=kick.name) in line + assert template("status_row_reveal").format(name=kick.name) in line + + def test_a_list_no_owner_answers_reads_as_the_name_alone(self) -> None: + """A double-click is the one gesture nothing on the row draws, so a list that neither + sounds nor reveals has nothing to offer beyond what the row is called.""" + kick = recording() + + line = messages(kick, collapse_levels=True).name(user_data=kick.key) + + assert line == kick.name + + def test_a_list_that_sounds_a_row_says_so(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True, host=Host(playable=True)).name(user_data=kick.key) + + assert template("status_row_play").format(name=kick.name) in line + + +class TestWhatARowExplainsOnHover(BaseTestSuite): + """The hover names where the recording is, and why it is grayed out where it contributes + nothing, so a reader meets the reason beside the row it belongs to.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + row: StemRowViewModel + key: str + + test_cases = ( + TestCase(label="a_folder_stands_for_what_it_holds", row=folder(), key="folder_tooltip"), + TestCase( + label="a_recording_off_disk_says_so", + row=recording(available=False), + key="missing_tooltip", + ), + TestCase( + label="a_recording_holding_no_frames_says_so", + row=recording(offered_channels=frozenset()), + key="unoffered_tooltip", + ), + TestCase( + label="a_recording_on_no_channel_says_what_would_bring_it_in", + row=recording(channels=frozenset()), + key="inert_tooltip", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_hover_names_the_reading_the_row_stands_at(self, test_case: TestCase) -> None: + explanation = messages(test_case.row, collapse_levels=True).row_explanation(test_case.row) + + assert template(test_case.key) in explanation + + def test_the_hover_opens_on_where_the_recording_is(self) -> None: + kick = recording() + + explanation = messages(kick, collapse_levels=True).row_explanation(kick) + + assert explanation.startswith(str(kick.path)) + + def test_a_recording_in_play_explains_itself_by_its_path_alone(self) -> None: + kick = recording() + + explanation = messages(kick, collapse_levels=True).row_explanation(kick) + + assert explanation == str(kick.path) + + +class TestWhatAFolderSays(BaseTestSuite): + """A folder stands for the recordings gathered below it, so its lines name the group rather + than any one recording in it.""" + + def test_its_row_reads_out_how_many_it_holds(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).name(user_data=sources.key) + + assert line == template("status_folder_row").format(name=sources.name, count=HOLDS) + + def test_its_box_reaches_every_recording_in_it(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).channel(user_data=(sources.key, ChannelName.PULSE1)) + + assert line == template("status_folder_channel").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=sources.name, + ) + + def test_its_button_takes_the_recordings_with_it(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).remove(user_data=sources.key) + + assert line == template("status_folder_remove").format(name=sources.name) + + +class TestWhatTheMarkerSays(BaseTestSuite): + """The marker beside a folder's name offers the move it would make from where it now stands, + so the line follows the folder rather than stating one of the two readings.""" + + def test_a_closed_folder_offers_to_show_what_it_holds(self) -> None: + sources = folder() + + line = messages(sources, collapse_levels=True).twisty(user_data=sources.key) + + assert line == template("status_folder_open").format(name=sources.name) + + def test_an_open_folder_offers_to_hide_it_again(self) -> None: + sources = folder() + open_folders = OpenFolders() + open_folders.toggle(sources.key) + + line = messages(sources, collapse_levels=True, open_folders=open_folders).twisty(user_data=sources.key) + + assert line == template("status_folder_close").format(name=sources.name) + + +class TestWhatABoxSays(BaseTestSuite): + """A box names the channel it answers for, and a channel switched off everywhere says that + the row stays quiet on it however the box reads.""" + + def test_a_channel_in_play_names_what_the_box_does(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).channel(user_data=(kick.key, ChannelName.PULSE1)) + + assert line == template("status_channel").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=kick.name, + ) + + def test_a_muted_channel_says_the_row_stays_quiet_on_it(self) -> None: + kick = recording() + + line = messages( + kick, + collapse_levels=True, + muted_channels=frozenset({ChannelName.PULSE1}), + ).channel(user_data=(kick.key, ChannelName.PULSE1)) + + assert line == template("status_channel_muted").format( + channel=channel_label(LANGUAGE_MANAGER, ChannelName.PULSE1), + name=kick.name, + ) + + def test_the_box_beside_a_row_reaches_every_channel(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).master(user_data=kick.key) + + assert line == template("status_master").format(name=kick.name) + + def test_the_button_takes_the_recording_off_the_list(self) -> None: + kick = recording() + + line = messages(kick, collapse_levels=True).remove(user_data=kick.key) + + assert line == template("status_remove").format(name=kick.name) + + +class TestARowTheReadingHasLetGo(BaseTestSuite): + """A hover is answered a frame after it landed, by which time the list may have moved on, so + every answer is read from the reading standing now.""" + + @pytest.mark.parametrize( + "answer", + ("name", "master", "remove", "twisty"), + ) + def test_a_widget_naming_a_row_that_went_explains_nothing(self, answer: str) -> None: + answering = messages(recording(), collapse_levels=True) + + assert getattr(answering, answer)(user_data="/audio/gone.wav") == "" + + def test_a_box_naming_a_row_that_went_explains_nothing(self) -> None: + answering = messages(recording(), collapse_levels=True) + + assert answering.channel(user_data=("/audio/gone.wav", ChannelName.PULSE1)) == "" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_shape.py b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py new file mode 100644 index 000000000..0fde51fad --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py @@ -0,0 +1,209 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.ui.elements.stems.shape import ListShape, Reshape, RowPlacement +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) + + +def placed( + key: str, + *, + level: int = 0, + opened: bool = False, + held: Tuple[str, ...] = (), + offered: Tuple[ChannelName, ...] = CHANNELS, +) -> RowPlacement: + """One row as a shape records it.""" + return RowPlacement( + key=key, + level=level, + offered=frozenset(offered), + opened=opened, + held=held, + ) + + +def shaped(*rows: RowPlacement, columns: Tuple[ChannelName, ...] = CHANNELS, collapsed: bool = True) -> ListShape: + """The shape a list of these rows amounts to.""" + return ListShape(columns=columns, collapsed=collapsed, rows=rows) + + +class TestWhatAReadingAsksFor(BaseTestSuite): + """A shape says whether the widgets standing can be brought to a new reading, and how far. + + The whole list is drawn again where the tables are built around what changed — the columns, + the banding, which rows stand and where. A recording leaving the folder it was gathered under + reaches that folder's own region alone, which is what lets the rows around it keep the widgets + they stand as and the reader keep the scroll they left. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + standing: ListShape + incoming: ListShape + expected: Reshape + + test_cases = ( + TestCase( + label="the_same_shape_asks_for_nothing", + standing=shaped(placed("bass"), placed("drums", held=("one", "two"))), + incoming=shaped(placed("bass"), placed("drums", held=("one", "two"))), + expected=Reshape.within(()), + ), + TestCase( + label="a_recording_leaving_a_folder_reaches_that_folder", + standing=shaped(placed("bass"), placed("drums", held=("one", "two"))), + incoming=shaped(placed("bass"), placed("drums", held=("one",))), + expected=Reshape.within(("drums",)), + ), + TestCase( + label="two_folders_changing_at_once_name_them_both", + standing=shaped(placed("drums", held=("one", "two")), placed("keys", held=("three", "four"))), + incoming=shaped(placed("drums", held=("one",)), placed("keys", held=("three",))), + expected=Reshape.within(("drums", "keys")), + ), + TestCase( + label="a_row_arriving_reaches_the_whole_list", + standing=shaped(placed("bass")), + incoming=shaped(placed("bass"), placed("lead")), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_leaving_reaches_the_whole_list", + standing=shaped(placed("bass"), placed("lead")), + incoming=shaped(placed("bass")), + expected=Reshape.everything(), + ), + TestCase( + label="a_folder_opening_reaches_the_whole_list", + standing=shaped(placed("drums", held=("one",))), + incoming=shaped(placed("drums", opened=True, held=("one",))), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_changing_band_reaches_the_whole_list", + standing=shaped(placed("bass", level=0)), + incoming=shaped(placed("bass", level=1)), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_offering_another_channel_reaches_the_whole_list", + standing=shaped(placed("bass", offered=(ChannelName.PULSE1,))), + incoming=shaped(placed("bass", offered=CHANNELS)), + expected=Reshape.everything(), + ), + TestCase( + label="a_column_arriving_reaches_the_whole_list", + standing=shaped(placed("bass"), columns=(ChannelName.PULSE1,)), + incoming=shaped(placed("bass"), columns=CHANNELS), + expected=Reshape.everything(), + ), + TestCase( + label="the_banding_changing_reaches_the_whole_list", + standing=shaped(placed("bass"), collapsed=True), + incoming=shaped(placed("bass"), collapsed=False), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_that_both_moves_and_loses_a_recording_reaches_the_whole_list", + standing=shaped(placed("drums", level=0, held=("one", "two"))), + incoming=shaped(placed("drums", level=1, held=("one",))), + expected=Reshape.everything(), + ), + TestCase( + label="a_row_taking_another_rows_place_reaches_the_whole_list", + standing=shaped(placed("bass"), placed("lead")), + incoming=shaped(placed("lead"), placed("bass")), + expected=Reshape.everything(), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_what_the_incoming_shape_asks_of_the_standing_one(self, test_case: TestCase) -> None: + assert test_case.incoming.against(test_case.standing) == test_case.expected + + +class TestWhetherAnythingIsDrawn(BaseTestSuite): + """Whoever answers a reshape settles the regions once widgets have been built.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + reshape: Reshape + + test_cases = ( + TestCase(label="naming_no_folder_draws_nothing", reshape=Reshape.within(()), expected=False), + TestCase(label="the_whole_list_draws", reshape=Reshape.everything(), expected=True), + TestCase(label="one_folder_draws", reshape=Reshape.within(("drums",)), expected=True), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_whether_widgets_were_built(self, test_case: TestCase) -> None: + assert test_case.reshape.redraws is test_case.expected + + +class TestWhereARowStands(BaseTestSuite): + """A placement answers whether two readings put the same row in the same place. + + What a folder holds is answered separately, so it plays no part in where the folder's own + row stands; the band it sits in and whether it stands open both do. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + standing: RowPlacement + incoming: RowPlacement + + test_cases = ( + TestCase( + label="what_a_folder_holds_leaves_it_where_it_was", + standing=placed("drums", held=("one",)), + incoming=placed("drums", held=("one", "two")), + expected=True, + ), + TestCase( + label="a_row_that_moved_band_stands_elsewhere", + standing=placed("drums", level=1), + incoming=placed("drums", level=0), + expected=False, + ), + TestCase( + label="a_folder_that_opened_stands_elsewhere", + standing=placed("drums"), + incoming=placed("drums", opened=True), + expected=False, + ), + TestCase( + label="a_row_offered_other_channels_stands_elsewhere", + standing=placed("drums", offered=(ChannelName.PULSE1,)), + incoming=placed("drums", offered=CHANNELS), + expected=False, + ), + TestCase( + label="another_row_stands_elsewhere", + standing=placed("drums"), + incoming=placed("bass"), + expected=False, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_whether_two_readings_stand_the_row_the_same_way(self, test_case: TestCase) -> None: + assert test_case.incoming.stands_where(test_case.standing) is test_case.expected 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 e92e356ee..e13796fac 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,6 +7,7 @@ from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.configs import Config from sampletones_core.configs.display import format_frequencies, format_sample_rate, short_hash +from sampletones_core.constants.enums import DEFAULT_CHANNELS from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( ConfigGroupNode, @@ -18,7 +19,7 @@ from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager -CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) +CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions") / CONFIG_FIELDS.directory_name RECONSTRUCTION_PATH: Final[Path] = CONFIG_DIRECTORY / f"song{EXT_FILE_RECONSTRUCTION}" 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 8dcfa73ce..145a2aeef 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 @@ -273,7 +273,7 @@ def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: Brows class TestFollowingTheReader: - """A click on a row is how it folds, and the browser reads what it stands as afterwards.""" + """A click on a row is how it folds, and the browser reads what it stands as afterward.""" def test_a_click_reads_the_row_the_frame_after_it_landed( self, diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index 47e40de3a..b6c2ef49c 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -95,7 +95,7 @@ def test_the_reader_finds_each_stage_under_its_own_name(self, window: GUIExportW class TestHowTheStageUnderWayReads: """A stage arriving at an end carries a bar; one measured against a limit carries a figure.""" - def test_a_travelling_stage_shows_its_bar(self, window: GUIExportWindow) -> None: + def test_a_traveling_stage_shows_its_bar(self, window: GUIExportWindow) -> None: render(window, traveling=True) assert shown(TAG_SETTINGS_EXPORT_GROUP_MEASURED) assert not shown(TAG_SETTINGS_EXPORT_GROUP_WORKING) @@ -126,7 +126,7 @@ def test_a_running_export_offers_a_stop(self, window: GUIExportWindow) -> None: assert dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] def test_an_export_already_stopping_offers_no_further_stop(self, window: GUIExportWindow) -> None: - render(window, phase=ExportPhase.CANCELLING) + render(window, phase=ExportPhase.CANCELING) assert not dpg.get_item_configuration(TAG_SETTINGS_EXPORT_BUTTON_CANCEL)["enabled"] def test_pressing_cancel_asks_the_run_to_stop(self, window: GUIExportWindow) -> None: @@ -139,6 +139,6 @@ def test_pressing_cancel_asks_the_run_to_stop(self, window: GUIExportWindow) -> def test_a_run_already_stopping_takes_no_second_ask(self, window: GUIExportWindow) -> None: asked: List[bool] = [] window.on_cancel = lambda: asked.append(True) - render(window, phase=ExportPhase.CANCELLING) + render(window, phase=ExportPhase.CANCELING) dpg.get_item_callback(compose_tag(TAG_SETTINGS_EXPORT_BUTTON_CANCEL, SUF_BUTTON))() assert asked == [] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py index 9b8cece34..b461e12e5 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py @@ -176,7 +176,7 @@ def test_a_control_off_screen_takes_no_focus(self, window: GUIRenderWindow) -> N 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) + render(window, phase=RenderPhase.CANCELING) assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py new file mode 100644 index 000000000..9136e95f2 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py @@ -0,0 +1,101 @@ +from pathlib import Path +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, TAG_GLOBAL_THEME_DANGER_BUTTON +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, + TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER, + TAG_MAIN_CONVERTER_WINDOW_SCAN, +) +from sampletones_application.ui.panels.dialogs.scanning import GUIScanWindow +from tests.suite.base import BaseTestSuite + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +OPENING: Final[str] = LANGUAGE_MANAGER["main.converter.message.scan_opening"] +PROGRESS: Final[str] = LANGUAGE_MANAGER["main.converter.template.scan_progress"] +ROOT: Final[Path] = Path("/music/takes") +FOUND: Final[int] = 128 + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIScanWindow: + return GUIScanWindow( + layout=layout_config.tabs.main.converter, + language_manager=LANGUAGE_MANAGER, + ) + + +def open_on(window: GUIScanWindow, root: Path) -> None: + """Names the folder and builds the tree, the way ``open`` does without a live frame.""" + window.open(root) + + +def press_stop() -> None: + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON))() + + +class TestWhatTheWindowSays(BaseTestSuite): + """The reader is told which folder is being read and how far the walk has got.""" + + def test_it_opens_on_the_folder_it_was_given(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER) == OPENING.format(name=ROOT.name) + + def test_it_counts_what_the_walk_has_met(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + window.report(FOUND) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_SCAN_FOLDER) == PROGRESS.format(count=FOUND, name=ROOT.name) + + +class TestHowStopReads(BaseTestSuite): + """Stop gives up on a reading the reader asked for, so it carries the tone the interface + gives an action that undoes what is under way.""" + + def test_stop_carries_the_tone_of_the_action_it_is(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + theme = dpg.get_item_theme(compose_tag(TAG_MAIN_CONVERTER_BUTTON_STOP_SCAN, SUF_BUTTON)) + + assert dpg.get_item_alias(theme) == TAG_GLOBAL_THEME_DANGER_BUTTON + + +class TestGivingUp(BaseTestSuite): + """Stop is answered by the window itself, so it closes however the reading ends. + + The window carries no close of its own, and a walk that dies partway reports nothing, so a + Stop that waited for the walk's next word would leave the reader a window with no way out. + """ + + def test_stop_takes_the_window_away(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + + press_stop() + + assert dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_SCAN) is False + + def test_stop_asks_the_walk_to_give_up(self, window: GUIScanWindow) -> None: + asked: List[bool] = [] + window.on_stop = lambda: asked.append(True) + open_on(window, ROOT) + + press_stop() + + assert asked == [True] + + def test_a_walk_ending_after_stop_leaves_the_window_gone(self, window: GUIScanWindow) -> None: + open_on(window, ROOT) + press_stop() + + window.close() + + assert dpg.does_item_exist(TAG_MAIN_CONVERTER_WINDOW_SCAN) is False diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py new file mode 100644 index 000000000..e287ffa48 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -0,0 +1,345 @@ +from pathlib import Path +from typing import Final, List, Optional, Sequence + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.sources import SourceKind +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, SUF_CHECKBOX, SUF_TEXT +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.stems.tags import StemsTags +from sampletones_application.ui.panels.dialogs.stem_selection import Answer, GUIStemSelectionWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.gestures import DOUBLE_CLICKED, click_row_name +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +GATHERED: Final[int] = MAX_STEM_SOURCES + 4 +TAGS: Final[StemsTags] = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIStemSelectionWindow: + return GUIStemSelectionWindow( + layout=layout_config.tabs.main.converter, + stems_layout=layout_config.general.stems, + glyphs=layout_config.glyphs.common, + language_manager=LANGUAGE_MANAGER, + status_bar=GUIStatusBar(), + title=LANGUAGE_MANAGER["main.converter.title.stem_selection_dialog"], + message=LANGUAGE_MANAGER["main.converter.message.stem_selection_prompt"], + limit_template=LANGUAGE_MANAGER["main.converter.template.stem_selection_limit"], + add_label=LANGUAGE_MANAGER["main.converter.label.add_stems_button"], + cancel_label=LANGUAGE_MANAGER["global.dialog.label.cancel"], + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def recording_row(path: Path) -> StemRowViewModel: + """One gathered recording, as the converter's list draws it.""" + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + path=path, + held=(), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def folder_row(root: Path, held: Sequence[Path]) -> StemRowViewModel: + """A gathered folder standing for the recordings below it.""" + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + path=root, + held=tuple(recording_row(path) for path in held), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def paths(count: int = GATHERED) -> List[Path]: + return [Path(f"/audio/take_{index}.wav") for index in range(count)] + + +def candidates(count: int = GATHERED) -> List[StemRowViewModel]: + return [recording_row(path) for path in paths(count)] + + +def render( + window: GUIStemSelectionWindow, + offered: Sequence[StemRowViewModel], + answer: Optional[Answer] = None, +) -> None: + """Builds the widget tree for what was offered, the way ``open`` does without a live frame.""" + window.open(offered, MAX_STEM_SOURCES, answer if answer is not None else discard) + + +def discard(_picked: List[Path]) -> None: + """The answer a case makes no use of.""" + + +def box_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_CHECKBOX) + + +def pick(row: StemRowViewModel) -> None: + """Click one row's box the way DearPyGui reports a checkbox.""" + tag = box_of(row) + dpg.get_item_callback(tag)(tag, not dpg.get_value(tag), dpg.get_item_user_data(tag)) + + +def name_of(row: StemRowViewModel) -> str: + return TAGS.row(row.key, SUF_TEXT) + + +def sound(row: StemRowViewModel) -> None: + """Double-click one row's name the way DearPyGui reports the gesture.""" + click_row_name(TAGS, row.key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) + + +def click_name(row: StemRowViewModel, value: bool) -> None: + """Click one row's name the way DearPyGui does: the widget moves, then the callback runs.""" + name_tag = name_of(row) + dpg.set_value(name_tag, value) + dpg.get_item_callback(name_tag)(name_tag, value, row.key) + + +def add_enabled() -> bool: + return bool(dpg.get_item_configuration(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))["enabled"]) + + +class TestWhatIsOffered(BaseTestSuite): + """Everything gathered is offered, with as many as a mix holds arriving ticked.""" + + def test_every_recording_gathered_gets_a_box(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + for row in offered: + assert dpg.does_item_exist(box_of(row)) + + def test_the_ones_a_mix_holds_arrive_ticked(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + assert [dpg.get_value(box_of(row)) for row in offered[:MAX_STEM_SOURCES]] == [True] * MAX_STEM_SOURCES + + def test_the_rest_arrive_clear(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + assert not any(dpg.get_value(box_of(row)) for row in offered[MAX_STEM_SOURCES:]) + + def test_a_recording_past_the_room_waits(self, window: GUIStemSelectionWindow) -> None: + """A mix is built from a fixed number of recordings, so a full pick answers what it holds.""" + offered = candidates() + render(window, offered) + + assert dpg.get_item_configuration(box_of(offered[MAX_STEM_SOURCES]))["enabled"] is False + + def test_the_ones_it_holds_still_answer(self, window: GUIStemSelectionWindow) -> None: + """Letting a recording go is how the reader makes room for another.""" + offered = candidates() + render(window, offered) + + assert dpg.get_item_configuration(box_of(offered[0]))["enabled"] is True + + +class TestSettlingTheMix(BaseTestSuite): + """The mix is settled once the pick fits, and the line above says where the pick stands.""" + + def test_a_pick_that_fits_settles(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + pick(offered[0]) + pick(offered[MAX_STEM_SOURCES]) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [paths()[1 : MAX_STEM_SOURCES + 1]] + + def test_a_pick_past_the_room_leaves_the_mix_as_it_was(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + + render(window, offered, answered.append) + pick(offered[MAX_STEM_SOURCES]) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + for row in offered[:MAX_STEM_SOURCES]: + pick(row) + + assert add_enabled() is False + + def test_letting_one_go_opens_the_room_for_another(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + beyond = offered[MAX_STEM_SOURCES] + + pick(offered[0]) + assert dpg.get_item_configuration(box_of(beyond))["enabled"] is True + pick(beyond) + + assert dpg.get_value(box_of(beyond)) is True + assert add_enabled() is True + + def test_the_line_reads_what_stands_picked(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + opening = dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) + + pick(offered[0]) + + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) != opening + + def test_a_list_a_mix_already_holds_arrives_settling(self, window: GUIStemSelectionWindow) -> None: + offered = candidates(MAX_STEM_SOURCES) + render(window, offered) + assert add_enabled() is True + + +class TestAFolderInTheQuestion(BaseTestSuite): + """A folder stands as the row it stands as in the card, answering for what it holds.""" + + def test_the_folder_stands_as_one_row(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + render(window, [folder_row(Path("/audio/takes"), held)]) + + assert dpg.does_item_exist(box_of(folder_row(Path("/audio/takes"), held))) + + def test_it_counts_the_recordings_it_holds(self, window: GUIStemSelectionWindow) -> None: + """The line above counts recordings, so a folder counts as what it brings in.""" + held = paths(3) + render(window, [folder_row(Path("/audio/takes"), held)]) + + assert "3" in dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) + + def test_one_click_lets_the_whole_folder_go(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + folder = folder_row(Path("/audio/takes"), held) + render(window, [folder]) + assert dpg.get_value(box_of(folder)) is True + + pick(folder) + + assert add_enabled() is False + + def test_a_folder_larger_than_the_room_takes_as_many_as_fit( + self, + window: GUIStemSelectionWindow, + ) -> None: + """A folder settles either way, so it lets go of a full mix and takes what fits again.""" + answered: List[List[Path]] = [] + held = paths(GATHERED) + folder = folder_row(Path("/audio/takes"), held) + render(window, [folder], answered.append) + assert add_enabled() is True + + pick(folder) + assert add_enabled() is False + + pick(folder) + + assert add_enabled() is True + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + assert answered == [held[:MAX_STEM_SOURCES]] + + def test_what_it_holds_is_what_the_mix_takes(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + answered: List[List[Path]] = [] + render(window, [folder_row(Path("/audio/takes"), held)], answered.append) + + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [held] + + +class TestAskingTwice(BaseTestSuite): + """The window takes its whole tree down between one opening and the next, so a second asking + draws the rows rather than repainting the ones that are gone.""" + + def test_the_rows_are_drawn_again(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + window.hide() + + render(window, offered) + + for row in offered: + assert dpg.does_item_exist(box_of(row)) + + def test_the_pick_still_settles(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + render(window, offered) + window.hide() + + render(window, offered, answered.append) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [paths()[:MAX_STEM_SOURCES]] + + +class TestHearingWhatARowStandsFor(BaseTestSuite): + """A reader decides by ear, so the question sounds a recording the way the card's list does.""" + + def test_a_double_clicked_recording_is_named_to_sound(self, window: GUIStemSelectionWindow) -> None: + played: List[Path] = [] + offered = candidates() + window.on_source_played = played.append + render(window, offered) + + sound(offered[0]) + + assert played == [offered[0].path] + + def test_clicking_a_name_leaves_the_row_reading_as_it_stood(self, window: GUIStemSelectionWindow) -> None: + """The pick is made by the boxes, so a click on a name marks no row as picked out.""" + offered = candidates() + render(window, offered) + + click_name(offered[0], True) + + assert dpg.get_value(name_of(offered[0])) is False diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py new file mode 100644 index 000000000..eeeaf007d --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -0,0 +1,897 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterator, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.elements.main import ConverterStemMoveElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + TAG_MAIN_CONVERTER_GROUP_CONTROLS, + TAG_MAIN_CONVERTER_GROUP_CONVERT, + TAG_MAIN_CONVERTER_GROUP_INPUT, + TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_GROUP_SUMMARY, + TAG_MAIN_CONVERTER_RADIO_MODE, + TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, + TAG_MAIN_CONVERTER_WINDOW_STEMS, +) +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.panels.main.converter import menus as menus_module +from sampletones_application.ui.panels.main.converter.panel import GUIConverterPanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.setup import setup_themes +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyEvent, KeyRouter, focus +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.main.converter import ( + ConversionPhase, + ConverterViewModel, +) +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE +from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.gestures import CLICKED, click_row_name +from tests.suite.shortcuts import rebound_source, shipped_source + +ROOT_TAG = "test_root" +LANGUAGE_MANAGER = LanguageManager(LANG_EN) +ACTION_LABEL = "Convert 2 recordings" +STATUS_TEXT = "No tasks in progress." +RECORDING = Path("/audio/kick.wav") +REBOUND_REMOVAL = "Ctrl+Shift+K" +SEPARATOR = "separator" + + +@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, themes and header geometry the card draws under.""" + 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)) + GUIPanel.configure_section_header( + layout_config.glyphs, + layout_config.general.section_header, + layout_config.general.collapse, + ) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +def row( + name: str, + *, + level: int = 0, + position: int = 0, + level_size: int = 1, + level_count: int = 1, +) -> StemRowViewModel: + path = Path(f"/audio/{name}.wav") + return StemRowViewModel( + key=str(path), + kind=SourceKind.RECORDING, + path=path, + held=(), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=level, + position=position, + level_size=level_size, + level_count=level_count, + ) + + +def folder(name: str, *, holds: int) -> StemRowViewModel: + """A row standing for everything gathered below one folder.""" + root = Path(f"/audio/{name}") + return StemRowViewModel( + key=str(root), + kind=SourceKind.FOLDER, + path=root, + held=tuple(row(f"{name}/take_{index}") for index in range(holds)), + channels=frozenset({ChannelName.PULSE1}), + partial_channels=frozenset(), + bends=frozenset(), + offered_channels=frozenset({ChannelName.PULSE1}), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def view( + *rows: StemRowViewModel, + output: OutputKind = OutputKind.PER_RECORDING, + phase: ConversionPhase = ConversionPhase.IDLE, + input_path: Optional[Path] = None, + output_path: Optional[Path] = None, + selected_key: Optional[str] = None, +) -> ConverterViewModel: + return ConverterViewModel( + phase=phase, + status_text=STATUS_TEXT, + action_label=ACTION_LABEL, + progress=0.0, + input_path=input_path, + output_path=output_path, + is_file=True, + other_operation_active=False, + output=output, + stem_sources=rows, + channel_cap=len(ChannelName), + max_channel_cap=len(ChannelName), + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + max_sources=8, + selected_key=selected_key, + ) + + +def build( + layout_config: LayoutConfig, + *, + key_router: Optional[KeyRouter] = None, + tab_active: ActivePredicate = lambda: True, + shortcut_source: Optional[ShortcutSource] = None, +) -> Tuple[GUIConverterPanel, List[OutputKind]]: + """The card as the application builds it, over the output switch it reports.""" + panel = GUIConverterPanel( + layout=layout_config.tabs.main.converter, + stems_layout=layout_config.general.stems, + inputs=layout_config.general.inputs, + path_colors=layout_config.general.colors.paths, + language_manager=LANGUAGE_MANAGER, + status_bar=GUIStatusBar(), + key_router=key_router if key_router is not None else KeyRouter(), + shortcut_source=shipped_source() if shortcut_source is None else shortcut_source, + tab_active=tab_active, + ) + reported: List[OutputKind] = [] + panel.on_output_changed = reported.append + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + panel.update_view(view()) + return panel, reported + + +@pytest.fixture +def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, in the order a reader meets them, the rules between them included.""" + items: List[Dict[str, Any]] = [] + monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: items.append(kwargs) or 0) + monkeypatch.setattr( + menus_module.dpg, + "add_separator", + lambda **_kwargs: items.append({"label": SEPARATOR}) or 0, + ) + return items + + +def right_click(panel: GUIConverterPanel, entry: StemRowViewModel) -> None: + """Land a right-click on the row's name, which is what puts that row's menu up.""" + click_row_name(panel.stems_list.tags, entry.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + +def shows(tag: str) -> bool: + return bool(dpg.get_item_configuration(tag)["show"]) + + +class TestTheOutputSwitch: + """The card opens on what the run writes, which the button below it repeats.""" + + def test_it_offers_both_kinds_of_run(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + offered = dpg.get_item_configuration(TAG_MAIN_CONVERTER_RADIO_MODE)["items"] + + assert offered == [ + LANGUAGE_MANAGER["main.converter.label.mode_each"], + LANGUAGE_MANAGER["main.converter.label.mode_mixed"], + ] + + def test_it_stands_above_the_button(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + body = dpg.get_item_children(dpg.get_item_parent(TAG_MAIN_CONVERTER_GROUP_CONVERT), 1) + switch = dpg.get_item_parent(TAG_MAIN_CONVERTER_RADIO_MODE) + + assert body.index(switch) < body.index(dpg.get_alias_id(TAG_MAIN_CONVERTER_GROUP_CONVERT)) + + def test_it_reads_what_the_view_names(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"), output=OutputKind.MIXED)) + + assert dpg.get_value(TAG_MAIN_CONVERTER_RADIO_MODE) == LANGUAGE_MANAGER["main.converter.label.mode_mixed"] + + def test_a_reader_turning_it_reports_the_kind(self, dpg_context: None, layout_config: LayoutConfig) -> None: + _panel, reported = build(layout_config) + + callback = dpg.get_item_callback(TAG_MAIN_CONVERTER_RADIO_MODE) + callback(TAG_MAIN_CONVERTER_RADIO_MODE, LANGUAGE_MANAGER["main.converter.label.mode_mixed"]) + + assert reported == [OutputKind.MIXED] + + +def action_button() -> str: + """The button widget the action group holds, which is where its label is drawn.""" + return compose_tag(TAG_MAIN_CONVERTER_BUTTON_ACTION, SUF_BUTTON) + + +class TestTheActionButton: + """The button says what the run writes, which the logic composes and the card renders.""" + + def test_it_reads_the_label_the_view_carries(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"))) + + assert dpg.get_item_label(action_button()) == ACTION_LABEL + + def test_nothing_listed_leaves_it_waiting(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert dpg.get_item_configuration(action_button())["enabled"] is False + + +class TestTheRunControls: + """The choices answer for what the list holds, so they arrive with it.""" + + def test_they_stand_away_until_something_is_listed(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_CONTROLS) + + def test_they_arrive_with_the_first_recording(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"))) + + assert shows(TAG_MAIN_CONVERTER_GROUP_CONTROLS) + + def test_the_order_arrives_with_the_second_recording_of_a_mix( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), output=OutputKind.MIXED)) + assert not shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + panel.update_view(view(row("kick"), row("snare"), output=OutputKind.MIXED)) + assert shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + def test_a_run_writing_one_apiece_has_no_order_to_take( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), row("snare"))) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_ORDER) + + def test_they_stand_below_the_list(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + body = dpg.get_item_children(dpg.get_item_parent(TAG_MAIN_CONVERTER_WINDOW_STEMS), 1) + + assert body.index(dpg.get_alias_id(TAG_MAIN_CONVERTER_WINDOW_STEMS)) < body.index( + dpg.get_alias_id(TAG_MAIN_CONVERTER_GROUP_CONTROLS) + ) + + +class TestTheList: + """The gathered recordings are what a run converts either way, so the list always stands.""" + + def test_it_stands_whichever_run_the_switch_names(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + assert shows(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + panel.update_view(view(row("kick"), output=OutputKind.MIXED)) + + assert shows(TAG_MAIN_CONVERTER_WINDOW_STEMS) + + def test_an_empty_list_says_how_to_fill_it(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert shows(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) + assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) == ( + LANGUAGE_MANAGER["main.converter.message.stems_empty_hint"] + ) + + def test_the_hint_leaves_with_the_first_recording(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"))) + + assert not shows(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) + + def test_an_empty_list_stands_away_so_the_hint_has_the_room( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A list holding nothing draws a heading and its rules over empty room, so it stands away.""" + panel, _reported = build(layout_config) + + panel.update_view(view()) + + assert not shows(panel.stems_list.tag) + + def test_the_list_comes_back_with_the_first_recording( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"))) + + assert shows(panel.stems_list.tag) + + +class TestTheKeysTheListClaims: + """A row picked out puts the list on the keyboard, and everything else is left to travel on.""" + + @staticmethod + def _press(router: KeyRouter, shortcut_id: ShortcutId) -> bool: + """Offer the press the shipped scheme gives ``shortcut_id`` to the scopes, as the router does.""" + combination = shipped_source().shortcut(shortcut_id).combination + assert combination is not None + return router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + def test_a_press_rests_while_no_row_is_picked_out( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + panel.update_view(view(row("kick"))) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_a_field_holds_the_keys( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A reader typing a path keeps the plain keys, so Del edits the field rather than the list.""" + monkeypatch.setattr(focus, "is_field_focused", lambda: True) + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_another_tab_is_in_front( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router, tab_active=lambda: False) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_a_press_rests_while_the_list_stands_inert( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A run holds the list still, so the key answers to the rule the row's own button reads.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key, phase=ConversionPhase.RUNNING)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is False + assert removed == [] + + def test_it_removes_the_recording_picked_out(self, dpg_context: None, layout_config: LayoutConfig) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + panel.on_source_removed = removed.append + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert removed == [kick.path] + + def test_it_removes_the_folder_picked_out_and_everything_it_holds( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A folder is taken out as a folder, so the recordings below it leave with it.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + sources = folder("sources", holds=3) + panel.update_view(view(sources, selected_key=sources.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert folders == [sources.path] + assert removed == [] + + def test_a_recording_picked_out_leaves_on_its_own( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A recording standing beside a folder is taken out as a recording.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + kick = row("kick") + panel.update_view(view(folder("sources", holds=2), kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True + assert removed == [kick.path] + assert folders == [] + + def test_a_press_it_has_no_action_for_travels_on(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """The list yields whatever its category leaves unnamed, so the shortcuts still hear it.""" + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SAVE_PROJECT) is False + + +class TestTheDestination: + """Where a run writes is on screen whatever the card is doing; what it reads is not.""" + + def test_it_stands_with_nothing_listed(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config) + + assert shows(TAG_MAIN_CONVERTER_GROUP_SUMMARY) + + def test_the_input_line_waits_for_a_run(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config) + + panel.update_view(view(row("kick"), input_path=RECORDING)) + + assert not shows(TAG_MAIN_CONVERTER_GROUP_INPUT) + + def test_the_input_line_names_the_recording_a_run_is_reading( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config) + + panel.update_view( + view( + row("kick"), + phase=ConversionPhase.RUNNING, + input_path=RECORDING, + ) + ) + + assert shows(TAG_MAIN_CONVERTER_GROUP_INPUT) + assert panel.input_path_text.path == RECORDING + + +class TestTheMenuARightClickPutsUp: + """A right-click on a row raises the menu the card draws for it, over the row it landed on.""" + + def test_a_right_click_raises_the_row_s_menu( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The list reports the gesture and the card answers it, which is what puts a menu up.""" + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + click_row_name(panel.stems_list.tags, kick.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] in [item["label"] for item in registered] + + def test_a_rule_divides_sounding_a_recording_from_moving_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """Play sounds the recording where it stands; everything under the rule moves it or takes + it off the list, so the two readings of the menu are kept apart.""" + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + right_click(panel, kick) + + labels = [item["label"] for item in registered] + assert labels[labels.index(LANGUAGE_MANAGER["global.context.label.play"]) + 1] == SEPARATOR + + def test_the_item_a_folder_offers_opens_it_on_the_list( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The item does what the marker beside the folder's name does, so a reader reaching for + the menu meets the same folder open.""" + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + right_click(panel, sources) + opening = next( + item for item in registered if item["label"] == LANGUAGE_MANAGER["main.converter.label.context_open_folder"] + ) + opening["callback"]() + + assert panel.stems_list.stands_open(sources.key) + + def test_a_right_click_picks_the_row_it_stands_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The menu prints the key that removes a row, so both name the row the menu stands over.""" + panel, _reported = build(layout_config) + selected: List[Tuple[Path, SourceKind]] = [] + panel.on_row_selected = lambda path, kind: selected.append((path, kind)) + kick, snare = row("kick"), row("snare") + panel.update_view(view(kick, snare, selected_key=kick.key)) + + click_row_name(panel.stems_list.tags, snare.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert selected == [(snare.path, SourceKind.RECORDING)] + + def test_a_folder_offers_what_reaches_everything_below_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + labels = [item["label"] for item in registered] + assert LANGUAGE_MANAGER["main.converter.label.context_open_folder"] in labels + assert LANGUAGE_MANAGER["main.converter.label.context_remove_folder"] in labels + assert LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] not in labels + + def test_an_open_folder_offers_to_close_again( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + panel.stems_list.toggle_folder(sources.key) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + + assert LANGUAGE_MANAGER["main.converter.label.context_close_folder"] in [item["label"] for item in registered] + + def test_a_folder_removed_from_its_menu_takes_everything_it_holds( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + folders: List[Path] = [] + panel.on_folder_removed = folders.append + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + + click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) + removal = next( + item + for item in registered + if item["label"] == LANGUAGE_MANAGER["main.converter.label.context_remove_folder"] + ) + removal["callback"]() + + assert folders == [sources.path] + + +class TestTheMovesAMixOffers(BaseTestSuite): + """A run mixing its recordings orders them, so a row's menu offers the moves that reorder it.""" + + @staticmethod + def _label(element: ConverterStemMoveElements) -> str: + return LANGUAGE_MANAGER[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] + + @classmethod + def _offered( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, Dict[str, Any]]: + """The move items the row's menu registers, each under the label it prints.""" + right_click(panel, entry) + offered = {cls._label(element) for element in ConverterStemMoveElements} + return {item["label"]: item for item in registered if item["label"] in offered} + + @classmethod + def _moves( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, bool]: + """The moves the row's menu offers and whether each stands live, in the order it lists them.""" + offered = cls._offered(panel, entry, registered) + return {label: bool(item.get("enabled", True)) for label, item in offered.items()} + + def test_a_run_writing_one_reconstruction_apiece_offers_removal_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + moves = self._moves(panel, kick, registered) + + assert list(moves) == [self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM)] + + def test_a_mix_offers_every_move_a_row_can_make( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), output=OutputKind.MIXED)) + + moves = self._moves(panel, kick, registered) + + assert list(moves) == [ + self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP), + self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN), + self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE), + self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW), + self._label(ConverterStemMoveElements.CONTEXT_ISOLATE), + self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM), + ] + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + """One row's placement among the levels, and the moves that placement leaves it.""" + + rows: Tuple[StemRowViewModel, ...] + subject: int + expected: Dict[ConverterStemMoveElements, bool] + + test_cases = ( + TestCase( + label="alone_on_the_first_of_two_levels", + rows=(row("kick", level=0, level_count=2), row("snare", level=1, level_count=2)), + subject=0, + expected={ + ConverterStemMoveElements.CONTEXT_MOVE_UP: False, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN: False, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE: False, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW: True, + ConverterStemMoveElements.CONTEXT_ISOLATE: False, + }, + ), + TestCase( + label="second_on_the_last_of_two_levels", + rows=( + row("hat", level=0, level_count=2), + row("kick", level=1, position=0, level_size=2, level_count=2), + row("snare", level=1, position=1, level_size=2, level_count=2), + ), + subject=2, + expected={ + ConverterStemMoveElements.CONTEXT_MOVE_UP: True, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN: False, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE: True, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW: False, + ConverterStemMoveElements.CONTEXT_ISOLATE: True, + }, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_each_move_stands_live_where_the_row_has_room_for_it( + self, + test_case: TestCase, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + panel.update_view(view(*test_case.rows, output=OutputKind.MIXED)) + + moves = self._moves(panel, test_case.rows[test_case.subject], registered) + + expected = {self._label(element): live for element, live in test_case.expected.items()} + assert {label: moves[label] for label in expected} == expected + + def test_each_move_reports_the_direction_it_prints( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """An offset is added to the row's place, so moving earlier reports -1 and later reports 1. + + The item is fired rather than read, since a move standing under the right label still + reorders the wrong way while the offset behind it is the mirror of what the label says. + """ + panel, _reported = build(layout_config) + moved: List[Tuple[Path, int]] = [] + joined: List[Tuple[Path, int]] = [] + isolated: List[Path] = [] + panel.on_source_moved = lambda path, offset: moved.append((path, offset)) + panel.on_source_level_joined = lambda path, offset: joined.append((path, offset)) + panel.on_source_isolated = isolated.append + kick = row("kick", level=1, position=0, level_size=2, level_count=3) + snare = row("snare", level=1, position=1, level_size=2, level_count=3) + panel.update_view(view(row("hat", level=0, level_count=3), kick, snare, output=OutputKind.MIXED)) + + offered = self._offered(panel, snare, registered) + for element in ( + ConverterStemMoveElements.CONTEXT_MOVE_UP, + ConverterStemMoveElements.CONTEXT_MOVE_DOWN, + ConverterStemMoveElements.CONTEXT_JOIN_ABOVE, + ConverterStemMoveElements.CONTEXT_JOIN_BELOW, + ConverterStemMoveElements.CONTEXT_ISOLATE, + ): + offered[self._label(element)]["callback"]() + + assert moved == [(snare.path, -1), (snare.path, 1)] + assert joined == [(snare.path, -1), (snare.path, 1)] + assert isolated == [snare.path] + + +class TestTheRemovalItemInTheMenu: + """Taking a row out is one action, so the item and the key print and reach the same thing.""" + + @staticmethod + def _removal( + panel: GUIConverterPanel, + entry: StemRowViewModel, + registered: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """The removal item the row's menu prints, reached the way a reader reaches it.""" + label = LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] + right_click(panel, entry) + return next(item for item in registered if item["label"] == label) + + def test_it_takes_the_recording_it_stands_over_and_nothing_else( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """The item is fired, since a removal printing the right key still reaches nothing.""" + panel, _reported = build(layout_config) + removed: List[Path] = [] + folders: List[Path] = [] + panel.on_source_removed = removed.append + panel.on_folder_removed = folders.append + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + self._removal(panel, kick, registered)["callback"]() + + assert removed == [kick.path] + assert folders == [] + + def test_it_prints_the_key_that_does_the_same_thing( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + removal = self._removal(panel, kick, registered) + + assert removal["shortcut"] == shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_it_prints_whatever_the_scheme_in_place_gives_the_action( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + """A rebind reaches the menu, which is what tells the printed key from a written one.""" + source = rebound_source(ShortcutId.SOURCES_REMOVE_SOURCE, REBOUND_REMOVAL) + panel, _reported = build(layout_config, shortcut_source=source) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + removal = self._removal(panel, kick, registered) + + assert removal["shortcut"] == REBOUND_REMOVAL + assert removal["shortcut"] != shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) + + def test_it_stands_inert_while_a_run_holds_the_list( + self, + dpg_context: None, + layout_config: LayoutConfig, + registered: List[Dict[str, Any]], + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), phase=ConversionPhase.RUNNING)) + + removal = self._removal(panel, kick, registered) + + assert removal["enabled"] is False 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 6eb4b96b3..932caefe4 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,6 +1,7 @@ from pathlib import Path from typing import List, Set, Tuple +import dearpygui.dearpygui as dpg import pytest from sampletones_application.ui.elements.tree import tree as tree_module @@ -183,7 +184,7 @@ def test_the_rows_fold_while_the_model_still_states_them( 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( + def test_the_folders_the_model_held_are_dropped_afterward( self, folded: List[Tuple[str, bool]], ) -> None: @@ -217,27 +218,31 @@ def __init__(self, *, can_add_stems: bool) -> None: self.node = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC / "song.wav")[0] self.autoplay = FakeAutoplayLogic() self.gathered: List[Path] = [] - self.selected: List[Path] = [] self.panel._logic = self.autoplay # type: ignore[assignment] self.panel.can_add_stems = lambda: can_add_stems self.panel.on_file_add_requested = self.gathered.append - self.panel.on_wave_file_clicked = self.selected.append def click(self, monkeypatch: pytest.MonkeyPatch, *, holding_ctrl: bool) -> None: held = {explorer_module.Modifier.CTRL} if holding_ctrl else set() monkeypatch.setattr(explorer_module, "capture_modifiers", lambda: frozenset(held)) self.panel._audio_node_clicked(self.node) + def double_click(self) -> None: + self.panel._on_file_node_double_clicked( + 0, + (dpg.mvMouseButton_Left, 0), + (self.node, 0), + ) + class TestClickingARecording: - """Ctrl gathers a recording as a stem; a plain click hands it to the converter and plays it.""" + """A plain click plays a recording; Ctrl and a double-click each gather it as a stem.""" - def test_a_plain_click_selects_the_recording_and_plays_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_a_plain_click_plays_the_recording_and_gathers_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: clicked = RecordingClick(can_add_stems=True) clicked.click(monkeypatch, holding_ctrl=False) - assert clicked.selected == [MUSIC / "song.wav"] assert clicked.autoplay.played == [clicked.node] assert clicked.gathered == [] @@ -247,7 +252,6 @@ def test_holding_ctrl_gathers_the_recording_as_a_stem(self, monkeypatch: pytest. clicked.click(monkeypatch, holding_ctrl=True) assert clicked.gathered == [MUSIC / "song.wav"] - assert clicked.selected == [] assert clicked.autoplay.played == [] def test_a_busy_converter_leaves_ctrl_the_plain_click(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -255,5 +259,19 @@ def test_a_busy_converter_leaves_ctrl_the_plain_click(self, monkeypatch: pytest. clicked.click(monkeypatch, holding_ctrl=True) - assert clicked.selected == [MUSIC / "song.wav"] + assert clicked.autoplay.played == [clicked.node] + assert clicked.gathered == [] + + def test_a_double_click_gathers_the_recording(self) -> None: + clicked = RecordingClick(can_add_stems=True) + + clicked.double_click() + + assert clicked.gathered == [MUSIC / "song.wav"] + + def test_a_double_click_gathers_nothing_while_the_converter_is_busy(self) -> None: + clicked = RecordingClick(can_add_stems=False) + + clicked.double_click() + assert clicked.gathered == [] diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py deleted file mode 100644 index 9bb777781..000000000 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ /dev/null @@ -1,156 +0,0 @@ -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 ChannelName -from tests.suite.base import BaseTestSuite -from tests.suite.case import BaseRegularTestCase - -DRIVE = 1.5 - -ALL_CHANNELS = frozenset(ChannelName) - - -class Harness: - """The panel over its checkboxes as DearPyGui holds them, without a window to hold them in.""" - - def __init__( - self, - checked: FrozenSet[ChannelName], - monkeypatch: pytest.MonkeyPatch, - ) -> None: - self.values: Dict[str, bool] = { - GUIReconstructorPanel._get_generator_checkbox_tag(channel): channel in checked for channel in ChannelName - } - 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[ChannelName]: - return frozenset( - channel - for channel in ChannelName - if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(channel)] - ) - - -class TestToggleChannel(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[ChannelName] - channel: ChannelName - expected: FrozenSet[ChannelName] - - test_cases = ( - TestCase( - label="switching one off leaves the rest", - checked=ALL_CHANNELS, - channel=ChannelName.TRIANGLE, - expected=ALL_CHANNELS - {ChannelName.TRIANGLE}, - ), - TestCase( - label="switching one on adds it alone", - checked=frozenset(), - channel=ChannelName.PULSE1, - expected=frozenset({ChannelName.PULSE1}), - ), - TestCase( - label="the last one switched off leaves nothing selected", - checked=frozenset({ChannelName.NOISE}), - channel=ChannelName.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_channel(test_case.channel) - - 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_channel(test_case.channel) - - assert harness.reported == [ - GenerationSettingsUpdate( - drive=DRIVE, - channels=[channel for channel in ChannelName if channel in test_case.expected], - ) - ] - - def test_switching_a_generator_twice_returns_the_set_it_started_from( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - harness = Harness(ALL_CHANNELS, monkeypatch) - - harness.panel.toggle_channel(ChannelName.PULSE2) - harness.panel.toggle_channel(ChannelName.PULSE2) - - assert harness.checked() == ALL_CHANNELS - - def test_the_generators_are_reported_in_the_order_the_tracker_shows_them( - self, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - harness = Harness(frozenset({ChannelName.NOISE, ChannelName.PULSE1}), monkeypatch) - - harness.panel.toggle_channel(ChannelName.TRIANGLE) - - assert self._generators(harness.reported) == [ - ChannelName.PULSE1, - ChannelName.TRIANGLE, - ChannelName.NOISE, - ] - - @staticmethod - def _generators(reported: List[GenerationSettingsUpdate]) -> List[ChannelName]: - return list(reported[-1].channels) - - -class TestCheckboxTags: - def test_each_generator_carries_a_tag_of_its_own(self) -> None: - tags: Tuple[str, ...] = tuple( - GUIReconstructorPanel._get_generator_checkbox_tag(channel) for channel in ChannelName - ) - - assert len(set(tags)) == len(tags) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_source.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py new file mode 100644 index 000000000..103d28bc6 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -0,0 +1,324 @@ +from typing import FrozenSet, Iterator, List, Optional, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SettingsField, SourceKind +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LANG_EN, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, + THEME_DIRECTORY, +) +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_HEADING, SUF_TABLE, SUF_TEXT +from sampletones_application.tags.main import ( + PRE_MAIN_SOURCE_SLOT, + TAG_MAIN_SOURCE_GROUP_GRID, + TAG_MAIN_SOURCE_SLIDER_DRIVE, + TAG_MAIN_SOURCE_TABLE_GRID, + TAG_MAIN_SOURCE_TEXT_INSPECTING, + TAG_MAIN_SOURCE_TEXT_UNPICKED, +) +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.panels.main.source.panel import GUISourceSettingsPanel +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 +from sampletones_application.view_model.main.source import ( + InspectedSourceViewModel, + SettingsSlotViewModel, + SourceSettingsPanelViewModel, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName + +ROOT_TAG = "test_root" +DRIVE = 1.5 +HELD_RECORDINGS = 1939 + + +@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, themes and header geometry the card draws under.""" + 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)) + GUIPanel.configure_section_header( + layout_config.glyphs, + layout_config.general.section_header, + layout_config.general.collapse, + ) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +def slot( + field: SettingsField, + *, + offered: FrozenSet[ChannelName], + held: FrozenSet[ChannelName] = frozenset(), + partial: FrozenSet[ChannelName] = frozenset(), +) -> SettingsSlotViewModel: + return SettingsSlotViewModel( + field=field, + offered_channels=offered, + held_channels=held, + partial_channels=partial, + ) + + +def view( + *slots: SettingsSlotViewModel, + inspected: Optional[InspectedSourceViewModel] = None, + live: bool = True, +) -> SourceSettingsPanelViewModel: + return SourceSettingsPanelViewModel( + slots=slots, + inspected=inspected, + drive=DRIVE, + live=live, + ) + + +def recording(name: str) -> InspectedSourceViewModel: + return InspectedSourceViewModel(name=name, kind=SourceKind.RECORDING, holds=1) + + +def folder(name: str, holds: int) -> InspectedSourceViewModel: + return InspectedSourceViewModel(name=name, kind=SourceKind.FOLDER, holds=holds) + + +def channels_slot(*, held: FrozenSet[ChannelName] = frozenset()) -> SettingsSlotViewModel: + return slot(SettingsField.CHANNELS, offered=frozenset(ChannelName.items()), held=held) + + +def build( + layout_config: LayoutConfig, + initial: SourceSettingsPanelViewModel, +) -> Tuple[GUISourceSettingsPanel, List[Tuple[SettingsField, ChannelName]]]: + """The card as the application builds it, over the choices it reports.""" + panel = GUISourceSettingsPanel( + initial, + layout=layout_config.tabs.main.source, + inputs=layout_config.general.inputs, + stems_layout=layout_config.general.stems, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + ) + reported: List[Tuple[SettingsField, ChannelName]] = [] + panel.on_slot_toggled = lambda field, channel: reported.append((field, channel)) + with dpg.window(tag=ROOT_TAG): + panel.create_panel(ROOT_TAG) + + return panel, reported + + +def box_tag(field: SettingsField, channel_name: ChannelName) -> str: + return compose_tag(PRE_MAIN_SOURCE_SLOT, field.value, channel_name.value) + + +def shows(tag: str) -> bool: + return bool(dpg.get_item_configuration(tag)["show"]) + + +class TestDrive: + """Drive answers for the run as a whole, so it stands whatever the reader is looking at.""" + + def test_it_stands_with_nothing_picked(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + assert dpg.get_value(TAG_MAIN_SOURCE_SLIDER_DRIVE) == pytest.approx(DRIVE) + + def test_it_stands_above_the_row_the_card_edits(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + body = dpg.get_item_children(dpg.get_item_parent(TAG_MAIN_SOURCE_TEXT_INSPECTING), 1) + drive = dpg.get_item_parent(TAG_MAIN_SOURCE_SLIDER_DRIVE) + + assert body.index(drive) < body.index(dpg.get_alias_id(TAG_MAIN_SOURCE_TEXT_INSPECTING)) + + +class TestNothingPicked: + """With no row picked the card says which gesture gives it one.""" + + def test_the_hint_stands(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + assert shows(TAG_MAIN_SOURCE_TEXT_UNPICKED) + + def test_the_grid_stands_away(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + assert not shows(TAG_MAIN_SOURCE_GROUP_GRID) + + def test_the_row_is_named_by_nothing(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + assert not shows(TAG_MAIN_SOURCE_TEXT_INSPECTING) + + +class TestAPickedRow: + """A picked row is named above the grid its choices stand in.""" + + def test_a_recording_reads_its_own_name(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view(view(channels_slot(), inspected=recording("bass"))) + + assert dpg.get_value(TAG_MAIN_SOURCE_TEXT_INSPECTING) == "bass" + + def test_a_folder_reads_how_many_it_stands_for(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view(view(channels_slot(), inspected=folder("VEH2 Loops", HELD_RECORDINGS))) + + named = dpg.get_value(TAG_MAIN_SOURCE_TEXT_INSPECTING) + assert named.startswith("VEH2 Loops") + assert str(HELD_RECORDINGS) in named + + def test_the_grid_comes_with_it(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view(view(channels_slot(), inspected=recording("bass"))) + + assert shows(TAG_MAIN_SOURCE_GROUP_GRID) + assert not shows(TAG_MAIN_SOURCE_TEXT_UNPICKED) + + +class TestTheGrid: + """The channels are named once above the row, and each cell holds the boxes it offers.""" + + def test_the_grid_rules_the_names_off_from_the_boxes( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """The heading draws no rule of its own, so the grid below it is what divides the two.""" + build(layout_config, view()) + + assert dpg.get_item_configuration(TAG_MAIN_SOURCE_TABLE_GRID)["borders_outerH"] is True + + def test_the_heading_draws_no_rule_of_its_own( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """One line divides the names from the boxes, so the heading leaves the drawing of it to + the grid rather than adding a second beside it.""" + build(layout_config, view()) + + heading = compose_tag(TAG_MAIN_SOURCE_GROUP_GRID, SUF_HEADING, SUF_TABLE) + assert dpg.get_item_configuration(heading)["borders_outerH"] is False + + def test_every_channel_is_named(self, dpg_context: None, layout_config: LayoutConfig) -> None: + build(layout_config, view()) + + for channel_name in ChannelName.items(): + assert dpg.does_item_exist(compose_tag(TAG_MAIN_SOURCE_GROUP_GRID, SUF_HEADING, channel_name, SUF_TEXT)) + + def test_a_channel_the_row_takes_reads_ticked(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view( + view( + channels_slot(held=frozenset({ChannelName.PULSE1})), + inspected=recording("bass"), + ) + ) + + assert dpg.get_value(box_tag(SettingsField.CHANNELS, ChannelName.PULSE1)) is True + assert dpg.get_value(box_tag(SettingsField.CHANNELS, ChannelName.PULSE2)) is False + + def test_a_channel_the_folder_half_holds_reads_clear(self, dpg_context: None, layout_config: LayoutConfig) -> None: + """A tick would state an answer the folder has yet to give, so a divided reading is clear.""" + panel, _reported = build(layout_config, view()) + + panel.update_view( + view( + slot( + SettingsField.CHANNELS, + offered=frozenset(ChannelName.items()), + partial=frozenset({ChannelName.PULSE1}), + ), + inspected=folder("takes", 2), + ) + ) + + assert dpg.get_value(box_tag(SettingsField.CHANNELS, ChannelName.PULSE1)) is False + + def test_a_bend_stands_only_where_its_channel_reads_one( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view( + view( + channels_slot(held=frozenset(TONE_CHANNELS)), + slot(SettingsField.BENDS, offered=frozenset(TONE_CHANNELS)), + inspected=recording("bass"), + ) + ) + + assert shows(box_tag(SettingsField.BENDS, ChannelName.TRIANGLE)) + assert not dpg.does_item_exist(box_tag(SettingsField.BENDS, ChannelName.NOISE)) + + def test_a_running_conversion_holds_the_boxes(self, dpg_context: None, layout_config: LayoutConfig) -> None: + panel, _reported = build(layout_config, view()) + + panel.update_view(view(channels_slot(), inspected=recording("bass"), live=False)) + + assert dpg.get_item_configuration(box_tag(SettingsField.CHANNELS, ChannelName.PULSE1))["enabled"] is False + + +class TestTheChoicesTheCardReports: + """The card settles nothing itself: it names the choice a reader made and hands it on.""" + + @pytest.mark.parametrize("channel", list(ChannelName.items())) + def test_a_box_names_the_choice_and_the_channel_it_stands_on( + self, + channel: ChannelName, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + _panel, reported = build(layout_config, view(channels_slot(), inspected=recording("bass"))) + + callback = dpg.get_item_callback(box_tag(SettingsField.CHANNELS, channel)) + callback(None, True, dpg.get_item_user_data(box_tag(SettingsField.CHANNELS, channel))) + + assert reported == [(SettingsField.CHANNELS, channel)] + + +class TestBoxTags: + def test_every_choice_and_channel_carries_a_tag_of_its_own( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + build(layout_config, view()) + tags = tuple( + box_tag(field, channel) + for channel in ChannelName.items() + for field in SettingsField + if dpg.does_item_exist(box_tag(field, channel)) + ) + + assert len(set(tags)) == len(tags) + assert tags diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py index 0a8977fc0..a18020a9b 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_stems_panel.py @@ -5,6 +5,7 @@ import pytest from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.sources import SourceKind from sampletones_application.layout.config import LayoutConfig from sampletones_application.layout.loader import load_layout_config from sampletones_application.paths import ( @@ -87,6 +88,7 @@ def _row( *, name: str, channels: FrozenSet[ChannelName] = frozenset(CHANNELS), + bends: FrozenSet[ChannelName] = frozenset(), offered_channels: FrozenSet[ChannelName] = frozenset(CHANNELS), level: int = 0, position: int = 0, @@ -94,6 +96,10 @@ def _row( level_count: int = 1, ) -> StemRowViewModel: return StemRowViewModel( + kind=SourceKind.RECORDING, + held=(), + partial_channels=frozenset(), + bends=bends, key=str(stem_id), path=Path(f"/audio/{name}.wav"), channels=channels, @@ -114,9 +120,12 @@ def _view_model( return ReconstructionStemsViewModel( reconstruction_loaded=True, stems=StemsListViewModel( + selected_key=None, rows=rows, channels_in_play=CHANNELS if rows else (), muted_channels=muted_channels, + picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=False, ), @@ -132,8 +141,8 @@ def test_one_row_per_recording(self, panel: GUIReconstructionStemsPanel) -> None panel.update_view(_view_model(_row(0, name="kick"), _row(1, name="snare"))) stems_list = panel.stems_list - assert dpg.get_item_label(stems_list.row_tag("0", SUF_TEXT)) == "kick" - assert dpg.get_item_label(stems_list.row_tag("1", SUF_TEXT)) == "snare" + assert dpg.get_item_label(stems_list.tags.row("0", SUF_TEXT)) == "kick" + assert dpg.get_item_label(stems_list.tags.row("1", SUF_TEXT)) == "snare" def test_a_row_offers_a_box_on_every_channel_its_recording_holds( self, @@ -144,15 +153,15 @@ def test_a_row_offers_a_box_on_every_channel_its_recording_holds( panel.update_view(_view_model(_row(0, name="kick", offered_channels=frozenset({ChannelName.PULSE1})))) stems_list = panel.stems_list - assert dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.PULSE1)) - assert not dpg.does_item_exist(stems_list.channel_tag("0", ChannelName.NOISE)) + assert dpg.does_item_exist(stems_list.tags.channel("0", ChannelName.PULSE1)) + assert not dpg.does_item_exist(stems_list.tags.channel("0", ChannelName.NOISE)) def test_every_row_carries_a_master_box(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) panel.update_view(_view_model(_row(0, name="kick"))) - assert dpg.get_value(panel.stems_list.row_tag("0", SUF_CHECKBOX)) + assert dpg.get_value(panel.stems_list.tags.row("0", SUF_CHECKBOX)) def test_rows_follow_a_changed_recording_set(self, panel: GUIReconstructionStemsPanel) -> None: render(panel) @@ -161,8 +170,8 @@ def test_rows_follow_a_changed_recording_set(self, panel: GUIReconstructionStems panel.update_view(_view_model(_row(1, name="snare"))) stems_list = panel.stems_list - assert not dpg.does_item_exist(stems_list.row_tag("0", SUF_TEXT)) - assert dpg.does_item_exist(stems_list.row_tag("1", SUF_TEXT)) + assert not dpg.does_item_exist(stems_list.tags.row("0", SUF_TEXT)) + assert dpg.does_item_exist(stems_list.tags.row("1", SUF_TEXT)) class TestStemsPanelSelection: @@ -175,7 +184,7 @@ def test_unticking_a_channel_reports_the_recording_and_what_it_keeps( render(panel) panel.update_view(_view_model(_row(0, name="kick"))) - noise_tag = panel.stems_list.channel_tag("0", ChannelName.NOISE) + noise_tag = panel.stems_list.tags.channel("0", ChannelName.NOISE) dpg.set_value(noise_tag, False) dpg.get_item_callback(noise_tag)(noise_tag, False, ("0", ChannelName.NOISE)) @@ -190,7 +199,7 @@ def test_unticking_the_master_box_silences_the_recording_everywhere( render(panel) panel.update_view(_view_model(_row(0, name="kick"))) - master_tag = panel.stems_list.row_tag("0", SUF_CHECKBOX) + master_tag = panel.stems_list.tags.row("0", SUF_CHECKBOX) dpg.get_item_callback(master_tag)(master_tag, False, "0") assert reported == [(0, frozenset())] @@ -206,7 +215,7 @@ def test_the_remove_button_reports_the_recording_it_stands_for( panel.on_stem_remove_requested = requested.append panel.update_view(_view_model(_row(0, name="bass"), _row(1, name="lead"))) - tag = panel.stems_list.row_tag("0", SUF_BUTTON) + tag = panel.stems_list.tags.row("0", SUF_BUTTON) dpg.get_item_callback(tag)(tag, None, dpg.get_item_user_data(tag)) assert requested == [0] @@ -219,7 +228,7 @@ def test_the_last_recording_standing_offers_no_way_out( render(panel) panel.update_view(_view_model(_row(0, name="bass"))) - assert not dpg.is_item_enabled(panel.stems_list.row_tag("0", SUF_BUTTON)) + assert not dpg.is_item_enabled(panel.stems_list.tags.row("0", SUF_BUTTON)) class TestStemsPanelLevels: @@ -255,8 +264,8 @@ def test_collapsing_redraws_the_rows_in_one_table(self, panel: GUIReconstruction True, ) - assert dpg.does_item_exist(panel.stems_list.table_tag) - assert dpg.does_item_exist(panel.stems_list.row_tag("1", SUF_TEXT)) + assert dpg.does_item_exist(panel.stems_list.tags.table) + assert dpg.does_item_exist(panel.stems_list.tags.row("1", SUF_TEXT)) def test_a_reader_who_collapsed_the_levels_keeps_them_collapsed_across_an_edit( self, @@ -278,7 +287,7 @@ def test_a_reader_who_collapsed_the_levels_keeps_them_collapsed_across_an_edit( ) ) - assert dpg.does_item_exist(panel.stems_list.table_tag) + assert dpg.does_item_exist(panel.stems_list.tags.table) class TestStemsPanelStates: @@ -302,3 +311,59 @@ def test_the_empty_state_shows_for_a_loaded_reconstruction_without_source( assert dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_EMPTY) assert not dpg.is_item_shown(TAG_RECONSTRUCTIONS_RECONSTRUCTION_TEXT_STEMS_SETUP) assert not dpg.is_item_shown(panel.stems_list.tag) + + +class TestTheBendARecordingTook: + """A finished reconstruction records the bend each recording carried, which the list states. + + The choice was made when the reconstruction was written, so the box reports it rather than + offering it. + """ + + @staticmethod + def _bend_tag(panel: GUIReconstructionStemsPanel, stem_id: int, channel_name: ChannelName) -> str: + return panel.stems_list.tags.bend(str(stem_id), channel_name) + + def test_a_tone_channel_carries_a_box_beside_its_own( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + render(panel) + panel.update_view(_view_model(_row(1, name="bass"))) + + assert dpg.does_item_exist(self._bend_tag(panel, 1, ChannelName.PULSE1)) + + def test_it_reads_the_bend_the_recording_took( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + render(panel) + panel.update_view(_view_model(_row(1, name="bass", bends=frozenset({ChannelName.PULSE1})))) + + assert dpg.get_value(self._bend_tag(panel, 1, ChannelName.PULSE1)) is True + + def test_a_channel_it_did_not_bend_reads_clear( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + render(panel) + panel.update_view(_view_model(_row(1, name="bass"))) + + assert dpg.get_value(self._bend_tag(panel, 1, ChannelName.PULSE1)) is False + + def test_the_box_states_rather_than_asks(self, panel: GUIReconstructionStemsPanel) -> None: + render(panel) + panel.update_view(_view_model(_row(1, name="bass"))) + + tag = self._bend_tag(panel, 1, ChannelName.PULSE1) + assert dpg.get_item_configuration(tag)["enabled"] is False + + def test_a_channel_loading_no_divider_carries_none( + self, + panel: GUIReconstructionStemsPanel, + ) -> None: + """A bend moves a note by a fraction of a divider, so the noise channel has nothing to bend.""" + render(panel) + panel.update_view(_view_model(_row(1, name="bass"))) + + assert not dpg.does_item_exist(self._bend_tag(panel, 1, ChannelName.NOISE)) 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 a770434a1..699a3e130 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 @@ -61,11 +61,11 @@ def test_a_later_extend_keeps_the_anchor_it_began_on(self) -> None: 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 + def test_extending_backward_names_the_same_region_as_forward(self) -> None: + backward = _state(row=4, column=3).extend_to(_Cell(2, 1)).region + forward = _state(row=2, column=1).extend_to(_Cell(4, 3)).region - assert backwards == forwards + assert backward == forward def test_extending_leaves_nothing_pending(self) -> None: assert _state(pending="5").extend_to(_Cell(4, 3)).pending == "" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index a30928472..750017fec 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 @@ -52,12 +52,12 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: assert region is not None assert (region.first_row, region.last_row) == (4, 5) - def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: + def test_extending_upward_names_the_same_region_as_downward(self) -> None: """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" - upwards = _state(SubColumn.VOICE, row=5).extend_row(-1, ROW_COUNT).region - downwards = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).region + upward = _state(SubColumn.VOICE, row=5).extend_row(-1, ROW_COUNT).region + downward = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).region - assert upwards == downwards + assert upward == downward def test_a_further_extend_keeps_the_original_anchor(self) -> None: extended = _state(SubColumn.VOICE, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT) 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 f9fe80ae8..55fb9a1e5 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -207,7 +207,7 @@ class TestCase(BaseRegularTestCase): expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT), ), TestCase( - label="any capitalisation", + label="any capitalization", text="ctrl+shift+z", expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT), ), diff --git a/tests/unit/sampletones_application/utils/gui/test_callbacks.py b/tests/unit/sampletones_application/utils/gui/test_callbacks.py new file mode 100644 index 000000000..c9b4d77cb --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_callbacks.py @@ -0,0 +1,161 @@ +import threading +from dataclasses import dataclass +from typing import Any, Final, Iterator, List, Sequence, Tuple +from unittest.mock import patch + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.callbacks import hold_callbacks, run_held_callbacks +from sampletones_application.utils.gui.render_thread import answered_while_drawing +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +SENDER = "the.widget" +APP_DATA = 7 +USER_DATA = "carried" +JOB = (SENDER, APP_DATA, USER_DATA) +ANSWER: Final[str] = "the reader answered" +WAITING_TIMEOUT: Final[float] = 5.0 +RENDER_THREAD_READING: Final[str] = "sampletones_application.utils.gui.render_thread.is_render_thread" + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +def held(*jobs: Tuple[Any, ...]) -> Any: + """Stands in for what DearPyGui hands back, which is a list of jobs or nothing at all.""" + return patch.object(dpg, "get_callback_queue", return_value=list(jobs) or None) + + +class TestHoldingACallback(BaseTestSuite): + """A gesture is gathered rather than answered where it lands, so the frame runs it.""" + + def test_it_asks_dearpygui_to_gather_them(self, dpg_context: None) -> None: + hold_callbacks() + + assert dpg.get_app_configuration()["manual_callback_management"] + + +class TestRunningWhatWasHeld(BaseTestSuite): + """Each gathered gesture runs on the thread that drew the items it reaches.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + declared: int + expected: Sequence[Any] + + test_cases = ( + TestCase(label="a_callback_taking_nothing", declared=0, expected=()), + TestCase(label="a_callback_taking_the_sender", declared=1, expected=(SENDER,)), + TestCase(label="a_callback_taking_the_payload", declared=2, expected=(SENDER, APP_DATA)), + TestCase(label="a_callback_taking_the_user_data", declared=3, expected=(SENDER, APP_DATA, USER_DATA)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_a_callback_is_handed_the_arguments_it_declares(self, test_case: TestCase) -> None: + received: List[Sequence[Any]] = [] + callback = self._taking(test_case.declared, received) + with held((callback, *JOB)): + run_held_callbacks() + + assert received == [tuple(test_case.expected)] + + def test_a_callback_taking_whatever_comes_is_handed_all_of_it(self) -> None: + received: List[Sequence[Any]] = [] + + def callback(*arguments: Any) -> None: + received.append(arguments) + + with held((callback, *JOB)): + run_held_callbacks() + + assert received == [JOB] + + def test_a_gesture_without_a_callback_is_passed_over(self) -> None: + with held((None, *JOB)): + run_held_callbacks() + + def test_an_empty_queue_leaves_the_frame_alone(self) -> None: + with held(): + run_held_callbacks() + + def test_a_failing_gesture_leaves_the_rest_to_run(self) -> None: + """A gesture that raises is reported rather than taking the frame's other gestures down.""" + received: List[Sequence[Any]] = [] + + def failing() -> None: + raise RuntimeError("the gesture went wrong") + + with held((failing, *JOB), (self._taking(0, received), *JOB)): + run_held_callbacks() + + assert received == [()] + + def test_the_gestures_run_in_the_order_they_were_made(self) -> None: + order: List[str] = [] + with held( + (lambda: order.append("first"), *JOB), + (lambda: order.append("second"), *JOB), + ): + run_held_callbacks() + + assert order == ["first", "second"] + + @staticmethod + def _taking(declared: int, received: List[Sequence[Any]]) -> Any: + """A callback declaring ``declared`` of DearPyGui's arguments, recording what it was handed.""" + recorders = ( + lambda: received.append(()), + lambda sender: received.append((sender,)), + lambda sender, app_data: received.append((sender, app_data)), + lambda sender, app_data, user_data: received.append((sender, app_data, user_data)), + ) + return recorders[declared] + + +class TestAGestureThatWaits(BaseTestSuite): + """A gesture standing on the render thread holds the frames up, so its waiting stands aside.""" + + def test_it_reports_what_the_waiting_answered(self) -> None: + with patch.object(dpg, "is_dearpygui_running", return_value=True): + assert answered_while_drawing(lambda: ANSWER) == ANSWER + + def test_it_draws_while_the_waiting_stands(self) -> None: + drawn: List[int] = [] + waiting = threading.Event() + + def answer() -> str: + waiting.wait(WAITING_TIMEOUT) + return ANSWER + + def frame() -> None: + drawn.append(len(drawn)) + waiting.set() + + with ( + patch.object(dpg, "is_dearpygui_running", return_value=True), + patch.object(dpg, "render_dearpygui_frame", side_effect=frame), + ): + answered_while_drawing(answer) + + assert drawn + + def test_a_failure_reaches_the_gesture_that_waited(self) -> None: + def failing() -> str: + raise RuntimeError("the dialog went wrong") + + with patch.object(dpg, "is_dearpygui_running", return_value=True): + with pytest.raises(RuntimeError): + answered_while_drawing(failing) + + def test_work_reached_from_elsewhere_runs_where_it_stands(self) -> None: + """A thread of our own holds no frames up, so its waiting needs nothing standing aside.""" + with patch(RENDER_THREAD_READING, return_value=False): + assert answered_while_drawing(lambda: ANSWER) == ANSWER diff --git a/tests/unit/sampletones_application/utils/gui/test_dpg.py b/tests/unit/sampletones_application/utils/gui/test_dpg.py new file mode 100644 index 000000000..37667c72c --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_dpg.py @@ -0,0 +1,42 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.utils.gui.dpg import dpg_get_item_parent + +ROOT_TAG = "test_root" +CHILD_TAG = "test_child" + + +@pytest.fixture +def dpg_context() -> Iterator[None]: + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +class TestAnAbsentItem: + """A queued callback can remove an item underneath a lookup, which resolves to nothing.""" + + def test_a_standing_item_names_the_one_holding_it(self, dpg_context: None) -> None: + with dpg.window(tag=ROOT_TAG): + dpg.add_text("held", tag=CHILD_TAG) + + assert dpg_get_item_parent(CHILD_TAG) is not None + + def test_an_item_that_has_gone_names_nothing(self, dpg_context: None) -> None: + assert dpg_get_item_parent("never_built") is None + + def test_the_library_raises_the_base_class_for_an_absent_item(self, dpg_context: None) -> None: + """What the helper's broad catch answers: there is nothing narrower to name. + + The day DearPyGui raises a type of its own is the day this test fails and the catch + narrows to it. + """ + with pytest.raises(Exception) as raised: + dpg.get_item_parent("never_built") + + assert type(raised.value) is Exception # pylint: disable=unidiomatic-typecheck diff --git a/tests/unit/sampletones_application/utils/gui/test_render_thread.py b/tests/unit/sampletones_application/utils/gui/test_render_thread.py new file mode 100644 index 000000000..210847338 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_render_thread.py @@ -0,0 +1,61 @@ +import threading +from typing import List + +import pytest + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.utils.gui.render_thread import ( + claim_render_thread, + is_render_thread, + on_render_thread, + release_render_thread, +) + + +@pytest.fixture +def unclaimed() -> None: + """A context no run has claimed, over a queue live enough to drain what reaches it.""" + release_render_thread() + CallbackQueue.start() + + +class TestWhereWorkRuns: + """Work that creates or deletes widgets belongs on the thread holding DearPyGui's context.""" + + def test_an_unclaimed_context_runs_where_it_stands(self, unclaimed: None) -> None: + ran: List[str] = [] + + on_render_thread(ran.append, "built") + + assert ran == ["built"] + + def test_the_thread_a_run_claimed_runs_where_it_stands(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + try: + on_render_thread(ran.append, "drawn") + finally: + release_render_thread() + + assert ran == ["drawn"] + + def test_another_thread_joins_the_queue_the_loop_drains(self, unclaimed: None) -> None: + ran: List[str] = [] + claim_render_thread() + worker = threading.Thread(target=on_render_thread, args=(ran.append, "gestured")) + try: + worker.start() + worker.join() + + assert ran == [] + CallbackQueue.process(1.0) + finally: + release_render_thread() + + assert ran == ["gestured"] + + def test_a_run_lets_the_thread_go(self, unclaimed: None) -> None: + claim_render_thread() + release_render_thread() + + assert is_render_thread() is True 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 b638a04e8..7a08bf162 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -4,6 +4,8 @@ import pytest from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SourceKind from sampletones_application.view_model.main.converter import ( ConversionPhase, ConverterAction, @@ -28,6 +30,10 @@ def _row( ) -> StemRowViewModel: path = Path(f"/audio/{name}.wav") return StemRowViewModel( + kind=SourceKind.RECORDING, + held=(), + partial_channels=frozenset(), + bends=frozenset(), key=str(path), path=path, channels=channels, @@ -46,8 +52,8 @@ def _view_model( other_operation_active: bool = False, progress: float = 0.0, input_path: Optional[Path] = Path("/audio/sample.wav"), - stems_mode: bool = False, - stem_sources: Tuple[StemRowViewModel, ...] = (), + mixes: bool = False, + stem_sources: Tuple[StemRowViewModel, ...] = (_row("sample"),), channel_cap: int = len(ENABLED_CHANNELS), max_sources: int = MAX_STEM_SOURCES, ) -> ConverterViewModel: @@ -60,13 +66,13 @@ def _view_model( output_path=Path("/reconstructions"), is_file=True, other_operation_active=other_operation_active, - stems_mode=stems_mode, + output=OutputKind.MIXED if mixes else OutputKind.PER_RECORDING, stem_sources=stem_sources, - enabled_channels=ENABLED_CHANNELS, channel_cap=channel_cap, max_channel_cap=len(ENABLED_CHANNELS), hierarchy_mode=HierarchyMode.ROUND_ROBIN, max_sources=max_sources, + selected_key=None, ) @@ -114,7 +120,7 @@ class TestPrimaryAction: (ConversionPhase.IDLE, ConverterAction.CONVERT), (ConversionPhase.WAITING, ConverterAction.CANCEL), (ConversionPhase.RUNNING, ConverterAction.CANCEL), - (ConversionPhase.CANCELLING, ConverterAction.CANCEL), + (ConversionPhase.CANCELING, ConverterAction.CANCEL), (ConversionPhase.COMPLETED, ConverterAction.CONVERT), (ConversionPhase.CANCELED, ConverterAction.CONVERT), (ConversionPhase.FAILED, ConverterAction.CONVERT), @@ -133,7 +139,7 @@ class TestPrimaryActionEnabled: [ (ConversionPhase.WAITING, True), (ConversionPhase.RUNNING, True), - (ConversionPhase.CANCELLING, False), + (ConversionPhase.CANCELING, False), ], ) def test_cancel_enablement(self, phase: ConversionPhase, enabled: bool) -> None: @@ -157,7 +163,7 @@ def test_a_listed_recording_counts_as_an_input(self) -> None: view_model = _view_model( phase=ConversionPhase.IDLE, input_path=None, - stems_mode=True, + mixes=True, stem_sources=(_row("bass"),), ) @@ -166,24 +172,30 @@ def test_a_listed_recording_counts_as_an_input(self) -> None: assert view_model.convert_button_enabled is True def test_an_empty_list_offers_nothing_to_convert(self) -> None: - view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True) + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=True, stem_sources=()) assert view_model.has_input is False assert view_model.convert_button_enabled is False - def test_the_selected_path_carries_a_classic_conversion(self) -> None: - view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=False) + def test_a_gathered_recording_carries_a_run_writing_one_apiece(self) -> None: + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=False, stem_sources=(_row("bass"),)) assert view_model.has_input is True + def test_a_run_writing_one_apiece_takes_more_than_a_mix_could_hold(self) -> None: + rows = tuple(_row(str(index)) for index in range(MAX_STEM_SOURCES)) + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=False, stem_sources=rows) + + assert view_model.can_add_source is True + def test_a_full_list_takes_no_more(self) -> None: rows = tuple(_row(str(index)) for index in range(MAX_STEM_SOURCES)) - view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=rows) + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=True, stem_sources=rows) assert view_model.can_add_source is False def test_a_list_with_room_takes_another(self) -> None: - view_model = _view_model(phase=ConversionPhase.IDLE, stems_mode=True, stem_sources=(_row("bass"),)) + view_model = _view_model(phase=ConversionPhase.IDLE, mixes=True, stem_sources=(_row("bass"),)) assert view_model.can_add_source is True @@ -194,12 +206,11 @@ def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None: view_model = _view_model( phase=ConversionPhase.IDLE, input_path=None, - stems_mode=True, + mixes=True, stem_sources=(_row("bass", channels=frozenset()),), ) assert view_model.has_input is False - assert view_model.playing_count == 0 assert view_model.convert_button_enabled is False 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 c0d457848..dddb170cd 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -19,9 +19,12 @@ from sampletones_core.constants.enums import HierarchyMode EMPTY_STEMS = StemsListViewModel( + selected_key=None, rows=(), channels_in_play=(), muted_channels=frozenset(), + picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=False, ) diff --git a/tests/unit/sampletones_application/view_model/shared/test_agreement.py b/tests/unit/sampletones_application/view_model/shared/test_agreement.py new file mode 100644 index 000000000..b2ad5e20c --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_agreement.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.view_model.shared.agreement import Agreement +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestTheReadingAGroupGives(BaseTestSuite): + """A row standing for several recordings reads as what they agree on.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Agreement + holdings: Tuple[bool, ...] + + test_cases = ( + TestCase(label="standing_for_nothing", holdings=(), expected=Agreement.NONE), + TestCase(label="one_holding_it", holdings=(True,), expected=Agreement.ALL), + TestCase(label="one_leaving_it", holdings=(False,), expected=Agreement.NONE), + TestCase(label="every_one_holding_it", holdings=(True, True, True), expected=Agreement.ALL), + TestCase(label="none_holding_it", holdings=(False, False), expected=Agreement.NONE), + TestCase(label="some_holding_it", holdings=(True, False, True), expected=Agreement.SOME), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_the_group_reads_as_what_it_agrees_on(self, test_case: TestCase) -> None: + assert Agreement.over(test_case.holdings) == test_case.expected + + +class TestWhatOneGestureSettlesTo(BaseTestSuite): + """One gesture always moves a group, whichever reading it stood at.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: bool + agreement: Agreement + + test_cases = ( + TestCase(label="from_none", agreement=Agreement.NONE, expected=True), + TestCase(label="from_some", agreement=Agreement.SOME, expected=True), + TestCase(label="from_all", agreement=Agreement.ALL, expected=False), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_a_gesture_settles_the_whole_group(self, test_case: TestCase) -> None: + assert test_case.agreement.settles_to is test_case.expected diff --git a/tests/unit/sampletones_application/view_model/shared/test_export.py b/tests/unit/sampletones_application/view_model/shared/test_export.py index c20f47912..f744e4825 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_export.py +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -66,10 +66,10 @@ def test_a_stage_the_run_never_reached_is_left_off_the_list(self) -> None: class TestHowTheStageUnderWayReads: """A stage arriving at an end carries a bar; one that does not carries the turning symbol.""" - def test_a_travelling_stage_shows_its_bar(self) -> None: + def test_a_traveling_stage_shows_its_bar(self) -> None: assert view_model(traveling=True).progress_visible is True - def test_a_travelling_stage_hides_the_turning_symbol(self) -> None: + def test_a_traveling_stage_hides_the_turning_symbol(self) -> None: assert view_model(traveling=True).working_visible is False def test_a_stage_without_an_end_shows_the_turning_symbol(self) -> None: @@ -86,7 +86,7 @@ def test_a_running_export_takes_a_stop(self) -> None: assert view_model(phase=ExportPhase.EXPORTING).cancel_enabled is True def test_an_export_already_stopping_takes_no_further_stop(self) -> None: - assert view_model(phase=ExportPhase.CANCELLING).cancel_enabled is False + assert view_model(phase=ExportPhase.CANCELING).cancel_enabled is False def test_an_export_being_stopped_still_holds_the_screen(self) -> None: - assert view_model(phase=ExportPhase.CANCELLING).is_active is True + assert view_model(phase=ExportPhase.CANCELING).is_active is True diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py index 52ab51cb2..448cdcbe3 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_render.py +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -143,7 +143,7 @@ def test_rendering_shows_the_progress_alone(self) -> None: assert view.cancel_enabled def test_a_render_already_stopping_takes_no_further_stop(self) -> None: - view = view_model(wave_settings(), phase=RenderPhase.CANCELLING) + view = view_model(wave_settings(), phase=RenderPhase.CANCELING) assert view.is_active assert not view.cancel_enabled diff --git a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py index 192b18410..7b6e69d6b 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -2,10 +2,12 @@ from sampletones_core.compatibility.fields import ( AUDIO_FILEPATH, + BENDS, CHANNEL_NAME, CHANNELS, GENERATOR_NAME, INSTRUCTIONS, + SETTINGS, STEMS_DATA, ) from sampletones_core.compatibility.reconstruction.v2_2 import update @@ -33,13 +35,16 @@ def test_renames_instruction_entries(self) -> None: assert upgraded["instructions_data"][0][CHANNEL_NAME] == "pulse1" assert GENERATOR_NAME not in upgraded["instructions_data"][0] - def test_renames_embedded_channel_selection(self) -> None: + def test_the_embedded_channel_selection_moves_onto_the_stems_record(self) -> None: data = {"config": {"generation": {"generators": ["pulse1", "noise"], "drive": 1.0}}} upgraded = update(data) - assert upgraded["config"]["generation"]["channels"] == ["pulse1", "noise"] - assert "generators" not in upgraded["config"]["generation"] + generation = upgraded["config"]["generation"] + assert generation == {"drive": 1.0} + assert upgraded[STEMS_DATA]["config"]["entries"] == [ + {"id": 0, SETTINGS: {CHANNELS: ["pulse1", "noise"], BENDS: []}} + ] def test_stamps_the_embedded_config_metadata(self) -> None: data = {"config": {"metadata": {"reconstruction_data_version": "2.1"}}} @@ -66,7 +71,7 @@ def test_payload_without_known_sections_gains_the_stems_record(self) -> None: assert upgraded["id"] == "abc" assert upgraded[AUDIO_FILEPATH] == [] - assert upgraded[STEMS_DATA]["config"]["entries"][0]["channels"] == [] + assert upgraded[STEMS_DATA]["config"]["entries"][0][SETTINGS][CHANNELS] == [] assert upgraded[STEMS_DATA]["assignments"] == [] def test_a_single_path_records_as_a_one_tuple(self) -> None: @@ -88,7 +93,7 @@ def test_a_file_without_stems_data_gains_the_single_entry_record(self) -> None: upgraded = update(data) stems_data = upgraded[STEMS_DATA] - assert stems_data["config"]["entries"] == [{"id": 0, CHANNELS: ["pulse1", "noise"]}] + assert stems_data["config"]["entries"] == [{"id": 0, SETTINGS: {CHANNELS: ["pulse1", "noise"], BENDS: []}}] assert stems_data["config"]["channel_cap"] == DEFAULT_STEMS_CHANNEL_CAP assert stems_data["assignments"] == [ {CHANNEL_NAME: "pulse1", "stem_ids": [0, 0]}, diff --git a/tests/unit/sampletones_core/compatibility/test_binary.py b/tests/unit/sampletones_core/compatibility/test_binary.py index d77d3f262..7b15c58e4 100644 --- a/tests/unit/sampletones_core/compatibility/test_binary.py +++ b/tests/unit/sampletones_core/compatibility/test_binary.py @@ -40,7 +40,7 @@ def test_malformed_payload_returns_the_same_bytes(self) -> None: assert upgrade_binary(ObjectKind.RECONSTRUCTION, binary) is binary - def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None: + def test_reconstruction_upgrade_names_streams_by_channel_and_stamps(self) -> None: binary = msgpack.packb( { "metadata": {"reconstruction_data_version": "2.1"}, @@ -59,6 +59,7 @@ def test_reconstruction_upgrade_renames_channels_and_stamps(self) -> None: assert data["approximations_data"][0]["channel_name"] == "pulse1" assert "generator_name" not in data["approximations_data"][0] - assert data["config"]["generation"]["channels"] == ["pulse1", "noise"] + assert "channels" not in data["config"]["generation"] + assert data["stems_data"]["config"]["entries"][0]["settings"]["channels"] == ["pulse1", "noise"] assert data["config"]["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION assert data["metadata"]["reconstruction_data_version"] == SAMPLETONES_RECONSTRUCTION_DATA_VERSION 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 ec285f6ac..3865b3c2d 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -5,7 +5,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) 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 @@ -72,7 +75,7 @@ def build_reconstruction( config=Config(), coefficient=1.0, audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data(list(Config().generation.channels), instructions), + stems_data=single_entry_stems_data(list(DEFAULT_CHANNELS), instructions), ) diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 9c1ad563c..14f707d09 100644 --- a/tests/unit/sampletones_core/formats/famitracker/conftest.py +++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py @@ -6,7 +6,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) from sampletones_core.instructions.implementation.noise import NoiseInstruction from sampletones_core.instructions.implementation.pulse import PulseInstruction from sampletones_core.instructions.implementation.triangle import TriangleInstruction @@ -38,7 +41,7 @@ def build_reconstruction( config=Config(), coefficient=1.0, audio_filepath=(Path("/dev/null"),), - stems_data=single_entry_stems_data(list(Config().generation.channels), instructions), + stems_data=single_entry_stems_data(list(DEFAULT_CHANNELS), instructions), ) diff --git a/tests/unit/sampletones_core/performance/test_voice.py b/tests/unit/sampletones_core/performance/test_voice.py index 059852aef..4f8b02ea1 100644 --- a/tests/unit/sampletones_core/performance/test_voice.py +++ b/tests/unit/sampletones_core/performance/test_voice.py @@ -6,7 +6,11 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, FeatureKey +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, +) from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.instructions import ( @@ -48,7 +52,7 @@ def _reconstruction( coefficient=1.0, audio_filepath=(Path("/dev/null"),), stems_data=single_entry_stems_data( - list(Config().generation.channels), + list(DEFAULT_CHANNELS), {channel_name: instructions_list}, ), ) diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index d6ee1eed2..be4c0583e 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -206,7 +206,7 @@ def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None: assert song.order[0][ChannelName.PULSE1] == 0 - def test_duplicate_carries_an_unmaterialised_index_across(self) -> None: + def test_duplicate_carries_an_unmaterialized_index_across(self) -> None: song = _song() song.set_order_entry(0, ChannelName.PULSE1, 7) 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 55af0c517..a97d4e877 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -8,10 +8,15 @@ format_spectrum_method, format_transformation_gamma, ) -from sampletones_core.constants.enums import ChannelName, abbreviate_channel_names +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + abbreviate_channel_names, +) from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields HASH = "6edf7c948606917a78b45d153c7ca7e0" +CHANNELS = frozenset(DEFAULT_CHANNELS) @pytest.fixture(scope="module") @@ -21,47 +26,52 @@ def config() -> Config: class TestGenerateConfigDirectoryName: def test_result_contains_sample_rate(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) assert str(config.library.sample_rate) in name def test_result_contains_nes_frequency(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) assert str(config.library.nes_frequency) in name def test_same_config_produces_same_name(self, config: Config) -> None: assert ConfigDirectoryFields.generate_config_directory_name( - config - ) == ConfigDirectoryFields.generate_config_directory_name(config) + config, CHANNELS + ) == ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) + + def test_different_channel_sets_produce_different_names(self, config: Config) -> None: + """The directory names what a run hands out, so two sets of channels never share one.""" + assert ConfigDirectoryFields.generate_config_directory_name( + config, CHANNELS + ) != ConfigDirectoryFields.generate_config_directory_name(config, frozenset({ChannelName.PULSE1})) - def test_different_generator_sets_produce_different_names(self, config: Config) -> None: - single_generator_config = config.model_copy( - update={"generation": config.generation.model_copy(update={"channels": [ChannelName.PULSE1]})} - ) + def test_the_name_reads_the_same_however_the_set_was_gathered(self, config: Config) -> None: + """A set has no order of its own, so the name states the channels in the app's own order.""" + reversed_set = frozenset(reversed(list(DEFAULT_CHANNELS))) assert ConfigDirectoryFields.generate_config_directory_name( - config - ) != ConfigDirectoryFields.generate_config_directory_name(single_generator_config) + config, CHANNELS + ) == ConfigDirectoryFields.generate_config_directory_name(config, reversed_set) class TestConfigDirectoryFields: def test_round_trips_with_generate_config_directory_name(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.directory_name == name def test_parses_components(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) fields = ConfigDirectoryFields.from_directory_name(name) assert fields is not None assert fields.sr == config.library.sample_rate assert fields.nf == config.library.nes_frequency assert fields.sm == config.library.spectrum_method assert fields.tg == config.library.transformation_gamma - assert fields.channels == tuple(config.generation.channels) + assert fields.channels == tuple(DEFAULT_CHANNELS) def test_directory_name_embeds_field_keys(self, config: Config) -> None: - name = ConfigDirectoryFields.generate_config_directory_name(config) + name = ConfigDirectoryFields.generate_config_directory_name(config, CHANNELS) segments = name.split("_") assert {"sr", "nf", "sm", "tg", "gn", "ch"}.issubset(segments) @@ -83,12 +93,12 @@ def test_malformed_names_return_none(self, name: str) -> None: assert ConfigDirectoryFields.from_directory_name(name) is None def test_display_name_combines_formatted_parts(self, config: Config) -> None: - fields = ConfigDirectoryFields.from_config(config) + fields = ConfigDirectoryFields.from_config(config, CHANNELS) display = fields.display_name 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 format_transformation_gamma(config.library.transformation_gamma) in display - assert abbreviate_channel_names(list(config.generation.channels)) in display + assert abbreviate_channel_names(list(DEFAULT_CHANNELS)) in display assert DISPLAY_SEPARATOR in display diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 04f32ab78..ad341fb65 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,16 +4,18 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS from sampletones_core.reconstructions.converter.paths import ( filter_files, get_audio_files, get_output_path, get_relative_path, group_output_path, - top_level_audio_files, ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +CHANNELS = frozenset(DEFAULT_CHANNELS) + @pytest.fixture(scope="module") def config() -> Config: @@ -51,7 +53,7 @@ def test_file_input_returns_path_with_reconstruction_extension( ) -> None: audio_file = tmp_path / "song.wav" audio_file.touch() - result = get_output_path(config, audio_file) + result = get_output_path(config, audio_file, CHANNELS) assert result.suffix == EXT_FILE_RECONSTRUCTION def test_directory_input_returns_path_ending_with_directory_name( @@ -59,7 +61,7 @@ def test_directory_input_returns_path_ending_with_directory_name( config: Config, tmp_path: Path, ) -> None: - result = get_output_path(config, tmp_path) + result = get_output_path(config, tmp_path, CHANNELS) assert result.name == tmp_path.name def test_non_existent_input_raises_file_not_found_error( @@ -69,7 +71,7 @@ def test_non_existent_input_raises_file_not_found_error( ) -> None: missing = tmp_path / "does_not_exist" with pytest.raises(FileNotFoundError): - get_output_path(config, missing) + get_output_path(config, missing, CHANNELS) class TestGetAudioFiles: @@ -117,25 +119,3 @@ def test_excludes_files_with_existing_output(self, tmp_path: Path) -> None: output_file.touch() result = filter_files([audio_file], tmp_path, output_directory) assert result == [] - - -class TestTopLevelAudioFiles: - """Gathering the sources of one reconstruction stays with the folder that was pointed at.""" - - def test_reports_the_audio_files_the_folder_itself_holds(self, tmp_path: Path) -> None: - (tmp_path / "b.wav").touch() - (tmp_path / "a.wav").touch() - (tmp_path / "notes.txt").write_text("not audio") - - assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav", "b.wav"] - - def test_a_nested_recording_stays_where_it_is(self, tmp_path: Path) -> None: - nested = tmp_path / "nested" - nested.mkdir() - (nested / "deep.wav").touch() - (tmp_path / "a.wav").touch() - - assert [path.name for path in top_level_audio_files(tmp_path)] == ["a.wav"] - - def test_a_folder_of_nothing_reports_nothing(self, tmp_path: Path) -> None: - assert top_level_audio_files(tmp_path) == [] diff --git a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index 57fe42d78..3b33b1d4e 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -1,11 +1,20 @@ from pathlib import Path -from typing import List +from typing import List, Optional import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.converter.paths.utils import get_output_path, group_output_path +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + bending_channels, +) +from sampletones_core.reconstructions.converter.paths.utils import ( + config_directory_path, + get_output_path, + group_output_path, +) +from sampletones_core.reconstructions.converter.plan.batch import BatchConversion, BatchEntry from sampletones_core.reconstructions.converter.plan.directory import DirectoryConversion from sampletones_core.reconstructions.converter.plan.group import GroupConversion from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig @@ -17,9 +26,13 @@ def config() -> Config: return Config() +CHANNELS = frozenset(DEFAULT_CHANNELS) + + @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + return StemsConfig.single_entry(channels, bending_channels(channels)) def _write_audio_files(directory: Path, names: List[str]) -> List[Path]: @@ -47,7 +60,7 @@ def test_one_source_makes_one_job_over_that_source( assert len(jobs) == 1 assert jobs[0].sources == (source,) assert jobs[0].stems == stems - assert jobs[0].output_path == get_output_path(config, source) + assert jobs[0].output_path == get_output_path(config, source, CHANNELS) def test_several_sources_make_one_job_over_all_of_them( self, @@ -62,7 +75,7 @@ def test_several_sources_make_one_job_over_all_of_them( assert len(jobs) == 1 assert jobs[0].sources == sources - assert jobs[0].output_path == group_output_path(config, sources) + assert jobs[0].output_path == group_output_path(config, sources, CHANNELS) def test_the_setup_travels_with_the_job( self, @@ -70,7 +83,7 @@ def test_the_setup_travels_with_the_job( tmp_path: Path, ) -> None: sources = tuple(_write_audio_files(tmp_path, ["a.wav", "b.wav"])) - targeted = StemsConfig.single_entry([ChannelName.PULSE1], channel_cap=1) + targeted = StemsConfig.single_entry([ChannelName.PULSE1], bending_channels([ChannelName.PULSE1]), channel_cap=1) jobs = GroupConversion(sources=sources, stems=targeted).jobs(config) @@ -99,7 +112,7 @@ def test_the_output_tree_mirrors_the_input_tree( tmp_path: Path, ) -> None: _write_audio_files(tmp_path, ["nested/deeper/b.wav"]) - output_path = get_output_path(config, tmp_path) + output_path = get_output_path(config, tmp_path, CHANNELS) jobs = DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) @@ -127,6 +140,115 @@ def test_a_directory_holding_nothing_to_convert_raises( DirectoryConversion(directory=tmp_path, stems=stems).jobs(config) +def _batch(stems: StemsConfig, *sources: Path, base_directory: Optional[Path] = None) -> BatchConversion: + entries = tuple(BatchEntry(source=source, stems=stems, base_directory=base_directory) for source in sources) + return BatchConversion(entries=entries) + + +class TestBatchConversion: + def test_every_recording_becomes_its_own_single_source_job( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = _write_audio_files(tmp_path, ["a.wav", "b.wav"]) + + jobs = _batch(stems, *sources).jobs(config) + + assert [job.sources for job in jobs] == [(sources[0],), (sources[1],)] + + def test_a_recording_named_by_itself_is_written_where_a_single_conversion_writes_it( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["song.wav"])[0] + + jobs = _batch(stems, source).jobs(config) + + assert jobs[0].output_path == group_output_path(config, (source,), CHANNELS) + + def test_a_recording_gathered_from_a_folder_mirrors_that_folder( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + source = _write_audio_files(tmp_path, ["nested/deeper/b.wav"])[0] + + jobs = _batch(stems, source, base_directory=tmp_path).jobs(config) + + mirrored = config_directory_path(config, CHANNELS) / tmp_path.name + assert jobs[0].output_path == mirrored / "nested" / "deeper" / "b.stn" + + def test_each_recording_carries_the_setup_it_was_given( + self, + config: Config, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + sources = _write_audio_files(tmp_path, ["a.wav", "b.wav"]) + targeted = StemsConfig.single_entry([ChannelName.NOISE], []) + + plan = BatchConversion( + entries=( + BatchEntry(source=sources[0], stems=stems, base_directory=None), + BatchEntry(source=sources[1], stems=targeted, base_directory=None), + ) + ) + + assert [job.stems for job in plan.jobs(config)] == [stems, targeted] + + def test_a_gathered_recording_already_written_is_left_as_it_stands( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + sources = _write_audio_files(tmp_path / "loops", ["a.wav", "b.wav"]) + plan = _batch(stems, *sources, base_directory=tmp_path / "loops") + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + assert [job.sources[0].name for job in plan.jobs(config)] == ["b.wav"] + + def test_a_recording_the_reader_named_is_written_again( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = _batch(stems, source) + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + assert len(plan.jobs(config)) == 1 + + def test_a_batch_left_with_nothing_to_write_raises( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path / "loops", ["a.wav"])[0] + plan = _batch(stems, source, base_directory=tmp_path / "loops") + written = plan.jobs(config)[0].output_path + written.parent.mkdir(parents=True, exist_ok=True) + written.touch() + + with pytest.raises(NoFilesToProcessError): + plan.jobs(config) + + def test_a_batch_holding_no_recording_raises(self, config: Config) -> None: + with pytest.raises(NoFilesToProcessError): + BatchConversion(entries=()).jobs(config) + + def _config_writing_under(reconstructions_directory: Path) -> Config: """A configuration whose reconstructions are written under ``reconstructions_directory``.""" config = Config() @@ -174,6 +296,35 @@ def test_a_directory_sitting_at_the_target_path_leaves_the_answer_empty( assert plan.existing_targets(config) == () + def test_a_batch_names_the_target_standing_for_a_recording_the_reader_named( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path, ["song.wav"])[0] + plan = _batch(stems, source) + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + assert plan.existing_targets(config) == (target,) + + def test_a_batch_settles_a_gathered_recording_itself( + self, + stems: StemsConfig, + tmp_path: Path, + ) -> None: + """A folder is picked up where the last run stopped, so a written one puts nothing to the reader.""" + config = _config_writing_under(tmp_path / "out") + source = _write_audio_files(tmp_path / "loops", ["a.wav"])[0] + plan = _batch(stems, source, base_directory=tmp_path / "loops") + target = plan.jobs(config)[0].output_path + target.parent.mkdir(parents=True) + target.touch() + + assert plan.existing_targets(config) == () + def test_a_directory_conversion_settles_the_question_itself( self, stems: StemsConfig, @@ -194,8 +345,12 @@ class TestGroupOutputPath: def test_one_source_names_the_file_after_itself(self, config: Config, tmp_path: Path) -> None: source = tmp_path / "song.wav" source.touch() - assert group_output_path(config, (source,)) == get_output_path(config, source) + assert group_output_path(config, (source,), CHANNELS) == get_output_path( + config, + source, + CHANNELS, + ) def test_sources_sharing_a_directory_name_the_file_after_it(self, config: Config, tmp_path: Path) -> None: sources = tuple(_write_audio_files(tmp_path / "session", ["a.wav", "b.wav"])) - assert group_output_path(config, sources).stem == "session" + assert group_output_path(config, sources, CHANNELS).stem == "session" diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py index cd1045770..3d666de16 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -1,9 +1,15 @@ from pathlib import Path +from typing import Final, List from unittest.mock import MagicMock import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + bending_channels, +) from sampletones_core.reconstructions.converter.conversion import reconstruct_job from sampletones_core.reconstructions.converter.job import ConversionJob from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor @@ -11,6 +17,8 @@ from sampletones_shared.exceptions import UnsupportedAudioFormatError from sampletones_shared.utils.progress import silent_reporter +CHANNELS: Final[List[ChannelName]] = list(DEFAULT_CHANNELS) + @pytest.fixture def mock_reconstructor() -> MagicMock: @@ -20,7 +28,7 @@ def mock_reconstructor() -> MagicMock: def _job(tmp_path: Path, output_path: Path) -> ConversionJob: return ConversionJob( sources=(tmp_path / "song.wav",), - stems=StemsConfig.single_entry(list(Config().generation.channels)), + stems=StemsConfig.single_entry(CHANNELS, bending_channels(CHANNELS)), output_path=output_path, ) diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py index 15fc2c2cd..225ab5fd4 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -4,6 +4,10 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + bending_channels, +) from sampletones_core.reconstructions.converter import ( DirectoryConversion, GroupConversion, @@ -23,7 +27,8 @@ def config() -> Config: @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + return StemsConfig.single_entry(channels, bending_channels(channels)) def _group(path: Path, stems: StemsConfig) -> GroupConversion: diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 2491b6f62..1be56547a 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -9,7 +9,13 @@ from pydantic import ValidationError from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, FeatureKey, HierarchyMode +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + FeatureKey, + HierarchyMode, + bending_channels, +) from sampletones_core.data import Metadata from sampletones_core.features import resting_held_features, resting_reference from sampletones_core.instructions import PulseInstruction @@ -22,6 +28,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) @@ -64,7 +71,7 @@ def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: coefficient=1.0, audio_filepath=(Path("/dev/null"),), stems_data=single_entry_stems_data( - list(Config().generation.channels), + list(DEFAULT_CHANNELS), {ChannelName.PULSE1: instructions}, ), ) @@ -87,7 +94,12 @@ def _saved_playing_channels_only(path: Path) -> Path: class TestStemsDataRoundTrip: def test_stems_data_survives_save_and_load(self, tmp_path: Path) -> None: stems_config = StemsConfig( - entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + entries=[ + StemEntry( + id=0, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ) + ], hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT), channel_cap=1, ) @@ -124,8 +136,18 @@ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> No stems_data = StemsData( config=StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, @@ -150,6 +172,7 @@ def test_audio_filepath_tuple_survives_save_and_load(self, tmp_path: Path) -> No def test_paths_numbering_the_entries_is_enforced(self) -> None: stems_data = StemsData.single_entry( + [ChannelName.PULSE1], [ChannelName.PULSE1], [ChannelAssignment(channel_name=ChannelName.PULSE1, stem_ids=[0])], channel_cap=1, @@ -181,8 +204,18 @@ def test_stem_sources_yield_the_recorded_tuple(self) -> None: stems_data = StemsData( config=StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.PULSE1]), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, @@ -360,7 +393,9 @@ def test_a_2_1_file_loads_through_the_upgrade( item["generator_name"] = item.pop("channel_name") generation = data["config"]["generation"] - generation["generators"] = generation.pop("channels") + generation["generators"] = [ + str(channel_name) for channel_name in reconstruction.stems_data.config.entries[0].settings.channels + ] config_metadata = data["config"].get("metadata") if isinstance(config_metadata, dict): config_metadata["reconstruction_data_version"] = "2.1" @@ -395,8 +430,9 @@ def test_a_2_1_file_without_stems_record_gains_the_single_entry_record( for item in data["instructions_data"]: item["generator_name"] = item.pop("channel_name") + channels = list(reconstruction.stems_data.config.entries[0].settings.channels) generation = data["config"]["generation"] - generation["generators"] = generation.pop("channels") + generation["generators"] = [str(channel_name) for channel_name in channels] config_metadata = data["config"].get("metadata") if isinstance(config_metadata, dict): config_metadata["reconstruction_data_version"] = "2.1" @@ -407,7 +443,7 @@ def test_a_2_1_file_without_stems_record_gains_the_single_entry_record( stems_data = loaded.stems_data assert stems_data.config.entries[0].id == 0 - assert stems_data.config.entries[0].channels == list(loaded.config.generation.channels) + assert stems_data.config.entries[0].settings.channels == channels assert loaded.audio_filepath == reconstruction.audio_filepath for channel, stem_ids in stems_data.assignments_by_channel.items(): assert len(stem_ids) == len(loaded.instructions[channel]) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py index 0186e7e08..fd1733d8d 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ChannelName, bending_channels from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment from sampletones_core.reconstructions.reconstruction.stems.data import StemsData from sampletones_core.reconstructions.reconstruction.stems.filter import ( @@ -12,6 +12,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings FRAME_LENGTH: Final[int] = 2 EVERY_CHANNEL: Final[Tuple[ChannelName, ...]] = tuple(ChannelName.items()) @@ -23,7 +24,13 @@ def _heard(*stem_ids: int) -> StemSelection: def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: - entries = [StemEntry(id=stem_id, channels=[ChannelName.PULSE1]) for stem_id in range(3)] + entries = [ + StemEntry( + id=stem_id, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ) + for stem_id in range(3) + ] return StemsData( config=StemsConfig( entries=entries, diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py index 39e4a123f..0759230be 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py @@ -6,7 +6,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.algorithm import RESTING_STEM_ID -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction from sampletones_core.reconstructions.reconstruction.stems.channel_assignment import ChannelAssignment @@ -15,6 +15,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings STEM_A: Final[int] = 0 STEM_B: Final[int] = 1 @@ -41,9 +42,21 @@ def _noise() -> NoiseInstruction: def _stems_config() -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=STEM_A, channels=[ChannelName.PULSE1]), - StemEntry(id=STEM_B, channels=[ChannelName.PULSE1, ChannelName.NOISE]), - StemEntry(id=STEM_C, channels=[ChannelName.PULSE1]), + StemEntry( + id=STEM_A, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), + StemEntry( + id=STEM_B, + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.NOISE]), + ), + ), + StemEntry( + id=STEM_C, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), ], hierarchy=StemsHierarchy( levels=[[STEM_A], [STEM_B, STEM_C]], @@ -304,6 +317,7 @@ def test_removing_the_last_recording_is_refused(self) -> None: coefficient=1.0, audio_filepath=(RECORDINGS[STEM_A],), stems_data=StemsData.single_entry( + [ChannelName.PULSE1], [ChannelName.PULSE1], [ ChannelAssignment( diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py index dcb80d9ac..8c3f2731a 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/conftest.py @@ -5,7 +5,10 @@ from sampletones_core.configs import Config from sampletones_core.constants.algorithm import STEM_ACTIVITY_FLOOR -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, +) from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor, get_feature_extractor from sampletones_core.fft.fragment.audio import FragmentedAudio @@ -35,7 +38,7 @@ def extractor(config: Config, window: Window) -> FeatureExtractor: @pytest.fixture(scope="module") def channels(config: Config) -> Dict[ChannelName, GeneratorUnion]: - return get_generators_by_channels(config, config.generation.channels) + return get_generators_by_channels(config, DEFAULT_CHANNELS) @pytest.fixture(scope="module") diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py new file mode 100644 index 000000000..b2d665a52 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass +from typing import Dict, Final, List, Tuple + +import numpy as np +import pytest + +from sampletones_core.configs import Config +from sampletones_core.configs.generation import GenerationConfig +from sampletones_core.constants.algorithm import RESTING_STEM_ID +from sampletones_core.constants.enums import ChannelName +from sampletones_core.generators import get_generators_by_channels +from sampletones_core.instructions import PulseInstruction, TriangleInstruction +from sampletones_core.reconstructions.reconstructor.matching import ScoredCandidate +from sampletones_core.reconstructions.reconstructor.refinement import refiner +from sampletones_core.reconstructions.reconstructor.refinement.refiner import PitchRefiner +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry +from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +TONES: Final[List[ChannelName]] = [ChannelName.PULSE1, ChannelName.TRIANGLE] +PITCH: Final[int] = 60 +VOLUME: Final[int] = 12 +FRAMES: Final[int] = 4 +BEND: Final[int] = 3 +SECONDS: Final[float] = 0.2 +STEM_A: Final[int] = 0 +STEM_B: Final[int] = 1 + + +@pytest.fixture(scope="module") +def config() -> Config: + return Config() + + +def _stems(*entries: StemEntry) -> StemsConfig: + """A setup naming ``entries`` on one precedence level.""" + return StemsConfig( + entries=list(entries), + hierarchy=StemsHierarchy(levels=[[entry.id for entry in entries]]), + channel_cap=len(TONES), + ) + + +def _recording(config: Config, channel_name: ChannelName) -> np.ndarray: + """A steady tone standing a little off the note ``PITCH`` names, so a reading has room to move.""" + generator = get_generators_by_channels(config, TONES)[channel_name] + sample_rate = config.library.sample_rate + frequency = generator.sounds_at(PITCH, BEND) + + count = int(sample_rate * SECONDS) + phase = (np.arange(count) * frequency / sample_rate) % 1.0 + return np.where(phase < 0.5, 0.4, -0.4).astype(np.float64) + + +def _stream(channel_name: ChannelName) -> List[ScoredCandidate]: + """One channel's frames, each sounding the note the matching chose and no bend.""" + instruction = ( + PulseInstruction(on=True, pitch=PITCH, volume=VOLUME, duty_cycle=2) + if channel_name is ChannelName.PULSE1 + else TriangleInstruction(on=True, pitch=PITCH) + ) + return [ScoredCandidate(instruction=instruction, cost=0.0, approximation=np.zeros(1)) for _ in range(FRAMES)] + + +def _bends(streams: Dict[ChannelName, List[ScoredCandidate]], channel_name: ChannelName) -> List[int]: + return [candidate.instruction.timer_offset for candidate in streams[channel_name]] + + +def _refine( + config: Config, + stems: StemsConfig, + stem_ids: Dict[ChannelName, List[int]], +) -> Dict[ChannelName, List[ScoredCandidate]]: + refined = PitchRefiner( + config=config, + channels=get_generators_by_channels(config, TONES), + stems=stems, + ) + return refined.refine( + {channel_name: _stream(channel_name) for channel_name in TONES}, + stem_ids, + {STEM_A: _recording(config, ChannelName.PULSE1)}, + ) + + +class TestWhichChannelsAStemCarries(BaseTestSuite): + """A stem states the channels it bends, and the refinement acts on those alone. + + The same recording reaches both channels of one stem here, so what separates them is only + what the entry names — which is the whole of the switch. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + bends: Tuple[ChannelName, ...] + bent: Tuple[ChannelName, ...] + + test_cases: Tuple["TestWhichChannelsAStemCarries.TestCase", ...] = ( + TestCase(label="both channels", bends=tuple(TONES), bent=tuple(TONES)), + TestCase(label="the pulse alone", bends=(ChannelName.PULSE1,), bent=(ChannelName.PULSE1,)), + TestCase(label="the triangle alone", bends=(ChannelName.TRIANGLE,), bent=(ChannelName.TRIANGLE,)), + TestCase(label="neither channel", bends=(), bent=()), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_only_the_channels_the_stem_names_are_carried( + self, + config: Config, + test_case: "TestWhichChannelsAStemCarries.TestCase", + ) -> None: + stems = _stems(StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=list(test_case.bends)))) + streams = _refine(config, stems, {channel_name: [STEM_A] * FRAMES for channel_name in TONES}) + + for channel_name in TONES: + bends = _bends(streams, channel_name) + if channel_name in test_case.bent: + assert any(bends), f"{channel_name} was named and stayed at its note" + else: + assert not any(bends), f"{channel_name} was left out and moved anyway" + + +class TestWhatTheRefinementReadsPerStem: + """A reading is taken for the recording a bend is read from, and for no other.""" + + @staticmethod + def _counted(monkeypatch: pytest.MonkeyPatch) -> List[int]: + readings: List[int] = [] + + def counting(recording: np.ndarray, sample_rate: int, hop_length: int) -> refiner.InstantaneousPitch: + readings.append(len(recording)) + return refiner.InstantaneousPitch(recording, sample_rate, hop_length) + + monkeypatch.setattr(refiner, "InstantaneousPitch", counting) + return readings + + def test_a_stem_carrying_nothing_is_never_read( + self, + config: Config, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + readings = self._counted(monkeypatch) + stems = _stems( + StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=[])), + StemEntry(id=STEM_B, settings=StemSettings(channels=TONES, bends=list(TONES))), + ) + _refine(config, stems, {channel_name: [STEM_A] * FRAMES for channel_name in TONES}) + + assert not readings + + def test_a_frame_no_stem_took_is_left_at_its_note(self, config: Config) -> None: + """A resting frame names no recording, so there is nothing to read a bend out of.""" + stems = _stems(StemEntry(id=STEM_A, settings=StemSettings(channels=TONES, bends=list(TONES)))) + resting = {channel_name: [RESTING_STEM_ID] * FRAMES for channel_name in TONES} + streams = _refine(config, stems, resting) + + assert not any(_bends(streams, ChannelName.PULSE1)) + assert not any(_bends(streams, ChannelName.TRIANGLE)) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py index 48f837bfe..1ddfea177 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -2,17 +2,22 @@ from pydantic import ValidationError from sampletones_core.constants.algorithm import DEFAULT_STEMS_CHANNEL_CAP -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings def _stems_config() -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.NOISE]), + StemEntry( + id=0, settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])) + ), + StemEntry( + id=1, settings=StemSettings(channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])) + ), ], hierarchy=StemsHierarchy(levels=[[0], [1]], mode=HierarchyMode.STRICT), channel_cap=DEFAULT_STEMS_CHANNEL_CAP, @@ -31,8 +36,18 @@ def test_duplicate_entry_ids_raise(self) -> None: with pytest.raises(ValidationError): StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=0, channels=[ChannelName.NOISE]), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE]) + ), + ), ], hierarchy=StemsHierarchy(levels=[[0]]), ) @@ -55,7 +70,14 @@ class TestStemsConfigHierarchy: def test_a_duplicated_stem_raises(self) -> None: with pytest.raises(ValidationError, match="exactly once"): StemsConfig( - entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + entries=[ + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ) + ], hierarchy=StemsHierarchy(levels=[[0], [0]]), ) @@ -63,8 +85,18 @@ def test_a_stem_left_out_raises(self) -> None: with pytest.raises(ValidationError, match="exactly once"): StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.NOISE]), + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ), + StemEntry( + id=1, + settings=StemSettings( + channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE]) + ), + ), ], hierarchy=StemsHierarchy(levels=[[0]]), ) @@ -72,7 +104,14 @@ def test_a_stem_left_out_raises(self) -> None: def test_an_unknown_stem_raises(self) -> None: with pytest.raises(ValidationError, match="exactly once"): StemsConfig( - entries=[StemEntry(id=0, channels=[ChannelName.PULSE1])], + entries=[ + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), + ) + ], hierarchy=StemsHierarchy(levels=[[0], [5]]), ) @@ -81,7 +120,7 @@ class TestStemsConfigViews: def test_entries_are_keyed_by_their_id(self) -> None: stems = _stems_config() assert set(stems.entries_by_id) == {0, 1} - assert stems.entries_by_id[1].channels == [ChannelName.NOISE] + assert stems.entries_by_id[1].settings.channels == [ChannelName.NOISE] def test_covered_channels_gather_every_entry(self) -> None: stems = _stems_config() @@ -93,7 +132,15 @@ def test_frame_budget_stops_at_the_covered_channels(self) -> None: def test_frame_budget_stops_at_the_cap(self) -> None: stems = StemsConfig( - entries=[StemEntry(id=0, channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE])], + entries=[ + StemEntry( + id=0, + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]), + ), + ) + ], hierarchy=StemsHierarchy(levels=[[0]]), channel_cap=1, ) @@ -103,13 +150,14 @@ def test_frame_budget_stops_at_the_cap(self) -> None: class TestSingleEntry: def test_names_one_stem_over_every_channel(self) -> None: channels = [ChannelName.PULSE1, ChannelName.TRIANGLE] - stems = StemsConfig.single_entry(channels) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) - assert [entry.channels for entry in stems.entries] == [channels] + assert [entry.settings.channels for entry in stems.entries] == [channels] assert stems.hierarchy.levels == [[0]] assert stems.covered_channels == frozenset(channels) def test_carries_the_cap_it_is_given(self) -> None: - stems = StemsConfig.single_entry([ChannelName.PULSE1, ChannelName.TRIANGLE], channel_cap=1) + channels = [ChannelName.PULSE1, ChannelName.TRIANGLE] + stems = StemsConfig.single_entry(channels, bending_channels(channels), channel_cap=1) assert stems.channel_cap == 1 assert stems.frame_budget == 1 diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py index b581bdf15..37e64e562 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -5,7 +5,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ChannelName, HierarchyMode, bending_channels from sampletones_core.fft import Fragment, Window from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion @@ -15,6 +15,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment @@ -30,7 +31,12 @@ def _config( channel_cap: int, ) -> StemsConfig: return StemsConfig( - entries=[StemEntry(id=stem_id, channels=list(channels)) for stem_id, channels in entries.items()], + entries=[ + StemEntry( + id=stem_id, settings=StemSettings(channels=list(channels), bends=bending_channels(list(channels))) + ) + for stem_id, channels in entries.items() + ], hierarchy=StemsHierarchy(levels=levels, mode=mode), channel_cap=channel_cap, ) @@ -214,7 +220,7 @@ def test_invariants_and_determinism( counts: Dict[int, int] = {} for choice in assignment.choices: counts[choice.stem_id] = counts.get(choice.stem_id, 0) + 1 - assert choice.channel_name in stems_config.entries_by_id[choice.stem_id].channel_set + assert choice.channel_name in stems_config.entries_by_id[choice.stem_id].settings.channel_set assert all(count <= stems_config.channel_cap for count in counts.values()) if stems_config.hierarchy.mode == HierarchyMode.STRICT: diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py index b73d7339b..b0b915e5b 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -4,7 +4,12 @@ import pytest from sampletones_core.constants.algorithm import SINGLE_STATE_LATTICE_WIDTH -from sampletones_core.constants.enums import ChannelName, HierarchyMode +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + HierarchyMode, + bending_channels, +) from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion @@ -13,6 +18,7 @@ from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.reconstructor.stems.configs.entry import StemEntry from sampletones_core.reconstructions.reconstructor.stems.configs.hierarchy import StemsHierarchy +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_core.reconstructions.reconstructor.stems.models.choice import StemChoice from sampletones_core.reconstructions.reconstructor.stems.models.frame_assignment import StemFrameAssignment @@ -29,7 +35,10 @@ def _config( channel_cap: int, ) -> StemsConfig: return StemsConfig( - entries=[StemEntry(id=stem_id, channels=channels) for stem_id, channels in entries.items()], + entries=[ + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) + for stem_id, channels in entries.items() + ], hierarchy=StemsHierarchy(levels=levels, mode=mode), channel_cap=channel_cap, ) @@ -62,7 +71,7 @@ def test_channel_outside_enabled_channels_raises( extractor: FeatureExtractor, ) -> None: stems_config = _config({0: [ChannelName.PULSE2]}, [[0]], HierarchyMode.STRICT, 1) - with pytest.raises(ValueError, match="configuration lacks"): + with pytest.raises(ValueError, match="the run was not built for"): _assign(synthetic_fragment, stems_config, channels, matcher, extractor) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py new file mode 100644 index 000000000..87073fd95 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from typing import List, Optional, Type + +import pytest +from pydantic import ValidationError + +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + + +class TestTheBendsASettingsHolds(BaseTestSuite): + """A bend names a channel the recording occupies and whose hardware loads a divider.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + expected: Optional[Type[Exception]] + channels: List[ChannelName] + bends: List[ChannelName] + + test_cases = ( + TestCase( + label="bending_nothing", + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=[], + expected=None, + ), + TestCase( + label="bending_a_channel_it_occupies", + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE], + bends=[ChannelName.TRIANGLE], + expected=None, + ), + TestCase( + label="bending_every_tone_channel_it_occupies", + channels=list(sorted(TONE_CHANNELS)), + bends=list(sorted(TONE_CHANNELS)), + expected=None, + ), + TestCase( + label="bending_a_channel_it_leaves_out", + channels=[ChannelName.PULSE1], + bends=[ChannelName.TRIANGLE], + expected=ValidationError, + ), + TestCase( + label="bending_the_noise_channel", + channels=[ChannelName.NOISE], + bends=[ChannelName.NOISE], + expected=ValidationError, + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label) + def test_settings_are_held_to_their_bends(self, test_case: TestCase) -> None: + if test_case.expected is None: + settings = StemSettings(channels=test_case.channels, bends=test_case.bends) + assert settings.bends == test_case.bends + return + + with pytest.raises(test_case.expected): + StemSettings(channels=test_case.channels, bends=test_case.bends) + + +class TestTheSetsSettingsAnswerWith: + """The membership tests an assignment and the refinement each read once per frame.""" + + def test_the_channel_set_holds_every_channel_occupied(self) -> None: + settings = StemSettings( + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=[ChannelName.PULSE1], + ) + assert settings.channel_set == frozenset({ChannelName.PULSE1, ChannelName.NOISE}) + + def test_the_bend_set_holds_every_channel_bent(self) -> None: + settings = StemSettings( + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE], + bends=[ChannelName.TRIANGLE], + ) + assert settings.bend_set == frozenset({ChannelName.TRIANGLE}) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 3c61c6e16..478c3ad82 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -7,6 +7,10 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + bending_channels, +) from sampletones_core.fft import Fragment, Window from sampletones_core.generators import MIXER_LEVELS from sampletones_core.library import InstructionLibraryData @@ -20,7 +24,7 @@ def _make_reconstructor(config: Config, library_data: InstructionLibraryData) -> mock_library.get.return_value = library_data mock_library.create_key.return_value = MagicMock() mock_library.get_path.return_value = "test/path" - return Reconstructor(config, library=mock_library) + return Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=mock_library) class TestReconstructorInit: @@ -30,7 +34,7 @@ def test_generators_initialized_from_config( library_data: InstructionLibraryData, ) -> None: reconstructor = _make_reconstructor(config, library_data) - expected_names = set(config.generation.channels) + expected_names = set(DEFAULT_CHANNELS) assert set(reconstructor.channels.keys()) == expected_names def test_window_created_from_config( @@ -51,7 +55,7 @@ def test_none_library_data_raises_no_library_data_error(self, config: Config) -> mock_library.create_key.return_value = MagicMock() mock_library.get_path.return_value = "test/path" with pytest.raises(NoLibraryDataError): - Reconstructor(config, library=mock_library) + Reconstructor(config, frozenset(DEFAULT_CHANNELS), library=mock_library) class TestReconstructorGetCoefficient: @@ -97,7 +101,8 @@ def test_a_capped_setup_anchors_to_what_one_frame_reaches( ) -> None: """One channel per frame reaches one channel's weight, so that is what the level is measured against.""" reconstructor = _make_reconstructor(config, library_data) - capped = StemsConfig.single_entry(list(config.generation.channels), channel_cap=1) + channels = list(DEFAULT_CHANNELS) + capped = StemsConfig.single_entry(channels, bending_channels(channels), channel_cap=1) audio = np.ones(config.library.frame_length, dtype=np.float32) * 0.5 loudest = max(MIXER_LEVELS[generator.class_name()] for generator in reconstructor.channels.values()) @@ -106,7 +111,8 @@ def test_a_capped_setup_anchors_to_what_one_frame_reaches( def _full_setup(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(DEFAULT_CHANNELS) + return StemsConfig.single_entry(channels, bending_channels(channels)) def _total_mixer(reconstructor: Reconstructor) -> float: diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py index 993da11ef..01a4606c0 100644 --- a/tests/unit/sampletones_core/structures/tree/test_factory.py +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -1,12 +1,13 @@ from pathlib import Path from sampletones_core.configs import Config +from sampletones_core.constants.enums import DEFAULT_CHANNELS 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()) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) RECONSTRUCTIONS_DIRECTORY = Path("/reconstructions") diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index 880bd9926..70bd86e68 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -1,7 +1,7 @@ from pathlib import Path from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import DEFAULT_CHANNELS, GeneratorName from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( @@ -15,7 +15,7 @@ from sampletones_core.structures.tree.type import NodeType LIBRARY_KEY = InstructionLibraryKey.from_config(Config()) -CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config(), frozenset(DEFAULT_CHANNELS)) class TestTreeNode: diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 821ea056b..dadbad2e7 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -206,7 +206,7 @@ class TestCase(BaseRegularTestCase): name="Kick (pulse1)", extension=".fti", expected="Kick (pulse1).fti", - label="carries_a_parenthesised_slice_name", + label="carries_a_parenthesized_slice_name", ), TestCase( name="Kick v1.2", @@ -568,7 +568,7 @@ class TestCase(BaseRegularTestCase): ) def _create_resolved_mock(self, resolved_path: Any) -> MagicMock: - """Stands in for the resolved path, keeping the path flavour each case declares. + """Stands in for the resolved path, keeping the path flavor each case declares. Every case states its expectation as a ``PurePosixPath`` or a ``PureWindowsPath``, so the parts come from that pure path and the case reads the same on either platform. diff --git a/tests/unit/sampletones_shared/utils/test_callbacks.py b/tests/unit/sampletones_shared/utils/test_callbacks.py index a00ef0e59..917056228 100644 --- a/tests/unit/sampletones_shared/utils/test_callbacks.py +++ b/tests/unit/sampletones_shared/utils/test_callbacks.py @@ -1,3 +1,4 @@ +import logging from dataclasses import dataclass from typing import Any, Dict, Optional, Tuple, Type, Union from unittest.mock import MagicMock @@ -178,10 +179,15 @@ def test_call(self, test_case: TestCase) -> None: result = instance.call(test_case.callback, *test_case.args, **test_case.kwargs) assert result == test_case.expected - def test_call_logs_warning_for_none_callback(self, caplog: pytest.LogCaptureFixture) -> None: + def test_call_notes_an_unset_callback_at_debug(self, caplog: pytest.LogCaptureFixture) -> None: + """Partial wiring is expected, so an unset hook is a debug note rather than a warning.""" + caplog.set_level(logging.DEBUG) instance = TestableCallbackClass() + instance.call(None) + assert "No callback for TestableCallbackClass to call" in caplog.text + assert caplog.records[-1].levelno == logging.DEBUG def test_call_with_callback_that_raises_exception(self) -> None: instance = TestableCallbackClass() @@ -214,12 +220,14 @@ def test_unset_hook_reports_the_default(self) -> None: assert instance.query(instance.on_data, default=False) is False - def test_unset_hook_logs_a_warning(self, caplog: pytest.LogCaptureFixture) -> None: + def test_unset_hook_is_noted_at_debug(self, caplog: pytest.LogCaptureFixture) -> None: + caplog.set_level(logging.DEBUG) instance = TestableCallbackClass() instance.query(instance.on_data, default=None) assert "No callback for TestableCallbackClass to query" in caplog.text + assert caplog.records[-1].levelno == logging.DEBUG def test_answer_of_none_is_reported_as_given(self) -> None: """A hook answering ``None`` is wired, so its answer stands rather than the default.""" diff --git a/tests/unit/sampletones_shared/utils/test_hashing.py b/tests/unit/sampletones_shared/utils/test_hashing.py new file mode 100644 index 000000000..f9c9152c3 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_hashing.py @@ -0,0 +1,243 @@ +import pytest + +from sampletones_shared.utils.hashing import ( + HASH_LENGTH, + IDENTITY_SEPARATOR, + calculate_hash, + hash_model, + hash_models, + identity_digest, +) +from tests.suite.dummy import NestedModel, SimpleModel + + +class TestCalculateHash: + def test_hash_string(self) -> None: + hash1 = calculate_hash("test") + hash2 = calculate_hash("test".encode("utf-8")) + hash3 = calculate_hash(b"test") + hash4 = calculate_hash("different") + + assert hash1 == hash2 + assert hash1 == hash3 + assert hash1 != hash4 + assert isinstance(hash1, str) + assert len(hash1) == HASH_LENGTH + + def test_hash_integer(self) -> None: + hash1 = calculate_hash(42) + hash2 = calculate_hash(42) + hash3 = calculate_hash(1) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_float(self) -> None: + hash1 = calculate_hash(3.14) + hash2 = calculate_hash(3.14) + hash3 = calculate_hash(2.71) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_bytes(self) -> None: + hash1 = calculate_hash(b"same") + hash2 = calculate_hash(b"same") + hash3 = calculate_hash(b"different") + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_bytes_same_as_strings(self) -> None: + hash1 = calculate_hash(b"data") + hash2 = calculate_hash("data") + hash3 = calculate_hash("data".encode("utf-8")) + + assert hash1 == hash2 + assert hash1 == hash3 + + def test_hash_boolean(self) -> None: + hash1 = calculate_hash(True) + hash2 = calculate_hash(True) + hash3 = calculate_hash(False) + + assert hash1 == hash2 + assert hash1 != hash3 + assert isinstance(hash1, str) + + def test_hash_null_different_representations(self) -> None: + hash_false = calculate_hash(False) + hash_zero = calculate_hash(0) + hash_float_zero = calculate_hash(0.0) + hash_empty_string = calculate_hash("") + hash_null_bytes = calculate_hash(b"") + hash_none = calculate_hash(None) + + assert hash_false == hash_zero + assert hash_false == hash_float_zero + assert hash_false == hash_null_bytes + assert hash_false == hash_empty_string + assert hash_false != hash_none + + def test_hash_base_model(self) -> None: + model1 = SimpleModel(value=42, name="test") + model2 = SimpleModel(value=42, name="test") + + hash1 = calculate_hash(model1) + hash2 = calculate_hash(model2) + + assert hash1 == hash2 + + def test_hash_different_base_models(self) -> None: + model1 = SimpleModel(value=1, name="test") + model2 = SimpleModel(value=1, name="test") + model3 = SimpleModel(value=2, name="test") + + hash1 = calculate_hash(model1) + hash2 = calculate_hash(model2) + hash3 = calculate_hash(model3) + + assert hash1 == hash2 + assert hash1 != hash3 + + def test_hash_custom_length(self) -> None: + hash_16 = calculate_hash("test", length=16) + hash_64 = calculate_hash("test", length=64) + + assert hash_16 != hash_64 + assert len(hash_16) == 16 + assert len(hash_64) == 64 + assert hash_64.startswith(hash_16) + + def test_hash_zero_length_raises(self) -> None: + with pytest.raises(ValueError): + calculate_hash("test", length=0) + + def test_hash_excessive_length_raises(self) -> None: + with pytest.raises(ValueError): + calculate_hash("test", length=65) + + def test_hash_tuple(self) -> None: + hash1 = calculate_hash((1, 2, 3)) + hash2 = calculate_hash((1, 2, 3)) + + assert hash1 == hash2 + + def test_hash_frozenset(self) -> None: + hash1 = calculate_hash(frozenset([1, 2, 3])) + hash2 = calculate_hash(frozenset([1, 2, 3])) + hash3 = calculate_hash(frozenset([3, 2, 1])) + + assert hash1 == hash2 + assert hash1 == hash3 + assert isinstance(hash1, str) + + +class TestHashModels: + def test_hash_single_model(self) -> None: + model = SimpleModel(value=42, name="test") + hash1 = hash_model(model) + hash2 = hash_model(model) + + assert hash1 == hash2 + assert isinstance(hash1, str) + assert len(hash1) == HASH_LENGTH + + def test_hash_different_single_models(self) -> None: + model1 = SimpleModel(value=1, name="test1") + model2 = SimpleModel(value=2, name="test2") + + hash1 = hash_model(model1) + hash2 = hash_model(model2) + + assert hash1 != hash2 + + def test_hash_multiple_models(self) -> None: + model1 = SimpleModel(value=1, name="first") + model2 = SimpleModel(value=2, name="second") + + hash1 = hash_models(model1, model2) + hash2 = hash_models(model1, model2) + + assert hash1 == hash2 + + def test_hash_multiple_models_order_matters(self) -> None: + model1 = SimpleModel(value=1, name="first") + model2 = SimpleModel(value=2, name="second") + + hash_forward = hash_models(model1, model2) + hash_backward = hash_models(model2, model1) + + assert hash_forward != hash_backward + + def test_hash_single_vs_multiple(self) -> None: + model = SimpleModel(value=42, name="test") + + hash_single = hash_model(model) + hash_multiple = hash_models(model) + + assert hash_single == hash_multiple + + def test_hash_nested_model(self) -> None: + inner = SimpleModel(value=1, name="inner") + outer = NestedModel(simple=inner, items=[1, 2, 3]) + + hash1 = hash_model(outer) + hash2 = hash_model(outer) + + assert hash1 == hash2 + + def test_hash_models_custom_length(self) -> None: + model = SimpleModel(value=42, name="test") + + hash_16 = hash_model(model, length=16) + hash_64 = hash_model(model, length=64) + + assert len(hash_16) == 16 + assert len(hash_64) == 64 + + def test_hash_models_many(self) -> None: + models = [SimpleModel(value=i, name=f"model{i}") for i in range(10)] + + hash1 = hash_models(*models) + hash2 = hash_models(*models) + + assert hash1 == hash2 + + +class TestEdgeCases: + def test_calculate_hash_list_raises(self) -> None: + with pytest.raises(TypeError): + calculate_hash([1, 2, 3]) + + def test_calculate_hash_dict_raises(self) -> None: + with pytest.raises(TypeError): + calculate_hash({"key": "value"}) + + +class TestIdentityDigest: + def test_digest_repeats_for_one_identity(self) -> None: + assert identity_digest("folder", "take") == identity_digest("folder", "take") + + def test_identities_spelled_apart_digest_apart(self) -> None: + assert identity_digest("folder", "take") != identity_digest("folder_take") + + def test_the_order_the_parts_stand_in_settles_the_digest(self) -> None: + assert identity_digest("folder", "take") != identity_digest("take", "folder") + + def test_the_separator_a_name_carries_nowhere_joins_the_parts(self) -> None: + assert identity_digest("folder", "take") == calculate_hash(f"folder{IDENTITY_SEPARATOR}take") + + def test_parts_split_at_a_different_place_digest_apart(self) -> None: + """The separator stands between the parts, so where one ends and the next opens is read.""" + assert identity_digest("fol", "dertake") != identity_digest("folder", "take") + + def test_digest_takes_the_length_it_is_asked_for(self) -> None: + assert len(identity_digest("take", length=8)) == 8 + assert len(identity_digest("take")) == HASH_LENGTH + + def test_a_shorter_digest_opens_the_full_one(self) -> None: + assert identity_digest("take").startswith(identity_digest("take", length=8)) diff --git a/tests/unit/sampletones_shared/utils/test_serialization.py b/tests/unit/sampletones_shared/utils/test_serialization.py index aac19e9de..fbb495f1b 100644 --- a/tests/unit/sampletones_shared/utils/test_serialization.py +++ b/tests/unit/sampletones_shared/utils/test_serialization.py @@ -6,12 +6,8 @@ import pytest from sampletones_shared.utils.serialization import ( - HASH_LENGTH, - calculate_hash, deserialize_array, dump, - hash_model, - hash_models, load_binary, load_json, load_yaml, @@ -21,7 +17,6 @@ serialize_array, snake_to_camel, ) -from tests.suite.dummy import NestedModel, SimpleModel class TestDump: @@ -319,203 +314,6 @@ def test_serialize_different_dtypes(self) -> None: assert deserialized.dtype == dtype -class TestCalculateHash: - def test_hash_string(self) -> None: - hash1 = calculate_hash("test") - hash2 = calculate_hash("test".encode("utf-8")) - hash3 = calculate_hash(b"test") - hash4 = calculate_hash("different") - - assert hash1 == hash2 - assert hash1 == hash3 - assert hash1 != hash4 - assert isinstance(hash1, str) - assert len(hash1) == HASH_LENGTH - - def test_hash_integer(self) -> None: - hash1 = calculate_hash(42) - hash2 = calculate_hash(42) - hash3 = calculate_hash(1) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_float(self) -> None: - hash1 = calculate_hash(3.14) - hash2 = calculate_hash(3.14) - hash3 = calculate_hash(2.71) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_bytes(self) -> None: - hash1 = calculate_hash(b"same") - hash2 = calculate_hash(b"same") - hash3 = calculate_hash(b"different") - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_bytes_same_as_strings(self) -> None: - hash1 = calculate_hash(b"data") - hash2 = calculate_hash("data") - hash3 = calculate_hash("data".encode("utf-8")) - - assert hash1 == hash2 - assert hash1 == hash3 - - def test_hash_boolean(self) -> None: - hash1 = calculate_hash(True) - hash2 = calculate_hash(True) - hash3 = calculate_hash(False) - - assert hash1 == hash2 - assert hash1 != hash3 - assert isinstance(hash1, str) - - def test_hash_null_different_representations(self) -> None: - hash_false = calculate_hash(False) - hash_zero = calculate_hash(0) - hash_float_zero = calculate_hash(0.0) - hash_empty_string = calculate_hash("") - hash_null_bytes = calculate_hash(b"") - hash_none = calculate_hash(None) - - assert hash_false == hash_zero - assert hash_false == hash_float_zero - assert hash_false == hash_null_bytes - assert hash_false == hash_empty_string - assert hash_false != hash_none - - def test_hash_base_model(self) -> None: - model1 = SimpleModel(value=42, name="test") - model2 = SimpleModel(value=42, name="test") - - hash1 = calculate_hash(model1) - hash2 = calculate_hash(model2) - - assert hash1 == hash2 - - def test_hash_different_base_models(self) -> None: - model1 = SimpleModel(value=1, name="test") - model2 = SimpleModel(value=1, name="test") - model3 = SimpleModel(value=2, name="test") - - hash1 = calculate_hash(model1) - hash2 = calculate_hash(model2) - hash3 = calculate_hash(model3) - - assert hash1 == hash2 - assert hash1 != hash3 - - def test_hash_custom_length(self) -> None: - hash_16 = calculate_hash("test", length=16) - hash_64 = calculate_hash("test", length=64) - - assert hash_16 != hash_64 - assert len(hash_16) == 16 - assert len(hash_64) == 64 - assert hash_64.startswith(hash_16) - - def test_hash_zero_length_raises(self) -> None: - with pytest.raises(ValueError): - calculate_hash("test", length=0) - - def test_hash_excessive_length_raises(self) -> None: - with pytest.raises(ValueError): - calculate_hash("test", length=65) - - def test_hash_tuple(self) -> None: - hash1 = calculate_hash((1, 2, 3)) - hash2 = calculate_hash((1, 2, 3)) - - assert hash1 == hash2 - - def test_hash_frozenset(self) -> None: - hash1 = calculate_hash(frozenset([1, 2, 3])) - hash2 = calculate_hash(frozenset([1, 2, 3])) - hash3 = calculate_hash(frozenset([3, 2, 1])) - - assert hash1 == hash2 - assert hash1 == hash3 - assert isinstance(hash1, str) - - -class TestHashModels: - def test_hash_single_model(self) -> None: - model = SimpleModel(value=42, name="test") - hash1 = hash_model(model) - hash2 = hash_model(model) - - assert hash1 == hash2 - assert isinstance(hash1, str) - assert len(hash1) == HASH_LENGTH - - def test_hash_different_single_models(self) -> None: - model1 = SimpleModel(value=1, name="test1") - model2 = SimpleModel(value=2, name="test2") - - hash1 = hash_model(model1) - hash2 = hash_model(model2) - - assert hash1 != hash2 - - def test_hash_multiple_models(self) -> None: - model1 = SimpleModel(value=1, name="first") - model2 = SimpleModel(value=2, name="second") - - hash1 = hash_models(model1, model2) - hash2 = hash_models(model1, model2) - - assert hash1 == hash2 - - def test_hash_multiple_models_order_matters(self) -> None: - model1 = SimpleModel(value=1, name="first") - model2 = SimpleModel(value=2, name="second") - - hash_forward = hash_models(model1, model2) - hash_backward = hash_models(model2, model1) - - assert hash_forward != hash_backward - - def test_hash_single_vs_multiple(self) -> None: - model = SimpleModel(value=42, name="test") - - hash_single = hash_model(model) - hash_multiple = hash_models(model) - - assert hash_single == hash_multiple - - def test_hash_nested_model(self) -> None: - inner = SimpleModel(value=1, name="inner") - outer = NestedModel(simple=inner, items=[1, 2, 3]) - - hash1 = hash_model(outer) - hash2 = hash_model(outer) - - assert hash1 == hash2 - - def test_hash_models_custom_length(self) -> None: - model = SimpleModel(value=42, name="test") - - hash_16 = hash_model(model, length=16) - hash_64 = hash_model(model, length=64) - - assert len(hash_16) == 16 - assert len(hash_64) == 64 - - def test_hash_models_many(self) -> None: - models = [SimpleModel(value=i, name=f"model{i}") for i in range(10)] - - hash1 = hash_models(*models) - hash2 = hash_models(*models) - - assert hash1 == hash2 - - class TestSnakeToCamel: def test_snake_to_camel_basic(self) -> None: assert snake_to_camel("hello_world") == "HelloWorld" @@ -562,14 +360,6 @@ def test_dump_non_serializable_raises(self) -> None: with pytest.raises(TypeError): dump(object()) - def test_calculate_hash_list_raises(self) -> None: - with pytest.raises(TypeError): - calculate_hash([1, 2, 3]) - - def test_calculate_hash_dict_raises(self) -> None: - with pytest.raises(TypeError): - calculate_hash({"key": "value"}) - def test_save_json_nested_complex(self) -> None: data = {"level1": {"level2": {"level3": [1, 2, {"level4": "deep"}]}}} diff --git a/tests/unit/scripts/checks/test_rendered_literals.py b/tests/unit/scripts/checks/test_rendered_literals.py new file mode 100644 index 000000000..e38c0c0f0 --- /dev/null +++ b/tests/unit/scripts/checks/test_rendered_literals.py @@ -0,0 +1,120 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_shared.meta.source.modules import SourceModule +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.scripts import load_script +from tests.suite.source import parse_source + +check_rendered_literals = load_script("checks/rendered_literals.py") + +CASE_MODULE: Final[Path] = Path("tests/unit/test_card.py") + + +def locations(source: str) -> List[str]: + module = SourceModule(path=CASE_MODULE, tree=parse_source(source)) + return [finding.location for finding in check_rendered_literals.findings([module])] + + +def renderings(source: str) -> List[str]: + module = SourceModule(path=CASE_MODULE, tree=parse_source(source)) + return [finding.rendering for finding in check_rendered_literals.findings([module])] + + +class TestWhatIsReported(BaseTestSuite): + """A case rendering a value and holding the result against a written-out string. + + Which rendering it used is named in the report, so a reader meets the expression the finding is + about rather than hunting for it on the line. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + source: str + expected: str + + test_cases = ( + TestCase( + label="rendered_on_the_left", + source='assert str(view.path) == "/audio/kick.wav"\n', + expected="str()", + ), + TestCase( + label="rendered_on_the_right", + source='assert "/audio/kick.wav" == str(view.path)\n', + expected="str()", + ), + TestCase( + label="rendered_by_a_template", + source='assert f"{view.path}" == "/audio/kick.wav"\n', + expected="an f-string", + ), + TestCase( + label="rendered_inside_a_chain", + source='assert "a" == str(view.path) == other\n', + expected="str()", + ), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_is_reported_where_it_sits(self, test_case: TestCase) -> None: + assert locations(test_case.source) == [f"{CASE_MODULE}:1"] + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_rendering_it_used_is_named(self, test_case: TestCase) -> None: + assert renderings(test_case.source) == [test_case.expected] + + +class TestWhatIsLeftAlone(BaseTestSuite): + """A case comparing values, or rendering both sides, states no one platform's answer. + + A call the case makes is left alone whatever it returns, which is what keeps the check to the + one shape it is about rather than to every comparison holding a string. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + source: str + + test_cases = ( + TestCase(label="a_path_against_a_path", source='assert view.path == Path("/audio/kick.wav")\n'), + TestCase(label="both_sides_rendered", source="assert str(view.path) == str(expected)\n"), + TestCase(label="a_value_against_a_literal", source='assert view.name == "kick"\n'), + TestCase( + label="another_call_against_a_literal", + source='assert abbreviate_channel_names([PULSE1]) == "P"\n', + ), + TestCase(label="a_template_holding_no_value", source='assert f"kick" == "kick"\n'), + TestCase(label="a_fragment_read_out_of_a_rendering", source='assert "kick" in str(view.path)\n'), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_it_is_passed_over(self, test_case: TestCase) -> None: + assert locations(test_case.source) == [] + + +class TestMain: + def test_no_case_in_the_repository_holds_rendered_text_against_a_literal(self) -> None: + assert check_rendered_literals.main([]) == 0 + + def test_a_case_that_does_is_reported_where_it_sits( + self, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + cases = tmp_path / "cases" + cases.mkdir() + module = cases / "test_card.py" + module.write_text( + 'def test_it() -> None:\n assert str(view.path) == "/audio/kick.wav"\n', + encoding="utf-8", + ) + + exit_code = check_rendered_literals.main(["--tests", str(cases)]) + + assert exit_code == 1 + assert f"{module}:2" in capsys.readouterr().err