From a9e99fba419f35a45dc1841a0d68fb1c97573eda Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 00:21:52 +0200 Subject: [PATCH 001/130] Split: the architecture document's principles from their mechanism --- docs/development/architecture.md | 107 ++++------------- docs/development/bugs-and-todos.md | 28 +++++ docs/development/guidelines.md | 1 + docs/development/keyboard.md | 181 +++++++++++++++++++++++++++++ docs/development/palette.md | 41 +++++++ docs/development/playback.md | 4 +- docs/development/vocabularies.md | 94 +++++++++++++++ docs/index.md | 3 + 8 files changed, 371 insertions(+), 88 deletions(-) create mode 100644 docs/development/keyboard.md create mode 100644 docs/development/palette.md create mode 100644 docs/development/vocabularies.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 39fca4369..b9684f851 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`. --- @@ -65,11 +65,15 @@ A hook the panel consults for state rather than notifies of an event is read thr 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 thread that created the DearPyGui context is the only one that may build, configure, or destroy an item, so work reaching the interface from anywhere else arrives on that thread first. Two directions cross it. -**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. +**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. + +**A gesture that rebuilds widgets crosses through `on_render_thread`.** DearPyGui invokes a widget's callback on a thread of its own, so a panel that rebuilds itself straight from a gesture creates and drops widgets while the render thread walks them, and a callback 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 callback that reads a value or sets one on a standing widget runs where it is called; one that creates or deletes items goes through the helper. + +**Work that needs a drawn frame is scheduled through `FrameCallbackManager`.** 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` names the frame the work is picked up on, and is how a callback waits for one. ### 7. Construction flows from the composition root @@ -79,39 +83,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 +104,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); its one global handler is bound in `shell.py` once the DPG context exists. 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 | +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). -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. - -**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). --- @@ -215,6 +148,8 @@ They read the source as an AST through the shared layer in `sampletones_shared/m **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. +**A contract and the code that meets it change together.** A change that alters a contract this document states lands with the document edit that states it, and a deviation it knowingly leaves behind lands with a ledger entry — `guidelines.md` § Documents holds the general rule. Every branch therefore leaves an updated contract, a recorded deviation, or both, which is what a later reader has to go on. + --- ## Layer Reference @@ -230,7 +165,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m - 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:** @@ -327,7 +262,7 @@ There are two coordinator kinds: - 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/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 04a3613e5..951ab6d6f 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -116,6 +116,27 @@ 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 is wired in one constructor. `coordinators/tabs/main.py::MainTabCoordinator.__init__` + builds the tab's panels, logic objects and services and then wires them hook by hook, which makes + it by far the longest body in the coordinator layer and leaves a reader tracing a panel's hook to + what answers it by eye. The wiring divides by collaborator — a method per panel, stating what that + panel offers and what answers each hook — the way a tab coordinator already divides into a + subpackage once it holds several concerns. +* Several calls reach the Main tab's panels through wrappers of the coordinator's own, where the + Coordinators contract sanctions a wrapper only for an intent-level guard a contract requires. A + wrapper that renames a call or reorders its arguments is work the logic object behind the call + should be doing. +* `logic/main/explorer.py::ExplorerLogic` forwards every member to the `ExplorerManager` it + constructs, declaring no state and no rule of its own. Either the logic object takes a job — the + explorer's own state machine — or its consumers hold the manager. +* `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. The recovery it makes is real: a queued + callback can remove an item underneath the lookup. Naming the exception DearPyGui raises for an + absent item is what closes it, and the helper sits under every item lookup in the interface. +* `MainTabCoordinator` is constructed by no test. Every fixture in + `tests/unit/sampletones_application/coordinators/tabs/test_main.py` builds the object through + `__new__` and populates its privates by hand, so the wiring the application actually runs is + exercised nowhere: a hook left unset or a call routed to the wrong object passes the suite. * 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 +146,13 @@ again. ## Bugs +* The Main tab's browser stands as it was after a conversion. `MainTabCoordinator.refresh_browser()` + is reached by no caller, while `Application._refresh_reconstruction_trees` refreshes the + Reconstruction and Sequencer tabs, so a reconstruction just written appears in the two browsers it + was not started from. +* A library directory chosen from the explorer's context menu lasts only for the session. The Browse + button's path stores the choice through `session_manager.set_library_path`; the menu's path reaches + `change_library_directory` on the panel alone, so the next start opens on the previous directory. * 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..8d9713b89 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -82,6 +82,7 @@ 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. 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. ## Tests diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md new file mode 100644 index 000000000..fd245d41c --- /dev/null +++ b/docs/development/keyboard.md @@ -0,0 +1,181 @@ +# 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, 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, 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 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 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` | + +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 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 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. + +The router is constructed at the composition root and injected into every consumer (architecture +principle 7); its one global handler is bound in `shell.py` once the DPG context exists. + +--- + +## 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 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 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 analogue 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..266d2796e --- /dev/null +++ b/docs/development/palette.md @@ -0,0 +1,41 @@ +# 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 + +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. + +## 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/vocabularies.md b/docs/development/vocabularies.md new file mode 100644 index 000000000..95c7797b0 --- /dev/null +++ b/docs/development/vocabularies.md @@ -0,0 +1,94 @@ +# 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 + +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 +from the joiner, so a fragment reads as the segment it names and either end composes onto it. + +### 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/index.md b/docs/index.md index 0052d4d1a..f6443d609 100644 --- a/docs/index.md +++ b/docs/index.md @@ -61,6 +61,9 @@ 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. - [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. From dbd8b8b04a1fb44d1dbd419d8fe859e5e317f42b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 25 Aug 2026 16:22:59 +0200 Subject: [PATCH 002/130] Moved: the choice to bend from the configuration onto the stem --- docs/concepts/reconstruction.md | 21 ++- .../logic/main/converter.py | 8 +- .../logic/main/stems.py | 7 +- src/sampletones_core/compatibility/fields.py | 1 + .../compatibility/reconstruction/v2_2.py | 8 +- src/sampletones_core/configs/generation.py | 6 +- src/sampletones_core/constants/algorithm.py | 1 - src/sampletones_core/constants/enums.py | 20 ++- .../reconstruction/stems/data.py | 4 +- .../reconstructor/reconstructor.py | 11 +- .../reconstructor/refinement/refiner.py | 41 ++++- .../reconstructor/stems/configs/config.py | 10 +- .../reconstructor/stems/configs/entry.py | 33 +++- .../scripts/reconstruction.py | 4 +- .../compression/planes/separate.py | 3 +- .../specification/channels.py | 10 +- .../specification/compression.py | 3 +- tests/integration/assets/reconstruction.py | 7 +- .../reconstruction/test_conversion_jobs.py | 16 +- .../test_conversion_progress.py | 4 +- .../reconstruction/test_pitch_refinement.py | 75 +++++---- .../test_stems_reconstruction.py | 19 ++- .../services/test_conversion.py | 4 +- tests/suite/stems.py | 5 +- .../logic/main/test_converter.py | 4 +- .../logic/reconstruction/test_data.py | 17 +- .../logic/reconstruction/test_manager.py | 6 +- .../reconstruction/test_reconstruction.py | 6 +- .../compatibility/reconstruction/test_v2_2.py | 3 +- .../converter/plan/test_plans.py | 7 +- .../converter/test_conversion.py | 6 +- .../converter/test_converter.py | 4 +- .../reconstruction/test_reconstruction.py | 13 +- .../reconstruction/test_stems_filter.py | 7 +- .../reconstruction/test_stems_removal.py | 13 +- .../reconstructor/refinement/test_refiner.py | 159 ++++++++++++++++++ .../reconstructor/stems/test_config.py | 31 ++-- .../reconstructor/stems/test_equivalence.py | 7 +- .../reconstructor/stems/test_frame.py | 7 +- .../reconstructor/test_reconstructor.py | 7 +- 40 files changed, 462 insertions(+), 156 deletions(-) create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 52945c0dc..5b7b3633c 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 towards 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 @@ -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/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index f061adac4..4e292b155 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -32,7 +32,7 @@ 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.constants.enums import ChannelName, HierarchyMode, bending_channels from sampletones_core.parallelization import ETAEstimator, TaskProgress from sampletones_core.reconstructions.converter import ( ConversionPlan, @@ -495,7 +495,11 @@ def _stems_setup(self, config: Config) -> ConversionSetup: return ConversionSetup( sources=(), - stems=StemsConfig.single_entry(enabled, channel_cap=self._effective_channel_cap), + stems=StemsConfig.single_entry( + enabled, + bending_channels(enabled), + channel_cap=self._effective_channel_cap, + ), ) @property diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py index 236a77ff4..114d928fe 100644 --- a/src/sampletones_application/logic/main/stems.py +++ b/src/sampletones_application/logic/main/stems.py @@ -2,7 +2,7 @@ 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.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 @@ -232,7 +232,10 @@ def derive_conversion_setup( 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)] + entries = [ + StemEntry(id=stem_id, channels=channels, bends=bending_channels(channels)) + for stem_id, (_source, channels) in enumerate(ordered) + ] return ConversionSetup( sources=tuple(source.path for source, _channels in ordered), stems=StemsConfig( diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index c5223a00e..98a1ff0e1 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -13,6 +13,7 @@ STEMS_DATA: Final = "stems_data" ENTRIES: Final = "entries" ID: Final = "id" +BENDS: Final = "bends" 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..b45aa4294 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, @@ -53,7 +54,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 +70,7 @@ def _default_stems_data(data: SerializedData) -> SerializedData: ] return { CONFIG: { - ENTRIES: [{ID: 0, CHANNELS: channels}], + ENTRIES: [{ID: 0, CHANNELS: channels, BENDS: []}], HIERARCHY: {LEVELS: [[0]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)}, CHANNEL_CAP: DEFAULT_STEMS_CHANNEL_CAP, }, @@ -138,7 +139,8 @@ def update(data: SerializedData) -> SerializedData: ``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. + single-entry stems record every reconstruction states, down to the channels each + stem carries towards its own recording. """ updated = dict(data) updated = _renamed_stream_keys(updated) diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py index 1c9a7ba88..bdc169019 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -12,7 +12,6 @@ MAX_DRIVE, PERCEPTUAL_EXPONENT, PHASE_ALIGNER, - REFINE_PITCH, REFINEMENT_CHANGE_WEIGHT, REFINEMENT_CONFIDENCE, REFINEMENT_WINDOW, @@ -80,8 +79,10 @@ class RefinementConfig(DataModel): actually stands and bends the note it landed on towards 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 +90,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) 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..92e451715 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 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,14 @@ class CQTWindow(StrEnum): ] +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/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..8ea9e5a31 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -7,7 +7,7 @@ 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 from sampletones_core.fft import FragmentedAudio, Window from sampletones_core.generators import ( MIXER_LEVELS, @@ -114,7 +114,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.config.generation.channels) + stems_config = StemsConfig.single_entry(channels, bending_channels(channels)) return self.reconstruct([path], stems_config) def reconstruct( @@ -160,7 +161,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 +171,9 @@ def reconstruct( stems_data=self._build_stems_data(stems_config, assignment.stem_ids), ) - def _refiner(self) -> PitchRefiner: + def _refiner(self, stems_config: StemsConfig) -> PitchRefiner: """The pass that carries each chosen note towards the fundamental the recording sounds.""" - return PitchRefiner(config=self.config, channels=self.channels) + 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..a5c61afe1 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 @@ -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, @@ -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 towards its own recording.""" + entry = self.stems.entries_by_id.get(stem_id) + if entry is None: + return frozenset() + + return entry.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 @@ -138,18 +160,19 @@ def _proposal( return generator.bend_towards(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/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py index fcaa1d30d..65dc790a1 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -34,21 +34,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, 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 towards its own recording.""" + return frozenset(channel for entry in self.entries for channel in entry.bends) + @cached_property def entries_by_id(self) -> Dict[int, StemEntry]: """The entries keyed by the id the hierarchy names them with.""" diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py index 822ab5b65..578bb58a3 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -1,9 +1,9 @@ from functools import cached_property -from typing import FrozenSet, List +from typing import FrozenSet, List, Self -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName from sampletones_core.data import DataModel @@ -18,8 +18,35 @@ class StemEntry(DataModel): ..., description="The channels the stem may occupy", ) + bends: List[ChannelName] = Field( + ..., + description="The channels whose notes this stem carries to the divider its recording sounds", + ) @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) + + @cached_property + def bend_set(self) -> FrozenSet[ChannelName]: + """The channels this stem 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 stem occupies and whose hardware loads a divider. + + Raises: + ValueError: If a bent channel lies outside the stem'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"stem {self.id} bends channels it does not occupy: {sorted(unheld)}") + + toneless = self.bend_set - TONE_CHANNELS + if toneless: + raise ValueError(f"stem {self.id} bends channels that 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..6331f8c40 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -4,6 +4,7 @@ from tqdm import tqdm from sampletones_core.configs import Config +from sampletones_core.constants.enums import bending_channels from sampletones_core.library import InstructionLibrary from sampletones_core.parallelization import TaskProgress, TaskStatus from sampletones_core.reconstructions import Reconstructor @@ -140,4 +141,5 @@ def on_error(_exception: Exception) -> None: 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)) + channels = list(config.generation.channels) + return StemsConfig.single_entry(channels, bending_channels(channels)) 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/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/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 0ec1ed42d..58976d1ed 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -7,7 +7,7 @@ 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 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 @@ -57,7 +57,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, 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, diff --git a/tests/integration/reconstruction/test_conversion_jobs.py b/tests/integration/reconstruction/test_conversion_jobs.py index c004b35e7..57feeee11 100644 --- a/tests/integration/reconstruction/test_conversion_jobs.py +++ b/tests/integration/reconstruction/test_conversion_jobs.py @@ -3,7 +3,7 @@ import pytest 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.reconstructions import Reconstruction, Reconstructor from sampletones_core.reconstructions.converter import ( DirectoryConversion, @@ -54,7 +54,8 @@ 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)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.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)) @@ -74,7 +75,7 @@ def test_each_recording_is_written_on_its_own(self, tmp_path: Path) -> None: 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) @@ -109,7 +110,8 @@ def test_the_reading_climbs_from_nothing_to_the_whole_run(self, tmp_path: Path) config = _writing_to(Config(), tmp_path / "out") reconstructor = Reconstructor(config, library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.channels) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() @@ -124,7 +126,8 @@ def test_the_matching_stage_counts_the_frames_the_recording_holds(self, tmp_path config = _writing_to(Config(), tmp_path / "out") reconstructor = Reconstructor(config, library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.channels) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) reporter: RecordingReporter[ReconstructionProgress] = RecordingReporter() @@ -137,7 +140,8 @@ def test_a_withdrawn_job_unwinds_and_writes_nothing(self, tmp_path: Path) -> Non config = _writing_to(Config(), tmp_path / "out") reconstructor = Reconstructor(config, library=build_mini_library(config)) source = write_three_stem_recordings(config, tmp_path)[0] - stems = StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.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..056f5ec8f 100644 --- a/tests/integration/reconstruction/test_conversion_progress.py +++ b/tests/integration/reconstruction/test_conversion_progress.py @@ -7,6 +7,7 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import 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 +41,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(config.generation.channels) + stems = StemsConfig.single_entry(channels, bending_channels(channels)) plan = GroupConversion(sources=(source,), stems=stems) recorder = ProgressRecorder() diff --git a/tests/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py index 22c1fb87b..f0201016c 100644 --- a/tests/integration/reconstruction/test_pitch_refinement.py +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -7,7 +7,7 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config -from sampletones_core.configs.generation import GenerationConfig, RefinementConfig +from sampletones_core.configs.generation import GenerationConfig from sampletones_core.constants.enums import ChannelName, FeatureKey from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.fft import Window @@ -22,6 +22,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 +34,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(generation=GenerationConfig(channels=PULSE_ONLY)) def _library(config: Config) -> InstructionLibrary: @@ -87,8 +90,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, library=_library(config)) + reconstruction = reconstructor.reconstruct( + [audio_path], + StemsConfig.single_entry(PULSE_ONLY, bends), + ) assert reconstruction is not None return reconstruction @@ -110,13 +118,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: @@ -128,38 +131,38 @@ class TestAConversionLandsOnTheNoteTheSourceSounds: def test_a_detuned_tone_comes_back_bent_towards_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 +172,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 +195,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 +228,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..b62e4bed5 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -8,7 +8,7 @@ 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 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 @@ -34,6 +34,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(config.generation.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 +47,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])), ], hierarchy=StemsHierarchy( levels=[[0], [1]], @@ -490,7 +496,7 @@ def test_a_cap_of_one_leaves_every_frame_to_one_channel(self, tmp_path: Path) -> 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 +545,10 @@ 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, 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, diff --git a/tests/integration/sampletones_application/services/test_conversion.py b/tests/integration/sampletones_application/services/test_conversion.py index fb03df1b6..570d3025d 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -4,13 +4,15 @@ from sampletones_application.services.conversion.service import ConversionService from sampletones_core.configs import Config +from sampletones_core.constants.enums import 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(config.generation.channels) + return StemsConfig.single_entry(channels, bending_channels(channels)) class TestConversionServiceArgumentRouting: 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/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index f01e9aa72..4c0046987 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -20,7 +20,7 @@ ConverterViewModel, ) 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.reconstructions.converter import DirectoryConversion, GroupConversion from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig from sampletones_core.reconstructions.stage import ReconstructionStage @@ -505,7 +505,7 @@ def test_the_setup_covers_every_enabled_channel(self, converter_logic: Converter plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - assert plan.stems == StemsConfig.single_entry(channels, channel_cap=len(channels)) + assert plan.stems == StemsConfig.single_entry(channels, bending_channels(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: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 146b98806..d37b90db4 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 @@ -289,8 +289,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) @@ -342,8 +342,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) @@ -419,7 +419,12 @@ 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, 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..bbff28fe2 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -8,7 +8,7 @@ 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 @@ -24,8 +24,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index a5e1a0065..30e60f9bb 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 @@ -921,8 +921,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]]), ) 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..f79de1090 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -2,6 +2,7 @@ from sampletones_core.compatibility.fields import ( AUDIO_FILEPATH, + BENDS, CHANNEL_NAME, CHANNELS, GENERATOR_NAME, @@ -88,7 +89,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, 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/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index 57fe42d78..efe9c09fd 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -4,7 +4,7 @@ import pytest 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.reconstructions.converter.paths.utils import get_output_path, group_output_path from sampletones_core.reconstructions.converter.plan.directory import DirectoryConversion from sampletones_core.reconstructions.converter.plan.group import GroupConversion @@ -19,7 +19,8 @@ def config() -> Config: @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.channels) + return StemsConfig.single_entry(channels, bending_channels(channels)) def _write_audio_files(directory: Path, names: List[str]) -> List[Path]: @@ -70,7 +71,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) diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py index cd1045770..967818903 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -1,9 +1,11 @@ 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 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 +13,8 @@ from sampletones_shared.exceptions import UnsupportedAudioFormatError from sampletones_shared.utils.progress import silent_reporter +CHANNELS: Final[List[ChannelName]] = list(Config().generation.channels) + @pytest.fixture def mock_reconstructor() -> MagicMock: @@ -20,7 +24,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..db280a4c6 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -4,6 +4,7 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import bending_channels from sampletones_core.reconstructions.converter import ( DirectoryConversion, GroupConversion, @@ -23,7 +24,8 @@ def config() -> Config: @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - return StemsConfig.single_entry(list(config.generation.channels)) + channels = list(config.generation.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..f57e24d05 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -9,7 +9,7 @@ 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 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 @@ -87,7 +87,7 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]))], hierarchy=StemsHierarchy(levels=[[0]], mode=HierarchyMode.STRICT), channel_cap=1, ) @@ -124,8 +124,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, @@ -150,6 +150,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 +182,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy(levels=[[0, 1]], mode=HierarchyMode.STRICT), channel_cap=1, 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..30c88c2a3 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 ( @@ -23,7 +23,10 @@ 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, 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..1aa50e38b 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 @@ -41,9 +41,13 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry( + id=STEM_B, + channels=[ChannelName.PULSE1, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.NOISE]), + ), + StemEntry(id=STEM_C, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy( levels=[[STEM_A], [STEM_B, STEM_C]], @@ -304,6 +308,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/refinement/test_refiner.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py new file mode 100644 index 000000000..bc195c049 --- /dev/null +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py @@ -0,0 +1,159 @@ +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 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(generation=GenerationConfig(channels=TONES)) + + +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, 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, channels=TONES, bends=[]), + StemEntry(id=STEM_B, 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, 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..b86a42d5b 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -2,7 +2,7 @@ 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 @@ -11,8 +11,8 @@ def _stems_config() -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=0, channels=[ChannelName.PULSE1]), - StemEntry(id=1, channels=[ChannelName.NOISE]), + StemEntry(id=0, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])), ], hierarchy=StemsHierarchy(levels=[[0], [1]], mode=HierarchyMode.STRICT), channel_cap=DEFAULT_STEMS_CHANNEL_CAP, @@ -31,8 +31,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=0, channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])), ], hierarchy=StemsHierarchy(levels=[[0]]), ) @@ -55,7 +55,7 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]))], hierarchy=StemsHierarchy(levels=[[0], [0]]), ) @@ -63,8 +63,8 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_channels([ChannelName.NOISE])), ], hierarchy=StemsHierarchy(levels=[[0]]), ) @@ -72,7 +72,7 @@ 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, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]))], hierarchy=StemsHierarchy(levels=[[0], [5]]), ) @@ -93,7 +93,13 @@ 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, + 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 +109,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 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..64e1a46ba 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 @@ -30,7 +30,10 @@ 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, 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, ) 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..d26d989a7 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,7 @@ 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 ChannelName, HierarchyMode, bending_channels from sampletones_core.fft import Fragment from sampletones_core.fft.features import FeatureExtractor from sampletones_core.generators import GeneratorUnion @@ -29,7 +29,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, channels=channels, bends=bending_channels(channels)) + for stem_id, channels in entries.items() + ], hierarchy=StemsHierarchy(levels=levels, mode=mode), channel_cap=channel_cap, ) diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index 3c61c6e16..fdb02d27b 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -7,6 +7,7 @@ import pytest from sampletones_core.configs import Config +from sampletones_core.constants.enums import bending_channels from sampletones_core.fft import Fragment, Window from sampletones_core.generators import MIXER_LEVELS from sampletones_core.library import InstructionLibraryData @@ -97,7 +98,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(config.generation.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 +108,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(config.generation.channels) + return StemsConfig.single_entry(channels, bending_channels(channels)) def _total_mixer(reconstructor: Reconstructor) -> float: From c4855d834befebb4ae8916a1db13ad85591d45ee Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 00:51:08 +0200 Subject: [PATCH 003/130] Gave: the reconstructor and the output paths the channels a run hands out --- docs/api/index.md | 7 +-- docs/guide/command-line.md | 3 ++ src/sampletones/__main__.py | 35 ++++++++++++++- .../logic/main/converter.py | 5 ++- src/sampletones_core/calibration/runner.py | 12 +++-- src/sampletones_core/configs/config.py | 7 +-- src/sampletones_core/constants/enums.py | 11 ++++- .../reconstructions/converter/converter.py | 10 ++++- .../reconstructions/converter/paths/fields.py | 16 ++++--- .../reconstructions/converter/paths/utils.py | 17 +++++-- .../converter/plan/directory.py | 2 +- .../reconstructions/converter/plan/group.py | 2 +- .../reconstructor/reconstructor.py | 24 ++++++---- .../stems/assignment/validation.py | 6 +-- .../scripts/reconstruction.py | 30 ++++++++----- tests/integration/assets/reconstruction.py | 26 ++++++++--- .../reconstruction/test_conversion_jobs.py | 29 +++++++----- .../test_conversion_progress.py | 7 ++- .../reconstruction/test_decoding.py | 8 +++- .../reconstruction/test_pitch_refinement.py | 10 +++-- .../test_stems_reconstruction.py | 38 +++++++++------- .../services/conftest.py | 7 ++- .../services/test_conversion.py | 7 ++- tests/suite/conversion.py | 13 +++++- tests/suite/performance.py | 8 +++- tests/suite/player.py | 7 ++- tests/suite/sequencer.py | 7 ++- .../logic/main/test_converter.py | 3 +- .../ui/elements/tree/test_detail_items.py | 3 +- .../formats/bitphase/test_project_builder.py | 7 ++- .../formats/famitracker/conftest.py | 7 ++- .../performance/test_voice.py | 8 +++- .../converter/paths/test_fields.py | 44 ++++++++++++------- .../converter/paths/test_utils.py | 9 ++-- .../converter/plan/test_plans.py | 25 ++++++++--- .../converter/test_conversion.py | 8 +++- .../converter/test_converter.py | 7 ++- .../reconstruction/test_reconstruction.py | 19 +++++--- .../reconstructions/reconstructor/conftest.py | 7 ++- .../reconstructor/refinement/test_refiner.py | 2 +- .../reconstructor/stems/test_frame.py | 9 +++- .../reconstructor/test_reconstructor.py | 15 ++++--- .../structures/tree/test_factory.py | 3 +- .../structures/tree/test_node.py | 4 +- 44 files changed, 370 insertions(+), 164 deletions(-) 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/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/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/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 4e292b155..1fca10519 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -420,7 +420,7 @@ def _handle_library_progress(self, progress: TaskProgress) -> None: def _assign_paths(self, input_path: Path, config: Config) -> bool: try: - self._output_path = get_output_path(config, input_path) + self._output_path = get_output_path(config, input_path, frozenset(config.generation.channels)) self._input_path = input_path self._is_file = input_path.is_file() except FileNotFoundError as exception: @@ -543,7 +543,8 @@ def _update_stems_output_path(self) -> None: sources = self._source_paths if sources: - self._output_path = group_output_path(self._config_manager.config, sources) + config = self._config_manager.config + self._output_path = group_output_path(config, sources, frozenset(config.generation.channels)) def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: """The gathered recordings as the panel reads them, each stating where it stands. 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/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/constants/enums.py b/src/sampletones_core/constants/enums.py index 92e451715..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, FrozenSet, List, Literal +from typing import AbstractSet, Dict, Final, FrozenSet, List, Literal class GeneratorName(StrEnum): @@ -121,6 +121,15 @@ 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. 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/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index e284dd05a..79452e7b1 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,6 +14,7 @@ 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 @@ -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..2956f4285 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, List, Tuple from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) @@ -28,9 +29,15 @@ def get_relative_path( 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) + """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. + """ + config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) output_directory = to_path(config.general.reconstructions_directory) / config_directory if input_path.is_dir(): return output_directory / input_path.name @@ -47,6 +54,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,10 +63,13 @@ 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) + config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) output_directory = to_path(config.general.reconstructions_directory) / config_directory return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) 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/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 8ea9e5a31..4d77ab61b 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, bending_channels +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,7 @@ def __call__(self, path: Pathlike) -> Optional[Reconstruction]: Raises: TypeError: If ``path`` is not a string or ``Path``. """ - channels = 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) diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py index 597f307d4..80c09b45d 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 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/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py index 6331f8c40..c61d73725 100644 --- a/src/sampletones_core/scripts/reconstruction.py +++ b/src/sampletones_core/scripts/reconstruction.py @@ -1,10 +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 bending_channels +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 @@ -12,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 @@ -26,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") @@ -41,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") @@ -52,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() @@ -62,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}") @@ -118,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, ) @@ -139,7 +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.""" - channels = list(config.generation.channels) - return StemsConfig.single_entry(channels, bending_channels(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/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 58976d1ed..2b6703325 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, bending_channels +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 @@ -70,8 +76,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( @@ -127,6 +133,7 @@ def reconstruct_sample( audio: np.ndarray, config: Config, library: InstructionLibrary, + channels: FrozenSet[ChannelName], *, tmp_dir: Pathlike, name: str, @@ -134,7 +141,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") @@ -152,7 +159,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)}") @@ -188,7 +202,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 57feeee11..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, bending_channels +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,9 @@ 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] - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) stems = StemsConfig.single_entry(channels, bending_channels(channels)) jobs = GroupConversion(sources=(source,), stems=stems).jobs(config) @@ -71,7 +76,7 @@ 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) @@ -97,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() @@ -108,9 +113,9 @@ 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] - channels = 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() @@ -124,9 +129,9 @@ 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] - channels = 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() @@ -138,9 +143,9 @@ 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] - channels = 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 056f5ec8f..ee58841ce 100644 --- a/tests/integration/reconstruction/test_conversion_progress.py +++ b/tests/integration/reconstruction/test_conversion_progress.py @@ -7,7 +7,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import bending_channels +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 @@ -41,7 +44,7 @@ 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 - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) stems = StemsConfig.single_entry(channels, bending_channels(channels)) plan = GroupConversion(sources=(source,), stems=stems) 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 f0201016c..8e58fb972 100644 --- a/tests/integration/reconstruction/test_pitch_refinement.py +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -8,7 +8,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, FeatureKey +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 @@ -39,7 +43,7 @@ def _config() -> Config: - return Config(generation=GenerationConfig(channels=PULSE_ONLY)) + return Config() def _library(config: Config) -> InstructionLibrary: @@ -92,7 +96,7 @@ def _noise_path(path: Path, config: Config) -> 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, library=_library(config)) + reconstructor = Reconstructor(config, frozenset(PULSE_ONLY), library=_library(config)) reconstruction = reconstructor.reconstruct( [audio_path], StemsConfig.single_entry(PULSE_ONLY, bends), diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index b62e4bed5..649089b0a 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -8,7 +8,12 @@ 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, bending_channels +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 @@ -20,6 +25,7 @@ STEM_B_ID, STEM_C_ID, STEM_RECORDING_DURATION_SECONDS, + THREE_STEM_CHANNELS, build_mini_library, three_stem_config, three_stem_reconstruction_config, @@ -36,7 +42,7 @@ 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(config.generation.channels) + channels = list(DEFAULT_CHANNELS) return StemsConfig.single_entry(channels, bending_channels(channels), channel_cap=channel_cap) @@ -62,7 +68,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) @@ -105,7 +111,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( @@ -121,7 +127,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) @@ -155,7 +161,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) @@ -179,7 +185,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) @@ -204,7 +210,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) @@ -288,7 +294,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 @@ -385,7 +391,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) @@ -438,7 +444,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) @@ -447,7 +453,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].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} @@ -461,7 +467,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 @@ -491,7 +497,7 @@ 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( @@ -559,10 +565,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 570d3025d..007993364 100644 --- a/tests/integration/sampletones_application/services/test_conversion.py +++ b/tests/integration/sampletones_application/services/test_conversion.py @@ -4,14 +4,17 @@ from sampletones_application.services.conversion.service import ConversionService from sampletones_core.configs import Config -from sampletones_core.constants.enums import bending_channels +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: - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) return StemsConfig.single_entry(channels, bending_channels(channels)) 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/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..4f2ca890a 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), ) diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 4c0046987..92b53d6c7 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -169,7 +169,8 @@ 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 + plan = converter_logic._conversion_plan(config, path) + return plan.jobs(config)[0].output_path @staticmethod def _standing(target: Path) -> None: 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_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/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..a3d71b136 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,6 +4,7 @@ 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, @@ -14,6 +15,8 @@ ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +CHANNELS = frozenset(DEFAULT_CHANNELS) + @pytest.fixture(scope="module") def config() -> Config: @@ -51,7 +54,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 +62,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 +72,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: 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 efe9c09fd..af1e1d643 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py +++ b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py @@ -4,7 +4,11 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, bending_channels +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + ChannelName, + bending_channels, +) from sampletones_core.reconstructions.converter.paths.utils import get_output_path, group_output_path from sampletones_core.reconstructions.converter.plan.directory import DirectoryConversion from sampletones_core.reconstructions.converter.plan.group import GroupConversion @@ -17,9 +21,12 @@ def config() -> Config: return Config() +CHANNELS = frozenset(DEFAULT_CHANNELS) + + @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) return StemsConfig.single_entry(channels, bending_channels(channels)) @@ -48,7 +55,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, @@ -63,7 +70,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, @@ -100,7 +107,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) @@ -195,8 +202,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 967818903..3d666de16 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_conversion.py @@ -5,7 +5,11 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import ChannelName, bending_channels +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 @@ -13,7 +17,7 @@ from sampletones_shared.exceptions import UnsupportedAudioFormatError from sampletones_shared.utils.progress import silent_reporter -CHANNELS: Final[List[ChannelName]] = list(Config().generation.channels) +CHANNELS: Final[List[ChannelName]] = list(DEFAULT_CHANNELS) @pytest.fixture diff --git a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py index db280a4c6..225ab5fd4 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/test_converter.py +++ b/tests/unit/sampletones_core/reconstructions/converter/test_converter.py @@ -4,7 +4,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import bending_channels +from sampletones_core.constants.enums import ( + DEFAULT_CHANNELS, + bending_channels, +) from sampletones_core.reconstructions.converter import ( DirectoryConversion, GroupConversion, @@ -24,7 +27,7 @@ def config() -> Config: @pytest.fixture(scope="module") def stems(config: Config) -> StemsConfig: - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) return StemsConfig.single_entry(channels, bending_channels(channels)) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index f57e24d05..fb31ced4b 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, bending_channels +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 @@ -64,7 +70,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}, ), ) @@ -361,7 +367,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].channels + ] config_metadata = data["config"].get("metadata") if isinstance(config_metadata, dict): config_metadata["reconstruction_data_version"] = "2.1" @@ -396,8 +404,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].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" @@ -408,7 +417,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].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/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 index bc195c049..0392c9144 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py @@ -31,7 +31,7 @@ @pytest.fixture(scope="module") def config() -> Config: - return Config(generation=GenerationConfig(channels=TONES)) + return Config() def _stems(*entries: StemEntry) -> StemsConfig: 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 d26d989a7..1269712f3 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, bending_channels +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 @@ -65,7 +70,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/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py index fdb02d27b..478c3ad82 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py @@ -7,7 +7,10 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import bending_channels +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 @@ -21,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: @@ -31,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( @@ -52,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: @@ -98,7 +101,7 @@ 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) - channels = list(config.generation.channels) + 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 @@ -108,7 +111,7 @@ def test_a_capped_setup_anchors_to_what_one_frame_reaches( def _full_setup(config: Config) -> StemsConfig: - channels = list(config.generation.channels) + channels = list(DEFAULT_CHANNELS) return StemsConfig.single_entry(channels, bending_channels(channels)) 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: From 0487cd88d727c11aa9c488651d247fe64660bbb1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 00:55:43 +0200 Subject: [PATCH 004/130] Added: the batch conversion writing one reconstruction per gathered recording --- .../reconstructions/converter/__init__.py | 10 +- .../converter/paths/__init__.py | 4 + .../reconstructions/converter/paths/utils.py | 31 +++- .../converter/plan/__init__.py | 3 + .../reconstructions/converter/plan/batch.py | 79 ++++++++++ .../converter/plan/test_plans.py | 147 +++++++++++++++++- 6 files changed, 267 insertions(+), 7 deletions(-) create mode 100644 src/sampletones_core/reconstructions/converter/plan/batch.py diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py index d40870f70..5888e4f1e 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -10,9 +10,17 @@ group_output_path, top_level_audio_files, ) -from .plan import ConversionPlan, DirectoryConversion, GroupConversion +from .plan import ( + BatchConversion, + BatchEntry, + ConversionPlan, + DirectoryConversion, + GroupConversion, +) __all__ = [ + "BatchConversion", + "BatchEntry", "ConfigDirectoryFields", "ConversionJob", "ConversionPlan", diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 4cb6f7fb1..973210975 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -2,20 +2,24 @@ 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, + holds_audio_files, top_level_audio_files, ) __all__ = [ "ConfigDirectoryFields", + "config_directory_path", "filter_files", "get_audio_files", "get_output_path", "get_relative_path", "group_output_path", + "holds_audio_files", "top_level_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 2956f4285..291d1508b 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -26,6 +26,19 @@ 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, @@ -37,8 +50,7 @@ def get_output_path( ``channels`` names what the run hands out, which the configuration's own directory is named after alongside the settings that shaped the library. """ - config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) - output_directory = to_path(config.general.reconstructions_directory) / config_directory + output_directory = config_directory_path(config, channels) if input_path.is_dir(): return output_directory / input_path.name @@ -69,8 +81,7 @@ def group_output_path( Raises: ValueError: If ``sources`` is empty. """ - config_directory = ConfigDirectoryFields.generate_config_directory_name(config, channels) - 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()) @@ -86,6 +97,18 @@ def get_audio_files( return audio_files +def holds_audio_files( + input_directory: Path, + extensions: Tuple[str, ...] = EXT_FILES_AUDIO, +) -> bool: + """Whether a batch of this folder would find anything to convert. + + A batch reaches every recording below the folder, so the walk goes as deep and stops at the + first one it meets, which is what makes the answer cheap enough for a gesture to ask for it. + """ + return any(path.is_file() and path.suffix.lower() in extensions for path in input_directory.rglob("*")) + + def top_level_audio_files( input_directory: Path, extensions: Tuple[str, ...] = EXT_FILES_AUDIO, 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/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py b/tests/unit/sampletones_core/reconstructions/converter/plan/test_plans.py index af1e1d643..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,5 +1,5 @@ from pathlib import Path -from typing import List +from typing import List, Optional import pytest @@ -9,7 +9,12 @@ ChannelName, bending_channels, ) -from sampletones_core.reconstructions.converter.paths.utils import get_output_path, group_output_path +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 @@ -135,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() @@ -182,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, From 42a69d6e694336b4fba8a6e9b9e3715a5102ccaa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 01:20:29 +0200 Subject: [PATCH 005/130] Gave: a stem entry the settings its recording is converted with --- docs/concepts/stems.md | 10 ++- .../logic/main/stems.py | 3 +- src/sampletones_core/compatibility/fields.py | 1 + .../compatibility/reconstruction/v2_2.py | 8 +- .../reconstructor/refinement/refiner.py | 2 +- .../reconstructor/stems/assignment/session.py | 2 +- .../stems/assignment/validation.py | 2 +- .../reconstructor/stems/configs/config.py | 7 +- .../reconstructor/stems/configs/entry.py | 45 ++--------- .../reconstructor/stems/configs/settings.py | 55 +++++++++++++ tests/integration/assets/reconstruction.py | 3 +- .../test_stems_reconstruction.py | 15 +++- .../logic/main/test_converter.py | 2 +- .../logic/reconstruction/test_data.py | 26 ++++-- .../logic/reconstruction/test_manager.py | 11 ++- .../reconstruction/test_reconstruction.py | 11 ++- .../compatibility/reconstruction/test_v2_2.py | 5 +- .../reconstruction/test_reconstruction.py | 42 ++++++++-- .../reconstruction/test_stems_filter.py | 6 +- .../reconstruction/test_stems_removal.py | 17 +++- .../reconstructor/refinement/test_refiner.py | 9 ++- .../reconstructor/stems/test_config.py | 65 ++++++++++++--- .../reconstructor/stems/test_equivalence.py | 7 +- .../reconstructor/stems/test_frame.py | 3 +- .../reconstructor/stems/test_settings.py | 81 +++++++++++++++++++ 25 files changed, 337 insertions(+), 101 deletions(-) create mode 100644 src/sampletones_core/reconstructions/reconstructor/stems/configs/settings.py create mode 100644 tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_settings.py diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index e5e2fc86b..6b32ece80 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -138,9 +138,13 @@ into one job, and `DirectoryConversion` scans a folder into one single-source jo per audio file. `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 towards 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/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py index 114d928fe..8079d238c 100644 --- a/src/sampletones_application/logic/main/stems.py +++ b/src/sampletones_application/logic/main/stems.py @@ -6,6 +6,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 Level = Tuple["StemSource", ...] @@ -233,7 +234,7 @@ def derive_conversion_setup( ordered = [pair for level in playing if level for pair in level] entries = [ - StemEntry(id=stem_id, channels=channels, bends=bending_channels(channels)) + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) for stem_id, (_source, channels) in enumerate(ordered) ] return ConversionSetup( diff --git a/src/sampletones_core/compatibility/fields.py b/src/sampletones_core/compatibility/fields.py index 98a1ff0e1..ac5345ac4 100644 --- a/src/sampletones_core/compatibility/fields.py +++ b/src/sampletones_core/compatibility/fields.py @@ -14,6 +14,7 @@ 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 b45aa4294..783ea3da3 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -21,6 +21,7 @@ METADATA, MODE, RECONSTRUCTION_DATA_VERSION, + SETTINGS, STEM_IDS, STEMS_DATA, ) @@ -70,7 +71,7 @@ def _default_stems_data(data: SerializedData) -> SerializedData: ] return { CONFIG: { - ENTRIES: [{ID: 0, CHANNELS: channels, BENDS: []}], + ENTRIES: [{ID: 0, SETTINGS: {CHANNELS: channels, BENDS: []}}], HIERARCHY: {LEVELS: [[0]], MODE: str(DEFAULT_STEMS_HIERARCHY_MODE)}, CHANNEL_CAP: DEFAULT_STEMS_CHANNEL_CAP, }, @@ -139,8 +140,9 @@ def update(data: SerializedData) -> SerializedData: ``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, down to the channels each - stem carries towards its own recording. + 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 towards its + own recording. """ updated = dict(data) updated = _renamed_stream_keys(updated) diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py index a5c61afe1..5d1375511 100644 --- a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py @@ -99,7 +99,7 @@ def _bends_of(self, stem_id: int) -> FrozenSet[ChannelName]: if entry is None: return frozenset() - return entry.bend_set + 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.""" 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 80c09b45d..849cea995 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/assignment/validation.py @@ -20,6 +20,6 @@ def validate_stems_config( """ 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 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 65dc790a1..5c1607ba0 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): @@ -45,7 +46,7 @@ def single_entry( single-file conversion and the stems pipeline's simplest case. """ return cls( - entries=[StemEntry(id=0, channels=channels, bends=bends)], + entries=[StemEntry(id=0, settings=StemSettings(channels=channels, bends=bends))], hierarchy=StemsHierarchy(levels=[[0]]), channel_cap=channel_cap, ) @@ -53,7 +54,7 @@ def single_entry( @cached_property def bent_channels(self) -> FrozenSet[ChannelName]: """Every channel some stem carries towards its own recording.""" - return frozenset(channel for entry in self.entries for channel in entry.bends) + return frozenset(channel for entry in self.entries for channel in entry.settings.bends) @cached_property def entries_by_id(self) -> Dict[int, StemEntry]: @@ -63,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 578bb58a3..c1b65e9db 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/entry.py @@ -1,52 +1,19 @@ -from functools import cached_property -from typing import FrozenSet, List, Self +from pydantic import ConfigDict, Field -from pydantic import ConfigDict, Field, model_validator - -from sampletones_core.constants.enums import TONE_CHANNELS, 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", ) - bends: List[ChannelName] = Field( - ..., - description="The channels whose notes this stem carries to the divider its recording sounds", - ) - - @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) - - @cached_property - def bend_set(self) -> FrozenSet[ChannelName]: - """The channels this stem 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 stem occupies and whose hardware loads a divider. - - Raises: - ValueError: If a bent channel lies outside the stem'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"stem {self.id} bends channels it does not occupy: {sorted(unheld)}") - - toneless = self.bend_set - TONE_CHANNELS - if toneless: - raise ValueError(f"stem {self.id} bends channels that read no bend: {sorted(toneless)}") - - return self 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/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 2b6703325..a03457c13 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -28,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 @@ -64,7 +65,7 @@ def three_stem_config() -> StemsConfig: """ return StemsConfig( entries=[ - StemEntry(id=stem_id, channels=channels, bends=bending_channels(channels)) + StemEntry(id=stem_id, settings=StemSettings(channels=channels, bends=bending_channels(channels))) for stem_id, channels in THREE_STEM_ENTRY_CHANNELS.items() ], hierarchy=StemsHierarchy( diff --git a/tests/integration/reconstruction/test_stems_reconstruction.py b/tests/integration/reconstruction/test_stems_reconstruction.py index 649089b0a..ef8df05ba 100644 --- a/tests/integration/reconstruction/test_stems_reconstruction.py +++ b/tests/integration/reconstruction/test_stems_reconstruction.py @@ -20,6 +20,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 tests.integration.assets.reconstruction import ( STEM_A_ID, STEM_B_ID, @@ -53,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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_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]], @@ -453,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(DEFAULT_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} @@ -552,7 +557,9 @@ 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), bends=bending_channels(list(channels))) + StemEntry( + id=index, settings=StemSettings(channels=list(channels), bends=bending_channels(list(channels))) + ) for index in range(len(_DISJOINT_TONES)) ], hierarchy=StemsHierarchy( diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index 92b53d6c7..a819f5f02 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -603,7 +603,7 @@ def test_the_rows_channels_and_levels_reach_the_setup(self, converter_logic: Con plan = converter_logic._conversion_plan(config, Path("/audio/a.wav")) - assert plan.stems.entries[0].channels == [ChannelName.PULSE1] + 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) -> None: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index d37b90db4..54a49f14a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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]]), ) @@ -421,7 +434,10 @@ def _three_recordings( config=StemsConfig( entries=[ StemEntry( - id=stem_id, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + id=stem_id, + settings=StemSettings( + channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1]) + ), ) for stem_id in range(3) ], diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index bbff28fe2..b1099fbc7 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -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_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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 30e60f9bb..23720b65b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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_core/compatibility/reconstruction/test_v2_2.py b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py index f79de1090..78ba18418 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -7,6 +7,7 @@ CHANNELS, GENERATOR_NAME, INSTRUCTIONS, + SETTINGS, STEMS_DATA, ) from sampletones_core.compatibility.reconstruction.v2_2 import update @@ -67,7 +68,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: @@ -89,7 +90,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"], BENDS: []}] + 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/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index fb31ced4b..1be56547a 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -28,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, ) @@ -93,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], bends=bending_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, ) @@ -130,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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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, @@ -188,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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.PULSE1], bends=bending_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, @@ -368,7 +394,7 @@ def test_a_2_1_file_loads_through_the_upgrade( generation = data["config"]["generation"] generation["generators"] = [ - str(channel_name) for channel_name in reconstruction.stems_data.config.entries[0].channels + 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): @@ -404,7 +430,7 @@ 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].channels) + channels = list(reconstruction.stems_data.config.entries[0].settings.channels) generation = data["config"]["generation"] generation["generators"] = [str(channel_name) for channel_name in channels] config_metadata = data["config"].get("metadata") @@ -417,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 == 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 30c88c2a3..fd1733d8d 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_filter.py @@ -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()) @@ -24,7 +25,10 @@ def _heard(*stem_ids: int) -> StemSelection: def _stems_data(*stem_lists: Tuple[ChannelName, List[int]]) -> StemsData: entries = [ - StemEntry(id=stem_id, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])) + StemEntry( + id=stem_id, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ) for stem_id in range(3) ] return StemsData( 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 1aa50e38b..0759230be 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_stems_removal.py @@ -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,13 +42,21 @@ def _noise() -> NoiseInstruction: def _stems_config() -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=STEM_A, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + StemEntry( + id=STEM_A, + settings=StemSettings(channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), + ), StemEntry( id=STEM_B, - channels=[ChannelName.PULSE1, ChannelName.NOISE], - bends=bending_channels([ChannelName.PULSE1, ChannelName.NOISE]), + 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])), ), - StemEntry(id=STEM_C, channels=[ChannelName.PULSE1], bends=bending_channels([ChannelName.PULSE1])), ], hierarchy=StemsHierarchy( levels=[[STEM_A], [STEM_B, STEM_C]], diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py index 0392c9144..b2d665a52 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/refinement/test_refiner.py @@ -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 from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -110,7 +111,7 @@ def test_only_the_channels_the_stem_names_are_carried( config: Config, test_case: "TestWhichChannelsAStemCarries.TestCase", ) -> None: - stems = _stems(StemEntry(id=STEM_A, channels=TONES, bends=list(test_case.bends))) + 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: @@ -142,8 +143,8 @@ def test_a_stem_carrying_nothing_is_never_read( ) -> None: readings = self._counted(monkeypatch) stems = _stems( - StemEntry(id=STEM_A, channels=TONES, bends=[]), - StemEntry(id=STEM_B, channels=TONES, bends=list(TONES)), + 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}) @@ -151,7 +152,7 @@ def test_a_stem_carrying_nothing_is_never_read( 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, channels=TONES, bends=list(TONES))) + 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) 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 b86a42d5b..1ddfea177 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_config.py @@ -6,13 +6,18 @@ 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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=0, channels=[ChannelName.NOISE], bends=bending_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], bends=bending_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], bends=bending_channels([ChannelName.PULSE1])), - StemEntry(id=1, channels=[ChannelName.NOISE], bends=bending_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], bends=bending_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() @@ -96,8 +135,10 @@ def test_frame_budget_stops_at_the_cap(self) -> None: entries=[ StemEntry( id=0, - channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE], - bends=bending_channels([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]), + settings=StemSettings( + channels=[ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE], + bends=bending_channels([ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE]), + ), ) ], hierarchy=StemsHierarchy(levels=[[0]]), @@ -111,7 +152,7 @@ def test_names_one_stem_over_every_channel(self) -> None: channels = [ChannelName.PULSE1, ChannelName.TRIANGLE] 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) 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 64e1a46ba..37e64e562 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_equivalence.py @@ -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 @@ -31,7 +32,9 @@ def _config( ) -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=stem_id, channels=list(channels), bends=bending_channels(list(channels))) + 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), @@ -217,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 1269712f3..b0b915e5b 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py +++ b/tests/unit/sampletones_core/reconstructions/reconstructor/stems/test_frame.py @@ -18,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 @@ -35,7 +36,7 @@ def _config( ) -> StemsConfig: return StemsConfig( entries=[ - StemEntry(id=stem_id, channels=channels, bends=bending_channels(channels)) + 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), 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}) From f9bf5b1f6d9e4ce49acc9d8c7ac094018dffb861 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 02:16:00 +0200 Subject: [PATCH 006/130] Added: converter source structures --- .../logic/main/converter.py | 91 ++++-- .../logic/main/sources/__init__.py | 0 .../logic/main/sources/agreement.py | 35 +++ .../logic/main/sources/derive.py | 87 ++++++ .../logic/main/sources/folder.py | 30 ++ .../logic/main/sources/key.py | 37 +++ .../logic/main/sources/levels.py | 174 ++++++++++++ .../logic/main/sources/list.py | 237 ++++++++++++++++ .../logic/main/sources/recording.py | 35 +++ .../logic/main/sources/row.py | 25 ++ .../logic/main/sources/slots.py | 100 +++++++ .../logic/main/stems.py | 263 ------------------ .../logic/main/sources/__init__.py | 0 .../logic/main/sources/factories.py | 26 ++ .../logic/main/sources/test_agreement.py | 49 ++++ .../logic/main/sources/test_derive.py | 202 ++++++++++++++ .../logic/main/sources/test_levels.py | 147 ++++++++++ .../logic/main/sources/test_list.py | 196 +++++++++++++ .../logic/main/sources/test_slots.py | 69 +++++ .../logic/main/test_stems.py | 218 --------------- 20 files changed, 1514 insertions(+), 507 deletions(-) create mode 100644 src/sampletones_application/logic/main/sources/__init__.py create mode 100644 src/sampletones_application/logic/main/sources/agreement.py create mode 100644 src/sampletones_application/logic/main/sources/derive.py create mode 100644 src/sampletones_application/logic/main/sources/folder.py create mode 100644 src/sampletones_application/logic/main/sources/key.py create mode 100644 src/sampletones_application/logic/main/sources/levels.py create mode 100644 src/sampletones_application/logic/main/sources/list.py create mode 100644 src/sampletones_application/logic/main/sources/recording.py create mode 100644 src/sampletones_application/logic/main/sources/row.py create mode 100644 src/sampletones_application/logic/main/sources/slots.py delete mode 100644 src/sampletones_application/logic/main/stems.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/__init__.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/factories.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_agreement.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_derive.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_levels.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_list.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_slots.py delete mode 100644 tests/unit/sampletones_application/logic/main/test_stems.py diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index 1fca10519..f2f0f0d3b 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -6,13 +6,15 @@ 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 ( +from sampletones_application.logic.main.sources.derive import ( ConversionSetup, - StemLevels, - StemSource, derive_conversion_setup, - effective_channels, ) +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 from sampletones_application.services.conversion.result import ConversionItem, ConversionResult from sampletones_application.services.result import ( ServiceCanceled, @@ -42,6 +44,7 @@ group_output_path, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_core.reconstructions.stage import ReconstructionStage from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger @@ -118,7 +121,8 @@ def __init__( self._written: Tuple[Path, ...] = () self._is_file: bool = True self._stems_mode: bool = False - self._levels: StemLevels = StemLevels() + self._sources: SourceList = SourceList() + self._levels: MixLevels = MixLevels() self._channel_cap: int = len(ChannelName) self._hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE self._system_progress = SystemProgress() @@ -151,7 +155,7 @@ def source_count(self) -> int: @property def room_for_sources(self) -> int: """How many more recordings the stems list has room for.""" - return MAX_STEM_SOURCES - self.source_count + return self._levels.room @property def is_active(self) -> bool: @@ -198,17 +202,18 @@ def add_sources(self, paths: Sequence[Path]) -> None: 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: + if not self._levels.room: break - self._levels = self._levels.add(StemSource(path=path, channels=enabled)) + self._sources = self._sources.add_recording(self._gathered(path)) + self._levels = self._levels.add(path) self._refresh_setup() def remove_source(self, path: Path) -> None: """Takes a recording out of the stems list.""" + self._sources = self._sources.remove(SourceKey.recording(path)) self._levels = self._levels.remove(path) self._refresh_setup() @@ -218,13 +223,18 @@ def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> N 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. """ + recording = self._sources.recording(path) + if recording is None: + return + enabled = frozenset(self._config_manager.config.generation.channels) - self._apply( - self._levels.replace_source( - path, - lambda source: source.with_channels((source.channels - enabled) | channels), - ) + held = recording.settings.channel_set + self._sources = self._sources.written( + recording.key, + CHANNEL_SLOT, + (held - enabled) | channels, ) + self._refresh_setup() def move_source_within_level(self, path: Path, offset: int) -> None: """Moves a recording past the neighbor it shares a level with.""" @@ -246,6 +256,14 @@ 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 _gathered(self, path: Path) -> Recording: + """A recording joining the list, holding the channels the run enables and bending each.""" + enabled = list(self._config_manager.config.generation.channels) + return Recording( + path=path, + settings=StemSettings(channels=enabled, bends=bending_channels(enabled)), + ) + def set_stems_mode(self, stems_mode: bool) -> None: """Switches between converting one selection and mixing several recordings into one. @@ -487,8 +505,9 @@ def _stems_setup(self, config: Config) -> ConversionSetup: enabled = list(config.generation.channels) if self._stems_mode: return derive_conversion_setup( + self._sources, self._levels, - enabled, + frozenset(enabled), channel_cap=self._effective_channel_cap, hierarchy_mode=self._hierarchy_mode, ) @@ -515,21 +534,32 @@ def _effective_channel_cap(self) -> int: 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.""" + def _apply(self, levels: MixLevels) -> None: + """Takes up rewritten levels and follows them 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)) + self._sources = self._sources.add_recording(self._gathered(self._input_path)) + self._levels = self._levels.add(self._input_path) def _leave_stems_mode(self) -> None: if self._levels.count: self._levels = self._levels.keep_first() + self._sources = self._kept_to_levels() self._assign_paths(self._levels.paths[0], self._config_manager.config) + def _kept_to_levels(self) -> SourceList: + """The list holding the recordings the levels still name, which is what a mix converts.""" + standing = frozenset(self._levels.paths) + sources = self._sources + for path in self._sources.paths: + if path not in standing: + sources = sources.remove(SourceKey.recording(path)) + + return sources + def _refresh_setup(self) -> None: """Follows the setup wherever it changed: the destination it now names, and the view.""" self._update_stems_output_path() @@ -553,23 +583,32 @@ def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: 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) + enabled = frozenset(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(), + key=str(path), + path=path, + channels=self._held_channels(path, enabled), + offered_channels=enabled, + available=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) + for position, path in enumerate(level) ) + def _held_channels( + self, + path: Path, + enabled: FrozenSet[ChannelName], + ) -> FrozenSet[ChannelName]: + """The channels one gathered recording takes, among the ones the run enables.""" + recording = self._sources.recording(path) + return recording.settings.channel_set & enabled if recording is not None else frozenset() + def _on_conversion_complete(self, written: Tuple[Path, ...]) -> None: self._written = written if len(written) == 1: 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/agreement.py b/src/sampletones_application/logic/main/sources/agreement.py new file mode 100644 index 000000000..06e305dc5 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/agreement.py @@ -0,0 +1,35 @@ +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 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..0ba086028 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/derive.py @@ -0,0 +1,87 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import AbstractSet, 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_application.logic.main.sources.slots import CHANNEL_SLOT +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 + + +@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, + enabled_channels: AbstractSet[ChannelName], + *, + channel_cap: int, + hierarchy_mode: HierarchyMode, +) -> ConversionSetup: + """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. + + Each recording is narrowed to the channels the run still enables, and one left holding none + 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, enabled_channels) 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], + enabled_channels: AbstractSet[ChannelName], +) -> List[Recording]: + """The recordings of one level, each narrowed to the channels the run enables and still playing.""" + narrowed = [] + for path in level: + recording = sources.recording(path) + if recording is None: + continue + + settings = CHANNEL_SLOT.write(recording.settings, recording.settings.channel_set & enabled_channels) + if settings.channels: + narrowed.append(recording.with_settings(settings)) + + return narrowed + + +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..60579fdf0 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/key.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from enum import StrEnum +from pathlib import Path +from typing import Self + + +class SourceKind(StrEnum): + """The two kinds of row a converter's list holds.""" + + RECORDING = "recording" + FOLDER = "folder" + + +@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..98313a8a2 --- /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 room(self) -> int: + """How many more recordings this mix reaches.""" + return MAX_STEM_SOURCES - 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 keep_first(self) -> Self: + """Keeps the recording that picks first, which is the one a single-source run carries.""" + paths = self.paths + return self.of([[paths[0]]]) if paths 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 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..567adfa05 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/list.py @@ -0,0 +1,237 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Callable, Dict, FrozenSet, Optional, Self, Tuple + +from sampletones_application.logic.main.sources.agreement import Agreement +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_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, ...] = () + + @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. + """ + return tuple(recording for row in self.rows 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 any(recording.path == path for recording in self.recordings) + + def recording(self, path: Path) -> Optional[Recording]: + return next((recording for recording in self.recordings if recording.path == path), None) + + def row(self, key: SourceKey) -> Optional[SourceRow]: + return next((row for row in self.rows if row.key == key), None) + + 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. + """ + for row in self.rows: + if row.key.names_folder and any(recording.path == path for recording in row.recordings): + return row.key.path + + return None + + 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. + """ + if self.row(folder.key) is not None: + return self + + gathered = self._gathered_by(folder) + 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 flattened(self) -> Self: + """The same recordings as loose rows, in the order they stand. + + A mix converts recordings alone, so a folder standing in the list contributes what it + holds and stops standing for them. + """ + return replace(self, rows=self.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: + if row.key != key: + rows += (row,) + continue + + changed = tuple(recording.with_settings(change(recording.settings)) for recording in row.recordings) + if key.names_folder: + rows += (Folder(root=key.path, recordings=changed),) + else: + rows += changed + + return rows 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..5c26fc381 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/row.py @@ -0,0 +1,25 @@ +from typing import Protocol, Tuple, runtime_checkable + +from sampletones_application.logic.main.sources.key import SourceKey +from sampletones_application.logic.main.sources.recording import Recording + + +@runtime_checkable +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/slots.py b/src/sampletones_application/logic/main/sources/slots.py new file mode 100644 index 000000000..aace6dbd2 --- /dev/null +++ b/src/sampletones_application/logic/main/sources/slots.py @@ -0,0 +1,100 @@ +from dataclasses import dataclass +from enum import StrEnum +from typing import Callable, Final, FrozenSet, Tuple + +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] + +ALL_CHANNELS: Final[FrozenSet[ChannelName]] = frozenset(ChannelName.items()) + + +class SettingsField(StrEnum): + """The per-recording choices a reader makes, by the name the settings hold each under.""" + + CHANNELS = "channels" + BENDS = "bends" + + +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 _bends_of(settings: StemSettings) -> FrozenSet[ChannelName]: + return settings.bend_set + + +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 offer it at all. 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 + channels_offered: 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.channels_offered + + 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, + channels_offered=ALL_CHANNELS, +) + +BEND_SLOT: Final[SettingsSlot] = SettingsSlot( + field=SettingsField.BENDS, + read=_bends_of, + write=_with_bends, + channels_offered=TONE_CHANNELS, +) + +SETTINGS_SLOTS: Final[Tuple[SettingsSlot, ...]] = (CHANNEL_SLOT, BEND_SLOT) diff --git a/src/sampletones_application/logic/main/stems.py b/src/sampletones_application/logic/main/stems.py deleted file mode 100644 index 8079d238c..000000000 --- a/src/sampletones_application/logic/main/stems.py +++ /dev/null @@ -1,263 +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, 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 - -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, settings=StemSettings(channels=channels, bends=bending_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/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_agreement.py b/tests/unit/sampletones_application/logic/main/sources/test_agreement.py new file mode 100644 index 000000000..4ad067c8f --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_agreement.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_application.logic.main.sources.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/logic/main/sources/test_derive.py b/tests/unit/sampletones_application/logic/main/sources/test_derive.py new file mode 100644 index 000000000..c2b9e0511 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_derive.py @@ -0,0 +1,202 @@ +from pathlib import Path +from typing import FrozenSet, 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.unit.sampletones_application.logic.main.sources.factories import recording + +ENABLED: FrozenSet[ChannelName] = frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE}) + + +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: + def test_a_recording_keeps_the_channels_the_run_still_enables(self) -> None: + sources, levels = _gathered(["lead"], holding=[ChannelName.PULSE1, ChannelName.PULSE2]) + + setup = derive_conversion_setup( + sources, + levels, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.channels == [ChannelName.PULSE1] + + def test_the_channels_stand_in_the_order_the_run_names_them(self) -> None: + sources, levels = _gathered(["lead"], holding=[ChannelName.NOISE, ChannelName.PULSE1]) + + setup = derive_conversion_setup( + sources, + levels, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.channels == [ChannelName.PULSE1, ChannelName.NOISE] + + 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, + ENABLED, + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.bends == [ChannelName.TRIANGLE] + + def test_a_bend_on_a_channel_the_run_leaves_out_goes_with_it(self) -> None: + sources = SourceList().add_recording( + recording( + str(_path("lead")), + [ChannelName.PULSE1, ChannelName.TRIANGLE], + [ChannelName.TRIANGLE], + ) + ) + levels = MixLevels.of([[_path("lead")]]) + + setup = derive_conversion_setup( + sources, + levels, + frozenset({ChannelName.PULSE1}), + channel_cap=1, + hierarchy_mode=HierarchyMode.STRICT, + ) + + assert setup.stems.entries[0].settings.bends == [] + + +class TestTheSetupTheLevelsAmountTo: + def test_a_recordings_position_is_its_stem_id(self) -> None: + sources, levels = _gathered(["a", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + 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: + sources, levels = _gathered(["a", "c"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + 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: + sources, levels = _gathered(["a"], ["b"]) + + setup = derive_conversion_setup( + sources, + levels, + ENABLED, + 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, + ENABLED, + channel_cap=2, + hierarchy_mode=HierarchyMode.ROUND_ROBIN, + ) + + assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN) + + +class TestARecordingThatTakesNoPart: + """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, + 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: + sources, levels = self._silent_beside(["silent", "b"]) + + setup = derive_conversion_setup( + sources, + levels, + ENABLED, + 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, + ENABLED, + 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..cef43f414 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_levels.py @@ -0,0 +1,147 @@ +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 + + +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: + """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_keeping_the_first_leaves_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 TestTheCeilingAMixHolds: + """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: + """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 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..3dd197f60 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -0,0 +1,196 @@ +from pathlib import Path + +from sampletones_application.logic.main.sources.agreement import Agreement +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_core.constants.enums import ChannelName +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording + + +class TestGatheringRecordings: + 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: + 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 TestLettingSourcesGo: + 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: + 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: + 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: + 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 + + +class TestFlatteningTheList: + def test_a_folder_gives_up_the_recordings_it_stood_for(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + sources = SourceList().add_folder(gathered).flattened() + + assert sources.row_count == 2 + assert sources.folder_root_of(Path("/audio/a.wav")) is None + + def test_the_recordings_keep_the_order_they_stood_in(self) -> None: + sources = SourceList().add_recording(recording("/other/a.wav")) + sources = sources.add_folder(folder("/audio", [recording("/audio/b.wav"), recording("/audio/c.wav")])) + + assert sources.flattened().paths == ( + Path("/other/a.wav"), + Path("/audio/b.wav"), + Path("/audio/c.wav"), + ) + + def test_the_recordings_keep_the_settings_they_stood_with(self) -> None: + gathered = folder("/audio", [recording("/audio/a.wav", [ChannelName.NOISE])]) + loose = SourceList().add_folder(gathered).flattened().recording(Path("/audio/a.wav")) + + assert loose is not None + assert loose.settings.channel_set == {ChannelName.NOISE} 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..d181d7ca7 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_slots.py @@ -0,0 +1,69 @@ +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.unit.sampletones_application.logic.main.sources.factories import settings + + +class TestWhichChannelsASlotIsOfferedOn: + """A choice reaches a reader only on the channels whose hardware answers it.""" + + def test_a_channel_is_offered_on_every_channel(self) -> None: + assert all(CHANNEL_SLOT.offers(channel_name) for channel_name in ChannelName.items()) + + def test_a_bend_is_offered_on_the_channels_that_read_one(self) -> None: + assert BEND_SLOT.channels_offered == TONE_CHANNELS + assert not BEND_SLOT.offers(ChannelName.NOISE) + + +class TestSettlingTheChannelsARecordingOccupies: + 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: + 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: + 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_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) From 489fd7bf80db97452e32661f2c3a575b6c27225a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 03:13:16 +0200 Subject: [PATCH 007/130] Refactored: converter settings management --- docs/formats/configuration.md | 10 ++-- docs/formats/reconstructions.md | 13 +++-- docs/guide/configuration.md | 12 ++-- .../config/managers/application.py | 9 +++ .../config/managers/config.py | 7 +-- .../config/managers/session.py | 9 +++ .../config/session/application/config.py | 5 ++ .../config/session/application/converter.py | 33 +++++++++++ .../coordinators/tabs/main.py | 18 +++++- .../logic/main/converter.py | 56 +++++++++++++------ .../compatibility/reconstruction/v2_2.py | 37 +++++++++--- src/sampletones_core/configs/generation.py | 11 ---- .../logic/main/test_converter.py | 44 ++++++++++----- .../logic/reconstruction/test_manager.py | 2 +- .../sampletones_application/test_startup.py | 8 +-- .../compatibility/reconstruction/test_v2_2.py | 9 ++- .../compatibility/test_binary.py | 5 +- 17 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/sampletones_application/config/session/application/converter.py 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/guide/configuration.md b/docs/guide/configuration.md index 95ece9029..af5529661 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,17 +1,19 @@ # 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. +sample rate, the NES frequency, how the audio is analyzed, and how candidates are +scored. Which channels a conversion uses travels with the conversion itself, so +each recording says which of them it may take. 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. ## From the interface The **Main** tab exposes the everyday settings (grouped under **General settings**, **Reconstructor settings**, and **Advanced settings**): -- which **Channels** take part, and the **Drive** applied to them; +- which **Channels** a recording takes when it joins a conversion, 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 diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 2e911a106..b2112eb7c 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -6,6 +6,7 @@ from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize 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 +154,14 @@ 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 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..2a942e0ee 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -10,6 +10,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings class SessionManager: @@ -84,6 +85,14 @@ 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) + 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..128ec7a1a --- /dev/null +++ b/src/sampletones_application/config/session/application/converter.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, ConfigDict, Field, field_serializer + +from sampletones_core.constants.enums import DEFAULT_CHANNELS, 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): + """What the converter starts a recording from, carried between runs. + + A recording joins the conversion holding these settings, and the reader then says otherwise for + it alone. Keeping them 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. + """ + + 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.", + ) + + @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") diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 66125638a..6b1e1976e 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -63,6 +63,7 @@ from sampletones_application.view_model.main.reconstructor import ( ReconstructorPanelViewModel, ) +from sampletones_application.view_model.main.updates import GenerationSettingsUpdate from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName from sampletones_core.reconstructions.converter import top_level_audio_files @@ -173,7 +174,7 @@ def __init__( ) self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( ReconstructorPanelViewModel( - channels=frozenset(_config.generation.channels), + channels=session_manager.converter_settings.channel_set, drive=_config.generation.drive, ), layout=layout.main.reconstructor, @@ -199,6 +200,7 @@ def __init__( ) self._converter_logic: ConverterLogic = ConverterLogic( config_manager, + session_manager, conversion_service, scheduling=layout.scheduling, language_manager=language_manager, @@ -220,7 +222,7 @@ def __init__( 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._reconstructor_panel.on_generation_settings_changed = self._apply_generation_settings 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 @@ -453,11 +455,21 @@ def _update_config_panel_view(self) -> None: ) ) + def _apply_generation_settings(self, update: GenerationSettingsUpdate) -> None: + """Routes one gesture on the reconstruction card to the two owners it reaches. + + Drive shapes every run, so it belongs to the generation configuration; the channels are + what a recording joins the converter's list holding, which the session carries between + runs. + """ + self._config_manager.apply_generation_settings(update) + self._converter_logic.set_joining_channels(frozenset(update.channels)) + def _update_reconstructor_panel_view(self) -> None: config = self._config_manager.config self._reconstructor_panel.update_view( ReconstructorPanelViewModel( - channels=frozenset(config.generation.channels), + channels=self._session_manager.converter_settings.channel_set, drive=config.generation.drive, ) ) diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index f2f0f0d3b..836ec5b9b 100644 --- a/src/sampletones_application/logic/main/converter.py +++ b/src/sampletones_application/logic/main/converter.py @@ -4,6 +4,7 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.conversion import MAX_STEM_SOURCES, MIN_CHANNEL_CAP from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.main.sources.derive import ( @@ -34,7 +35,7 @@ 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, bending_channels +from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.parallelization import ETAEstimator, TaskProgress from sampletones_core.reconstructions.converter import ( ConversionPlan, @@ -95,6 +96,7 @@ class ConverterLogic(CallbackMixin): def __init__( self, config_manager: ConfigManager, + session_manager: SessionManager, conversion_service: ConversionServiceProtocol, *, scheduling: SchedulingBehavior, @@ -103,6 +105,7 @@ def __init__( ) -> None: self._language_manager = language_manager self._config_manager = config_manager + self._session_manager = session_manager self._service = conversion_service self._scheduling = scheduling self._is_operation_active = is_operation_active @@ -227,7 +230,7 @@ def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> N if recording is None: return - enabled = frozenset(self._config_manager.config.generation.channels) + enabled = self._enabled_channels held = recording.settings.channel_set self._sources = self._sources.written( recording.key, @@ -257,12 +260,18 @@ def move_source_to_new_level(self, path: Path, position: int) -> None: self._apply(self._levels.move_to_new_level(path, position)) def _gathered(self, path: Path) -> Recording: - """A recording joining the list, holding the channels the run enables and bending each.""" - enabled = list(self._config_manager.config.generation.channels) - return Recording( - path=path, - settings=StemSettings(channels=enabled, bends=bending_channels(enabled)), - ) + """A recording joining the list, holding the settings a recording joins with.""" + return Recording(path=path, settings=self._joining_settings) + + @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._session_manager.converter_settings + + @property + def _enabled_channels(self) -> FrozenSet[ChannelName]: + """The channels a run hands out, which is what a joining recording holds.""" + return self._joining_settings.channel_set def set_stems_mode(self, stems_mode: bool) -> None: """Switches between converting one selection and mixing several recordings into one. @@ -281,6 +290,17 @@ def set_stems_mode(self, stems_mode: bool) -> None: self._refresh_setup() + def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: + """Names the channels a recording holds when it joins the list, carried between runs. + + A run hands out what a recording joins with, so narrowing this narrows every gathered + recording to the channels still named; each keeps the choice it was given for a channel + left out and gets it back when that channel returns. + """ + joining = CHANNEL_SLOT.write(self._joining_settings, channels) + self._session_manager.set_converter_settings(joining) + 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()) @@ -301,7 +321,7 @@ def start_conversion(self, confirmed: bool = False) -> None: logger.warning("A conversion or library generation is already in progress") return - if not self._config_manager.config.generation.channels: + if not self._enabled_channels: self.call(self.on_no_generators) return @@ -438,7 +458,7 @@ def _handle_library_progress(self, progress: TaskProgress) -> None: def _assign_paths(self, input_path: Path, config: Config) -> bool: try: - self._output_path = get_output_path(config, input_path, frozenset(config.generation.channels)) + self._output_path = get_output_path(config, input_path, self._enabled_channels) self._input_path = input_path self._is_file = input_path.is_file() except FileNotFoundError as exception: @@ -502,21 +522,21 @@ def _stems_setup(self, config: Config) -> ConversionSetup: 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._sources, self._levels, - frozenset(enabled), + self._enabled_channels, channel_cap=self._effective_channel_cap, hierarchy_mode=self._hierarchy_mode, ) + joining = self._joining_settings return ConversionSetup( sources=(), stems=StemsConfig.single_entry( - enabled, - bending_channels(enabled), + joining.channels, + joining.bends, channel_cap=self._effective_channel_cap, ), ) @@ -532,7 +552,7 @@ def _effective_channel_cap(self) -> int: 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) + return max(len(self._enabled_channels), MIN_CHANNEL_CAP) def _apply(self, levels: MixLevels) -> None: """Takes up rewritten levels and follows them wherever the setup changed.""" @@ -574,7 +594,7 @@ def _update_stems_output_path(self) -> None: sources = self._source_paths if sources: config = self._config_manager.config - self._output_path = group_output_path(config, sources, frozenset(config.generation.channels)) + self._output_path = group_output_path(config, sources, self._enabled_channels) def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: """The gathered recordings as the panel reads them, each stating where it stands. @@ -583,7 +603,7 @@ def _stem_rows(self, config: Config) -> Tuple[StemRowViewModel, ...]: 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 = frozenset(config.generation.channels) + enabled = self._enabled_channels return tuple( StemRowViewModel( key=str(path), @@ -691,7 +711,7 @@ def _emit_view_model( other_operation_active=self._is_operation_active(), stems_mode=self._stems_mode, stem_sources=self._stem_rows(config), - enabled_channels=frozenset(config.generation.channels), + enabled_channels=self._enabled_channels, channel_cap=self._effective_channel_cap, max_channel_cap=self._max_channel_cap(), hierarchy_mode=self._hierarchy_mode, diff --git a/src/sampletones_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py index 783ea3da3..0a6abfdeb 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -132,23 +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, down to the settings each - stem is converted with: the channels it takes, and the ones it carries towards its - own recording. + ``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 towards 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/generation.py b/src/sampletones_core/configs/generation.py index bdc169019..f44bc0854 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 ( @@ -27,8 +25,6 @@ TRANSITION_VOLUME_WEIGHT, ) from sampletones_core.constants.enums import ( - DEFAULT_CHANNELS, - ChannelName, PhaseAlignerName, SelectorName, SpectralDistance, @@ -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/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py index a819f5f02..53fbe242e 100644 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ b/tests/unit/sampletones_application/logic/main/test_converter.py @@ -4,6 +4,8 @@ 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, MIN_CHANNEL_CAP from sampletones_application.logic.main.converter import ( ConversionSuccess, @@ -52,7 +54,13 @@ def _config_writing_under(reconstructions_directory: Path) -> Config: @pytest.fixture -def converter_logic(tmp_path: Path) -> ConverterLogic: +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 converter_logic(tmp_path: Path, session_manager: SessionManager) -> 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 @@ -70,6 +78,7 @@ def converter_logic(tmp_path: Path) -> ConverterLogic: ) logic = ConverterLogic( config_manager, + session_manager, service, scheduling=scheduling, language_manager=FakeLanguageManager(TEXTS), # type: ignore[arg-type] @@ -143,10 +152,7 @@ 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": []})} - ) + converter_logic.set_joining_channels(frozenset()) on_no_generators = MagicMock() converter_logic.on_no_generators = on_no_generators @@ -499,15 +505,23 @@ def test_a_directory_becomes_a_directory_conversion(self, converter_logic: Conve 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.""" + def test_the_setup_covers_every_channel_a_recording_joins_with( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + """With no stems listed, one stem holds the settings a recording joins the list with.""" config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) - channels = list(config.generation.channels) + joining = session_manager.converter_settings plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - assert plan.stems == StemsConfig.single_entry(channels, bending_channels(channels), channel_cap=len(channels)) - assert plan.stems.covered_channels == frozenset(channels) + assert plan.stems == StemsConfig.single_entry( + joining.channels, + joining.bends, + channel_cap=len(joining.channels), + ) + assert plan.stems.covered_channels == joining.channel_set 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) @@ -631,9 +645,13 @@ def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLo ("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) + def test_the_cap_holds_within_the_channels_enabled( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + self._with_config(converter_logic) + channels = session_manager.converter_settings.channels converter_logic.set_channel_cap(len(channels) + 5) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index b1099fbc7..cec36b9a6 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -411,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/test_startup.py b/tests/unit/sampletones_application/test_startup.py index deed263c4..30d2f22ea 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -366,12 +366,12 @@ 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) + def test_the_main_tab_switches_the_channel_a_recording_joins_with(self, app: Application) -> None: + joining = app.session_manager.converter_settings.channel_set self._press(app, ChannelName.TRIANGLE, Tab.MAIN) - assert frozenset(app.config_manager.config.generation.channels) == selected ^ {ChannelName.TRIANGLE} + assert app.session_manager.converter_settings.channel_set == joining ^ {ChannelName.TRIANGLE} def test_the_sequencer_switches_its_mix(self, app: Application) -> None: self._press(app, ChannelName.NOISE, Tab.SEQUENCER) @@ -452,7 +452,7 @@ def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Pat """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})) 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 78ba18418..7b6e69d6b 100644 --- a/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py +++ b/tests/unit/sampletones_core/compatibility/reconstruction/test_v2_2.py @@ -35,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"}}} 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 From c763cfc9fe2bb3f7d61b44ac918c46f904543db4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 03:51:41 +0200 Subject: [PATCH 008/130] Split: the converter logic into the concerns it held --- .../coordinators/tabs/main.py | 8 +- .../logic/main/converter.py | 720 --------------- .../logic/main/converter/__init__.py | 0 .../logic/main/converter/destination.py | 74 ++ .../logic/main/converter/gathering.py | 122 +++ .../logic/main/converter/logic.py | 439 +++++++++ .../logic/main/converter/messages.py | 118 +++ .../logic/main/converter/run.py | 237 +++++ .../logic/main/converter/settings.py | 59 ++ .../logic/main/converter/setup.py | 67 ++ .../logic/main/converter/state.py | 29 + .../logic/main/converter/view.py | 90 ++ .../reconstructions/converter/__init__.py | 16 - .../coordinators/tabs/test_main.py | 2 +- .../logic/main/converter/__init__.py | 0 .../logic/main/converter/test_destination.py | 40 + .../logic/main/converter/test_gathering.py | 134 +++ .../logic/main/converter/test_logic.py | 684 ++++++++++++++ .../logic/main/converter/test_messages.py | 139 +++ .../logic/main/converter/test_run.py | 212 +++++ .../logic/main/converter/test_settings.py | 64 ++ .../logic/main/converter/test_setup.py | 115 +++ .../logic/main/converter/texts.py | 25 + .../logic/main/test_converter.py | 841 ------------------ .../sampletones_application/test_startup.py | 35 +- 25 files changed, 2678 insertions(+), 1592 deletions(-) delete mode 100644 src/sampletones_application/logic/main/converter.py create mode 100644 src/sampletones_application/logic/main/converter/__init__.py create mode 100644 src/sampletones_application/logic/main/converter/destination.py create mode 100644 src/sampletones_application/logic/main/converter/gathering.py create mode 100644 src/sampletones_application/logic/main/converter/logic.py create mode 100644 src/sampletones_application/logic/main/converter/messages.py create mode 100644 src/sampletones_application/logic/main/converter/run.py create mode 100644 src/sampletones_application/logic/main/converter/settings.py create mode 100644 src/sampletones_application/logic/main/converter/setup.py create mode 100644 src/sampletones_application/logic/main/converter/state.py create mode 100644 src/sampletones_application/logic/main/converter/view.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/__init__.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_destination.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_gathering.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_logic.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_messages.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_run.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_settings.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/test_setup.py create mode 100644 tests/unit/sampletones_application/logic/main/converter/texts.py delete mode 100644 tests/unit/sampletones_application/logic/main/test_converter.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6b1e1976e..3bc906537 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -9,10 +9,8 @@ from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) -from sampletones_application.logic.main.converter import ( - ConversionSuccess, - ConverterLogic, -) +from sampletones_application.logic.main.converter.logic import ConverterLogic +from sampletones_application.logic.main.converter.run import ConversionSuccess from sampletones_application.logic.main.explorer import ExplorerLogic from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters @@ -66,7 +64,7 @@ from sampletones_application.view_model.main.updates import GenerationSettingsUpdate 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.reconstructions.converter.paths 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 diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py deleted file mode 100644 index 836ec5b9b..000000000 --- a/src/sampletones_application/logic/main/converter.py +++ /dev/null @@ -1,720 +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.config.managers.session import SessionManager -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.sources.derive import ( - ConversionSetup, - derive_conversion_setup, -) -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 -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.reconstructor.stems.configs.settings import StemSettings -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, - session_manager: SessionManager, - 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._session_manager = session_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._sources: SourceList = SourceList() - self._levels: MixLevels = MixLevels() - 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 self._levels.room - - @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. - """ - for path in paths: - if not self._levels.room: - break - - self._sources = self._sources.add_recording(self._gathered(path)) - self._levels = self._levels.add(path) - - self._refresh_setup() - - def remove_source(self, path: Path) -> None: - """Takes a recording out of the stems list.""" - self._sources = self._sources.remove(SourceKey.recording(path)) - 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. - """ - recording = self._sources.recording(path) - if recording is None: - return - - enabled = self._enabled_channels - held = recording.settings.channel_set - self._sources = self._sources.written( - recording.key, - CHANNEL_SLOT, - (held - enabled) | channels, - ) - self._refresh_setup() - - 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 _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) - - @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._session_manager.converter_settings - - @property - def _enabled_channels(self) -> FrozenSet[ChannelName]: - """The channels a run hands out, which is what a joining recording holds.""" - return self._joining_settings.channel_set - - 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_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: - """Names the channels a recording holds when it joins the list, carried between runs. - - A run hands out what a recording joins with, so narrowing this narrows every gathered - recording to the channels still named; each keeps the choice it was given for a channel - left out and gets it back when that channel returns. - """ - joining = CHANNEL_SLOT.write(self._joining_settings, channels) - self._session_manager.set_converter_settings(joining) - 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._enabled_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._enabled_channels) - 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. - """ - if self._stems_mode: - return derive_conversion_setup( - self._sources, - self._levels, - self._enabled_channels, - channel_cap=self._effective_channel_cap, - hierarchy_mode=self._hierarchy_mode, - ) - - joining = self._joining_settings - return ConversionSetup( - sources=(), - stems=StemsConfig.single_entry( - joining.channels, - joining.bends, - 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._enabled_channels), MIN_CHANNEL_CAP) - - def _apply(self, levels: MixLevels) -> None: - """Takes up rewritten levels and follows them wherever the setup changed.""" - self._levels = levels - self._refresh_setup() - - def _enter_stems_mode(self) -> None: - if self._levels.count == 0 and self._input_path is not None and self._is_file: - self._sources = self._sources.add_recording(self._gathered(self._input_path)) - self._levels = self._levels.add(self._input_path) - - def _leave_stems_mode(self) -> None: - if self._levels.count: - self._levels = self._levels.keep_first() - self._sources = self._kept_to_levels() - self._assign_paths(self._levels.paths[0], self._config_manager.config) - - def _kept_to_levels(self) -> SourceList: - """The list holding the recordings the levels still name, which is what a mix converts.""" - standing = frozenset(self._levels.paths) - sources = self._sources - for path in self._sources.paths: - if path not in standing: - sources = sources.remove(SourceKey.recording(path)) - - return sources - - 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: - config = self._config_manager.config - self._output_path = group_output_path(config, sources, self._enabled_channels) - - 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 = self._enabled_channels - return tuple( - StemRowViewModel( - key=str(path), - path=path, - channels=self._held_channels(path, enabled), - offered_channels=enabled, - available=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, path in enumerate(level) - ) - - def _held_channels( - self, - path: Path, - enabled: FrozenSet[ChannelName], - ) -> FrozenSet[ChannelName]: - """The channels one gathered recording takes, among the ones the run enables.""" - recording = self._sources.recording(path) - return recording.settings.channel_set & enabled if recording is not None else frozenset() - - 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=self._enabled_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..14bf4fb2f --- /dev/null +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -0,0 +1,74 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import AbstractSet, Optional, Self, Tuple + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import ChannelName +from sampletones_core.reconstructions.converter.paths import get_output_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( + self, + config: Config, + input_path: Path, + channels: AbstractSet[ChannelName], + ) -> Self: + """The destination a newly picked recording or directory names. + + Raises: + FileNotFoundError: The path names nothing on disk. + OSError: The path cannot be read. + """ + return replace( + self, + input_path=input_path, + output_path=get_output_path(config, input_path, channels), + is_file=input_path.is_file(), + ) + + 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 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..ea96a6487 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass, replace +from pathlib import Path +from typing import FrozenSet, Optional, Self, Tuple + +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 recordings a mixed run is being set up from, and the order they pick in. + + The list says what each recording converts under and the levels say which of them picks first, + so a gathered path stands in both. Holding the two together is what keeps that true through + every gesture: one recording joins or leaves the setup in a single step, and the ceiling a mix + holds to is answered once, before either side takes it up. + """ + + sources: SourceList + levels: MixLevels + + @classmethod + def empty(cls) -> Self: + """The setup a converter opens with, which a reader fills by picking recordings.""" + return cls(sources=SourceList(), levels=MixLevels()) + + @property + def count(self) -> int: + """How many recordings the setup holds.""" + return self.levels.count + + @property + def room(self) -> int: + """How many more recordings the setup has room to mix.""" + return self.levels.room + + @property + def paths(self) -> Tuple[Path, ...]: + """The gathered recordings, in the order they pick in.""" + return self.levels.paths + + def recording(self, path: Path) -> Optional[Recording]: + """The gathered recording at ``path``, where the setup holds one.""" + return self.sources.recording(path) + + def add(self, recording: Recording) -> Self: + """One more recording, picking last among the ones already gathered. + + A mix holds 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, path: Path) -> Self: + """The setup without the recording at ``path``, which leaves both sides of it.""" + return replace( + self, + sources=self.sources.remove(SourceKey.recording(path)), + levels=self.levels.remove(path), + ) + + 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 written_among( + self, + path: Path, + slot: SettingsSlot, + value: FrozenSet[ChannelName], + offered: FrozenSet[ChannelName], + ) -> Self: + """The setup with one recording's slot settled to ``value``, among the channels ``offered``. + + A channel left out of the run reaches no checkbox, so the recording keeps whatever it was + given for it and gets that choice back when the channel returns. + """ + recording = self.recording(path) + if recording is None: + return self + + held = slot.read(recording.settings) + return self.written(path, slot, (held - offered) | value) + + 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 kept_first(self) -> Self: + """What is left when a mix becomes one conversion: the recording that picks first.""" + levels = self.levels.keep_first() + return replace(self, sources=self._narrowed_to(levels.paths), levels=levels) + + def _narrowed_to(self, standing: Tuple[Path, ...]) -> SourceList: + """The list holding the recordings ``standing`` names, which is what a mix converts.""" + kept = frozenset(standing) + sources = self.sources + for path in self.sources.paths: + if path not in kept: + sources = sources.remove(SourceKey.recording(path)) + + return sources 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..b4872841e --- /dev/null +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -0,0 +1,439 @@ +from pathlib import Path +from typing import Callable, FrozenSet, Optional, Sequence + +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.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 ( + conversion_plan, + playing_sources, +) +from sampletones_application.logic.main.converter.state import ConverterState +from sampletones_application.logic.main.converter.view import compose_view +from sampletones_application.logic.main.sources.levels import MixLevels +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_application.view_model.main.converter import ( + ConversionPhase, + ConverterViewModel, +) +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.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, 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, + stems_mode=False, + channel_cap=len(ChannelName), + hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + ), + gathering=Gathering.empty(), + destination=Destination.unset(), + ) + + 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[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._state.settings.stems_mode + + @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 stems list has room for.""" + return self._state.gathering.room + + @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 set_input_path(self, input_path: Path, convert: bool = False) -> None: + destination = self._aimed_at(self._state, input_path) + if destination is None: + return + + self._state = self._state.with_destination(destination) + if not self.is_active: + self._run.return_to_idle() + self._emit(self._messages.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. + """ + gathering = self._state.gathering + for path in paths: + gathering = gathering.add(self._gathered(path)) + + self._settle(self._state.with_gathering(gathering)) + + def remove_source(self, path: Path) -> None: + """Takes a recording out of the stems list.""" + self._settle(self._state.with_gathering(self._state.gathering.remove(path))) + + 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.""" + gathering = self._state.gathering.written_among( + path, + CHANNEL_SLOT, + channels, + self._settings.enabled_channels, + ) + 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_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 + + state = self._state.with_settings(self._settings.with_stems_mode(stems_mode)) + self._settle(self._entered(state) if stems_mode else self._left(state)) + + def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: + """Names the channels a recording holds when it joins the list, carried between runs. + + A run hands out what a recording joins with, so narrowing this narrows every gathered + recording to the channels still named; each keeps the choice it was given for a channel + left out and gets it back when that channel returns. + """ + settings = self._settings.with_joining_channels(channels) + self._session_manager.set_converter_settings(settings.joining) + self._settle(self._state.with_settings(settings)) + + 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._settings.enabled_channels: + self.call(self.on_no_generators) + return + + plan = conversion_plan(self._state) + if plan is None: + logger.warning("Nothing is selected to convert") + return + + standing_target = self._standing_target(plan) + if standing_target is not None and not confirmed: + self.call(self.on_target_exists, standing_target) + 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 _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) + + @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 _aimed_at(self, state: ConverterState, input_path: Path) -> Optional[Destination]: + """Where a newly picked path would write, or nothing where the path cannot be read.""" + config = self._config_manager.config.model_copy() + try: + return state.destination.aimed_at(config, input_path, state.settings.enabled_channels) + except FileNotFoundError as exception: + logger.error("Input file does not exist") + self.call(self.on_error, exception) + except OSError as exception: + logger.error("Invalid path") + self.call(self.on_error, exception) + + return None + + def _entered(self, state: ConverterState) -> ConverterState: + """The setup a mix opens with: the file the reader picked, where they picked one.""" + destination = state.destination + if state.gathering.count or destination.input_path is None or not destination.is_file: + return state + + return state.with_gathering(state.gathering.add(self._gathered(destination.input_path))) + + def _left(self, state: ConverterState) -> ConverterState: + """The setup a mix leaves behind: the recording that picked first, as the single input.""" + if not state.gathering.count: + return state + + gathering = state.gathering.kept_first() + destination = self._aimed_at(state, gathering.paths[0]) + state = state.with_gathering(gathering) + return state if destination is None else state.with_destination(destination) + + def _relevel(self, levels: MixLevels) -> None: + """Takes up rewritten levels and follows them wherever the setup changed.""" + 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 setup it reported on + is no longer the one on screen. + """ + self._state = self._redirected(state) + if not self.is_active: + self._run.return_to_idle() + self._emit(self._messages.idle, 0.0) + + def _redirected(self, state: ConverterState) -> ConverterState: + if not state.settings.stems_mode: + return state + + return state.with_destination( + state.destination.aimed_at_mix( + self._config_manager.config, + playing_sources(state), + state.settings.enabled_channels, + ) + ) + + def _standing_target(self, plan: ConversionPlan) -> Optional[Path]: + """The reconstruction ``plan`` 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 run writing one document asks about that document. + """ + targets = plan.existing_targets(self._config_manager.config) + return targets[0] if targets else None + + 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(running_input), + progress=progress, + running_input=running_input, + reconstructions_directory=self._config_manager.get_reconstructions_directory(), + other_operation_active=self._is_operation_active(), + ) + self.call(self.on_view_changed, view_model) + + def _action_label(self, running_input: Optional[Path]) -> str: + destination = self._state.destination + input_path = running_input if running_input is not None else destination.input_path + return self._messages.action_label( + phase=self._run.phase, + stems_mode=self.stems_mode, + is_file=destination.is_file, + input_path=input_path, + playing=len(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..b3aceff88 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -0,0 +1,118 @@ +from pathlib import Path +from typing import Dict, Final, Optional + +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 + + +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.cancelling: str = language_manager["main.converter.message.status_cancelling"] + 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, + stems_mode: bool, + is_file: bool, + input_path: Optional[Path], + playing: int, + ) -> 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 phase in ACTIVE_PHASES: + return self._language_manager["main.converter.label.cancel_button"] + + if stems_mode: + return self._mix_label(playing) + + base = ( + self._language_manager["main.converter.label.convert_sample_button"] + if is_file + else self._language_manager["main.converter.label.convert_directory_button"] + ) + if input_path is None: + return base + + return self._named_label(base, input_path.name) + + def _mix_label(self, playing: int) -> str: + """The stems label, named after how many recordings take part.""" + base = self._language_manager["main.converter.label.convert_stems_button"] + if not playing: + return base + + return self._named_label(base, str(playing)) + + def _named_label(self, base: str, subject: str) -> str: + return self._language_manager["main.converter.template.convert_label_template"].format(base, subject) + + 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..af4c60e42 --- /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 cancelling 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.CANCELLING + self._report(self._messages.cancelling, 0.0) + self._system_progress.error() + self._service.cancel() + + def abandon(self) -> None: + """Gives up a request that never reached the service, which is a cancellation 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.CANCELLING: + self._report(self._messages.cancelling, 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..468c90a25 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, replace +from typing import FrozenSet, Self + +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +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 a run hands out the + channels it names: every gathered recording is narrowed to them, so this one value settles + both what a new row starts from and what the whole run reaches. The rest name the shape of the + run itself — whether several recordings are being mixed into one reconstruction, how many + channels one recording may hold in a frame, and how the levels take turns. + """ + + joining: StemSettings + stems_mode: bool + channel_cap: int + hierarchy_mode: HierarchyMode + + @property + def enabled_channels(self) -> FrozenSet[ChannelName]: + """The channels a run hands out, which is what a joining recording holds.""" + return self.joining.channel_set + + @property + def max_channel_cap(self) -> int: + """The highest cap the enabled channels leave room for, which is at least one channel.""" + return max(len(self.enabled_channels), MIN_CHANNEL_CAP) + + @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 with_joining_channels(self, channels: FrozenSet[ChannelName]) -> Self: + """The settings a recording joins with, holding exactly ``channels``. + + A bend the recording carried on a channel left out goes with it, which is what keeps the + joining settings a value the core accepts. + """ + return replace(self, joining=CHANNEL_SLOT.write(self.joining, channels)) + + def with_stems_mode(self, stems_mode: bool) -> Self: + """The run named as a mix of several recordings, or as one conversion.""" + return replace(self, stems_mode=stems_mode) + + 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..180f095a7 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/setup.py @@ -0,0 +1,67 @@ +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 ( + ConversionPlan, + DirectoryConversion, + GroupConversion, +) +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig + + +def conversion_setup(state: ConverterState) -> ConversionSetup: + """The recordings and the stems setup a run converts under, carrying the channel cap. + + A mix is what the gathered levels amount to; a single conversion is one stem over every + enabled channel, which is the classic run's shape. + """ + settings = state.settings + if settings.stems_mode: + return derive_conversion_setup( + state.gathering.sources, + state.gathering.levels, + settings.enabled_channels, + channel_cap=settings.effective_channel_cap, + hierarchy_mode=settings.hierarchy_mode, + ) + + joining = settings.joining + return ConversionSetup( + sources=(), + stems=StemsConfig.single_entry( + joining.channels, + joining.bends, + channel_cap=settings.effective_channel_cap, + ), + ) + + +def playing_sources(state: ConverterState) -> Tuple[Path, ...]: + """The recordings that take part, in the order the conversion mixes them.""" + return conversion_setup(state).sources + + +def conversion_plan(state: ConverterState) -> Optional[ConversionPlan]: + """What a request amounts to: one reconstruction from the recordings gathered or the file + picked, or one per audio file the picked directory holds. + + A mix converts the recordings gathered for it, so it names a plan whichever path the reader + picked. A single conversion needs one, and a converter aimed at nothing has nothing to run. + """ + setup = conversion_setup(state) + if state.settings.stems_mode: + return GroupConversion(sources=setup.sources, stems=setup.stems) + + input_path = state.destination.input_path + if input_path is None: + return None + + if state.destination.is_file: + return GroupConversion(sources=(input_path,), stems=setup.stems) + + return DirectoryConversion(directory=input_path, stems=setup.stems) 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..259d66124 --- /dev/null +++ b/src/sampletones_application/logic/main/converter/state.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass, replace +from typing import 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 + + +@dataclass(frozen=True) +class ConverterState: + """What the converter is set up to do, as one value every gesture rewrites a part of. + + The three 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. + """ + + settings: RunSettings + gathering: Gathering + destination: Destination + + 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) 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..664c4284e --- /dev/null +++ b/src/sampletones_application/logic/main/converter/view.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import FrozenSet, Optional, Tuple + +from sampletones_application.constants.conversion import MAX_STEM_SOURCES +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.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.shared.stems import StemRowViewModel +from sampletones_core.constants.enums import ChannelName + + +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, +) -> 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 picked + while a run is under way; ``reconstructions_directory`` is where a converter that has been + aimed at nothing yet would write. + """ + 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, + stems_mode=settings.stems_mode, + stem_sources=stem_rows(state.gathering, settings.enabled_channels), + enabled_channels=settings.enabled_channels, + channel_cap=settings.effective_channel_cap, + max_channel_cap=settings.max_channel_cap, + hierarchy_mode=settings.hierarchy_mode, + max_sources=MAX_STEM_SOURCES, + ) + + +def stem_rows( + gathering: Gathering, + enabled_channels: FrozenSet[ChannelName], +) -> 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 run enables. A recording that has left the + disk since it was gathered reports itself as missing. + """ + levels = gathering.levels + return tuple( + StemRowViewModel( + key=str(path), + path=path, + channels=_held_channels(gathering, path, enabled_channels), + offered_channels=enabled_channels, + available=path.is_file(), + 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) + ) + + +def _display_output(destination: Destination, reconstructions_directory: Path) -> Path: + return destination.output_path if destination.output_path is not None else reconstructions_directory + + +def _held_channels( + gathering: Gathering, + path: Path, + enabled_channels: FrozenSet[ChannelName], +) -> FrozenSet[ChannelName]: + """The channels one gathered recording takes, among the ones the run enables.""" + recording = gathering.recording(path) + return recording.settings.channel_set & enabled_channels if recording is not None else frozenset() diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py index 5888e4f1e..345cd560a 100644 --- a/src/sampletones_core/reconstructions/converter/__init__.py +++ b/src/sampletones_core/reconstructions/converter/__init__.py @@ -1,15 +1,6 @@ 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, @@ -21,17 +12,10 @@ __all__ = [ "BatchConversion", "BatchEntry", - "ConfigDirectoryFields", "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/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 92d020a78..b4f7bbb96 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -6,7 +6,7 @@ from sampletones_application.constants.conversion import MAX_STEM_SOURCES 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.tags.main import ( TAG_MAIN_CONVERTER_DIALOG_CANCEL, TAG_MAIN_CONVERTER_DIALOG_LOAD, 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..9f18beff8 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Tuple + +from sampletones_application.logic.main.converter.destination import Destination +from sampletones_core.configs import Config + + +class TestTheDocumentARunIsMaking: + """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: + 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..df4f64d0e --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -0,0 +1,134 @@ +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.slots import CHANNEL_SLOT +from sampletones_core.constants.enums import ChannelName +from tests.unit.sampletones_application.logic.main.sources.factories import recording + + +def _gathered(*names: str) -> Gathering: + gathering = Gathering.empty() + for name in names: + gathering = gathering.add(recording(f"/audio/{name}.wav")) + + return gathering + + +def _names(gathering: Gathering) -> List[str]: + return [path.stem for path in gathering.paths] + + +class TestGatheringRecordings: + def test_a_recording_stands_in_the_list_and_on_a_level(self) -> None: + gathering = _gathered("bass") + + assert _names(gathering) == ["bass"] + assert gathering.recording(Path("/audio/bass.wav")) is not None + + def test_recordings_pick_in_the_order_they_were_gathered(self) -> None: + assert _names(_gathered("bass", "lead")) == ["bass", "lead"] + + def test_a_recording_already_gathered_keeps_what_it_holds(self) -> None: + gathering = _gathered("bass").written( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.NOISE}), + ) + + gathering = gathering.add(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 = _gathered("bass", "lead").remove(Path("/audio/bass.wav")) + + assert _names(gathering) == ["lead"] + assert gathering.recording(Path("/audio/bass.wav")) is None + + +class TestTheCeilingAMixHoldsTo: + """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 = _gathered(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]) + + gathering = gathering.add(recording("/audio/one_more.wav")) + + assert gathering.count == MAX_STEM_SOURCES + assert gathering.recording(Path("/audio/one_more.wav")) is None + + +class TestSettlingOneRecording: + def test_a_slot_settles_on_the_recording_named(self) -> None: + gathering = _gathered("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 = _gathered("bass") + + assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering + + +class TestSettlingAmongTheChannelsOffered: + """A reader answers for the channels the run enables, and the rest stands as it was.""" + + def _held(self, gathering: Gathering) -> FrozenSet[ChannelName]: + settled = gathering.recording(Path("/audio/bass.wav")) + assert settled is not None + return settled.settings.channel_set + + def test_a_channel_left_out_of_the_run_keeps_the_choice_it_was_given(self) -> None: + gathering = Gathering.empty().add(recording("/audio/bass.wav", [ChannelName.PULSE1, ChannelName.NOISE])) + + gathering = gathering.written_among( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset(), + frozenset({ChannelName.PULSE1}), + ) + + assert self._held(gathering) == {ChannelName.NOISE} + + def test_a_channel_the_reader_answered_for_settles_to_the_answer(self) -> None: + gathering = Gathering.empty().add(recording("/audio/bass.wav", [ChannelName.PULSE1])) + + gathering = gathering.written_among( + Path("/audio/bass.wav"), + CHANNEL_SLOT, + frozenset({ChannelName.PULSE2}), + frozenset({ChannelName.PULSE1, ChannelName.PULSE2}), + ) + + assert self._held(gathering) == {ChannelName.PULSE2} + + def test_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: + gathering = _gathered("bass") + + assert gathering.written_among(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset(), frozenset()) == gathering + + +class TestWhatAMixLeavesBehind: + def test_the_recording_that_picks_first_stays(self) -> None: + assert _names(_gathered("bass", "lead").kept_first()) == ["bass"] + + def test_the_recordings_it_leaves_go_from_the_list_as_well(self) -> None: + gathering = _gathered("bass", "lead").kept_first() + + assert gathering.recording(Path("/audio/lead.wav")) is None + assert gathering.sources.count == 1 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..d89df9ab1 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -0,0 +1,684 @@ +from pathlib import Path +from typing import Callable, 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.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 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" +OUTPUT_PATH: str = "sampletones_application.logic.main.converter.destination.get_output_path" + + +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 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 = MagicMock() + config_manager.config = _config_writing_under(reconstructions_directory) + config_manager.get_reconstructions_directory.return_value = 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 _gathered(converter_logic: ConverterLogic, *names: str) -> None: + converter_logic.set_stems_mode(True) + converter_logic.add_sources([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: + """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, + 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: + """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: + converter_logic.set_joining_channels(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: + """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.""" + _gathered(converter_logic, "a", "b") + + plan = _started_plan(converter_logic, service) + + assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + +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) + 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 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_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(SCHEDULING): + converter_logic.start_conversion() + + on_target_exists.assert_not_called() + assert _phase(converter_logic) == ConversionPhase.WAITING + + +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, + 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 TestPickingWhatToConvert: + """``get_output_path``'s contract is the ``OSError`` family: those failures abort the + selection 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(OUTPUT_PATH, side_effect=error): + converter_logic.set_input_path(Path("/tmp/input.wav")) + + converter_logic.on_error.assert_called_once_with(error) + converter_logic.emit_initial_view() + assert _view(converter_logic).input_path is None + + def test_unexpected_failure_propagates( + self, + converter_logic: ConverterLogic, + ) -> None: + converter_logic.on_error = MagicMock() + + with patch(OUTPUT_PATH, side_effect=KeyError("drive")), pytest.raises(KeyError): + converter_logic.set_input_path(Path("/tmp/input.wav")) + + converter_logic.on_error.assert_not_called() + + def test_a_picked_recording_reaches_the_view( + 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) + + +class TestWhatACompletedConversionLeaves: + """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: + """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: + """``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: + """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_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: ConverterLogic) -> None: + converter_logic.set_stems_mode(True) + + converter_logic.select_source(Path("/audio/bass.wav")) + converter_logic.select_source(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: + _gathered(converter_logic, "bass", "lead") + converter_logic.isolate_source(Path("/audio/lead.wav")) + + converter_logic.add_sources([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: + _gathered(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: + _gathered(converter_logic, "a", "b") + + converter_logic.remove_source(Path("/audio/a.wav")) + + assert self._names(converter_logic) == ["b"] + + def test_entering_stems_mode_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_stems_mode(True) + + assert self._names(converter_logic) == [source.stem] + + def test_leaving_stems_mode_keeps_the_recording_that_picks_first( + self, + converter_logic: ConverterLogic, + ) -> None: + _gathered(converter_logic, "a", "b") + + converter_logic.set_stems_mode(False) + + assert converter_logic.source_count == 1 + + def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: + _gathered(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 TestWhatTheGatheredRecordingsRun: + """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: + _gathered(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: + _gathered(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: + _gathered(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: + _gathered(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: + _gathered(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 TestTheStemsView: + """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: + _gathered(converter_logic, "a", "b") + + view_model = _view(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: + _gathered(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: + _gathered(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_stems_mode(True) + + 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_enabled( + self, + converter_logic: ConverterLogic, + session_manager: SessionManager, + ) -> None: + channels = session_manager.converter_settings.channels + + converter_logic.set_channel_cap(len(channels) + 5) + + assert _view(converter_logic).channel_cap == len(channels) + + +def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Path: + """Points the converter at a recording standing on disk, the way the browser does.""" + source = tmp_path / "song.wav" + source.touch() + converter_logic.set_input_path(source) + return source 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..c670411d5 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -0,0 +1,139 @@ +from pathlib import Path +from typing import Final, Optional + +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.unit.sampletones_application.logic.main.converter.texts import 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: + """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: + """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_a_file_names_the_recording_it_would_convert(self) -> None: + label = messages().action_label( + phase=ConversionPhase.IDLE, + stems_mode=False, + is_file=True, + input_path=Path("/audio/kick.wav"), + playing=0, + ) + + assert label == "Convert sample: kick.wav" + + def test_a_directory_uses_the_directory_variant(self) -> None: + label = messages().action_label( + phase=ConversionPhase.IDLE, + stems_mode=False, + is_file=False, + input_path=Path("/audio/drums"), + playing=0, + ) + + assert label == "Convert directory: drums" + + def test_nothing_picked_reads_the_bare_convert_label(self) -> None: + label = messages().action_label( + phase=ConversionPhase.IDLE, + stems_mode=False, + is_file=True, + input_path=None, + playing=0, + ) + + assert label == "Convert sample" + + def test_a_mix_names_how_many_recordings_take_part(self) -> None: + label = messages().action_label( + phase=ConversionPhase.IDLE, + stems_mode=True, + is_file=True, + input_path=Path("/audio/kick.wav"), + playing=3, + ) + + assert label == "Convert stems: 3" + + def test_a_mix_with_nobody_taking_part_reads_the_bare_label(self) -> None: + label = messages().action_label( + phase=ConversionPhase.IDLE, + stems_mode=True, + is_file=True, + input_path=None, + playing=0, + ) + + assert label == "Convert stems" + + @pytest.mark.parametrize( + "phase", + [ConversionPhase.WAITING, ConversionPhase.RUNNING, ConversionPhase.CANCELLING], + ) + def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: ConversionPhase) -> None: + label = messages().action_label( + phase=phase, + stems_mode=False, + is_file=True, + input_path=Path("/audio/kick.wav"), + playing=0, + ) + + assert label == "Cancel" 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..6cb54ac08 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -0,0 +1,212 @@ +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.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: + """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_cancelling_holds_resources_until_the_service_answers(self, driver: Driver) -> None: + driver.begin() + + driver.run.cancel() + + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.CANCELLING, 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: + 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_cancelled_run_keeps_reporting_the_cancelling_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_cancelling" + assert driver.run.phase == ConversionPhase.CANCELLING + + 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: + """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..cd0533144 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -0,0 +1,64 @@ +from typing import List + +import pytest + +from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +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 + +TONES: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _settings(channels: List[ChannelName], channel_cap: int = 4) -> RunSettings: + return RunSettings( + joining=_joining(channels), + stems_mode=False, + 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 TestTheChannelsARunHandsOut: + def test_the_channels_a_recording_joins_with_are_what_the_run_enables(self) -> None: + assert _settings(TONES).enabled_channels == frozenset(TONES) + + def test_narrowing_the_joining_channels_takes_the_bends_they_carried(self) -> None: + narrowed = _settings(TONES).with_joining_channels(frozenset({ChannelName.PULSE1})) + + assert narrowed.joining.channels == [ChannelName.PULSE1] + assert ChannelName.TRIANGLE not in narrowed.joining.bends + + +class TestTheCapARunHoldsTo: + def test_a_cap_beyond_the_channels_enabled_is_held_to_them(self) -> None: + settings = _settings(TONES).with_channel_cap(len(TONES) + 5) + + assert settings.effective_channel_cap == len(TONES) + + 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_a_cap_falls_with_the_channels_it_was_asked_for(self) -> None: + settings = _settings(TONES).with_channel_cap(3).with_joining_channels(frozenset({ChannelName.PULSE1})) + + assert settings.effective_channel_cap == 1 + + @pytest.mark.parametrize("channels", [[], [ChannelName.PULSE1]], ids=["none", "one"]) + def test_the_cap_always_leaves_room_for_one_channel(self, channels: List[ChannelName]) -> None: + assert _settings(channels).max_channel_cap == MIN_CHANNEL_CAP + + +class TestTheShapeOfTheRun: + def test_the_run_is_named_as_a_mix(self) -> None: + assert _settings(TONES).with_stems_mode(True).stems_mode 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..337d47514 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -0,0 +1,115 @@ +from pathlib import Path +from typing import List, Optional + +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 ( + 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 DirectoryConversion, GroupConversion +from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.unit.sampletones_application.logic.main.sources.factories import recording + +JOINING: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] + + +def _state( + *, + stems_mode: bool, + input_path: Optional[Path] = None, + is_file: bool = True, + gathered: Optional[List[str]] = None, + channel_cap: int = len(JOINING), + hierarchy_mode: HierarchyMode = DEFAULT_STEMS_HIERARCHY_MODE, +) -> ConverterState: + gathering = Gathering.empty() + for name in gathered or []: + gathering = gathering.add(recording(name, JOINING)) + + return ConverterState( + settings=RunSettings( + joining=StemSettings(channels=JOINING, bends=bending_channels(JOINING)), + stems_mode=stems_mode, + channel_cap=channel_cap, + hierarchy_mode=hierarchy_mode, + ), + gathering=gathering, + destination=Destination(input_path=input_path, output_path=None, is_file=is_file), + ) + + +class TestWhatOneConversionRuns: + """One reconstruction for a file, one per audio file for a directory.""" + + def test_a_file_becomes_one_group_over_that_file(self) -> None: + plan = conversion_plan(_state(stems_mode=False, input_path=Path("/audio/kick.wav"))) + + assert isinstance(plan, GroupConversion) + assert plan.sources == (Path("/audio/kick.wav"),) + + def test_a_directory_becomes_a_directory_conversion(self) -> None: + plan = conversion_plan(_state(stems_mode=False, input_path=Path("/audio"), is_file=False)) + + assert isinstance(plan, DirectoryConversion) + assert plan.directory == Path("/audio") + + def test_a_converter_aimed_at_nothing_names_no_plan(self) -> None: + assert conversion_plan(_state(stems_mode=False)) is None + + def test_the_setup_covers_every_channel_a_recording_joins_with(self) -> None: + """With no stems listed, one stem holds the settings a recording joins the list with.""" + state = _state(stems_mode=False, input_path=Path("/audio/kick.wav")) + + stems = conversion_setup(state).stems + + assert stems == StemsConfig.single_entry( + JOINING, + bending_channels(JOINING), + channel_cap=len(JOINING), + ) + assert stems.covered_channels == frozenset(JOINING) + + def test_the_cap_reaches_a_classic_conversion_too(self) -> None: + """One recording per frame is a choice a reader makes for every conversion, batch included.""" + state = _state(stems_mode=False, input_path=Path("/audio/kick.wav"), channel_cap=1) + + assert conversion_setup(state).stems.channel_cap == 1 + + +class TestWhatAMixRuns: + def test_a_mix_groups_every_gathered_recording(self) -> None: + plan = conversion_plan(_state(stems_mode=True, gathered=["/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_names_its_plan_without_a_recording_ever_being_picked(self) -> None: + """A mix converts what it gathered, so nothing about the browser's selection reaches it.""" + state = _state(stems_mode=True, gathered=["/audio/a.wav"]) + + assert conversion_plan(state) is not None + + def test_the_levels_take_turns_as_the_reader_asked(self) -> None: + state = _state( + stems_mode=True, + gathered=["/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(stems_mode=True, gathered=["/audio/a.wav", "/audio/b.wav"]) + + assert playing_sources(state) == (Path("/audio/a.wav"), Path("/audio/b.wav")) + + def test_a_single_conversion_mixes_nobody(self) -> None: + assert playing_sources(_state(stems_mode=False, input_path=Path("/audio/kick.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..673368226 --- /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_sample_button": "Convert sample", + "main.converter.label.convert_directory_button": "Convert directory", + "main.converter.label.convert_stems_button": "Convert stems", + "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": "", +} + + +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/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py deleted file mode 100644 index 53fbe242e..000000000 --- a/tests/unit/sampletones_application/logic/main/test_converter.py +++ /dev/null @@ -1,841 +0,0 @@ -from pathlib import Path -from typing import Dict, Final, Optional -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, 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, bending_channels -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 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 converter_logic(tmp_path: Path, session_manager: SessionManager) -> 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, - 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 - - -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: - converter_logic.set_joining_channels(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 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 - plan = converter_logic._conversion_plan(config, path) - return plan.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_channel_a_recording_joins_with( - self, - converter_logic: ConverterLogic, - session_manager: SessionManager, - ) -> None: - """With no stems listed, one stem holds the settings a recording joins the list with.""" - config = self._prepare(converter_logic, Path("/audio/kick.wav"), is_file=True) - joining = session_manager.converter_settings - - plan = converter_logic._conversion_plan(config, Path("/audio/kick.wav")) - - assert plan.stems == StemsConfig.single_entry( - joining.channels, - joining.bends, - channel_cap=len(joining.channels), - ) - assert plan.stems.covered_channels == joining.channel_set - - 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].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) -> 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, - session_manager: SessionManager, - ) -> None: - self._with_config(converter_logic) - channels = session_manager.converter_settings.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/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 30d2f22ea..145e39fff 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -35,7 +35,7 @@ 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_core.constants.enums import ChannelName from sampletones_core.reconstructions import Reconstruction @@ -426,6 +426,26 @@ def drop(tag: str, payload: str) -> None: dpg.get_item_configuration(tag)["drop_callback"](dpg.get_alias_id(tag), payload) +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).row_tag(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) + + class TestConverterStemsCard: """Gathering recordings paints the converter card: a row each, carrying what the reader set.""" @@ -479,11 +499,8 @@ def test_leaving_stems_mode_hides_the_list(self, app: Application, tmp_path: Pat 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 @@ -516,16 +533,16 @@ def test_dropping_a_recording_on_a_row_joins_that_rows_level(self, app: Applicat drop(stems_list(app).row_tag(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).level_tag(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)) - assert converter_logic._levels.level_count == 2 - assert converter_logic._levels.level_of(first) == 1 + assert dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE)) + assert _level_of(app, first) == stems_list(app).level_tag(1, SUF_TABLE) 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.""" From 18ab7518389332dc8890519095c7428434240a43 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 04:07:26 +0200 Subject: [PATCH 009/130] Decomposed: the stems list into the jobs it was doing --- .../logic/main/converter/output.py | 19 + .../ui/elements/stems/bands.py | 122 ++++ .../ui/elements/stems/gestures.py | 162 +++++ .../ui/elements/stems/list.py | 567 ++---------------- .../ui/elements/stems/messages.py | 108 ++++ .../ui/elements/stems/offer.py | 31 + .../ui/elements/stems/row.py | 190 ++++++ .../ui/elements/stems/shape.py | 43 ++ .../ui/elements/stems/tags.py | 68 +++ .../ui/panels/main/converter.py | 6 +- .../ui/panels/reconstruction/stems.py | 6 +- .../view_model/shared/stems.py | 30 +- .../sampletones_application/test_startup.py | 42 +- .../ui/elements/stems/test_list.py | 53 +- .../panels/reconstruction/test_stems_panel.py | 28 +- 15 files changed, 905 insertions(+), 570 deletions(-) create mode 100644 src/sampletones_application/logic/main/converter/output.py create mode 100644 src/sampletones_application/ui/elements/stems/bands.py create mode 100644 src/sampletones_application/ui/elements/stems/gestures.py create mode 100644 src/sampletones_application/ui/elements/stems/messages.py create mode 100644 src/sampletones_application/ui/elements/stems/offer.py create mode 100644 src/sampletones_application/ui/elements/stems/row.py create mode 100644 src/sampletones_application/ui/elements/stems/shape.py create mode 100644 src/sampletones_application/ui/elements/stems/tags.py diff --git a/src/sampletones_application/logic/main/converter/output.py b/src/sampletones_application/logic/main/converter/output.py new file mode 100644 index 000000000..69a157ace --- /dev/null +++ b/src/sampletones_application/logic/main/converter/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/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py new file mode 100644 index 000000000..cfcd2fbd7 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -0,0 +1,122 @@ +from typing import 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_STEMS_DROP_STRIP, +) +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.stems.gestures import StemsGestures +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 +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. + """ + + def __init__( + self, + tags: StemsTags, + *, + layout: StemsListLayout, + offer: StemsListOffer, + language_manager: LanguageManager, + rows: StemRowRenderer, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._rows = rows + self._gestures = gestures + self._level_template = language_manager["global.stems.template.level_caption"] + self._shape = ListShape.nothing() + + def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> None: + """Build the bands afresh where the view names a different shape than the one standing.""" + shape = ListShape.of(view_model) + if shape == self._shape: + return + + self._shape = shape + dpg.delete_item(self._tags.body, children_only=True) + if view_model.collapse_levels: + self._create_table(self._tags.table, view_model, view_model.rows) + 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_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: + 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.MONO_SMALL) + + 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.""" + with dpg.table( + tag=tag, + parent=self._tags.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + ): + self._rows.declare_columns(view_model) + for row in rows: + self._rows.create(row, view_model) 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..7951a9896 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -0,0 +1,162 @@ +from typing import Any, Callable, FrozenSet, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_TEXT, +) +from sampletones_application.ui.elements.status import GUIStatusBar +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 + +ChannelsCallback = Callable[[str, FrozenSet[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. + """ + + def __init__( + self, + tags: StemsTags, + *, + messages: StemsMessages, + status_bar: GUIStatusBar, + ) -> None: + self._tags = tags + self._messages = messages + self._status_bar = status_bar + self._view = StemsListViewModel.empty() + + self.on_channels_settled: Optional[ChannelsCallback] = 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 + + @property + def activatable(self) -> bool: + """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" + return self.on_row_activated is not 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 (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON): + 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_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)) + + 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, so the row reports every box it now holds ticked.""" + key, _channel_name = user_data + row = self._view.row(key) + if row is None: + return + + channels = frozenset( + channel_name + for channel_name in self._view.boxes_of(row) + if dpg.get_value(self._tags.channel(key, channel_name)) + ) + self._report(self.on_channels_settled, key, channels) + + 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_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> None: + self._report(self.on_removal_asked, user_data) + + def on_name_selected(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._report(self.on_row_activated, 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: + mouse_button, clicked_item = app_data + if mouse_button != dpg.mvMouseButton_Right: + return + + key = dpg.get_item_user_data(clicked_item) + if isinstance(key, str): + self._report(self.on_menu_asked, key) + + 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/list.py b/src/sampletones_application/ui/elements/stems/list.py index 16acae045..025e0d2b9 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,70 +1,38 @@ -from typing import Any, Callable, Dict, FrozenSet, Optional, Sequence, Tuple +from typing import Optional -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.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.gestures import ( + ChannelsCallback, + KeyOffsetCallback, + KeyPairCallback, + StemsGestures, ) -from sampletones_application.utils.gui.tooltip import show_tooltip +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.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 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. """ def __init__( @@ -74,41 +42,35 @@ def __init__( layout: StemsListLayout, 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._offer = offer + self._view = StemsListViewModel.empty() - 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._messages = StemsMessages( + language_manager, + offer=offer, + activatable=lambda: self.activatable, + ) + self._gestures = StemsGestures(self._tags, messages=self._messages, status_bar=status_bar) + self._rows = StemRowRenderer( + self._tags, + layout=layout, + offer=offer, + language_manager=language_manager, + messages=self._messages, + gestures=self._gestures, + ) + self._bands = LevelBands( + self._tags, + layout=layout, + offer=offer, + language_manager=language_manager, + rows=self._rows, + gestures=self._gestures, + ) self.on_channels_changed: Optional[ChannelsCallback] = None self.on_remove_requested: Optional[StringCallback] = None @@ -117,451 +79,54 @@ def __init__( self.on_dropped_on_row: Optional[KeyPairCallback] = None self.on_dropped_on_level: Optional[KeyOffsetCallback] = None + self._gestures.on_channels_settled = lambda key, channels: self.call(self.on_channels_changed, key, channels) + 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) + @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.""" 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() + self._gestures.create_handlers() well( parent, - self._well_tag, + self._tags.well, 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) + """Take up a new reading of the setup: rebuild the bands where it reshapes them, repaint + the rows either way.""" + self._view = view_model + self._messages.reads(view_model) + self._gestures.reads(view_model) + self._bands.rebuild_if_reshaped(view_model) for row in view_model.rows: - self._render_row(row) + self._rows.repaint(row, view_model, releasable=self._releasable) 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)) - - 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)) - - 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 _sync_rows(self, view_model: StemsListViewModel) -> None: - """Rebuild the bands when the recordings, their levels or their boxes change. - - 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. - """ - shape = self._row_shape(view_model) - if shape == self._shape: - 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) - - if view_model.level_count: - self._create_level_strip(view_model.level_count) - - @staticmethod - def _row_shape(view_model: StemsListViewModel) -> RowShape: - """What the bands are built from: the columns, the banding, and where each row stands. - - 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. - """ - 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. - - 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 - - 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 _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) - - 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 _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) - - 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)) - - def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: - """The box giving the recording a channel, where the recording holds frames on it. - - 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. - """ - if channel_name not in row.offered_channels: - dpg.add_spacer() - return - - 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 _render_row(self, row: StemRowViewModel) -> 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. - """ - 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) + return self._view.row(key) @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 "" - - if self._draggable: - return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) - - 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: - 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), - ) + return self._view.row_count > 1 or not self._offer.keeps_last_row 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..786bfc05c --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -0,0 +1,108 @@ +from typing import Any, Callable, Optional, Tuple + +from sampletones_application.categories.context import channel_label +from sampletones_application.categories.manager import LanguageManager +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, + activatable: Callable[[], bool], + ) -> None: + self._language_manager = language_manager + self._offer = offer + self._activatable = activatable + 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"] + + 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 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._offer.dragging: + lines.append(self._msg_drag) + + return "\n".join(lines) + + def name(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: + row = self._row(user_data) + if row is None: + return "" + + if self._offer.dragging: + return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) + + if self._activatable(): + return self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name) + + return 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 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 "" + + return self._language_manager["global.stems.message.status_remove"].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..d03472a7a --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -0,0 +1,31 @@ +from dataclasses import dataclass + + +@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 and a reconstruction's recorded assignment 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. + """ + + master_box: bool + removal: bool + keeps_last_row: bool + dragging: bool + + +GATHERED_SOURCES: StemsListOffer = StemsListOffer( + master_box=False, + removal=True, + keeps_last_row=False, + dragging=True, +) + +RECORDED_ASSIGNMENT: StemsListOffer = StemsListOffer( + master_box=True, + removal=True, + keeps_last_row=True, + dragging=False, +) 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..81f2f7e46 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -0,0 +1,190 @@ +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.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_TEXT, + SUF_TOOLTIP, + TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_DANGER_BUTTON, + 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.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 +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.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, + language_manager: LanguageManager, + messages: StemsMessages, + gestures: StemsGestures, + ) -> None: + self._tags = tags + self._layout = layout + self._offer = offer + self._language_manager = language_manager + self._messages = messages + self._gestures = gestures + self._lbl_remove = language_manager["global.stems.label.remove"] + + def declare_columns(self, view_model: StemsListViewModel) -> None: + """The columns every band holds to, so the rows line up across the bands.""" + if self._offer.master_box: + 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._offer.removal: + dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) + + def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Build the widgets one row stands as, in the columns the bands were declared with.""" + with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)): + if self._offer.master_box: + 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._offer.removal: + self._create_remove(row) + + def repaint( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + *, + releasable: bool, + ) -> 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. + """ + live = view_model.live + for channel_name in view_model.boxes_of(row): + tag = self._tags.channel(row.key, channel_name) + dpg_configure_item(tag, enabled=live) + dpg_set_value(tag, channel_name in row.channels) + ThemeRegistry.get(self._channel_theme(channel_name, view_model)).bind_to_item(tag) + + name_tag = self._tags.row(row.key, SUF_TEXT) + dpg_set_value(name_tag, False) + dpg_configure_item(name_tag, enabled=live) + 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 row.offers_channels) + dpg_set_value(master_tag, row.takes_part) + + if self._offer.removal: + dpg_configure_item(self._tags.row(row.key, SUF_BUTTON), enabled=live and releasable) + + def _create_master(self, row: StemRowViewModel) -> None: + """The box moving every channel the row offers at once.""" + master = dpg.add_checkbox( + tag=self._tags.row(row.key, SUF_CHECKBOX), + default_value=row.takes_part, + user_data=row.key, + callback=self._gestures.on_master_box, + ) + self._gestures.bind(master, SUF_CHECKBOX) + + 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._tags.row(row.key, SUF_TEXT), + 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.dragging: + 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.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 _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: + """The box giving the recording a channel, where the recording holds frames on it. + + 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. + """ + if channel_name not in row.offered_channels: + dpg.add_spacer() + return + + checkbox_tag = self._tags.channel(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._gestures.on_channel_box, + ) + self._gestures.bind(checkbox_tag, SUF_CHANNELS) + + 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, view_model: StemsListViewModel) -> str: + """The tone a channel's boxes take: its own color, muted where the channel is off.""" + if channel_name in view_model.muted_channels: + return TAG_GLOBAL_THEME_CHANNEL_MUTED + + 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..d40006056 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import FrozenSet, Self, Tuple + +from sampletones_application.view_model.shared.stems import StemsListViewModel +from sampletones_core.constants.enums import ChannelName + + +@dataclass(frozen=True) +class RowPlacement: + """Where one row stands: what it is, which band holds it, and which boxes it draws.""" + + key: str + level: int + offered: FrozenSet[ChannelName] + + +@dataclass(frozen=True) +class ListShape: + """What the bands are built from, so a change here is a rebuild 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) -> 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) 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=()) 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..5101ab8ba --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_GROUP, + SUF_HANDLER_REGISTRY, + SUF_LEVEL, + SUF_PAYLOAD, + 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. + """ + + 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) + + @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, 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 compose_tag( + self.prefix, + SUF_ROW, + key, + SUF_CHANNELS, + compose_tag(channel_name, SUF_CHECKBOX), + ) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 0233a8499..58220c0ad 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -50,6 +50,7 @@ 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.elements.stems.offer import GATHERED_SOURCES from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( @@ -139,10 +140,7 @@ def __init__( layout=stems_layout, language_manager=language_manager, status_bar=status_bar, - draggable=True, - removable=True, - retain_last_row=False, - master_checkbox=False, + offer=GATHERED_SOURCES, ) super().__init__(tag=TAG_MAIN_CONVERTER_PANEL) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index cab5ff5e3..5362e875b 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 ( @@ -66,10 +67,7 @@ def __init__( layout=stems_layout, 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 diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index eba1f7b37..cb7dc35cf 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -1,5 +1,6 @@ +from functools import cached_property from pathlib import Path -from typing import FrozenSet, Tuple +from typing import Dict, FrozenSet, Optional, Self, Tuple from pydantic import BaseModel @@ -82,6 +83,17 @@ class StemsListViewModel(BaseModel, frozen=True): live: bool collapse_levels: bool + @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(), + live=True, + collapse_levels=False, + ) + @property def row_count(self) -> int: return len(self.rows) @@ -91,6 +103,22 @@ 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) + + 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]: + return {row.key: row for row in self.rows} + @property def playing_count(self) -> int: """How many of the listed recordings hold a channel.""" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 145e39fff..3231da86c 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -428,7 +428,7 @@ def drop(tag: str, payload: str) -> None: 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).row_tag(str(path), SUF_GROUP))) + 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: @@ -465,8 +465,8 @@ def test_a_row_is_built_for_every_recording(self, app: Application, tmp_path: Pa 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.""" @@ -477,16 +477,16 @@ def test_a_rows_channels_show_what_was_set(self, app: Application, tmp_path: Pat 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"]) @@ -503,7 +503,7 @@ def test_the_list_stays_on_screen_while_a_conversion_runs(self, app: Application _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"]) @@ -511,38 +511,38 @@ 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: 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 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 _level_of(app, first) == _level_of(app, second) - assert not dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE)) + 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"]) - drop(stems_list(app).level_tag(1, SUF_STRIP), str(first)) + drop(stems_list(app).tags.level(1, SUF_STRIP), str(first)) - assert dpg.does_item_exist(stems_list(app).level_tag(1, SUF_TABLE)) - assert _level_of(app, first) == stems_list(app).level_tag(1, SUF_TABLE) + 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_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.""" @@ -563,7 +563,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/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 5b41f9e28..9b69c584a 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -31,6 +31,7 @@ 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 StemsListOffer 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 @@ -71,20 +72,22 @@ 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, ) -> GUIStemsList: stems_list = GUIStemsList( prefix=PREFIX, layout=layout_config.general.stems, 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, + ), ) with dpg.window(tag=ROOT_TAG): stems_list.create(ROOT_TAG) @@ -242,7 +245,7 @@ def test_a_draggable_list_makes_the_row_itself_the_thing_you_drag( dpg_context: None, layout_config, ) -> None: - stems_list = build(layout_config, draggable=True) + stems_list = build(layout_config, dragging=True) bass = row("bass") stems_list.update_view(view(bass)) @@ -254,7 +257,7 @@ def test_a_list_without_dragging_carries_no_payload_and_no_strip( dpg_context: None, layout_config, ) -> None: - stems_list = build(layout_config, draggable=False) + stems_list = build(layout_config, dragging=False) bass = row("bass") stems_list.update_view(view(bass)) @@ -263,7 +266,7 @@ def test_a_list_without_dragging_carries_no_payload_and_no_strip( assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_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) + stems_list = build(layout_config, removal=True) bass = row("bass") stems_list.update_view(view(bass)) @@ -271,7 +274,7 @@ def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layou 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) + stems_list = build(layout_config, removal=False) bass = row("bass") stems_list.update_view(view(bass)) @@ -285,7 +288,7 @@ def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( dpg_context: None, layout_config, ) -> 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)) @@ -293,7 +296,7 @@ def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( 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) + stems_list = build(layout_config, keeps_last_row=True) bass = row("bass") lead = row("lead") @@ -302,7 +305,7 @@ 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) + stems_list = build(layout_config, keeps_last_row=True) bass = row("bass") lead = row("lead") stems_list.update_view(view(bass, lead)) @@ -312,7 +315,7 @@ 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) + stems_list = build(layout_config, keeps_last_row=False) bass = row("bass") stems_list.update_view(view(bass)) @@ -429,7 +432,7 @@ def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_conf 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) + stems_list = build(layout_config, master_box=True) playing = row("bass") quiet = row("pad", channels=frozenset()) @@ -444,7 +447,7 @@ def test_ticking_the_master_box_hands_the_row_every_channel_it_offers( layout_config, ) -> 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)) @@ -456,7 +459,7 @@ def test_ticking_the_master_box_hands_the_row_every_channel_it_offers( def test_unticking_the_master_box_takes_every_channel_away(self, dpg_context: None, layout_config) -> 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)) @@ -471,7 +474,7 @@ def test_a_row_offering_no_channel_has_nothing_for_its_master_box_to_do( dpg_context: None, layout_config, ) -> 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)) @@ -521,7 +524,7 @@ def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: 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) + 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 +532,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 dpg.does_item_exist(stems_list.tags.table) assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_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) + 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,7 +547,7 @@ 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 not dpg.does_item_exist(stems_list.tags.table) 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)) @@ -552,7 +555,7 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf class TestActivation: def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: None, layout_config) -> 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)) 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..321e68fcc 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 @@ -132,8 +132,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 +144,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 +161,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 +175,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 +190,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 +206,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 +219,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 +255,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 +278,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: From 75859dd7585caa6ffd80c0b9a315a1ed41eb7bb2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 04:49:08 +0200 Subject: [PATCH 010/130] Named: the converter's output as a run over the sources gathered --- docs/concepts/stems.md | 7 +- docs/guide/interface.md | 58 +++--- src/sampletones_application/application.py | 4 +- .../main/converter => constants}/output.py | 0 .../coordinators/tabs/main.py | 75 +++----- .../logic/main/converter/destination.py | 42 ++++- .../logic/main/converter/gathering.py | 116 ++++++++---- .../logic/main/converter/logic.py | 173 ++++++++++-------- .../logic/main/converter/messages.py | 4 +- .../logic/main/converter/settings.py | 18 +- .../logic/main/converter/setup.py | 98 ++++++---- .../logic/main/converter/view.py | 59 +++++- .../ui/panels/main/converter.py | 15 +- .../view_model/main/converter.py | 21 ++- src/sampletones_config/lang/en.yaml | 24 +-- .../coordinators/tabs/test_main.py | 158 ++++++---------- .../logic/main/converter/test_gathering.py | 118 +++++++++--- .../logic/main/converter/test_logic.py | 137 +++++++------- .../logic/main/converter/test_messages.py | 12 +- .../logic/main/converter/test_settings.py | 5 +- .../logic/main/converter/test_setup.py | 101 +++++----- .../sampletones_application/test_startup.py | 19 +- .../view_model/main/test_converter.py | 27 ++- 23 files changed, 750 insertions(+), 541 deletions(-) rename src/sampletones_application/{logic/main/converter => constants}/output.py (100%) diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 6b32ece80..89cf6a564 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -134,8 +134,11 @@ 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, the diff --git a/docs/guide/interface.md b/docs/guide/interface.md index e01a5d67d..b8cd5a208 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -25,42 +25,50 @@ 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. -Converting one file or one stems mix always writes to the same filename. If a +Converting to one reconstruction 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. +**Convert anyway** to replace it. 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. -### Stems mode +### What to convert -**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. +The card holds a list of what a run converts. Click a recording in the browser to +add it; right-click and choose **Add as stem**, or Ctrl-click, to do the same. +Ctrl-click a folder — or use **Add folder as stems** — and the folder joins as +one row standing for the recordings inside it. 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. +in the list so you can bring it back. **x** takes a row out; taking out a folder +takes everything it holds. -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. +### One reconstruction each, or one from them all -**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. +**Mix into one** names what the run writes. Left clear, every recording in the +list gets a reconstruction of its own, and the ones a folder holds are written +into a tree mirroring that folder. Tick it and they mix into a single +reconstruction instead. + +A mix reaches eight recordings, so ticking it with a longer list asks which ones +to mix. The folders give up the recordings they stood for, and what you pick is +what the mix converts. + +While mixing, 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. **Order** sets how the levels take turns: round by round, or one level +filled before the next picks. **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. +single frame, and it applies to every conversion. Set to 1, each recording gets a +single voice. -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. +Reconstructing a file or a folder from the browser converts that one thing, so it +asks first where you have already gathered a list. ### Settings diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 1dbd25f28..e607f4a79 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1005,7 +1005,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 +1021,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() diff --git a/src/sampletones_application/logic/main/converter/output.py b/src/sampletones_application/constants/output.py similarity index 100% rename from src/sampletones_application/logic/main/converter/output.py rename to src/sampletones_application/constants/output.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 3bc906537..4508f1a91 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -6,6 +6,8 @@ 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.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind from sampletones_application.logic.instruction.library_manager import ( InstructionsLibraryManager, ) @@ -64,7 +66,6 @@ from sampletones_application.view_model.main.updates import GenerationSettingsUpdate from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName -from sampletones_core.reconstructions.converter.paths 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 @@ -266,7 +267,7 @@ def __init__( 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 +277,8 @@ 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._stem_selection_window.on_add = self._converter_logic.mix_only 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.""" @@ -291,40 +293,36 @@ def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: def _on_wave_file_clicked(self, filepath: Path) -> None: if not self._is_operation_active(): - self._converter_logic.select_source(filepath) + self._converter_logic.gather_recordings([filepath]) 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) + self._converter_logic.gather_folder(directory_path) 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._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._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( @@ -387,17 +385,18 @@ 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. 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) + candidates = self._converter_logic.gathered_paths + if not output.mixes or len(candidates) <= MAX_STEM_SOURCES: + self._converter_logic.set_output(output) return - self._confirm_discarding_stems(lambda: self._converter_logic.set_stems_mode(False)) + self._stem_selection_window.open(candidates, MAX_STEM_SOURCES) def _can_add_stems(self) -> bool: """The converter is free to gather recordings into a stems conversion.""" @@ -408,29 +407,14 @@ def _on_file_add_requested(self, filepath: Path) -> None: if self._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. - - 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. - """ + """Gathers a folder into the setup, standing for the recordings found below it.""" if self._is_operation_active(): return - candidates = top_level_audio_files(directory_path) - if not candidates: - 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) - return - - self._stem_selection_window.open(candidates, room) + self._converter_logic.gather_folder(directory_path) def _request_cancel_confirmation(self) -> None: self._dialogs.show_confirmation( @@ -613,8 +597,9 @@ 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.""" + self._converter_logic.convert_path(path) def save_browser_shape(self) -> None: """Writes down the folders the explorer stands open, so a later run reads down to them.""" diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 14bf4fb2f..86a5abc99 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -2,9 +2,15 @@ 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.paths import get_output_path, group_output_path +from sampletones_core.reconstructions.converter import BatchEntry +from sampletones_core.reconstructions.converter.paths import ( + config_directory_path, + get_output_path, + group_output_path, +) @dataclass(frozen=True) @@ -69,6 +75,40 @@ def aimed_at_mix( return replace(self, output_path=group_output_path(config, sources, channels)) + def aimed_at_batch( + self, + config: Config, + entries: Tuple[BatchEntry, ...], + channels: AbstractSet[ChannelName], + ) -> 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 run's settings hold, 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)) + + return replace(self, output_path=config_directory_path(config, channels)) + + 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 index ea96a6487..800299b86 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -2,6 +2,7 @@ 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 @@ -12,12 +13,16 @@ @dataclass(frozen=True) class Gathering: - """The recordings a mixed run is being set up from, and the order they pick in. + """The sources a reader gathered, and the order the ones a mix converts pick in. - The list says what each recording converts under and the levels say which of them picks first, - so a gathered path stands in both. Holding the two together is what keeps that true through - every gesture: one recording joins or leaves the setup in a single step, and the ceiling a mix - holds to is answered once, before either side takes it up. + 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 @@ -25,32 +30,54 @@ class Gathering: @classmethod def empty(cls) -> Self: - """The setup a converter opens with, which a reader fills by picking recordings.""" + """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 setup holds.""" - return self.levels.count + """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 room(self) -> int: - """How many more recordings the setup has room to mix.""" + """How many more recordings the mix has room to take.""" return self.levels.room @property def paths(self) -> Tuple[Path, ...]: - """The gathered recordings, in the order they pick in.""" + """Every gathered recording, in the order the list holds it.""" + return self.sources.paths + + @property + def mixed_paths(self) -> Tuple[Path, ...]: + """The recordings a mix converts, in the order they pick in.""" return self.levels.paths def recording(self, path: Path) -> Optional[Recording]: - """The gathered recording at ``path``, where the setup holds one.""" + """The gathered recording at ``path``, where the list holds one.""" return self.sources.recording(path) - def add(self, recording: Recording) -> Self: - """One more recording, picking last among the ones already gathered. + 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) - A mix holds a fixed number of recordings, so a setup with no room left stands as it is; + 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: @@ -62,13 +89,16 @@ def add(self, recording: Recording) -> Self: levels=self.levels.add(recording.path), ) - def remove(self, path: Path) -> Self: - """The setup without the recording at ``path``, which leaves both sides of it.""" - return replace( - self, - sources=self.sources.remove(SourceKey.recording(path)), - levels=self.levels.remove(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, @@ -102,21 +132,39 @@ def written_among( held = slot.read(recording.settings) return self.written(path, slot, (held - offered) | 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 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 kept_first(self) -> Self: - """What is left when a mix becomes one conversion: the recording that picks first.""" - levels = self.levels.keep_first() - return replace(self, sources=self._narrowed_to(levels.paths), levels=levels) + def mixing_only(self, paths: Tuple[Path, ...]) -> Self: + """The setup a mix runs from: exactly ``paths``, loose, each keeping what it was given. - def _narrowed_to(self, standing: Tuple[Path, ...]) -> SourceList: - """The list holding the recordings ``standing`` names, which is what a mix converts.""" - kept = frozenset(standing) - sources = self.sources - for path in self.sources.paths: - if path not in kept: - sources = sources.remove(SourceKey.recording(path)) - - return sources + This is what turning to a mix leaves behind — the folders give up the recordings they + stood for, and what the reader did not pick goes with them. + """ + recordings = {recording.path: recording for recording in self.sources.recordings} + sources = SourceList() + levels = MixLevels() + for path in paths: + recording = recordings.get(path) + if recording is None: + continue + + sources = sources.add_recording(recording) + levels = levels.add(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 index b4872841e..8126ed77c 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -1,9 +1,11 @@ from pathlib import Path -from typing import Callable, FrozenSet, Optional, Sequence +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.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind 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 @@ -16,11 +18,14 @@ ) from sampletones_application.logic.main.converter.settings import RunSettings from sampletones_application.logic.main.converter.setup import ( + batch_entries, conversion_plan, playing_sources, ) from sampletones_application.logic.main.converter.state import ConverterState from sampletones_application.logic.main.converter.view import compose_view +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.recording import Recording from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT @@ -33,6 +38,7 @@ from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.converter import ConversionPlan +from sampletones_core.reconstructions.converter.paths import top_level_audio_files from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger @@ -67,7 +73,7 @@ def __init__( self._state = ConverterState( settings=RunSettings( joining=session_manager.converter_settings, - stems_mode=False, + output=OutputKind.PER_RECORDING, channel_cap=len(ChannelName), hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, ), @@ -95,9 +101,9 @@ def __init__( self.is_library_available: Optional[Callable[[], bool]] = None @property - def stems_mode(self) -> bool: + def mixes(self) -> bool: """Several recordings are being gathered into one reconstruction.""" - return self._state.settings.stems_mode + return self._settings.mixes @property def source_count(self) -> int: @@ -106,9 +112,14 @@ def source_count(self) -> int: @property def room_for_sources(self) -> int: - """How many more recordings the stems list has room for.""" + """How many more recordings the mix has room for.""" return self._state.gathering.room + @property + def gathered_paths(self) -> Tuple[Path, ...]: + """Every gathered recording, which is what a reader picking a mix is offered.""" + return self._state.gathering.paths + @property def is_active(self) -> bool: """A conversion is occupying resources, from the request until it settles.""" @@ -124,45 +135,61 @@ def refresh_view(self) -> None: if self._run.phase == ConversionPhase.IDLE: self._emit(self._messages.idle, 0.0) - def set_input_path(self, input_path: Path, convert: bool = False) -> None: - destination = self._aimed_at(self._state, input_path) - if destination is None: - return + def gather_recordings(self, paths: Sequence[Path]) -> None: + """Gathers recordings into the setup, each converted under what a recording joins with. - self._state = self._state.with_destination(destination) - if not self.is_active: - self._run.return_to_idle() - self._emit(self._messages.idle, 0.0) + 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)) - if convert: - self.start_conversion() + self._settle(self._state.with_gathering(gathering)) - def select_source(self, path: Path) -> None: - """Answers a recording picked in the explorer: it joins the list, or becomes the input. + def gather_folder(self, root: Path) -> None: + """Gathers a folder, standing for every recording found directly below it. - 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. + 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. """ - if self.stems_mode: - self.add_sources([path]) + recordings = [self._gathered(path) for path in top_level_audio_files(root)] + if not recordings: + return + + if self.mixes: + self.gather_recordings([recording.path for recording in recordings]) return - self.set_input_path(path) + folder = Folder(root=root, recordings=tuple(recordings)) + self._settle(self._state.with_gathering(self._state.gathering.listing_folder(folder))) - def add_sources(self, paths: Sequence[Path]) -> None: - """Adds recordings to the stems list, up to the room it has left. + def convert_path(self, path: Path) -> None: + """Converts exactly what the reader named, which is what a Reconstruct asks for. - A path already listed keeps the row it has, so adding it again leaves the setup as it is. + The setup becomes that one source — a recording, or a folder standing for the recordings + below it — and the run starts, writing one reconstruction apiece. """ - gathering = self._state.gathering - for path in paths: - gathering = gathering.add(self._gathered(path)) + self._settle( + self._state.with_settings(self._settings.with_output(OutputKind.PER_RECORDING)).with_gathering( + Gathering.empty() + ) + ) + if path.is_dir(): + self.gather_folder(path) + else: + self.gather_recordings([path]) - self._settle(self._state.with_gathering(gathering)) + self.start_conversion() def remove_source(self, path: Path) -> None: - """Takes a recording out of the stems list.""" - self._settle(self._state.with_gathering(self._state.gathering.remove(path))) + """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)))) 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.""" @@ -194,17 +221,24 @@ 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_stems_mode(self, stems_mode: bool) -> None: - """Switches between converting one selection and mixing several recordings into one. + def set_output(self, output: OutputKind) -> None: + """Names what the run writes: one reconstruction per recording, or one from them all. - 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. + 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 stems_mode == self.stems_mode: + if output == self._settings.output: return - state = self._state.with_settings(self._settings.with_stems_mode(stems_mode)) - self._settle(self._entered(state) if stems_mode else self._left(state)) + gathering = self._state.gathering + settled = gathering.mixing_only(gathering.paths[:MAX_STEM_SOURCES]) 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, which is what a reader answers a full mix with.""" + gathering = self._state.gathering.mixing_only(tuple(paths)[:MAX_STEM_SOURCES]) + self._settle(self._state.with_settings(self._settings.with_output(OutputKind.MIXED)).with_gathering(gathering)) def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: """Names the channels a recording holds when it joins the list, carried between runs. @@ -286,45 +320,24 @@ 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 _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 _aimed_at(self, state: ConverterState, input_path: Path) -> Optional[Destination]: - """Where a newly picked path would write, or nothing where the path cannot be read.""" - config = self._config_manager.config.model_copy() - try: - return state.destination.aimed_at(config, input_path, state.settings.enabled_channels) - except FileNotFoundError as exception: - logger.error("Input file does not exist") - self.call(self.on_error, exception) - except OSError as exception: - logger.error("Invalid path") - self.call(self.on_error, exception) - - return None - - def _entered(self, state: ConverterState) -> ConverterState: - """The setup a mix opens with: the file the reader picked, where they picked one.""" - destination = state.destination - if state.gathering.count or destination.input_path is None or not destination.is_file: - return state - - return state.with_gathering(state.gathering.add(self._gathered(destination.input_path))) - - def _left(self, state: ConverterState) -> ConverterState: - """The setup a mix leaves behind: the recording that picked first, as the single input.""" - if not state.gathering.count: - return state + def _relevel(self, levels: MixLevels) -> None: + """Takes up rewritten levels and follows them wherever the setup changed. - gathering = state.gathering.kept_first() - destination = self._aimed_at(state, gathering.paths[0]) - state = state.with_gathering(gathering) - return state if destination is None else state.with_destination(destination) + 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 - def _relevel(self, levels: MixLevels) -> None: - """Takes up rewritten levels and follows them wherever the setup changed.""" self._settle(self._state.with_gathering(self._state.gathering.with_levels(levels))) def _settle(self, state: ConverterState) -> None: @@ -340,16 +353,14 @@ def _settle(self, state: ConverterState) -> None: self._emit(self._messages.idle, 0.0) def _redirected(self, state: ConverterState) -> ConverterState: - if not state.settings.stems_mode: - return state - - return state.with_destination( - state.destination.aimed_at_mix( - self._config_manager.config, - playing_sources(state), - state.settings.enabled_channels, - ) - ) + """The setup with its destination following the sources that take part in it.""" + config = self._config_manager.config + channels = state.settings.enabled_channels + destination = state.destination.named_after(state.gathering.sources) + if state.settings.mixes: + return state.with_destination(destination.aimed_at_mix(config, playing_sources(state), channels)) + + return state.with_destination(destination.aimed_at_batch(config, batch_entries(state), channels)) def _standing_target(self, plan: ConversionPlan) -> Optional[Path]: """The reconstruction ``plan`` would write over, where one stands. @@ -432,7 +443,7 @@ def _action_label(self, running_input: Optional[Path]) -> str: input_path = running_input if running_input is not None else destination.input_path return self._messages.action_label( phase=self._run.phase, - stems_mode=self.stems_mode, + mixes=self.mixes, is_file=destination.is_file, input_path=input_path, playing=len(playing_sources(self._state)), diff --git a/src/sampletones_application/logic/main/converter/messages.py b/src/sampletones_application/logic/main/converter/messages.py index b3aceff88..6f5c5d0f0 100644 --- a/src/sampletones_application/logic/main/converter/messages.py +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -53,7 +53,7 @@ def action_label( self, *, phase: ConversionPhase, - stems_mode: bool, + mixes: bool, is_file: bool, input_path: Optional[Path], playing: int, @@ -63,7 +63,7 @@ def action_label( if phase in ACTIVE_PHASES: return self._language_manager["main.converter.label.cancel_button"] - if stems_mode: + if mixes: return self._mix_label(playing) base = ( diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py index 468c90a25..31247210b 100644 --- a/src/sampletones_application/logic/main/converter/settings.py +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -2,6 +2,7 @@ from typing import FrozenSet, Self from sampletones_application.constants.conversion import MIN_CHANNEL_CAP +from sampletones_application.constants.output import OutputKind from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings @@ -14,12 +15,12 @@ class RunSettings: ``joining`` is what a recording is given when it joins the setup, and a run hands out the channels it names: every gathered recording is narrowed to them, so this one value settles both what a new row starts from and what the whole run reaches. The rest name the shape of the - run itself — whether several recordings are being mixed into one reconstruction, how many - channels one recording may hold in a frame, and how the levels take turns. + run itself — what it writes, how many channels one recording may hold in a frame, and how the + levels take turns. """ joining: StemSettings - stems_mode: bool + output: OutputKind channel_cap: int hierarchy_mode: HierarchyMode @@ -46,9 +47,14 @@ def with_joining_channels(self, channels: FrozenSet[ChannelName]) -> Self: """ return replace(self, joining=CHANNEL_SLOT.write(self.joining, channels)) - def with_stems_mode(self, stems_mode: bool) -> Self: - """The run named as a mix of several recordings, or as one conversion.""" - return replace(self, stems_mode=stems_mode) + @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.""" diff --git a/src/sampletones_application/logic/main/converter/setup.py b/src/sampletones_application/logic/main/converter/setup.py index 180f095a7..bb35c72c1 100644 --- a/src/sampletones_application/logic/main/converter/setup.py +++ b/src/sampletones_application/logic/main/converter/setup.py @@ -6,62 +6,84 @@ ConversionSetup, derive_conversion_setup, ) +from sampletones_application.logic.main.sources.recording import Recording +from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT from sampletones_core.reconstructions.converter import ( + BatchConversion, + BatchEntry, ConversionPlan, - DirectoryConversion, GroupConversion, ) from sampletones_core.reconstructions.reconstructor.stems.configs.config import StemsConfig def conversion_setup(state: ConverterState) -> ConversionSetup: - """The recordings and the stems setup a run converts under, carrying the channel cap. - - A mix is what the gathered levels amount to; a single conversion is one stem over every - enabled channel, which is the classic run's shape. - """ + """The recordings and the stems setup a mix converts under, carrying the channel cap.""" settings = state.settings - if settings.stems_mode: - return derive_conversion_setup( - state.gathering.sources, - state.gathering.levels, - settings.enabled_channels, - channel_cap=settings.effective_channel_cap, - hierarchy_mode=settings.hierarchy_mode, - ) - - joining = settings.joining - return ConversionSetup( - sources=(), - stems=StemsConfig.single_entry( - joining.channels, - joining.bends, - channel_cap=settings.effective_channel_cap, - ), + return derive_conversion_setup( + state.gathering.sources, + state.gathering.levels, + settings.enabled_channels, + 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 conversion mixes them.""" - return conversion_setup(state).sources + """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 the run hands out. + + 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: + narrowed = _narrowed(recording, state) + if not narrowed.settings.channels: + continue + + entries.append( + BatchEntry( + source=narrowed.path, + stems=StemsConfig.single_entry( + narrowed.settings.channels, + narrowed.settings.bends, + channel_cap=settings.effective_channel_cap, + ), + base_directory=gathering.folder_root_of(narrowed.path), + ) + ) + + return tuple(entries) def conversion_plan(state: ConverterState) -> Optional[ConversionPlan]: - """What a request amounts to: one reconstruction from the recordings gathered or the file - picked, or one per audio file the picked directory holds. + """What a request amounts to: one reconstruction from the recordings gathered, or one apiece. - A mix converts the recordings gathered for it, so it names a plan whichever path the reader - picked. A single conversion needs one, and a converter aimed at nothing has nothing to run. + A run with nobody taking part names no plan, which is what a converter aimed at nothing is. """ - setup = conversion_setup(state) - if state.settings.stems_mode: - return GroupConversion(sources=setup.sources, stems=setup.stems) + if state.settings.mixes: + setup = conversion_setup(state) + return GroupConversion(sources=setup.sources, stems=setup.stems) if setup.sources else None - input_path = state.destination.input_path - if input_path is None: - return None + entries = batch_entries(state) + return BatchConversion(entries=entries) if entries else None - if state.destination.is_file: - return GroupConversion(sources=(input_path,), stems=setup.stems) - return DirectoryConversion(directory=input_path, stems=setup.stems) +def _narrowed(recording: Recording, state: ConverterState) -> Recording: + """The recording as the run hands channels out to it.""" + settings = CHANNEL_SLOT.write( + recording.settings, + recording.settings.channel_set & state.settings.enabled_channels, + ) + return recording.with_settings(settings) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 664c4284e..4192779e6 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import FrozenSet, Optional, Tuple @@ -38,8 +39,8 @@ def compose_view( output_path=_display_output(destination, reconstructions_directory), is_file=destination.is_file, other_operation_active=other_operation_active, - stems_mode=settings.stems_mode, - stem_sources=stem_rows(state.gathering, settings.enabled_channels), + output=settings.output, + stem_sources=stem_rows(state.gathering, settings.enabled_channels, mixes=settings.mixes), enabled_channels=settings.enabled_channels, channel_cap=settings.effective_channel_cap, max_channel_cap=settings.max_channel_cap, @@ -51,21 +52,49 @@ def compose_view( def stem_rows( gathering: Gathering, enabled_channels: FrozenSet[ChannelName], + *, + mixes: bool, ) -> 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 run enables. A recording that has left the - disk since it was gathered reports itself as missing. + 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 has one band holding the whole list. """ - levels = gathering.levels + placements = _mixed_placements(gathering) if mixes else _listed_placements(gathering) return tuple( StemRowViewModel( - key=str(path), - path=path, - channels=_held_channels(gathering, path, enabled_channels), + key=str(placement.path), + path=placement.path, + channels=_held_channels(gathering, placement.path, enabled_channels), offered_channels=enabled_channels, - available=path.is_file(), + available=placement.path.is_file(), + level=placement.level, + position=placement.position, + level_size=placement.level_size, + level_count=placement.level_count, + ) + for placement in placements + ) + + +@dataclass(frozen=True) +class _Placement: + """Where one recording stands in the list the panel draws.""" + + path: Path + level: int + position: int + level_size: int + level_count: int + + +def _mixed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + levels = gathering.levels + return tuple( + _Placement( + path=path, level=level_index, position=position, level_size=len(level), @@ -76,6 +105,20 @@ def stem_rows( ) +def _listed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: + paths = gathering.paths + return tuple( + _Placement( + path=path, + level=0, + position=position, + level_size=len(paths), + level_count=1, + ) + for position, path in enumerate(paths) + ) + + 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/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 58220c0ad..11444908d 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -7,6 +7,7 @@ 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.constants.output import OutputKind 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 @@ -111,7 +112,8 @@ def __init__( 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_output_changed: Optional[Callable[[OutputKind], None]] = None + self.on_folder_removed: Optional[Callable[[Path], 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 @@ -297,7 +299,7 @@ def _create_stems_list(self) -> None: 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_set_value(TAG_MAIN_CONVERTER_CHECKBOX_STEMS_MODE, view_model.mixes) dpg_configure_item( TAG_MAIN_CONVERTER_INPUT_CHANNEL_CAP, max_value=view_model.max_channel_cap, @@ -308,18 +310,19 @@ def _update_setup(self, view_model: ConverterViewModel) -> None: 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_COMBO_HIERARCHY_MODE, show=view_model.mixes) + set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.mixes) 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_WINDOW_STEMS, show=True) 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) + """The switch names what the run writes, which the box states as a mix or not.""" + self.call(self.on_output_changed, OutputKind.MIXED if value else OutputKind.PER_RECORDING) 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))) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 4e6a3a87d..e156ab720 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, @@ -59,7 +60,7 @@ 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 @@ -67,6 +68,11 @@ class ConverterViewModel(BaseModel, frozen=True): hierarchy_mode: HierarchyMode max_sources: int + @property + def mixes(self) -> bool: + """Several recordings are being gathered into one reconstruction.""" + return self.output.mixes + @property def progress_overlay(self) -> str: """The percentage label rendered over the progress bar, derived from the fraction.""" @@ -82,11 +88,8 @@ 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: @@ -109,7 +112,7 @@ def stems_list(self) -> StemsListViewModel: channels_in_play=self.channels_in_play, muted_channels=frozenset(), live=not self.is_active, - collapse_levels=False, + collapse_levels=not self.mixes, ) @property @@ -124,8 +127,8 @@ def playing_count(self) -> int: @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: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 19772cb6a..d1b47f582 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -312,7 +312,7 @@ 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." @@ -383,28 +383,28 @@ 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.stems_mode: "Mix into one" 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.stems_mode_tooltip: "Mix the gathered recordings into one reconstruction, each holding the channels you give it. Left clear, every recording gets a reconstruction of its own." 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.stems_empty_hint: "Click recordings in the browser to gather what this run converts." +main.converter.message.discard_stems_prompt: "Converting this replaces the recordings you gathered. 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.message.stem_selection_prompt: "Pick the recordings to mix." +main.converter.message.status_stems_mode: "Mix the gathered recordings into one reconstruction." +main.converter.title.discard_stems_dialog: "Replace the list?" 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.title.stem_selection_dialog: "Pick recordings to mix" +main.converter.template.stem_selection_limit: "A mix holds {} of the {} recordings gathered." 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" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index b4f7bbb96..23d7597c8 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -1,10 +1,11 @@ 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.main import MainTabCoordinator from sampletones_application.logic.main.converter.run import ConversionSuccess from sampletones_application.tags.main import ( @@ -35,7 +36,8 @@ def _coordinator(*, operation_active: bool) -> MainTabCoordinator: 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 @@ -172,8 +174,8 @@ 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, ...] = (), room: int = MAX_STEM_SOURCES, ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) @@ -182,120 +184,74 @@ def _stems_coordinator( 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._stem_selection_window = MagicMock() return coordinator -class TestStemsModeSwitch: - """Leaving stems mode drops every recording but the first, so a list of several asks first.""" +class TestOutputSwitch: + """A mix reaches a fixed number of recordings, so a longer list is put to the reader first.""" - def test_entering_stems_mode_takes_effect_at_once(self) -> None: - coordinator = _stems_coordinator(stems_mode=False) + def test_turning_to_a_mix_takes_effect_at_once(self) -> None: + coordinator = _stems_coordinator(mixes=False) - coordinator._request_stems_mode(True) + coordinator._request_output(OutputKind.MIXED) - coordinator._converter_logic.set_stems_mode.assert_called_once_with(True) + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.MIXED) 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) + 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))) - coordinator._request_stems_mode(False) + coordinator._request_output(OutputKind.PER_RECORDING) - coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) + coordinator._converter_logic.set_output.assert_called_once_with(OutputKind.PER_RECORDING) coordinator._dialogs.show_confirmation.assert_not_called() - def test_leaving_with_several_recordings_asks_first(self) -> None: - coordinator = _stems_coordinator(source_count=3) + 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)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered) - coordinator._request_stems_mode(False) + coordinator._request_output(OutputKind.MIXED) - 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_confirming_the_prompt_leaves_stems_mode(self) -> None: - coordinator = _stems_coordinator(source_count=3) - - coordinator._request_stems_mode(False) - args, _ = coordinator._dialogs.show_confirmation.call_args - args[3]() - - coordinator._converter_logic.set_stems_mode.assert_called_once_with(False) - - 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_stems_mode(False) - _, kwargs = coordinator._dialogs.show_confirmation.call_args - kwargs["on_cancel"]() - - coordinator._converter_logic.set_stems_mode.assert_not_called() - coordinator._converter_logic.refresh_view.assert_called_once_with() - - -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() + candidates, room = coordinator._stem_selection_window.open.call_args.args + assert candidates == gathered + assert room == MAX_STEM_SOURCES - 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) - 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 +class TestDirectoryAdd: + """Ctrl-clicking a folder gathers it, standing for the recordings found below it.""" - def test_a_folder_holding_no_recordings_is_left_alone(self, tmp_path: Path) -> None: - (tmp_path / "notes.txt").write_text("not audio") + def test_a_folder_joins_the_setup(self, tmp_path: Path) -> None: coordinator = _stems_coordinator() coordinator._on_directory_add_requested(tmp_path) - 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) - - 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_called_once_with(tmp_path) def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: - (tmp_path / "a.wav").touch() coordinator = _stems_coordinator(operation_active=True) coordinator._on_directory_add_requested(tmp_path) - coordinator._converter_logic.add_sources.assert_not_called() + coordinator._converter_logic.gather_folder.assert_not_called() 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 +260,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 +277,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 @@ -371,17 +318,17 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: 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) + def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: + coordinator = _stems_coordinator(mixes=mixes, gathered=gathered) coordinator._on_reconstruct_file = MagicMock() coordinator._on_reconstruct_directory = MagicMock() return coordinator - 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") @@ -390,7 +337,7 @@ def test_a_classic_conversion_reconstructs_straight_away(self, tmp_path: Path) - @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) @@ -398,20 +345,19 @@ def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str coordinator._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) 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._converter_logic.set_output.assert_not_called() coordinator._on_reconstruct_directory.assert_not_called() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py index df4f64d0e..3ecb09197 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -3,15 +3,26 @@ 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.unit.sampletones_application.logic.main.sources.factories import recording +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording -def _gathered(*names: str) -> Gathering: +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.add(recording(f"/audio/{name}.wav")) + 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 @@ -20,33 +31,38 @@ 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.mixed_paths] + + class TestGatheringRecordings: def test_a_recording_stands_in_the_list_and_on_a_level(self) -> None: - gathering = _gathered("bass") + 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_gathered(self) -> None: - assert _names(_gathered("bass", "lead")) == ["bass", "lead"] + 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 = _gathered("bass").written( + gathering = _mixed("bass").written( Path("/audio/bass.wav"), CHANNEL_SLOT, frozenset({ChannelName.NOISE}), ) - gathering = gathering.add(recording("/audio/bass.wav", [ChannelName.PULSE1])) + 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 = _gathered("bass", "lead").remove(Path("/audio/bass.wav")) + 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 @@ -57,9 +73,9 @@ 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 = _gathered(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]) + gathering = _mixed(*[f"source{index}" for index in range(MAX_STEM_SOURCES)]) - gathering = gathering.add(recording("/audio/one_more.wav")) + gathering = gathering.mixing(recording("/audio/one_more.wav")) assert gathering.count == MAX_STEM_SOURCES assert gathering.recording(Path("/audio/one_more.wav")) is None @@ -67,7 +83,7 @@ def test_a_recording_arriving_at_a_full_setup_reaches_neither_side(self) -> None class TestSettlingOneRecording: def test_a_slot_settles_on_the_recording_named(self) -> None: - gathering = _gathered("bass", "lead").written( + gathering = _mixed("bass", "lead").written( Path("/audio/bass.wav"), CHANNEL_SLOT, frozenset({ChannelName.NOISE}), @@ -80,7 +96,7 @@ def test_a_slot_settles_on_the_recording_named(self) -> None: assert untouched.settings.channel_set == {ChannelName.PULSE1} def test_settling_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: - gathering = _gathered("bass") + gathering = _mixed("bass") assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering @@ -94,7 +110,7 @@ def _held(self, gathering: Gathering) -> FrozenSet[ChannelName]: return settled.settings.channel_set def test_a_channel_left_out_of_the_run_keeps_the_choice_it_was_given(self) -> None: - gathering = Gathering.empty().add(recording("/audio/bass.wav", [ChannelName.PULSE1, ChannelName.NOISE])) + gathering = Gathering.empty().listing(recording("/audio/bass.wav", [ChannelName.PULSE1, ChannelName.NOISE])) gathering = gathering.written_among( Path("/audio/bass.wav"), @@ -106,7 +122,7 @@ def test_a_channel_left_out_of_the_run_keeps_the_choice_it_was_given(self) -> No assert self._held(gathering) == {ChannelName.NOISE} def test_a_channel_the_reader_answered_for_settles_to_the_answer(self) -> None: - gathering = Gathering.empty().add(recording("/audio/bass.wav", [ChannelName.PULSE1])) + gathering = Gathering.empty().listing(recording("/audio/bass.wav", [ChannelName.PULSE1])) gathering = gathering.written_among( Path("/audio/bass.wav"), @@ -118,17 +134,73 @@ def test_a_channel_the_reader_answered_for_settles_to_the_answer(self) -> None: assert self._held(gathering) == {ChannelName.PULSE2} def test_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: - gathering = _gathered("bass") + gathering = _mixed("bass") assert gathering.written_among(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset(), frozenset()) == gathering -class TestWhatAMixLeavesBehind: - def test_the_recording_that_picks_first_stays(self) -> None: - assert _names(_gathered("bass", "lead").kept_first()) == ["bass"] +class TestTheListAPerRecordingRunConverts: + """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: + """A mix converts loose recordings and holds a fixed number of them.""" + + def test_the_recordings_picked_stand_alone_and_in_order(self) -> None: + gathering = Gathering.empty().listing_folder( + folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + ) + + gathering = gathering.mixing_only((Path("/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_stood_with(self) -> None: + gathering = Gathering.empty().listing(recording("/audio/a.wav", [ChannelName.NOISE])) + + settled = gathering.mixing_only((Path("/audio/a.wav"),)).recording(Path("/audio/a.wav")) + + assert settled is not None + assert settled.settings.channel_set == {ChannelName.NOISE} + + def test_a_path_the_list_never_gathered_takes_no_part(self) -> None: + gathering = _listed("a").mixing_only((Path("/audio/a.wav"), Path("/audio/stranger.wav"))) + + assert _names(gathering) == ["a"] + - def test_the_recordings_it_leaves_go_from_the_list_as_well(self) -> None: - gathering = _gathered("bass", "lead").kept_first() +class TestTurningAwayFromAMix: + def test_the_list_stands_and_the_picking_order_goes(self) -> None: + gathering = _mixed("bass", "lead").unmixed() - assert gathering.recording(Path("/audio/lead.wav")) is None - assert gathering.sources.count == 1 + 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 index d89df9ab1..f531c3c71 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -7,6 +7,7 @@ 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.logic.main.converter.logic import ConverterLogic from sampletones_application.logic.main.converter.run import ConversionSuccess from sampletones_application.services.conversion.result import ConversionResult @@ -23,7 +24,6 @@ from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS SCHEDULING: str = "sampletones_application.logic.main.converter.logic.CallbackQueue.add" -OUTPUT_PATH: str = "sampletones_application.logic.main.converter.destination.get_output_path" def _config_writing_under(reconstructions_directory: Path) -> Config: @@ -96,9 +96,15 @@ def _reports(service: MagicMock, result: ConversionResult) -> None: handler(result) -def _gathered(converter_logic: ConverterLogic, *names: str) -> None: - converter_logic.set_stems_mode(True) - converter_logic.add_sources([Path(f"/audio/{name}.wav") for name in names]) +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: @@ -213,7 +219,7 @@ def test_a_mix_runs_without_a_recording_ever_being_picked( service: MagicMock, ) -> None: """A mix converts the recordings it gathered, so nothing about the browser's selection gates it.""" - _gathered(converter_logic, "a", "b") + _mixing(converter_logic, "a", "b") plan = _started_plan(converter_logic, service) @@ -229,8 +235,8 @@ class TestOverwriteGuard: @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) + """Gathers ``path`` and answers where its run would write.""" + converter_logic.gather_recordings([path]) return _view(converter_logic).output_path @staticmethod @@ -292,16 +298,16 @@ def test_a_target_still_to_be_written_starts_straight_away( on_target_exists.assert_not_called() assert _phase(converter_logic) == ConversionPhase.WAITING - def test_a_batch_starts_without_asking( + def test_a_folder_starts_without_asking( self, converter_logic: ConverterLogic, tmp_path: Path, ) -> None: - """The scan keeps every reconstruction already written, so a standing file stops nothing.""" + """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.set_input_path(sources) + converter_logic.gather_folder(sources) on_target_exists = MagicMock() converter_logic.on_target_exists = on_target_exists @@ -345,51 +351,38 @@ def test_proceeds_when_nothing_is_active( assert _phase(converter_logic) == ConversionPhase.WAITING -class TestPickingWhatToConvert: - """``get_output_path``'s contract is the ``OSError`` family: those failures abort the - selection 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(OUTPUT_PATH, side_effect=error): - converter_logic.set_input_path(Path("/tmp/input.wav")) - - converter_logic.on_error.assert_called_once_with(error) - converter_logic.emit_initial_view() - assert _view(converter_logic).input_path is None +class TestWhatTheSetupNamesItselfBy: + """A setup holding one row is that row, which is what a reader converting one file reads.""" - def test_unexpected_failure_propagates( + def test_one_recording_names_itself( self, converter_logic: ConverterLogic, + tmp_path: Path, ) -> None: - converter_logic.on_error = MagicMock() + source = _aimed_at_a_recording(converter_logic, tmp_path) - with patch(OUTPUT_PATH, side_effect=KeyError("drive")), pytest.raises(KeyError): - converter_logic.set_input_path(Path("/tmp/input.wav")) + view_model = _view(converter_logic) - converter_logic.on_error.assert_not_called() + assert (view_model.input_path, view_model.is_file) == (source, True) - def test_a_picked_recording_reaches_the_view( + def test_one_folder_names_the_tree_it_mirrors( self, converter_logic: ConverterLogic, tmp_path: Path, ) -> None: - source = _aimed_at_a_recording(converter_logic, tmp_path) + sources = tmp_path / "sources" + sources.mkdir() + (sources / "song.wav").touch() + + converter_logic.gather_folder(sources) view_model = _view(converter_logic) + assert (view_model.input_path, view_model.is_file) == (sources, False) - assert (view_model.input_path, view_model.is_file) == (source, True) + 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: @@ -499,60 +492,66 @@ class TestGatheringRecordings: def _names(self, converter_logic: ConverterLogic) -> List[str]: return [row.name for row in _view(converter_logic).stem_sources] - def test_selecting_a_recording_in_stems_mode_adds_it(self, converter_logic: ConverterLogic) -> None: - converter_logic.set_stems_mode(True) - - converter_logic.select_source(Path("/audio/bass.wav")) - converter_logic.select_source(Path("/audio/lead.wav")) + 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: - _gathered(converter_logic, "bass", "lead") + _mixing(converter_logic, "bass", "lead") converter_logic.isolate_source(Path("/audio/lead.wav")) - converter_logic.add_sources([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: - _gathered(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES + 3)]) + _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: - _gathered(converter_logic, "a", "b") + _mixing(converter_logic, "a", "b") converter_logic.remove_source(Path("/audio/a.wav")) assert self._names(converter_logic) == ["b"] - def test_entering_stems_mode_carries_the_picked_file_in( + 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_stems_mode(True) + converter_logic.set_output(OutputKind.MIXED) assert self._names(converter_logic) == [source.stem] - def test_leaving_stems_mode_keeps_the_recording_that_picks_first( + def test_turning_away_from_a_mix_keeps_every_recording( self, converter_logic: ConverterLogic, ) -> None: - _gathered(converter_logic, "a", "b") + _mixing(converter_logic, "a", "b") + + converter_logic.set_output(OutputKind.PER_RECORDING) + + assert converter_logic.source_count == 2 - converter_logic.set_stems_mode(False) + 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 == 1 + assert converter_logic.source_count == MAX_STEM_SOURCES + 3 def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLogic) -> None: - _gathered(converter_logic, "a", "b") + _mixing(converter_logic, "a", "b") converter_logic.move_source_to_new_level(Path("/audio/b.wav"), 0) @@ -571,7 +570,7 @@ def test_the_rows_channels_and_levels_reach_the_setup( converter_logic: ConverterLogic, service: MagicMock, ) -> None: - _gathered(converter_logic, "a", "b") + _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")) @@ -585,7 +584,7 @@ def test_a_recording_left_with_no_channel_takes_no_part( converter_logic: ConverterLogic, service: MagicMock, ) -> None: - _gathered(converter_logic, "a", "b") + _mixing(converter_logic, "a", "b") converter_logic.set_source_channels(Path("/audio/a.wav"), frozenset()) plan = _started_plan(converter_logic, service) @@ -599,7 +598,7 @@ def test_the_hierarchy_mode_reaches_the_setup( converter_logic: ConverterLogic, service: MagicMock, ) -> None: - _gathered(converter_logic, "a") + _mixing(converter_logic, "a") converter_logic.set_hierarchy_mode(HierarchyMode.STRICT) @@ -610,7 +609,7 @@ def test_the_cap_the_reader_asked_for_reaches_the_setup( converter_logic: ConverterLogic, service: MagicMock, ) -> None: - _gathered(converter_logic, "a") + _mixing(converter_logic, "a") converter_logic.set_channel_cap(1) @@ -621,7 +620,7 @@ def test_the_configuration_reaches_the_service_with_the_plan( converter_logic: ConverterLogic, service: MagicMock, ) -> None: - _gathered(converter_logic, "a") + _mixing(converter_logic, "a") _started_plan(converter_logic, service) @@ -633,23 +632,23 @@ class TestTheStemsView: """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: - _gathered(converter_logic, "a", "b") + _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.stems_mode is True + 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: - _gathered(converter_logic, "a") + _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: - _gathered(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) + _mixing(converter_logic, *[str(index) for index in range(MAX_STEM_SOURCES)]) view_model = _view(converter_logic) @@ -657,7 +656,7 @@ def test_the_view_states_whether_another_recording_fits(self, converter_logic: C 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_stems_mode(True) + converter_logic.set_output(OutputKind.MIXED) view_model = _view(converter_logic) @@ -677,8 +676,8 @@ def test_the_cap_the_view_reports_holds_within_the_channels_enabled( def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Path: - """Points the converter at a recording standing on disk, the way the browser does.""" + """Gathers a recording standing on disk, the way a click in the browser does.""" source = tmp_path / "song.wav" source.touch() - converter_logic.set_input_path(source) + converter_logic.gather_recordings([source]) return source diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py index c670411d5..939f8ac92 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_messages.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -71,7 +71,7 @@ class TestActionLabel: def test_a_file_names_the_recording_it_would_convert(self) -> None: label = messages().action_label( phase=ConversionPhase.IDLE, - stems_mode=False, + mixes=False, is_file=True, input_path=Path("/audio/kick.wav"), playing=0, @@ -82,7 +82,7 @@ def test_a_file_names_the_recording_it_would_convert(self) -> None: def test_a_directory_uses_the_directory_variant(self) -> None: label = messages().action_label( phase=ConversionPhase.IDLE, - stems_mode=False, + mixes=False, is_file=False, input_path=Path("/audio/drums"), playing=0, @@ -93,7 +93,7 @@ def test_a_directory_uses_the_directory_variant(self) -> None: def test_nothing_picked_reads_the_bare_convert_label(self) -> None: label = messages().action_label( phase=ConversionPhase.IDLE, - stems_mode=False, + mixes=False, is_file=True, input_path=None, playing=0, @@ -104,7 +104,7 @@ def test_nothing_picked_reads_the_bare_convert_label(self) -> None: def test_a_mix_names_how_many_recordings_take_part(self) -> None: label = messages().action_label( phase=ConversionPhase.IDLE, - stems_mode=True, + mixes=True, is_file=True, input_path=Path("/audio/kick.wav"), playing=3, @@ -115,7 +115,7 @@ def test_a_mix_names_how_many_recordings_take_part(self) -> None: def test_a_mix_with_nobody_taking_part_reads_the_bare_label(self) -> None: label = messages().action_label( phase=ConversionPhase.IDLE, - stems_mode=True, + mixes=True, is_file=True, input_path=None, playing=0, @@ -130,7 +130,7 @@ def test_a_mix_with_nobody_taking_part_reads_the_bare_label(self) -> None: def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: ConversionPhase) -> None: label = messages().action_label( phase=phase, - stems_mode=False, + mixes=False, is_file=True, input_path=Path("/audio/kick.wav"), playing=0, diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py index cd0533144..2b0a23446 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_settings.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -3,6 +3,7 @@ import pytest 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 @@ -14,7 +15,7 @@ def _settings(channels: List[ChannelName], channel_cap: int = 4) -> RunSettings: return RunSettings( joining=_joining(channels), - stems_mode=False, + output=OutputKind.PER_RECORDING, channel_cap=channel_cap, hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, ) @@ -56,7 +57,7 @@ def test_the_cap_always_leaves_room_for_one_channel(self, channels: List[Channel class TestTheShapeOfTheRun: def test_the_run_is_named_as_a_mix(self) -> None: - assert _settings(TONES).with_stems_mode(True).stems_mode is True + 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) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_setup.py b/tests/unit/sampletones_application/logic/main/converter/test_setup.py index 337d47514..534315fbb 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_setup.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -1,10 +1,12 @@ 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, @@ -12,104 +14,111 @@ 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 DirectoryConversion, GroupConversion +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.unit.sampletones_application.logic.main.sources.factories import recording +from tests.unit.sampletones_application.logic.main.sources.factories import folder, recording JOINING: List[ChannelName] = [ChannelName.PULSE1, ChannelName.PULSE2, ChannelName.TRIANGLE] def _state( *, - stems_mode: bool, - input_path: Optional[Path] = None, - is_file: bool = True, - gathered: Optional[List[str]] = None, + 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 gathered or []: - gathering = gathering.add(recording(name, JOINING)) + 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)), - stems_mode=stems_mode, + output=output, channel_cap=channel_cap, hierarchy_mode=hierarchy_mode, ), gathering=gathering, - destination=Destination(input_path=input_path, output_path=None, is_file=is_file), + destination=Destination.unset(), ) -class TestWhatOneConversionRuns: - """One reconstruction for a file, one per audio file for a directory.""" +class TestWhatAPerRecordingRunConverts: + """One reconstruction per gathered recording, each under the settings its own row holds.""" - def test_a_file_becomes_one_group_over_that_file(self) -> None: - plan = conversion_plan(_state(stems_mode=False, input_path=Path("/audio/kick.wav"))) + 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, GroupConversion) - assert plan.sources == (Path("/audio/kick.wav"),) + assert isinstance(plan, BatchConversion) + assert [entry.source for entry in plan.entries] == [Path("/audio/kick.wav")] - def test_a_directory_becomes_a_directory_conversion(self) -> None: - plan = conversion_plan(_state(stems_mode=False, input_path=Path("/audio"), is_file=False)) + 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 isinstance(plan, DirectoryConversion) - assert plan.directory == Path("/audio") + assert entries[0].base_directory is None + assert entries[0].is_named is True - def test_a_converter_aimed_at_nothing_names_no_plan(self) -> None: - assert conversion_plan(_state(stems_mode=False)) is None + 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")) - def test_the_setup_covers_every_channel_a_recording_joins_with(self) -> None: - """With no stems listed, one stem holds the settings a recording joins the list with.""" - state = _state(stems_mode=False, input_path=Path("/audio/kick.wav")) + assert [entry.base_directory for entry in entries] == [Path("/audio"), Path("/audio")] - stems = conversion_setup(state).stems + 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 stems == StemsConfig.single_entry( - JOINING, - bending_channels(JOINING), - channel_cap=len(JOINING), - ) - assert stems.covered_channels == frozenset(JOINING) + assert entries[0].stems == StemsConfig.single_entry(JOINING, [], channel_cap=len(JOINING)) - def test_the_cap_reaches_a_classic_conversion_too(self) -> None: + 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(stems_mode=False, input_path=Path("/audio/kick.wav"), channel_cap=1) + 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] - assert conversion_setup(state).stems.channel_cap == 1 + def test_a_setup_holding_nothing_names_no_plan(self) -> None: + assert conversion_plan(_state(output=OutputKind.PER_RECORDING)) is None class TestWhatAMixRuns: def test_a_mix_groups_every_gathered_recording(self) -> None: - plan = conversion_plan(_state(stems_mode=True, gathered=["/audio/a.wav", "/audio/b.wav"])) + 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_names_its_plan_without_a_recording_ever_being_picked(self) -> None: - """A mix converts what it gathered, so nothing about the browser's selection reaches it.""" - state = _state(stems_mode=True, gathered=["/audio/a.wav"]) - - assert conversion_plan(state) is not None + 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( - stems_mode=True, - gathered=["/audio/a.wav"], + 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(stems_mode=True, gathered=["/audio/a.wav", "/audio/b.wav"]) + 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_single_conversion_mixes_nobody(self) -> None: - assert playing_sources(_state(stems_mode=False, input_path=Path("/audio/kick.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/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 3231da86c..8c35c9e42 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -11,6 +11,7 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME +from sampletones_application.constants.output import OutputKind from sampletones_application.logic.history.action import HistoryAction from sampletones_application.tags.general import ( SUF_BUTTON, @@ -457,8 +458,8 @@ 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: @@ -488,13 +489,15 @@ def test_removing_a_recording_takes_its_row_with_it(self, app: Application, tmp_ 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.""" @@ -548,10 +551,10 @@ def test_the_order_explanation_leaves_with_the_control_it_belongs_to(self, app: """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_stems_mode(True) + converter_logic.set_output(OutputKind.MIXED) assert dpg.get_item_configuration(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE)["show"] is True - converter_logic.set_stems_mode(False) + 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( 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..d0753fc92 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,7 @@ import pytest from sampletones_application.constants.conversion import MAX_STEM_SOURCES +from sampletones_application.constants.output import OutputKind from sampletones_application.view_model.main.converter import ( ConversionPhase, ConverterAction, @@ -46,8 +47,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,7 +61,7 @@ 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, @@ -157,7 +158,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 +167,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,7 +201,7 @@ 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()),), ) From d59415fd1e99011da23b1f76b3ef5fd85e522e59 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 05:11:01 +0200 Subject: [PATCH 011/130] Drew: a gathered folder as one row answering for what it holds --- .../constants/sources.py | 12 ++ .../coordinators/tabs/main.py | 1 + .../logic/main/converter/logic.py | 11 ++ .../logic/main/converter/view.py | 102 +++++++++++------ .../logic/main/sources/key.py | 8 +- .../logic/main/sources/list.py | 41 +++++-- .../logic/reconstruction/reconstruction.py | 4 + src/sampletones_application/tags/general.py | 24 ++++ .../ui/elements/stems/gestures.py | 19 +++- .../ui/elements/stems/list.py | 3 + .../ui/elements/stems/messages.py | 22 +++- .../ui/elements/stems/row.py | 38 +++++-- .../ui/panels/main/converter.py | 11 ++ .../ui/themes/channels.py | 11 ++ .../shared}/agreement.py | 0 .../view_model/shared/stems.py | 45 ++++++-- src/sampletones_config/lang/en.yaml | 5 + .../theme/channels/noise_partial.yaml | 12 ++ .../theme/channels/pulse1_partial.yaml | 12 ++ .../theme/channels/pulse2_partial.yaml | 12 ++ .../theme/channels/triangle_partial.yaml | 12 ++ .../logic/main/converter/test_logic.py | 107 +++++++++++++++++- .../logic/main/sources/test_list.py | 2 +- .../ui/elements/stems/test_list.py | 88 ++++++++++++++ .../panels/reconstruction/test_stems_panel.py | 4 + .../view_model/main/test_converter.py | 4 + .../shared}/test_agreement.py | 2 +- 27 files changed, 528 insertions(+), 84 deletions(-) create mode 100644 src/sampletones_application/constants/sources.py rename src/sampletones_application/{logic/main/sources => view_model/shared}/agreement.py (100%) create mode 100644 src/sampletones_config/theme/channels/noise_partial.yaml create mode 100644 src/sampletones_config/theme/channels/pulse1_partial.yaml create mode 100644 src/sampletones_config/theme/channels/pulse2_partial.yaml create mode 100644 src/sampletones_config/theme/channels/triangle_partial.yaml rename tests/unit/sampletones_application/{logic/main/sources => view_model/shared}/test_agreement.py (96%) diff --git a/src/sampletones_application/constants/sources.py b/src/sampletones_application/constants/sources.py new file mode 100644 index 000000000..14d3ececc --- /dev/null +++ b/src/sampletones_application/constants/sources.py @@ -0,0 +1,12 @@ +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" diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 4508f1a91..ec9535816 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -278,6 +278,7 @@ def __init__( 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._converter_panel.on_folder_removed = self._converter_logic.remove_folder + self._converter_panel.on_folder_channel_toggled = self._converter_logic.toggle_folder_channel self._stem_selection_window.on_add = self._converter_logic.mix_only def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 8126ed77c..0965ed6e2 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -201,6 +201,17 @@ def set_source_channels(self, path: Path, channels: FrozenSet[ChannelName]) -> N ) 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 + key = SourceKey.folder(root) + held = gathering.sources.agreement(key, CHANNEL_SLOT, channel_name).settles_to + self._settle(self._state.with_gathering(gathering.settled(key, CHANNEL_SLOT, channel_name, held))) + 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)) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 4192779e6..0e54c2f76 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -6,7 +6,10 @@ 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 CHANNEL_SLOT from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +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 @@ -24,9 +27,9 @@ def compose_view( ) -> 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 picked - while a run is under way; ``reconstructions_directory`` is where a converter that has been - aimed at nothing yet would write. + ``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. """ settings = state.settings destination = state.destination @@ -55,34 +58,22 @@ def stem_rows( *, mixes: bool, ) -> Tuple[StemRowViewModel, ...]: - """The gathered recordings as the panel reads them, each stating where it stands. + """The gathered sources 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 run enables. A recording 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 has one band holding the whole list. + 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 the run enables. 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( - StemRowViewModel( - key=str(placement.path), - path=placement.path, - channels=_held_channels(gathering, placement.path, enabled_channels), - offered_channels=enabled_channels, - available=placement.path.is_file(), - level=placement.level, - position=placement.position, - level_size=placement.level_size, - level_count=placement.level_count, - ) - for placement in placements - ) + return tuple(_row(placement, enabled_channels) for placement in placements) @dataclass(frozen=True) class _Placement: - """Where one recording stands in the list the panel draws.""" + """One source and where it stands in the list the panel draws.""" + source: SourceRow path: Path level: int position: int @@ -90,10 +81,54 @@ class _Placement: level_count: int +def _row(placement: _Placement, enabled_channels: FrozenSet[ChannelName]) -> StemRowViewModel: + source = placement.source + key = source.key + channels, partial = _readings(source, enabled_channels) + return StemRowViewModel( + key=str(placement.path), + kind=key.kind, + path=placement.path, + holds=source.count, + channels=channels, + partial_channels=partial, + offered_channels=enabled_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 _readings( + source: SourceRow, + enabled_channels: FrozenSet[ChannelName], +) -> Tuple[FrozenSet[ChannelName], FrozenSet[ChannelName]]: + """How the recordings a row stands for read on each channel the run enables. + + 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 enabled_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, @@ -102,32 +137,25 @@ def _mixed_placements(gathering: Gathering) -> Tuple[_Placement, ...]: ) 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, ...]: - paths = gathering.paths + rows = gathering.sources.rows return tuple( _Placement( - path=path, + source=source, + path=source.key.path, level=0, position=position, - level_size=len(paths), + level_size=len(rows), level_count=1, ) - for position, path in enumerate(paths) + 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 - - -def _held_channels( - gathering: Gathering, - path: Path, - enabled_channels: FrozenSet[ChannelName], -) -> FrozenSet[ChannelName]: - """The channels one gathered recording takes, among the ones the run enables.""" - recording = gathering.recording(path) - return recording.settings.channel_set & enabled_channels if recording is not None else frozenset() diff --git a/src/sampletones_application/logic/main/sources/key.py b/src/sampletones_application/logic/main/sources/key.py index 60579fdf0..d8ec6adfc 100644 --- a/src/sampletones_application/logic/main/sources/key.py +++ b/src/sampletones_application/logic/main/sources/key.py @@ -1,14 +1,8 @@ from dataclasses import dataclass -from enum import StrEnum from pathlib import Path from typing import Self - -class SourceKind(StrEnum): - """The two kinds of row a converter's list holds.""" - - RECORDING = "recording" - FOLDER = "folder" +from sampletones_application.constants.sources import SourceKind @dataclass(frozen=True) diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 567adfa05..19b70a61e 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -2,12 +2,12 @@ from pathlib import Path from typing import Callable, Dict, FrozenSet, Optional, Self, Tuple -from sampletones_application.logic.main.sources.agreement import Agreement 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 @@ -224,14 +224,35 @@ def _rows_with( ) -> Tuple[SourceRow, ...]: rows: Tuple[SourceRow, ...] = () for row in self.rows: - if row.key != key: - rows += (row,) - continue - - changed = tuple(recording.with_settings(change(recording.settings)) for recording in row.recordings) - if key.names_folder: - rows += (Folder(root=key.path, recordings=changed),) - else: - rows += changed + 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/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 910b23939..f63e35778 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 @@ -361,8 +362,11 @@ def _build_stems_view_model( rows = tuple( StemRowViewModel( key=str(stem_id), + kind=SourceKind.RECORDING, path=recordings[stem_id], + holds=1, channels=self._stem_channels.get(stem_id, frozenset()), + partial_channels=frozenset(), offered_channels=self._offered_stem_channels.get(stem_id, frozenset()), available=recordings[stem_id].is_file(), level=level_index, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 996f1212d..5d70ec4d7 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -266,6 +266,30 @@ 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, diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 7951a9896..696fc7faf 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -18,6 +18,7 @@ from sampletones_shared.types.callback import MessageCallback, StringCallback ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] +ChannelCallback = Callable[[str, ChannelName], None] KeyOffsetCallback = Callable[[str, int], None] KeyPairCallback = Callable[[str, str], None] @@ -44,6 +45,7 @@ def __init__( 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 @@ -87,16 +89,23 @@ def on_channel_box( _value: bool, user_data: Tuple[str, ChannelName], ) -> None: - """A box settles one channel, so the row reports every box it now holds ticked.""" - key, _channel_name = user_data + """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( - channel_name - for channel_name in self._view.boxes_of(row) - if dpg.get_value(self._tags.channel(key, channel_name)) + offered for offered in self._view.boxes_of(row) if dpg.get_value(self._tags.channel(key, offered)) ) self._report(self.on_channels_settled, key, channels) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 025e0d2b9..3593d7960 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -6,6 +6,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.bands import LevelBands from sampletones_application.ui.elements.stems.gestures import ( + ChannelCallback, ChannelsCallback, KeyOffsetCallback, KeyPairCallback, @@ -73,6 +74,7 @@ def __init__( ) 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 @@ -80,6 +82,7 @@ def __init__( self.on_dropped_on_level: Optional[KeyOffsetCallback] = 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) diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py index 786bfc05c..5025eadcf 100644 --- a/src/sampletones_application/ui/elements/stems/messages.py +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -33,16 +33,19 @@ def __init__( 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 recording is, why it is grayed out where it + """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 not row.available: + 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) @@ -59,6 +62,12 @@ def name(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: 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, + ) + if self._offer.dragging: return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) @@ -79,6 +88,12 @@ def channel( 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, @@ -102,6 +117,9 @@ def remove(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: 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 _row(self, key: str) -> Optional[StemRowViewModel]: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 81f2f7e46..9fd170aba 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -21,10 +21,14 @@ 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 +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, @@ -57,6 +61,7 @@ def __init__( 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 declare_columns(self, view_model: StemsListViewModel) -> None: """The columns every band holds to, so the rows line up across the bands.""" @@ -99,9 +104,10 @@ def repaint( 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, channel_name in row.channels) - ThemeRegistry.get(self._channel_theme(channel_name, view_model)).bind_to_item(tag) + dpg_set_value(tag, agreement is not Agreement.NONE) + 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, False) @@ -129,9 +135,9 @@ def _create_master(self, row: StemRowViewModel) -> None: self._gestures.bind(master, SUF_CHECKBOX) def _create_name(self, row: StemRowViewModel) -> None: - """The row itself: what names the recording, what you drag it by, and what you drop onto.""" + """The row itself: what names the source, what you drag it by, and what you drop onto.""" name = dpg.add_selectable( - label=row.name, + label=self._row_label(row), tag=self._tags.row(row.key, SUF_TEXT), user_data=row.key, callback=self._gestures.on_name_selected, @@ -150,6 +156,13 @@ def _create_name(self, row: StemRowViewModel) -> None: text_tag=self._tags.row(row.key, SUF_TOOLTIP), ) + 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) -> None: """The box giving the recording a channel, where the recording holds frames on it. @@ -164,7 +177,7 @@ def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> N dpg.add_checkbox( label=channel_label(self._language_manager, channel_name), tag=checkbox_tag, - default_value=channel_name in row.channels, + default_value=row.agreement_on(channel_name) is not Agreement.NONE, user_data=(row.key, channel_name), callback=self._gestures.on_channel_box, ) @@ -182,9 +195,18 @@ def _create_remove(self, row: StemRowViewModel) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON).bind_to_item(remove) self._gestures.bind(remove, SUF_BUTTON) - def _channel_theme(self, channel_name: ChannelName, view_model: StemsListViewModel) -> str: - """The tone a channel's boxes take: its own color, muted where the channel is off.""" + 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/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 11444908d..d6ef6e37f 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -114,6 +114,7 @@ def __init__( self.on_cancel_requested: Optional[VoidCallback] = None self.on_output_changed: Optional[Callable[[OutputKind], None]] = None self.on_folder_removed: Optional[Callable[[Path], None]] = None + self.on_folder_channel_toggled: Optional[Callable[[Path, ChannelName], 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 @@ -293,6 +294,7 @@ def _create_stems_list(self) -> None: self._stems_list.create(TAG_MAIN_CONVERTER_WINDOW_STEMS) self._stems_list.on_channels_changed = self._on_source_channels_changed + self._stems_list.on_channel_toggled = self._on_folder_channel_toggled 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 @@ -336,7 +338,16 @@ def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: def _on_source_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_source_removed(self, key: str) -> None: + 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_source(self, key: str, target_key: str) -> None: 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/logic/main/sources/agreement.py b/src/sampletones_application/view_model/shared/agreement.py similarity index 100% rename from src/sampletones_application/logic/main/sources/agreement.py rename to src/sampletones_application/view_model/shared/agreement.py diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index cb7dc35cf..50be9fb1b 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -4,23 +4,32 @@ from pydantic import BaseModel +from sampletones_application.constants.sources import SourceKind +from sampletones_application.view_model.shared.agreement import Agreement from sampletones_core.constants.enums import 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. """ key: str + kind: SourceKind path: Path + holds: int channels: FrozenSet[ChannelName] + partial_channels: FrozenSet[ChannelName] offered_channels: FrozenSet[ChannelName] available: bool level: int @@ -30,13 +39,25 @@ class StemRowViewModel(BaseModel, frozen=True): @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: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index d1b47f582..ec3bd76b8 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -265,6 +265,11 @@ 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 it holds." +global.stems.message.status_folder_row: "{name} holds {count} recordings." global.stems.label.remove: "x" 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." 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..231088762 --- /dev/null +++ b/src/sampletones_config/theme/channels/noise_partial.yaml @@ -0,0 +1,12 @@ +name: channel_noise_partial +tag: global.theme.channel_noise_partial + +components: + - item_type: Checkbox + entries: + - 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_partial.yaml b/src/sampletones_config/theme/channels/pulse1_partial.yaml new file mode 100644 index 000000000..0d97b4bf4 --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse1_partial.yaml @@ -0,0 +1,12 @@ +name: channel_pulse1_partial +tag: global.theme.channel_pulse1_partial + +components: + - item_type: Checkbox + entries: + - 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_partial.yaml b/src/sampletones_config/theme/channels/pulse2_partial.yaml new file mode 100644 index 000000000..8efba9549 --- /dev/null +++ b/src/sampletones_config/theme/channels/pulse2_partial.yaml @@ -0,0 +1,12 @@ +name: channel_pulse2_partial +tag: global.theme.channel_pulse2_partial + +components: + - item_type: Checkbox + entries: + - 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_partial.yaml b/src/sampletones_config/theme/channels/triangle_partial.yaml new file mode 100644 index 000000000..e041e271d --- /dev/null +++ b/src/sampletones_config/theme/channels/triangle_partial.yaml @@ -0,0 +1,12 @@ +name: channel_triangle_partial +tag: global.theme.channel_triangle_partial + +components: + - item_type: Checkbox + entries: + - type: color + key: CheckMark + value: .channel_triangle_soft + - type: color + key: Text + value: .channel_triangle_soft diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index f531c3c71..03efb630a 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, List +from typing import Callable, FrozenSet, List from unittest.mock import MagicMock, patch import pytest @@ -628,6 +628,111 @@ def test_the_configuration_reaches_the_service_with_the_plan( assert started_config == converter_logic._config_manager.config +class TestAFolderInTheList: + """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) + 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: + self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + + row = _view(converter_logic).stem_sources[0] + + assert row.channels == _joining_channels(converter_logic) + 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] + + +def _joining_channels(converter_logic: ConverterLogic) -> FrozenSet[ChannelName]: + return _view(converter_logic).enabled_channels + + class TestTheStemsView: """What the panel is told about the setup being built.""" diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py index 3dd197f60..f76b60954 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_list.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -1,9 +1,9 @@ from pathlib import Path -from sampletones_application.logic.main.sources.agreement import Agreement 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.unit.sampletones_application.logic.main.sources.factories import folder, recording 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 9b69c584a..b7e4749f1 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.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 ( @@ -25,6 +26,7 @@ SUF_STRIP, SUF_TEXT, TAG_GLOBAL_THEME_CHANNEL_MUTED, + TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) @@ -108,6 +110,9 @@ def row( ) -> StemRowViewModel: path = Path(f"/audio/{name}.wav") return StemRowViewModel( + kind=SourceKind.RECORDING, + holds=1, + partial_channels=frozenset(), key=str(path), path=path, channels=channels, @@ -149,6 +154,89 @@ def hover_handler(suffix: str) -> Callback: return dpg.get_item_callback(dpg.get_item_children(registry, 1)[-1]) +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, + holds=holds, + channels=channels, + partial_channels=partial_channels, + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +class TestFolderRows: + """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) -> None: + stems_list = build(layout_config) + sources = folder_row("sources", holds=3) + + stems_list.update_view(view(sources)) + + assert dpg.get_item_label(row_tag(sources, SUF_TEXT)) == "sources (3)" + + def test_a_channel_every_recording_holds_reads_ticked(self, dpg_context: None, layout_config) -> 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_ticked_in_the_softer_tone( + self, + dpg_context: None, + layout_config, + ) -> None: + 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 True + 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) -> 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) -> 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: stems_list = build(layout_config) 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 321e68fcc..4558b2f36 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 ( @@ -94,6 +95,9 @@ def _row( level_count: int = 1, ) -> StemRowViewModel: return StemRowViewModel( + kind=SourceKind.RECORDING, + holds=1, + partial_channels=frozenset(), key=str(stem_id), path=Path(f"/audio/{name}.wav"), channels=channels, 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 d0753fc92..0b3156d59 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -5,6 +5,7 @@ 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, @@ -29,6 +30,9 @@ def _row( ) -> StemRowViewModel: path = Path(f"/audio/{name}.wav") return StemRowViewModel( + kind=SourceKind.RECORDING, + holds=1, + partial_channels=frozenset(), key=str(path), path=path, channels=channels, diff --git a/tests/unit/sampletones_application/logic/main/sources/test_agreement.py b/tests/unit/sampletones_application/view_model/shared/test_agreement.py similarity index 96% rename from tests/unit/sampletones_application/logic/main/sources/test_agreement.py rename to tests/unit/sampletones_application/view_model/shared/test_agreement.py index 4ad067c8f..b2ad5e20c 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_agreement.py +++ b/tests/unit/sampletones_application/view_model/shared/test_agreement.py @@ -3,7 +3,7 @@ import pytest -from sampletones_application.logic.main.sources.agreement import Agreement +from sampletones_application.view_model.shared.agreement import Agreement from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase From d8e7a83d37bbe0530e0784448721ae704dc82200 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 05:11:15 +0200 Subject: [PATCH 012/130] Refreshed: every browser after a run, so a written reconstruction stands in each --- docs/development/bugs-and-todos.md | 11 ++++------- src/sampletones_application/application.py | 12 +++++++++--- .../coordinators/tabs/main.py | 2 +- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 951ab6d6f..e900451ed 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -137,6 +137,10 @@ again. `tests/unit/sampletones_application/coordinators/tabs/test_main.py` builds the object through `__new__` and populates its privates by hand, so the wiring the application actually runs is exercised nowhere: a hook left unset or a call routed to the wrong object passes the suite. +* `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. * 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` @@ -146,13 +150,6 @@ again. ## Bugs -* The Main tab's browser stands as it was after a conversion. `MainTabCoordinator.refresh_browser()` - is reached by no caller, while `Application._refresh_reconstruction_trees` refreshes the - Reconstruction and Sequencer tabs, so a reconstruction just written appears in the two browsers it - was not started from. -* A library directory chosen from the explorer's context menu lasts only for the session. The Browse - button's path stores the choice through `session_manager.set_library_path`; the menu's path reaches - `change_library_directory` on the panel alone, so the next start opens on the previous directory. * 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/src/sampletones_application/application.py b/src/sampletones_application/application.py index e607f4a79..e6f984558 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -489,8 +489,8 @@ def __init__( 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_canceled=self._refresh_browsers, + on_refresh_trees=self._refresh_browsers, on_generate_library=self._instructions_tab.ensure_library_loaded, stem_selection_window=self.stem_selection_window, ) @@ -1040,7 +1040,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() diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index ec9535816..7b99c5f81 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -238,7 +238,7 @@ def __init__( 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_set_as_library_directory=self._handle_select_library_directory, on_set_as_reconstructions_directory=self._advanced_settings_panel.change_reconstructions_directory, ) From 4888a8b4f63cb239a233a987972cc193d278b2fe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 05:37:51 +0200 Subject: [PATCH 013/130] Generated: the settings card from the choices the model declares --- docs/guide/interface.md | 10 +- .../constants/sources.py | 11 ++ .../coordinators/tabs/main.py | 44 +++--- .../logic/main/converter/logic.py | 67 ++++++++- .../logic/main/converter/settings.py | 4 + .../logic/main/converter/state.py | 21 ++- .../logic/main/converter/view.py | 68 ++++++++- .../logic/main/sources/slots.py | 41 +++--- .../logic/reconstruction/reconstruction.py | 9 +- src/sampletones_application/tags/main.py | 8 +- .../ui/elements/stems/gestures.py | 7 +- .../ui/elements/stems/row.py | 2 +- .../ui/panels/main/converter.py | 9 ++ .../ui/panels/main/reconstructor.py | 139 +++++++++++++----- .../view_model/main/converter.py | 2 + .../view_model/main/reconstructor.py | 47 +++++- .../view_model/main/updates.py | 6 +- .../view_model/shared/stems.py | 3 + src/sampletones_config/lang/en.yaml | 5 +- .../logic/main/converter/test_setup.py | 1 + .../logic/main/sources/test_slots.py | 16 +- .../ui/elements/stems/test_list.py | 19 ++- .../ui/panels/main/test_reconstructor.py | 138 ++++------------- .../panels/reconstruction/test_stems_panel.py | 1 + .../view_model/main/test_converter.py | 1 + .../reconstruction/test_reconstruction.py | 1 + 26 files changed, 449 insertions(+), 231 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index b8cd5a208..73cd51eff 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -72,9 +72,13 @@ asks first where you have already gathered a 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 +A few settings are worth knowing before you convert. **Reconstructor settings** +edits whatever the list has picked out: click a row and the card shows that +recording's — or that folder's — **Channels** and **Bends**, and a folder whose +recordings differ shows the choice half-lit until one click settles them all. +With nothing picked, the card edits what every recording joins the list with, so +that is where you set the channels a new row starts from. **Drive** sets how hard +the channels are pushed and holds for the whole run. **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 diff --git a/src/sampletones_application/constants/sources.py b/src/sampletones_application/constants/sources.py index 14d3ececc..c0ef4a985 100644 --- a/src/sampletones_application/constants/sources.py +++ b/src/sampletones_application/constants/sources.py @@ -10,3 +10,14 @@ class SourceKind(StrEnum): 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/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7b99c5f81..1052e56fa 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -63,7 +63,6 @@ from sampletones_application.view_model.main.reconstructor import ( ReconstructorPanelViewModel, ) -from sampletones_application.view_model.main.updates import GenerationSettingsUpdate from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import ChannelName from sampletones_core.structures.tree import FileSystemNode @@ -171,9 +170,18 @@ def __init__( language_manager=language_manager, status_bar=status_bar, ) + self._converter_logic: ConverterLogic = ConverterLogic( + config_manager, + session_manager, + conversion_service, + scheduling=layout.scheduling, + language_manager=language_manager, + is_operation_active=is_operation_active, + ) self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( ReconstructorPanelViewModel( - channels=session_manager.converter_settings.channel_set, + slots=self._converter_logic.settings_slots, + inspected=None, drive=_config.generation.drive, ), layout=layout.main.reconstructor, @@ -197,14 +205,6 @@ def __init__( status_bar=status_bar, path_colors=layout.path_colors, ) - self._converter_logic: ConverterLogic = ConverterLogic( - config_manager, - session_manager, - conversion_service, - scheduling=layout.scheduling, - language_manager=language_manager, - is_operation_active=is_operation_active, - ) self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, stems_layout=layout.stems, @@ -221,7 +221,8 @@ def __init__( 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 = self._apply_generation_settings + self._reconstructor_panel.on_generation_settings_changed = config_manager.apply_generation_settings + self._reconstructor_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 @@ -279,6 +280,7 @@ def __init__( self._converter_panel.on_source_dropped_on_level = self._converter_logic.move_source_to_new_level 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._stem_selection_window.on_add = self._converter_logic.mix_only def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: @@ -289,7 +291,9 @@ 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: + """The converter's own view, and the settings card that follows what it has picked out.""" self._converter_panel.update_view(view_model) + self._update_reconstructor_panel_view() self._on_busy_state_changed() def _on_wave_file_clicked(self, filepath: Path) -> None: @@ -438,22 +442,16 @@ def _update_config_panel_view(self) -> None: ) ) - def _apply_generation_settings(self, update: GenerationSettingsUpdate) -> None: - """Routes one gesture on the reconstruction card to the two owners it reaches. + def _update_reconstructor_panel_view(self) -> None: + """The settings card reads the choices from the converter and the drive from the config. - Drive shapes every run, so it belongs to the generation configuration; the channels are - what a recording joins the converter's list holding, which the session carries between - runs. + The two owners answer one card, so the composition point is where their readings meet. """ - self._config_manager.apply_generation_settings(update) - self._converter_logic.set_joining_channels(frozenset(update.channels)) - - def _update_reconstructor_panel_view(self) -> None: - config = self._config_manager.config self._reconstructor_panel.update_view( ReconstructorPanelViewModel( - channels=self._session_manager.converter_settings.channel_set, - drive=config.generation.drive, + slots=self._converter_logic.settings_slots, + inspected=self._converter_logic.inspected_name, + drive=self._config_manager.config.generation.drive, ) ) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 0965ed6e2..b589fcd5e 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -6,6 +6,7 @@ from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.conversion import MAX_STEM_SOURCES 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 @@ -23,17 +24,28 @@ playing_sources, ) from sampletones_application.logic.main.converter.state import ConverterState -from sampletones_application.logic.main.converter.view import compose_view +from sampletones_application.logic.main.converter.view import ( + compose_view, + inspected_name, + inspected_settings, + settings_slots, +) 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.recording import Recording -from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT +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.reconstructor import SettingsSlotViewModel +from sampletones_application.view_model.shared.agreement import Agreement from sampletones_core.configs import Config from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -79,6 +91,7 @@ def __init__( ), gathering=Gathering.empty(), destination=Destination.unset(), + selected=None, ) self._run = ConversionRun(conversion_service, messages=self._messages) @@ -183,6 +196,14 @@ def convert_path(self, path: Path) -> None: self.start_conversion() + 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, so the card edits what a recording joins the list with.""" + 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)))) @@ -191,6 +212,32 @@ 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_name(self) -> Optional[str]: + """What the settings card is editing, where a reader picked a row out of the list.""" + return inspected_name(self._state) + + def toggle_slot(self, field: SettingsField, channel_name: ChannelName) -> None: + """Settles one choice on ``channel_name``, wherever the settings card is pointed. + + 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. With no row picked the gesture reaches the + settings a recording joins the list with, which is what the run hands out. + """ + slot = SLOTS_BY_FIELD[field] + held = self._inspected_agreement(slot, channel_name).settles_to + selected = self._state.selected + if selected is None: + self._settle_joining(slot.settled(self._joining_settings, channel_name, held)) + return + + self._settle(self._state.with_gathering(self._state.gathering.settled(selected, slot, channel_name, held))) + 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.""" gathering = self._state.gathering.written_among( @@ -258,9 +305,7 @@ def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: recording to the channels still named; each keeps the choice it was given for a channel left out and gets it back when that channel returns. """ - settings = self._settings.with_joining_channels(channels) - self._session_manager.set_converter_settings(settings.joining) - self._settle(self._state.with_settings(settings)) + self._settle_joining(CHANNEL_SLOT.write(self._joining_settings, channels)) def set_channel_cap(self, channel_cap: int) -> None: """Names how many channels one recording may hold in a frame, for every conversion.""" @@ -327,6 +372,16 @@ def cleanup(self) -> None: def _settings(self) -> RunSettings: return self._state.settings + def _settle_joining(self, joining: StemSettings) -> None: + """Takes up the settings a recording joins the list with, and writes them down.""" + settings = self._settings.with_joining(joining) + self._session_manager.set_converter_settings(settings.joining) + self._settle(self._state.with_settings(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) @@ -358,7 +413,7 @@ def _settle(self, state: ConverterState) -> None: shows follows every gesture; a settled run returns to idle, since the setup it reported on is no longer the one on screen. """ - self._state = self._redirected(state) + self._state = self._redirected(state.selecting(state.selected)) if not self.is_active: self._run.return_to_idle() self._emit(self._messages.idle, 0.0) diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py index 31247210b..323083896 100644 --- a/src/sampletones_application/logic/main/converter/settings.py +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -39,6 +39,10 @@ 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 with_joining(self, joining: StemSettings) -> Self: + """The settings a recording joins the list with, as a reader settled them.""" + return replace(self, joining=joining) + def with_joining_channels(self, channels: FrozenSet[ChannelName]) -> Self: """The settings a recording joins with, holding exactly ``channels``. diff --git a/src/sampletones_application/logic/main/converter/state.py b/src/sampletones_application/logic/main/converter/state.py index 259d66124..0ec1f9b7a 100644 --- a/src/sampletones_application/logic/main/converter/state.py +++ b/src/sampletones_application/logic/main/converter/state.py @@ -1,23 +1,26 @@ from dataclasses import dataclass, replace -from typing import Self +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 three 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. + 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) @@ -27,3 +30,13 @@ def with_gathering(self, gathering: Gathering) -> Self: 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 index 0e54c2f76..6d638be4f 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -7,11 +7,17 @@ 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 CHANNEL_SLOT +from sampletones_application.logic.main.sources.slots import ( + CHANNEL_SLOT, + SETTINGS_SLOTS, + SettingsSlot, +) from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel +from sampletones_application.view_model.main.reconstructor import 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( @@ -49,9 +55,69 @@ def compose_view( max_channel_cap=settings.max_channel_cap, hierarchy_mode=settings.hierarchy_mode, max_sources=MAX_STEM_SOURCES, + selected_key=_selected_key(state), ) +def settings_slots(state: ConverterState) -> Tuple[SettingsSlotViewModel, ...]: + """The choices the settings card edits, read from what the card is inspecting. + + A picked row is read through the recordings it stands for; with none picked the card edits the + settings a recording joins the list with, which is what every new row starts from. + """ + 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: a picked row's recordings, or the joining settings.""" + selected = state.selected + if selected is None: + return (state.settings.joining,) + + row = state.gathering.sources.row(selected) + if row is None: + return () + + return tuple(recording.settings for recording in row.recordings) + + +def inspected_name(state: ConverterState) -> Optional[str]: + """What the card is editing, where a reader picked a row out of the list.""" + selected = state.selected + if selected is None: + return None + + return selected.path.name if selected.names_folder else selected.path.stem + + +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, enabled_channels: FrozenSet[ChannelName], diff --git a/src/sampletones_application/logic/main/sources/slots.py b/src/sampletones_application/logic/main/sources/slots.py index aace6dbd2..661d6d80f 100644 --- a/src/sampletones_application/logic/main/sources/slots.py +++ b/src/sampletones_application/logic/main/sources/slots.py @@ -1,23 +1,17 @@ from dataclasses import dataclass -from enum import StrEnum -from typing import Callable, Final, FrozenSet, Tuple +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()) -class SettingsField(StrEnum): - """The per-recording choices a reader makes, by the name the settings hold each under.""" - - CHANNELS = "channels" - BENDS = "bends" - - def _channels_of(settings: StemSettings) -> FrozenSet[ChannelName]: return settings.channel_set @@ -35,10 +29,19 @@ def _with_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], @@ -53,19 +56,19 @@ 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 offer it at all. 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. + 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 - channels_offered: FrozenSet[ChannelName] + offered: SettingsOffer - def offers(self, channel_name: ChannelName) -> bool: - """Whether this choice is put to a reader on ``channel_name``.""" - return channel_name in self.channels_offered + 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``.""" @@ -87,14 +90,16 @@ def settled( field=SettingsField.CHANNELS, read=_channels_of, write=_with_channels, - channels_offered=ALL_CHANNELS, + offered=_channels_offered, ) BEND_SLOT: Final[SettingsSlot] = SettingsSlot( field=SettingsField.BENDS, read=_bends_of, write=_with_bends, - channels_offered=TONE_CHANNELS, + 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/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index f63e35778..fa12e8e0c 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -61,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): @@ -388,6 +382,7 @@ def _build_stems_view_model( muted_channels=frozenset(channels_in_play) - frozenset(self._selected_channels), 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/tags/main.py b/src/sampletones_application/tags/main.py index 71650ce53..96b50bf13 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -243,7 +243,13 @@ "summary_hint", ) -PRE_MAIN_RECONSTRUCTOR_CHANNEL = "channel" +PRE_MAIN_RECONSTRUCTOR_SLOT = "slot" +TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING = TagName( + Page.MAIN, + Panel.RECONSTRUCTOR, + Widget.TEXT, + "inspecting", +) TAG_MAIN_CONVERTER_GROUP_CONTROLS = TagName( Page.MAIN, Panel.CONVERTER, diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 696fc7faf..1fcbf3442 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -11,7 +11,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar 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.utils.gui.dpg import dpg_delete_item from sampletones_application.view_model.shared.stems import StemsListViewModel from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.application import Sender @@ -121,9 +121,8 @@ def on_master_box(self, _sender: Sender, value: bool, user_data: str) -> None: def on_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> None: self._report(self.on_removal_asked, user_data) - def on_name_selected(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) + def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> None: + """Hand a clicked row on, and let the next view say which row now reads as picked out.""" if self.activatable: self._report(self.on_row_activated, user_data) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 9fd170aba..92450ce08 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -110,7 +110,7 @@ def repaint( 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, False) + dpg_set_value(name_tag, row.key == view_model.selected_key) dpg_configure_item(name_tag, enabled=live) 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 diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index d6ef6e37f..5cf026be0 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -8,6 +8,7 @@ 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.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 @@ -115,6 +116,7 @@ def __init__( self.on_output_changed: Optional[Callable[[OutputKind], None]] = None self.on_folder_removed: Optional[Callable[[Path], 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_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 @@ -296,6 +298,7 @@ def _create_stems_list(self) -> None: self._stems_list.on_channels_changed = self._on_source_channels_changed self._stems_list.on_channel_toggled = self._on_folder_channel_toggled self._stems_list.on_remove_requested = self._on_source_removed + self._stems_list.on_row_activated = self._on_row_selected 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 @@ -338,6 +341,12 @@ def _on_hierarchy_mode_changed(self, _sender: Sender, value: str) -> None: def _on_source_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> None: self.call(self.on_source_channels_changed, Path(key), channels) + def _on_row_selected(self, key: str) -> None: + """A clicked row is the one the settings card inspects, whichever kind it is.""" + row = self._stems_list.row(key) + if row is not None: + self.call(self.on_row_selected, Path(key), row.kind) + 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) diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index dc24775ce..5ae685a85 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -1,38 +1,53 @@ -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Dict, 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.constants.sources import SettingsField 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, + PRE_MAIN_RECONSTRUCTOR_SLOT, TAG_MAIN_RECONSTRUCTOR_PANEL, TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, ) 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.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_set_value +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.reconstructor import ( ReconstructorPanelViewModel, + SettingsSlotViewModel, ) from sampletones_application.view_model.main.updates import GenerationSettingsUpdate +from sampletones_application.view_model.shared.agreement import Agreement 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): + """The settings card: the per-recording choices a reader edits, and the drive the run holds to. + + The card is drawn from the slots the model declares rather than from a control per field, so a + further choice reaches the screen as one more slot and one more line naming it. What it edits + is whatever the list has picked out; with nothing picked it edits the settings a recording + joins the list with. + """ + def __init__( self, initial_view: ReconstructorPanelViewModel, @@ -50,6 +65,13 @@ def __init__( self._label_width = inputs.label_width self._status_bar = status_bar self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None + self.on_slot_toggled: Optional[Callable[[SettingsField, ChannelName], None]] = None + self._slot_labels: Dict[SettingsField, str] = { + SettingsField.CHANNELS: language_manager["main.reconstructor.label.slot_channels"], + SettingsField.BENDS: language_manager["main.reconstructor.label.slot_bends"], + } + self._msg_joining = language_manager["main.reconstructor.message.inspecting_joining"] + self._tpl_inspecting = language_manager["main.reconstructor.template.inspecting_row"] self._item_handler_tag = compose_tag(TAG_MAIN_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) super().__init__( @@ -66,7 +88,10 @@ def create_panel(self, parent: str) -> None: glyph=self._glyphs.headers.reconstruction, width=self.width, ): - self._create_generator_selection() + self._create_subject_line() + for slot in self._view.slots: + self._create_slot(slot) + dpg.add_separator() self._create_drive_slider() self._create_tooltips() @@ -77,29 +102,45 @@ def _setup_handlers(self) -> None: dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change) dpg.add_item_edited_handler(callback=self._on_parameter_change) - def _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_subject_line(self) -> None: + """What the card is editing, which the list settles by what a reader picks out of it.""" + text = dpg.add_text(self._subject_text(), tag=TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING) + FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) + + def _create_slot(self, slot: SettingsSlotViewModel) -> None: + """One choice: its name, and a box on every channel it is put to a reader on.""" + subheader(self._slot_labels[slot.field]) + with dpg.group(tag=self._slot_tag(slot.field)): + for channel_name in ChannelName.items(): + self._create_slot_box(slot, channel_name) + + def _create_slot_box(self, slot: SettingsSlotViewModel, channel_name: ChannelName) -> None: + checkbox_tag = self._slot_checkbox_tag(slot.field, channel_name) + dpg.add_checkbox( + label=channel_label(self._language_manager, channel_name), + default_value=slot.agreement_on(channel_name) is not Agreement.NONE, + tag=checkbox_tag, + show=slot.offers(channel_name), + user_data=(slot.field, channel_name), + callback=self._on_slot_box, + ) + ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) + + def _subject_text(self) -> str: + inspected = self._view.inspected + if inspected is None: + return self._msg_joining + + return self._tpl_inspecting.format(name=inspected) + + def _on_slot_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) def _create_drive_slider(self) -> None: with labeled_field(self._language_manager["main.reconstructor.label.slider_drive"], self._label_width): @@ -132,32 +173,50 @@ def _create_tooltips(self) -> None: ) def toggle_channel(self, channel: ChannelName) -> None: - """Switches one channel in or out of the set a reconstruction is built from. + """Switches one channel in or out of what the card is editing. - 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. + This is the gesture a click on the channel's box makes, reached by the key the channel + answers to, so the panel reports the same choice 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() + self.call(self.on_slot_toggled, SettingsField.CHANNELS, channel) 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) + dpg_set_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, self._subject_text()) + for slot in view_model.slots: + self._render_slot(slot) + + def _render_slot(self, slot: SettingsSlotViewModel) -> None: + """Draw what the choice currently stands at onto the boxes it already has.""" + for channel_name in ChannelName.items(): + checkbox_tag = self._slot_checkbox_tag(slot.field, channel_name) + agreement = slot.agreement_on(channel_name) + dpg_configure_item(checkbox_tag, show=slot.offers(channel_name)) + dpg_set_value(checkbox_tag, agreement is not Agreement.NONE) + ThemeRegistry.get(self._box_theme(channel_name, agreement)).bind_to_item(checkbox_tag) + + @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 _slot_tag(field: SettingsField) -> str: + return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value) @staticmethod - def _get_generator_checkbox_tag(channel: ChannelName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_CHANNEL, channel.value) + def _slot_checkbox_tag(field: SettingsField, channel: ChannelName) -> str: + return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value, channel.value) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index e156ab720..8658daaf2 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -67,6 +67,7 @@ class ConverterViewModel(BaseModel, frozen=True): max_channel_cap: int hierarchy_mode: HierarchyMode max_sources: int + selected_key: Optional[str] @property def mixes(self) -> bool: @@ -113,6 +114,7 @@ def stems_list(self) -> StemsListViewModel: muted_channels=frozenset(), live=not self.is_active, collapse_levels=not self.mixes, + selected_key=self.selected_key, ) @property diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py index 7799a6d61..41b542c68 100644 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ b/src/sampletones_application/view_model/main/reconstructor.py @@ -1,10 +1,53 @@ -from typing import FrozenSet +from typing import FrozenSet, Optional, Tuple from pydantic import BaseModel +from sampletones_application.constants.sources import SettingsField +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 ReconstructorPanelViewModel(BaseModel, frozen=True): - channels: FrozenSet[ChannelName] + """What the settings card shows: the choices it edits, and what it is editing them on. + + ``inspected`` names the row a reader picked out of the list; with none picked the card edits + the settings a recording joins the list with, which is what every new row starts from. + """ + + slots: Tuple[SettingsSlotViewModel, ...] + inspected: Optional[str] drive: float + + @property + def channels(self) -> FrozenSet[ChannelName]: + """The channels the run hands out, which is the first slot's own reading.""" + for slot in self.slots: + if slot.field is SettingsField.CHANNELS: + return slot.held_channels + + return frozenset() 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/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 50be9fb1b..4159b212a 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -96,6 +96,7 @@ 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. """ rows: Tuple[StemRowViewModel, ...] @@ -103,6 +104,7 @@ class StemsListViewModel(BaseModel, frozen=True): muted_channels: FrozenSet[ChannelName] live: bool collapse_levels: bool + selected_key: Optional[str] @classmethod def empty(cls) -> Self: @@ -113,6 +115,7 @@ def empty(cls) -> Self: muted_channels=frozenset(), live=True, collapse_levels=False, + selected_key=None, ) @property diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ec3bd76b8..9ea18dadb 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -343,7 +343,10 @@ main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for # ============================================================================= # Main tab — Reconstructor panel # ============================================================================= -main.reconstructor.label.section_channels: "Channels" +main.reconstructor.label.slot_channels: "Channels" +main.reconstructor.label.slot_bends: "Bends" +main.reconstructor.message.inspecting_joining: "Editing what every recording joins the list with." +main.reconstructor.template.inspecting_row: "Editing {name}." 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." diff --git a/tests/unit/sampletones_application/logic/main/converter/test_setup.py b/tests/unit/sampletones_application/logic/main/converter/test_setup.py index 534315fbb..baa305601 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_setup.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -55,6 +55,7 @@ def _state( ), gathering=gathering, destination=Destination.unset(), + selected=None, ) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_slots.py b/tests/unit/sampletones_application/logic/main/sources/test_slots.py index d181d7ca7..4308e1353 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_slots.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_slots.py @@ -8,14 +8,20 @@ class TestWhichChannelsASlotIsOfferedOn: - """A choice reaches a reader only on the channels whose hardware answers it.""" + """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: - assert all(CHANNEL_SLOT.offers(channel_name) for channel_name in ChannelName.items()) + 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_channels_that_read_one(self) -> None: - assert BEND_SLOT.channels_offered == TONE_CHANNELS - assert not BEND_SLOT.offers(ChannelName.NOISE) + 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: 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 b7e4749f1..5feef7be8 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, FrozenSet, Iterator, List, Tuple +from typing import Final, FrozenSet, Iterator, List, Optional, Tuple import dearpygui.dearpygui as dpg import pytest @@ -130,8 +130,10 @@ def view( live: bool = True, muted_channels: FrozenSet[ChannelName] = frozenset(), collapse_levels: bool = False, + selected_key: Optional[str] = None, ) -> StemsListViewModel: return StemsListViewModel( + selected_key=selected_key, rows=rows, channels_in_play=CHANNELS, muted_channels=muted_channels, @@ -641,7 +643,7 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf class TestActivation: - def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: None, layout_config) -> None: + def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> None: activated: List[str] = [] stems_list = build(layout_config, dragging=False) stems_list.on_row_activated = activated.append @@ -649,8 +651,17 @@ def test_a_clicked_row_reports_itself_and_stays_unselected(self, dpg_context: No 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) assert activated == [bass.key] - assert not dpg.get_value(name_tag) + + def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, layout_config) -> 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 diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py index 9bb777781..13928c7e5 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py @@ -1,39 +1,28 @@ -from dataclasses import dataclass -from typing import Dict, FrozenSet, List, Tuple +from typing import List, Tuple import pytest +from sampletones_application.constants.sources import SettingsField 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.""" + """The panel over the gestures it reports, without a window to hold its widgets.""" - 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 - } + def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: + self.toggled: List[Tuple[SettingsField, 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_slot_toggled = lambda field, channel: self.toggled.append((field, channel)) self.panel.on_generation_settings_changed = self.reported.append @staticmethod @@ -41,116 +30,49 @@ 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 +class TestTheChoicesTheCardReports: + """The card settles nothing itself: it names the choice a reader made and hands it on.""" - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_settings_the_panel_reports( + @pytest.mark.parametrize("channel", list(ChannelName.items())) + def test_the_key_a_channel_answers_to_settles_its_own_choice( self, - test_case: TestCase, + channel: ChannelName, 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 = Harness(monkeypatch) - harness.panel.toggle_channel(test_case.channel) + harness.panel.toggle_channel(channel) - assert harness.reported == [ - GenerationSettingsUpdate( - drive=DRIVE, - channels=[channel for channel in ChannelName if channel in test_case.expected], - ) - ] + assert harness.toggled == [(SettingsField.CHANNELS, channel)] - def test_switching_a_generator_twice_returns_the_set_it_started_from( + @pytest.mark.parametrize("field", list(SettingsField)) + def test_a_box_names_the_choice_and_the_channel_it_stands_on( self, + field: SettingsField, monkeypatch: pytest.MonkeyPatch, ) -> None: - harness = Harness(ALL_CHANNELS, monkeypatch) - - harness.panel.toggle_channel(ChannelName.PULSE2) - harness.panel.toggle_channel(ChannelName.PULSE2) + harness = Harness(monkeypatch) - assert harness.checked() == ALL_CHANNELS + harness.panel._on_slot_box(None, True, (field, ChannelName.TRIANGLE)) - 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) + assert harness.toggled == [(field, ChannelName.TRIANGLE)] - harness.panel.toggle_channel(ChannelName.TRIANGLE) + def test_drive_reaches_the_configuration_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Drive shapes every run, so it travels apart from the choices a row holds.""" + harness = Harness(monkeypatch) - assert self._generators(harness.reported) == [ - ChannelName.PULSE1, - ChannelName.TRIANGLE, - ChannelName.NOISE, - ] + harness.panel._report_generation_settings() - @staticmethod - def _generators(reported: List[GenerationSettingsUpdate]) -> List[ChannelName]: - return list(reported[-1].channels) + assert harness.reported == [GenerationSettingsUpdate(drive=DRIVE)] 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 + def test_every_choice_and_channel_carries_a_tag_of_its_own(self) -> None: + tags = tuple( + GUIReconstructorPanel._slot_checkbox_tag(field, channel) + for field in SettingsField + for channel in ChannelName.items() ) assert len(set(tags)) == len(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 4558b2f36..149c54a29 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 @@ -118,6 +118,7 @@ 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, 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 0b3156d59..ff8ea0077 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -72,6 +72,7 @@ def _view_model( max_channel_cap=len(ENABLED_CHANNELS), hierarchy_mode=HierarchyMode.ROUND_ROBIN, max_sources=max_sources, + selected_key=None, ) 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..9d7eaf24d 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -19,6 +19,7 @@ from sampletones_core.constants.enums import HierarchyMode EMPTY_STEMS = StemsListViewModel( + selected_key=None, rows=(), channels_in_play=(), muted_channels=frozenset(), From 0a1a4f51cbaf16b8c7a13e1b6db1a27f175baf0c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 05:52:12 +0200 Subject: [PATCH 014/130] Made: the channels a row holds the whole of what its reconstruction reaches --- .../logic/main/converter/destination.py | 14 ++---- .../logic/main/converter/logic.py | 22 ++++---- .../logic/main/converter/settings.py | 6 +-- .../logic/main/converter/setup.py | 25 +++------- .../logic/main/converter/view.py | 29 +++++------ .../logic/main/sources/derive.py | 34 +++++-------- .../view_model/main/converter.py | 9 ++-- .../logic/main/converter/test_logic.py | 20 +++----- .../logic/main/converter/test_settings.py | 17 +++---- .../logic/main/sources/test_derive.py | 50 ++----------------- 10 files changed, 68 insertions(+), 158 deletions(-) diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 86a5abc99..6a8d81aae 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -75,17 +75,12 @@ def aimed_at_mix( return replace(self, output_path=group_output_path(config, sources, channels)) - def aimed_at_batch( - self, - config: Config, - entries: Tuple[BatchEntry, ...], - channels: AbstractSet[ChannelName], - ) -> Self: + 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 run's settings hold, which is - the tree the batch writes into. + 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 @@ -93,7 +88,8 @@ def aimed_at_batch( if len(entries) == 1: return replace(self, output_path=entries[0].output_path(config)) - return replace(self, output_path=config_directory_path(config, channels)) + 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. diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index b589fcd5e..659190abb 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -21,6 +21,7 @@ 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 @@ -239,13 +240,8 @@ def toggle_slot(self, field: SettingsField, channel_name: ChannelName) -> None: self._settle(self._state.with_gathering(self._state.gathering.settled(selected, slot, channel_name, held))) 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.""" - gathering = self._state.gathering.written_among( - path, - CHANNEL_SLOT, - channels, - self._settings.enabled_channels, - ) + """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: @@ -325,13 +321,13 @@ def start_conversion(self, confirmed: bool = False) -> None: logger.warning("A conversion or library generation is already in progress") return - if not self._settings.enabled_channels: - self.call(self.on_no_generators) + if not self._state.gathering.count: + logger.warning("Nothing is gathered to convert") return plan = conversion_plan(self._state) if plan is None: - logger.warning("Nothing is selected to convert") + self.call(self.on_no_generators) return standing_target = self._standing_target(plan) @@ -421,12 +417,12 @@ def _settle(self, state: ConverterState) -> None: def _redirected(self, state: ConverterState) -> ConverterState: """The setup with its destination following the sources that take part in it.""" config = self._config_manager.config - channels = state.settings.enabled_channels destination = state.destination.named_after(state.gathering.sources) if state.settings.mixes: - return state.with_destination(destination.aimed_at_mix(config, playing_sources(state), channels)) + 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), channels)) + return state.with_destination(destination.aimed_at_batch(config, batch_entries(state))) def _standing_target(self, plan: ConversionPlan) -> Optional[Path]: """The reconstruction ``plan`` would write over, where one stands. diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py index 323083896..cf4276854 100644 --- a/src/sampletones_application/logic/main/converter/settings.py +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -31,12 +31,12 @@ def enabled_channels(self) -> FrozenSet[ChannelName]: @property def max_channel_cap(self) -> int: - """The highest cap the enabled channels leave room for, which is at least one channel.""" - return max(len(self.enabled_channels), MIN_CHANNEL_CAP) + """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: what the reader asked for, within the channels now enabled.""" + """The cap a run holds to, within the channels the hardware has.""" return min(self.channel_cap, self.max_channel_cap) def with_joining(self, joining: StemSettings) -> Self: diff --git a/src/sampletones_application/logic/main/converter/setup.py b/src/sampletones_application/logic/main/converter/setup.py index bb35c72c1..67205ca27 100644 --- a/src/sampletones_application/logic/main/converter/setup.py +++ b/src/sampletones_application/logic/main/converter/setup.py @@ -6,8 +6,6 @@ ConversionSetup, derive_conversion_setup, ) -from sampletones_application.logic.main.sources.recording import Recording -from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT from sampletones_core.reconstructions.converter import ( BatchConversion, BatchEntry, @@ -23,7 +21,6 @@ def conversion_setup(state: ConverterState) -> ConversionSetup: return derive_conversion_setup( state.gathering.sources, state.gathering.levels, - settings.enabled_channels, channel_cap=settings.effective_channel_cap, hierarchy_mode=settings.hierarchy_mode, ) @@ -38,7 +35,7 @@ def playing_sources(state: ConverterState) -> Tuple[Path, ...]: def batch_entries(state: ConverterState) -> Tuple[BatchEntry, ...]: - """One entry per gathered recording still holding a channel the run hands out. + """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, @@ -48,19 +45,18 @@ def batch_entries(state: ConverterState) -> Tuple[BatchEntry, ...]: gathering = state.gathering entries = [] for recording in gathering.sources.recordings: - narrowed = _narrowed(recording, state) - if not narrowed.settings.channels: + if not recording.settings.channels: continue entries.append( BatchEntry( - source=narrowed.path, + source=recording.path, stems=StemsConfig.single_entry( - narrowed.settings.channels, - narrowed.settings.bends, + recording.settings.channels, + recording.settings.bends, channel_cap=settings.effective_channel_cap, ), - base_directory=gathering.folder_root_of(narrowed.path), + base_directory=gathering.folder_root_of(recording.path), ) ) @@ -78,12 +74,3 @@ def conversion_plan(state: ConverterState) -> Optional[ConversionPlan]: entries = batch_entries(state) return BatchConversion(entries=entries) if entries else None - - -def _narrowed(recording: Recording, state: ConverterState) -> Recording: - """The recording as the run hands channels out to it.""" - settings = CHANNEL_SLOT.write( - recording.settings, - recording.settings.channel_set & state.settings.enabled_channels, - ) - return recording.with_settings(settings) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 6d638be4f..ee97693c1 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -8,6 +8,7 @@ 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, @@ -49,8 +50,7 @@ def compose_view( is_file=destination.is_file, other_operation_active=other_operation_active, output=settings.output, - stem_sources=stem_rows(state.gathering, settings.enabled_channels, mixes=settings.mixes), - enabled_channels=settings.enabled_channels, + stem_sources=stem_rows(state.gathering, mixes=settings.mixes), channel_cap=settings.effective_channel_cap, max_channel_cap=settings.max_channel_cap, hierarchy_mode=settings.hierarchy_mode, @@ -120,19 +120,19 @@ def _selected_key(state: ConverterState) -> Optional[str]: def stem_rows( gathering: Gathering, - enabled_channels: FrozenSet[ChannelName], *, 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 the run enables. 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. + 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, enabled_channels) for placement in placements) + return tuple(_row(placement) for placement in placements) @dataclass(frozen=True) @@ -147,10 +147,10 @@ class _Placement: level_count: int -def _row(placement: _Placement, enabled_channels: FrozenSet[ChannelName]) -> StemRowViewModel: +def _row(placement: _Placement) -> StemRowViewModel: source = placement.source key = source.key - channels, partial = _readings(source, enabled_channels) + channels, partial = _readings(source) return StemRowViewModel( key=str(placement.path), kind=key.kind, @@ -158,7 +158,7 @@ def _row(placement: _Placement, enabled_channels: FrozenSet[ChannelName]) -> Ste holds=source.count, channels=channels, partial_channels=partial, - offered_channels=enabled_channels, + offered_channels=ALL_CHANNELS, available=placement.path.is_dir() if key.names_folder else placement.path.is_file(), level=placement.level, position=placement.position, @@ -167,18 +167,15 @@ def _row(placement: _Placement, enabled_channels: FrozenSet[ChannelName]) -> Ste ) -def _readings( - source: SourceRow, - enabled_channels: FrozenSet[ChannelName], -) -> Tuple[FrozenSet[ChannelName], FrozenSet[ChannelName]]: - """How the recordings a row stands for read on each channel the run enables. +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 enabled_channels: + for channel_name in ALL_CHANNELS: agreement = Agreement.over( channel_name in CHANNEL_SLOT.read(recording.settings) for recording in source.recordings ) diff --git a/src/sampletones_application/logic/main/sources/derive.py b/src/sampletones_application/logic/main/sources/derive.py index 0ba086028..02f839dfa 100644 --- a/src/sampletones_application/logic/main/sources/derive.py +++ b/src/sampletones_application/logic/main/sources/derive.py @@ -1,12 +1,11 @@ from dataclasses import dataclass from pathlib import Path -from typing import AbstractSet, List, Sequence, Tuple +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_application.logic.main.sources.slots import CHANNEL_SLOT -from sampletones_core.constants.enums import ChannelName, HierarchyMode +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 @@ -27,18 +26,17 @@ class ConversionSetup: def derive_conversion_setup( sources: SourceList, levels: MixLevels, - enabled_channels: AbstractSet[ChannelName], *, channel_cap: int, hierarchy_mode: HierarchyMode, ) -> ConversionSetup: """Turns the levels a reader gathered into the recordings and the setup a conversion runs with. - Each recording is narrowed to the channels the run still enables, and one left holding none - 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. + 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, enabled_channels) for level in levels.levels] + 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)] @@ -52,23 +50,15 @@ def derive_conversion_setup( ) -def _recordings_of( - sources: SourceList, - level: Sequence[Path], - enabled_channels: AbstractSet[ChannelName], -) -> List[Recording]: - """The recordings of one level, each narrowed to the channels the run enables and still playing.""" - narrowed = [] +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 None: - continue - - settings = CHANNEL_SLOT.write(recording.settings, recording.settings.channel_set & enabled_channels) - if settings.channels: - narrowed.append(recording.with_settings(settings)) + if recording is not None and recording.settings.channels: + playing.append(recording) - return narrowed + return playing def _hierarchy( diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 8658daaf2..c82fefe79 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -62,7 +62,6 @@ class ConverterViewModel(BaseModel, frozen=True): other_operation_active: bool output: OutputKind stem_sources: Tuple[StemRowViewModel, ...] - enabled_channels: FrozenSet[ChannelName] channel_cap: int max_channel_cap: int hierarchy_mode: HierarchyMode @@ -98,12 +97,12 @@ def source_count(self) -> int: @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: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 03efb630a..73f91ea04 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, FrozenSet, List +from typing import Callable, List from unittest.mock import MagicMock, patch import pytest @@ -182,13 +182,14 @@ def test_wait_poll_does_not_emit_a_zero_progress_view( class TestNoChannelsGuard: - """With no channels enabled there is nothing to reconstruct, so the conversion must not start.""" + """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: converter_logic.set_joining_channels(frozenset()) + _listed(converter_logic, "a") on_no_generators = MagicMock() converter_logic.on_no_generators = on_no_generators @@ -661,7 +662,7 @@ def test_a_folder_its_recordings_agree_on_reads_as_held( row = _view(converter_logic).stem_sources[0] - assert row.channels == _joining_channels(converter_logic) + 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( @@ -729,10 +730,6 @@ def test_turning_to_a_mix_gives_up_the_folder( assert [row.stands_for_a_folder for row in rows] == [False, False] -def _joining_channels(converter_logic: ConverterLogic) -> FrozenSet[ChannelName]: - return _view(converter_logic).enabled_channels - - class TestTheStemsView: """What the panel is told about the setup being built.""" @@ -768,16 +765,13 @@ def test_an_empty_stems_list_offers_nothing_to_convert(self, converter_logic: Co 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_enabled( + def test_the_cap_the_view_reports_holds_within_the_channels_there_are( self, converter_logic: ConverterLogic, - session_manager: SessionManager, ) -> None: - channels = session_manager.converter_settings.channels - - converter_logic.set_channel_cap(len(channels) + 5) + converter_logic.set_channel_cap(len(ChannelName) + 5) - assert _view(converter_logic).channel_cap == len(channels) + assert _view(converter_logic).channel_cap == len(ChannelName) def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Path: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py index 2b0a23446..e39ae12b6 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_settings.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -1,7 +1,5 @@ from typing import List -import pytest - 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 @@ -37,22 +35,19 @@ def test_narrowing_the_joining_channels_takes_the_bends_they_carried(self) -> No class TestTheCapARunHoldsTo: - def test_a_cap_beyond_the_channels_enabled_is_held_to_them(self) -> None: - settings = _settings(TONES).with_channel_cap(len(TONES) + 5) + 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(TONES) + 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_a_cap_falls_with_the_channels_it_was_asked_for(self) -> None: + 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(TONES).with_channel_cap(3).with_joining_channels(frozenset({ChannelName.PULSE1})) - assert settings.effective_channel_cap == 1 - - @pytest.mark.parametrize("channels", [[], [ChannelName.PULSE1]], ids=["none", "one"]) - def test_the_cap_always_leaves_room_for_one_channel(self, channels: List[ChannelName]) -> None: - assert _settings(channels).max_channel_cap == MIN_CHANNEL_CAP + assert settings.effective_channel_cap == 3 class TestTheShapeOfTheRun: diff --git a/tests/unit/sampletones_application/logic/main/sources/test_derive.py b/tests/unit/sampletones_application/logic/main/sources/test_derive.py index c2b9e0511..f9e22f3ab 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_derive.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_derive.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import FrozenSet, List, Sequence, Tuple +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 @@ -7,8 +7,6 @@ from sampletones_core.constants.enums import ChannelName, HierarchyMode from tests.unit.sampletones_application.logic.main.sources.factories import recording -ENABLED: FrozenSet[ChannelName] = frozenset({ChannelName.PULSE1, ChannelName.TRIANGLE, ChannelName.NOISE}) - def _path(name: str) -> Path: return Path(f"/audio/{name}.wav") @@ -28,31 +26,17 @@ def _gathered( class TestWhatEachRecordingBringsToTheSetup: - def test_a_recording_keeps_the_channels_the_run_still_enables(self) -> None: + 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, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) - assert setup.stems.entries[0].settings.channels == [ChannelName.PULSE1] - - def test_the_channels_stand_in_the_order_the_run_names_them(self) -> None: - sources, levels = _gathered(["lead"], holding=[ChannelName.NOISE, ChannelName.PULSE1]) - - setup = derive_conversion_setup( - sources, - levels, - ENABLED, - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert setup.stems.entries[0].settings.channels == [ChannelName.PULSE1, ChannelName.NOISE] + 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( @@ -63,33 +47,12 @@ def test_a_bend_the_recording_carries_reaches_the_entry(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) assert setup.stems.entries[0].settings.bends == [ChannelName.TRIANGLE] - def test_a_bend_on_a_channel_the_run_leaves_out_goes_with_it(self) -> None: - sources = SourceList().add_recording( - recording( - str(_path("lead")), - [ChannelName.PULSE1, ChannelName.TRIANGLE], - [ChannelName.TRIANGLE], - ) - ) - levels = MixLevels.of([[_path("lead")]]) - - setup = derive_conversion_setup( - sources, - levels, - frozenset({ChannelName.PULSE1}), - channel_cap=1, - hierarchy_mode=HierarchyMode.STRICT, - ) - - assert setup.stems.entries[0].settings.bends == [] - class TestTheSetupTheLevelsAmountTo: def test_a_recordings_position_is_its_stem_id(self) -> None: @@ -98,7 +61,6 @@ def test_a_recordings_position_is_its_stem_id(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) @@ -111,7 +73,6 @@ def test_recordings_sharing_a_level_pick_together(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) @@ -124,7 +85,6 @@ def test_the_mix_lists_the_recordings_in_entry_order(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.ROUND_ROBIN, ) @@ -137,7 +97,6 @@ def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=2, hierarchy_mode=HierarchyMode.ROUND_ROBIN, ) @@ -166,7 +125,6 @@ def test_it_reaches_neither_the_mix_nor_the_entries(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) @@ -180,7 +138,6 @@ def test_a_level_left_with_nobody_taking_part_drops_out(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) @@ -194,7 +151,6 @@ def test_a_path_the_list_never_gathered_takes_no_part(self) -> None: setup = derive_conversion_setup( sources, levels, - ENABLED, channel_cap=1, hierarchy_mode=HierarchyMode.STRICT, ) From f6faeb059c2d38bcb62f188822f77d27ff5cb5eb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:07:02 +0200 Subject: [PATCH 015/130] Crossed: a gesture that rebuilds widgets to the render thread --- src/sampletones_application/application.py | 6 ++ .../coordinators/tabs/main.py | 12 +++- .../utils/gui/render_thread.py | 53 ++++++++++++++++ .../utils/gui/test_render_thread.py | 61 +++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/sampletones_application/utils/gui/render_thread.py create mode 100644 tests/unit/sampletones_application/utils/gui/test_render_thread.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index e6f984558..85761e052 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -135,6 +135,10 @@ 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 @@ -1655,6 +1659,7 @@ def _save_config(self) -> bool: return True def run(self) -> None: + claim_render_thread() try: while dpg.is_dearpygui_running(): self.frame() @@ -1663,6 +1668,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/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 1052e56fa..307764f8e 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -55,6 +55,7 @@ from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.utils.gui.render_thread import on_render_thread from sampletones_application.view_model.main.advanced import ( AdvancedSettingsPanelViewModel, ) @@ -131,6 +132,7 @@ def __init__( _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._explorer_logic: ExplorerLogic = ExplorerLogic( config_manager, @@ -291,7 +293,15 @@ 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: - """The converter's own view, and the settings card that follows what it has picked out.""" + """The converter's own view, and the settings card that follows what it has picked out. + + 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 _repaint_converter(self, view_model: ConverterViewModel) -> None: self._converter_panel.update_view(view_model) self._update_reconstructor_panel_view() self._on_busy_state_changed() 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..7c2ba776a --- /dev/null +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -0,0 +1,53 @@ +import threading +from typing import Any, Optional + +from sampletones_application.utils.callbacks.queue import CallbackQueue +from sampletones_shared.types.callback import Callback + +_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. + + DearPyGui invokes a widget's callback on a thread of its own, so a panel that rebuilds itself + straight from a gesture creates and drops widgets while the render thread walks them, and a + callback 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 callback that reads a value or sets one on a standing widget runs where it is called; one + that creates or deletes items comes through here. + """ + if is_render_thread(): + work(*args, **kwargs) + return + + CallbackQueue.add(work, *args, priority=priority, **kwargs) 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 From 32ed49563d1c446928dc6271fc86066046b511b6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:07:17 +0200 Subject: [PATCH 016/130] Recorded: why an absent DearPyGui item is caught as broadly as it is --- docs/development/bugs-and-todos.md | 7 ++-- src/sampletones_application/utils/gui/dpg.py | 13 ++++-- .../utils/gui/test_dpg.py | 42 +++++++++++++++++++ 3 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 tests/unit/sampletones_application/utils/gui/test_dpg.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index e900451ed..ac4c4bc69 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -130,9 +130,10 @@ again. constructs, declaring no state and no rule of its own. Either the logic object takes a job — the explorer's own state machine — or its consumers hold the manager. * `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. The recovery it makes is real: a queued - callback can remove an item underneath the lookup. Naming the exception DearPyGui raises for an - absent item is what closes it, and the helper sits under every item lookup in the interface. + 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. * `MainTabCoordinator` is constructed by no test. Every fixture in `tests/unit/sampletones_application/coordinators/tabs/test_main.py` builds the object through `__new__` and populates its privates by hand, so the wiring the application actually runs is 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/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 From 14cee1b8d5250b4fea461efb1e19faa91b2c9f5b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:23:13 +0200 Subject: [PATCH 017/130] Divided: the Main tab's wiring by the collaborator each hook reaches --- src/sampletones_application/application.py | 25 ++-- .../coordinators/tabs/hooks.py | 28 ++++ .../coordinators/tabs/main.py | 129 ++++++++++++------ .../logic/main/explorer.py | 51 ------- .../coordinators/tabs/test_main.py | 51 ++++--- 5 files changed, 162 insertions(+), 122 deletions(-) create mode 100644 src/sampletones_application/coordinators/tabs/hooks.py delete mode 100644 src/sampletones_application/logic/main/explorer.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 85761e052..201ce49fb 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, ) @@ -481,21 +482,23 @@ 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_browsers, - on_refresh_trees=self._refresh_browsers, - on_generate_library=self._instructions_tab.ensure_library_loaded, stem_selection_window=self.stem_selection_window, ) 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/main.py b/src/sampletones_application/coordinators/tabs/main.py index 307764f8e..752bc5767 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Optional import dearpygui.dearpygui as dpg @@ -8,12 +8,13 @@ from sampletones_application.config.managers.session import SessionManager 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.logic.instruction.library_manager import ( InstructionsLibraryManager, ) from sampletones_application.logic.main.converter.logic import ConverterLogic from sampletones_application.logic.main.converter.run import ConversionSuccess -from sampletones_application.logic.main.explorer import ExplorerLogic +from sampletones_application.logic.main.explorer_manager import ExplorerManager from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.main import MainTabParameters from sampletones_application.services.conversion.service import ConversionService @@ -68,7 +69,7 @@ from sampletones_core.constants.enums import ChannelName 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,47 +95,66 @@ 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, ) -> 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._explorer_logic: ExplorerLogic = ExplorerLogic( + 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, + ) + self._wire_settings(config_manager) + self._wire_explorer() + self._wire_converter( + config_manager, + library_manager, + conversion_service, + dialogs, + language_manager, + ) + + 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, @@ -158,6 +178,17 @@ def __init__( 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 + def _build_cards( + self, + config_manager: ConfigManager, + session_manager: SessionManager, + conversion_service: ConversionService, + *, + layout: MainTabParameters, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> 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( @@ -178,7 +209,7 @@ def __init__( conversion_service, scheduling=layout.scheduling, language_manager=language_manager, - is_operation_active=is_operation_active, + is_operation_active=self._hooks.is_operation_active, ) self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( ReconstructorPanelViewModel( @@ -217,6 +248,8 @@ def __init__( status_bar=status_bar, ) + 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_advanced_settings_panel_view) @@ -231,6 +264,8 @@ def __init__( 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, @@ -239,12 +274,24 @@ def __init__( 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_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) @@ -261,10 +308,10 @@ 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 @@ -304,27 +351,27 @@ def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._converter_panel.update_view(view_model) self._update_reconstructor_panel_view() - self._on_busy_state_changed() + self._hooks.on_busy_state_changed() def _on_wave_file_clicked(self, filepath: Path) -> None: - if not self._is_operation_active(): + if not self._hooks.is_operation_active(): self._converter_logic.gather_recordings([filepath]) def _on_directory_clicked(self, directory_path: Path) -> None: - if not self._is_operation_active(): + if not self._hooks.is_operation_active(): self._converter_logic.gather_folder(directory_path) def _request_reconstruct_file(self, filepath: Path) -> None: if self._notify_converter_running(): return - self._replacing_the_setup(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._replacing_the_setup(lambda: self._on_reconstruct_directory(directory_path)) + self._replacing_the_setup(lambda: self._hooks.on_reconstruct_directory(directory_path)) def _replacing_the_setup(self, reconstruct: VoidCallback) -> None: """Runs a conversion the browser asked for, asking first where it would drop what was gathered. @@ -366,7 +413,7 @@ def _confirm_overwriting_target(self, target: Path) -> 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.") @@ -379,7 +426,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"] @@ -415,18 +462,18 @@ def _request_output(self, output: OutputKind) -> None: 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.gather_recordings([filepath]) def _on_directory_add_requested(self, directory_path: Path) -> None: """Gathers a folder into the setup, standing for the recordings found below it.""" - if self._is_operation_active(): + if self._hooks.is_operation_active(): return self._converter_logic.gather_folder(directory_path) 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/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 23d7597c8..e5f5e81d8 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -6,6 +6,7 @@ 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.run import ConversionSuccess from sampletones_application.tags.main import ( @@ -26,15 +27,30 @@ CONTINUE_BUTTON_KEY: Final[str] = "main.converter.label.continue_button" +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.mixes = False coordinator._converter_logic.gathered_paths = () @@ -74,7 +90,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: @@ -83,7 +99,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: @@ -91,7 +107,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: @@ -100,14 +116,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 @@ -123,7 +139,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 @@ -179,7 +195,7 @@ def _stems_coordinator( room: 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() @@ -322,17 +338,14 @@ class TestReconstructReplacesTheSetup: """A Reconstruct converts what it names alone, so a setup already holding sources is asked about.""" def _coordinator(self, *, mixes: bool, gathered: Tuple[Path, ...] = ()) -> MainTabCoordinator: - coordinator = _stems_coordinator(mixes=mixes, gathered=gathered) - coordinator._on_reconstruct_file = MagicMock() - coordinator._on_reconstruct_directory = MagicMock() - return coordinator + return _stems_coordinator(mixes=mixes, gathered=gathered) 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"]) @@ -341,8 +354,8 @@ def test_a_gathered_list_is_asked_about_first(self, tmp_path: Path, gesture: str 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_converts_what_was_named(self, tmp_path: Path) -> None: @@ -351,7 +364,7 @@ def test_confirming_converts_what_was_named(self, tmp_path: Path) -> None: coordinator._request_reconstruct_directory(tmp_path) coordinator._dialogs.show_confirmation.call_args.args[3]() - 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(mixes=True, gathered=(Path("/audio/a.wav"),)) @@ -360,4 +373,4 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: coordinator._dialogs.show_confirmation.call_args.kwargs["on_cancel"]() coordinator._converter_logic.set_output.assert_not_called() - coordinator._on_reconstruct_directory.assert_not_called() + coordinator._hooks.on_reconstruct_directory.assert_not_called() From e87425b1dc1ecde006cc56863ae7149aac125927 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:23:22 +0200 Subject: [PATCH 018/130] Retired: the explorer logic object that forwarded every member --- docs/development/bugs-and-todos.md | 9 --------- src/sampletones_application/ui/panels/main/explorer.py | 10 +++++----- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ac4c4bc69..bce150dd9 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -116,19 +116,10 @@ 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 is wired in one constructor. `coordinators/tabs/main.py::MainTabCoordinator.__init__` - builds the tab's panels, logic objects and services and then wires them hook by hook, which makes - it by far the longest body in the coordinator layer and leaves a reader tracing a panel's hook to - what answers it by eye. The wiring divides by collaborator — a method per panel, stating what that - panel offers and what answers each hook — the way a tab coordinator already divides into a - subpackage once it holds several concerns. * Several calls reach the Main tab's panels through wrappers of the coordinator's own, where the Coordinators contract sanctions a wrapper only for an intent-level guard a contract requires. A wrapper that renames a call or reorders its arguments is work the logic object behind the call should be doing. -* `logic/main/explorer.py::ExplorerLogic` forwards every member to the `ExplorerManager` it - constructs, declaring no state and no rule of its own. Either the logic object takes a job — the - explorer's own state machine — or its consumers hold the manager. * `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 diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 8d79e3521..01f690bc4 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): From c1cef07c7f2ce1bf0206a13adbef1e9787fae74e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:30:23 +0200 Subject: [PATCH 019/130] Drove: the settings card's whole wiring chain from a click to the row it settles --- docs/development/bugs-and-todos.md | 10 ++-- .../sampletones_application/test_startup.py | 49 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bce150dd9..8043f68b2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -125,10 +125,12 @@ again. 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. -* `MainTabCoordinator` is constructed by no test. Every fixture in - `tests/unit/sampletones_application/coordinators/tabs/test_main.py` builds the object through - `__new__` and populates its privates by hand, so the wiring the application actually runs is - exercised nowhere: a hook left unset or a call routed to the wrong object passes the suite. +* 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 behaviour cannot see a hook left unset. Building + the object in that file is what closes it. * `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 diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 8c35c9e42..607a591f7 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -12,6 +12,7 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.constants.output import OutputKind +from sampletones_application.constants.sources import SettingsField from sampletones_application.logic.history.action import HistoryAction from sampletones_application.tags.general import ( SUF_BUTTON, @@ -26,6 +27,7 @@ TAG_MAIN_CONVERTER_WINDOW_STEMS, ) from sampletones_application.ui.elements.stems.list import GUIStemsList +from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -37,6 +39,7 @@ ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor 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 @@ -427,6 +430,25 @@ 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 = GUIReconstructorPanel._slot_checkbox_tag(field, channel_name) + 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))) @@ -547,6 +569,33 @@ def test_dropping_a_recording_in_a_gap_opens_a_level(self, app: Application, tmp 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 test_the_card_edits_what_a_recording_joins_with_where_nothing_is_picked( + self, + app: Application, + tmp_path: Path, + ) -> None: + app._main_tab._converter_logic.set_output(OutputKind.MIXED) + + _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) + joined = self._gather(app, tmp_path, ["a.wav"])[0] + + assert ChannelName.PULSE2 in _row_of(app, joined).channels + 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 From 42f7a8fb24186ba901055e49bb81911bd4898e16 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 06:33:19 +0200 Subject: [PATCH 020/130] Recorded: what the Main tab's own surface still renames --- docs/development/bugs-and-todos.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8043f68b2..d2c005fcc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -116,10 +116,12 @@ 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. -* Several calls reach the Main tab's panels through wrappers of the coordinator's own, where the - Coordinators contract sanctions a wrapper only for an intent-level guard a contract requires. A - wrapper that renames a call or reorders its arguments is work the logic object behind the call - should be doing. +* 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 From e4778a0cd600c8b0c8023f96fd7465342d547dc2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 11:38:41 +0200 Subject: [PATCH 021/130] Opened: a gathered folder onto the recordings it holds --- docs/guide/interface.md | 11 + .../categories/elements/main.py | 8 + .../coordinators/tabs/main.py | 1 + .../layout/general/stems.py | 4 + .../logic/main/converter/logic.py | 14 + .../logic/main/converter/view.py | 34 ++- .../logic/reconstruction/reconstruction.py | 2 +- .../logic/shared/tree.py | 38 ++- src/sampletones_application/tags/general.py | 5 + .../ui/elements/layout/geometry.py | 76 +++++ .../ui/elements/layout/region.py | 147 +++++++++ .../ui/elements/stems/bands.py | 53 +++- .../ui/elements/stems/expansion.py | 38 +++ .../ui/elements/stems/folder.py | 135 ++++++++ .../ui/elements/stems/gestures.py | 46 ++- .../ui/elements/stems/list.py | 83 ++++- .../ui/elements/stems/messages.py | 17 ++ .../ui/elements/stems/row.py | 79 +++-- .../ui/elements/stems/shape.py | 18 +- .../ui/elements/stems/tags.py | 23 ++ .../ui/panels/main/converter.py | 78 ++++- .../ui/panels/reconstruction/stems.py | 1 + .../view_model/shared/stems.py | 19 +- src/sampletones_config/lang/en.yaml | 7 +- .../layout/general/stems.yaml | 4 + .../ui/elements/layout/test_geometry.py | 160 ++++++++++ .../ui/elements/layout/test_region.py | 138 +++++++++ .../ui/elements/stems/test_expansion.py | 50 +++ .../ui/elements/stems/test_folder.py | 289 ++++++++++++++++++ .../ui/elements/stems/test_list.py | 5 +- .../panels/reconstruction/test_stems_panel.py | 2 +- .../view_model/main/test_converter.py | 2 +- 32 files changed, 1510 insertions(+), 77 deletions(-) create mode 100644 src/sampletones_application/ui/elements/layout/geometry.py create mode 100644 src/sampletones_application/ui/elements/layout/region.py create mode 100644 src/sampletones_application/ui/elements/stems/expansion.py create mode 100644 src/sampletones_application/ui/elements/stems/folder.py create mode 100644 tests/unit/sampletones_application/ui/elements/layout/test_geometry.py create mode 100644 tests/unit/sampletones_application/ui/elements/layout/test_region.py create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_expansion.py create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_folder.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 73cd51eff..f428e1878 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -43,6 +43,17 @@ all and the row grays out: that recording sits out of the conversion, and stays in the list so you can bring it back. **x** takes a row out; taking out a folder takes everything it holds. +A folder's row names how many recordings it brought in, and its checkboxes read +all three ways: ticked where every recording in it uses that channel, half-lit +where they differ, clear where none does. One click settles the whole folder. + +A folder arrives closed. Click the marker beside its name — or double-click the +name, or use **Show the recordings** in its menu — and it opens onto the +recordings it holds, in a panel of its own that scrolls once there are more than +it can show. Each of those recordings has its own checkboxes, so you can answer +for one of them without breaking the folder up. Double-clicking a recording plays +it. + ### One reconstruction each, or one from them all **Mix into one** names what the run writes. Left clear, every recording in the 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/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 752bc5767..486c832c7 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -330,6 +330,7 @@ def _wire_converter( 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_source_played = self._explorer_tree_logic.play_path self._stem_selection_window.on_add = self._converter_logic.mix_only def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 46fa060ff..3373cf25a 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -8,3 +8,7 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): level_strip_height: int well_padding: int well_margin: int + twisty_width: int + folder_ceiling: int + folder_indent: int + window_overscan: int diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 659190abb..2f8159c88 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -30,6 +30,7 @@ inspected_name, inspected_settings, settings_slots, + stem_rows, ) from sampletones_application.logic.main.sources.folder import Folder from sampletones_application.logic.main.sources.key import SourceKey @@ -47,6 +48,7 @@ ) from sampletones_application.view_model.main.reconstructor import 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.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName, HierarchyMode @@ -94,6 +96,7 @@ def __init__( 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 @@ -410,6 +413,7 @@ def _settle(self, state: ConverterState) -> None: is no longer the one on screen. """ 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) @@ -497,9 +501,19 @@ def _emit( 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, running_input: Optional[Path]) -> str: destination = self._state.destination input_path = running_input if running_input is not None else destination.input_path diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index ee97693c1..75fd36273 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -31,12 +31,15 @@ def compose_view( 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. + 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 @@ -50,7 +53,7 @@ def compose_view( is_file=destination.is_file, other_operation_active=other_operation_active, output=settings.output, - stem_sources=stem_rows(state.gathering, mixes=settings.mixes), + stem_sources=rows, channel_cap=settings.effective_channel_cap, max_channel_cap=settings.max_channel_cap, hierarchy_mode=settings.hierarchy_mode, @@ -155,7 +158,7 @@ def _row(placement: _Placement) -> StemRowViewModel: key=str(placement.path), kind=key.kind, path=placement.path, - holds=source.count, + held=_held(placement) if key.names_folder else (), channels=channels, partial_channels=partial, offered_channels=ALL_CHANNELS, @@ -167,6 +170,31 @@ def _row(placement: _Placement) -> StemRowViewModel: ) +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(), + 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. diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index fa12e8e0c..edb8939ad 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -358,7 +358,7 @@ def _build_stems_view_model( key=str(stem_id), kind=SourceKind.RECORDING, path=recordings[stem_id], - holds=1, + held=(), channels=self._stem_channels.get(stem_id, frozenset()), partial_channels=frozenset(), offered_channels=self._offered_stem_channels.get(stem_id, frozenset()), diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index c42332c9a..7aaf3626f 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -1,4 +1,5 @@ import threading +from pathlib import Path from typing import Callable, Optional from sampletones_application.config.managers.session import SessionManager @@ -89,13 +90,18 @@ def cancel_autoplay(self) -> None: self._pending_autoplay_node = None def play_node(self, node: FileSystemNode) -> None: + """Play the file a browser node stands for, where it is one this logic can sound.""" + if node.node_type == NodeType.FILE: + self.play_path(node.filepath) + + def play_path(self, path: Path) -> 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. + The session autoplay flag holds for what a selection sounds on its own; asking for a file + by name is a deliberate action, so it plays at ``NORMAL`` priority and outranks the + reconstruction and sequencer players. """ - self._play_file(node, PlaybackPriority.NORMAL) + self._play_file(path, PlaybackPriority.NORMAL) def is_playable_file(self, node: TreeNode) -> bool: """Whether the node is a file this logic knows how to play (reconstruction or audio).""" @@ -111,34 +117,24 @@ 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 + if self._session_manager.autoplay and node.node_type == NodeType.FILE: + self._play_file(node.filepath, PlaybackPriority.PREVIEW) - match node.filepath.suffix.lower(): + def _play_file(self, path: Path, priority: PlaybackPriority) -> None: + match path.suffix.lower(): case extensions.EXT_FILE_RECONSTRUCTION: try: - reconstruction = Reconstruction.load(node.filepath) + reconstruction = Reconstruction.load(path) 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}", - ) + logger.error_with_traceback(exception, f"Failed to play reconstruction file: {path}") 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, - ) + self._audio_device_manager.play_file(path, update=False, priority=priority) def is_node_favorite(self, node: TreeNode) -> bool: if not isinstance(node, FileSystemNode): diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 5d70ec4d7..754711ef5 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -810,6 +810,7 @@ SUF_STRIP = "strip" SUF_TABLE = "table" 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") @@ -819,7 +820,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/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py new file mode 100644 index 000000000..0dd675031 --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +Window = Tuple[int, int] + +GEOMETRY_TOLERANCE: Final[float] = 1.0 +UNMEASURED: Final[float] = 0.0 +ONE_ROW: Final[int] = 1 + + +@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 holds every row in its window, which draws a list + whole and gives the next frame something to measure. + + ``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 + + @classmethod + def unmeasured(cls, *, overscan: int) -> "RowGeometry": + """The reading a list starts from, before it has drawn a row to measure.""" + return cls(overscan=overscan, pitch=UNMEASURED) + + @property + def measured(self) -> bool: + """Whether a reading has been taken, which is what lets a region hold rows back.""" + return self.pitch > UNMEASURED + + 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.pitch) + 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 self.measured and 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.""" + if not self.windows(height=height, total=total): + return (0, total) + + count = self.size(height) + reached = int(offset / self.pitch) + return (max(0, min(reached - self.overscan, 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.pitch) + + 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. + """ + if rows <= 0 or block <= UNMEASURED: + return False + + pitch = block / rows + 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..853f9219b --- /dev/null +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -0,0 +1,147 @@ +from typing import Callable, Final + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + 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 + +SliceBuilder = Callable[[int, int], None] + +NO_ROWS: Final[Window] = (0, 0) +AUTO_HEIGHT: Final[int] = 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 opening a folder of thousands cost what + opening a folder of ten costs. + + 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. + """ + + def __init__( + self, + *, + tag: str, + geometry: RowGeometry, + ceiling: int, + padding: int, + margin: int, + ) -> None: + self._tag = tag + self._geometry = geometry + self._ceiling = ceiling + self._padding = padding + self._margin = margin + self._above_tag = compose_tag(tag, SUF_SPACER_ABOVE) + self._below_tag = compose_tag(tag, SUF_SPACER_BELOW) + self._body_tag = "" + self._height = float(ceiling) + self._total = 0 + self._drawn: Window = NO_ROWS + + @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 + + 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, + show=show, + ) + + def draw(self, total: int, build: SliceBuilder) -> 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. The scroll position is put back afterwards, so the + rows a reader was looking at are the rows they keep looking at. + """ + offset = self.offset + start, count = self._geometry.slice_of(offset=offset, height=self._height, total=total) + dpg_delete_children(self._body_tag) + self._reserve(self._above_tag, start) + build(start, count) + self._reserve(self._below_tag, total - start - count) + self._total = total + self._drawn = (start, count) + dpg.set_y_scroll(self._tag, offset) + + def settle(self) -> bool: + """Hold the region to its ceiling 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. + """ + self._hold() + moved = self._measure() + reached = self._geometry.slice_of(offset=self.offset, height=self._height, total=self._total) + return moved or reached != self._drawn + + @property + def offset(self) -> float: + """How far the region has been scrolled, read from the region itself.""" + if not dpg.does_item_exist(self._tag): + return 0.0 + + return float(dpg.get_y_scroll(self._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 _hold(self) -> None: + """Size the region to its rows up to its ceiling, and scroll them from there on.""" + content = self._content_height() + within = content <= self._ceiling + self._height = content if within else float(self._ceiling) + dpg_configure_item( + self._tag, + height=AUTO_HEIGHT if within else self._ceiling, + auto_resize_y=within, + no_scrollbar=within, + ) + + def _measure(self) -> bool: + """Read what one row takes from the block of rows the region last drew.""" + start, count = self._drawn + reserved = self._geometry.reserve(start) + self._geometry.reserve(self._total - start - count) + return self._geometry.take(block=self._body_height() - reserved, rows=count) + + def _content_height(self) -> float: + """The room the region's rows ask for, the margins it opens above and below included.""" + return self._body_height() + 2 * self._margin + + 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]) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index cfcd2fbd7..94ef10db8 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -1,4 +1,4 @@ -from typing import Sequence +from typing import List, Sequence import dearpygui.dearpygui as dpg @@ -12,6 +12,8 @@ ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +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.offer import StemsListOffer from sampletones_application.ui.elements.stems.row import StemRowRenderer @@ -31,6 +33,10 @@ class LevelBands: 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. + + A 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__( @@ -41,27 +47,35 @@ def __init__( offer: StemsListOffer, language_manager: LanguageManager, rows: StemRowRenderer, + folders: FolderRenderer, + open_folders: OpenFolders, gestures: StemsGestures, ) -> None: self._tags = tags self._layout = layout self._offer = offer self._rows = rows + self._folders = folders + self._open_folders = open_folders self._gestures = gestures self._level_template = language_manager["global.stems.template.level_caption"] self._shape = ListShape.nothing() - def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> None: - """Build the bands afresh where the view names a different shape than the one standing.""" - shape = ListShape.of(view_model) + def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> bool: + """Build the bands afresh where the view names a different shape than the one standing. + + Answers whether the bands were rebuilt, which is what tells a list its widgets are new. + """ + shape = ListShape.of(view_model, self._open_folders) if shape == self._shape: - return + return False self._shape = shape + self._folders.forget() dpg.delete_item(self._tags.body, children_only=True) if view_model.collapse_levels: - self._create_table(self._tags.table, view_model, view_model.rows) - return + self._create_listing(view_model) + return True for level_index in range(view_model.level_count): self._create_strip(level_index) @@ -75,6 +89,31 @@ def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> None: if view_model.level_count: self._create_strip(view_model.level_count) + return True + + def _create_listing(self, view_model: StemsListViewModel) -> None: + """Every row in one run, a folder breaking it so its own recordings stand below it.""" + loose: List[StemRowViewModel] = [] + segment = 0 + for row in view_model.rows: + if not row.stands_for_a_folder: + loose.append(row) + continue + + segment = self._flush(loose, view_model, segment) + self._folders.create(row, view_model) + + self._flush(loose, view_model, segment) + + def _flush(self, loose: List[StemRowViewModel], view_model: StemsListViewModel, segment: int) -> int: + """Draw the run of rows gathered since the last folder, and open the next run empty.""" + if not loose: + return segment + + self._create_table(self._tags.segment(segment), view_model, tuple(loose)) + loose.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. 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..cf3545f5c --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -0,0 +1,135 @@ +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.tags.general import SUF_TABLE +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import WindowedRegion +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. Opening + it sinks a region below the 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] = {} + + def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """Draw the folder's own row, and the region its recordings stand in while it is open.""" + with dpg.table( + tag=self._tags.folder(row.key, SUF_TABLE), + parent=self._tags.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + ): + self._rows.declare_columns(view_model) + self._rows.create(row, view_model) + + if self._open_folders.stands_open(row.key): + self._open(row, view_model) + + def forget(self) -> None: + """Let go of the regions a rebuild took down, so the next draw builds them afresh.""" + self._regions.clear() + + 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.""" + region = self._regions.get(row.key) + if region is None: + return + + for held in self._reached(region, row): + self._rows.repaint(held, view_model, releasable=False) + + 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.""" + region = WindowedRegion( + tag=self._tags.region(row.key), + geometry=self._geometry, + ceiling=self._layout.folder_ceiling, + padding=self._layout.well_padding + self._layout.folder_indent, + margin=self._layout.well_margin, + ) + region.create(self._tags.body) + 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)) + + 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.""" + with dpg.table( + tag=self._tags.held(row.key), + parent=region.body, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + ): + self._rows.declare_columns(view_model) + for held in row.held[start : start + count]: + self._rows.create(held, view_model) + + @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 index 1fcbf3442..0c85172ea 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -7,6 +7,7 @@ SUF_CHANNELS, SUF_CHECKBOX, SUF_TEXT, + SUF_TWISTY, ) from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.messages import StemsMessages @@ -51,23 +52,31 @@ def __init__( 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 @property def activatable(self) -> bool: """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" return self.on_row_activated is not None + @property + 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 + 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 (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON): + for kind in (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON, SUF_TWISTY): 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)): @@ -79,6 +88,9 @@ def create_handlers(self) -> None: 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)) @@ -121,6 +133,10 @@ def on_master_box(self, _sender: Sender, value: bool, user_data: str) -> None: 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: """Hand a clicked row on, and let the next view say which row now reads as picked out.""" if self.activatable: @@ -139,13 +155,33 @@ def on_level_drop(self, sender: Sender, app_data: str) -> None: self._report(self.on_dropped_on_level, app_data, position) 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: + key = self._named_by(app_data, dpg.mvMouseButton_Right) + if key is not None: + self._report(self.on_menu_asked, 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.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.""" + mouse_button, clicked_item = app_data + if mouse_button != button: + return None + key = dpg.get_item_user_data(clicked_item) - if isinstance(key, str): - self._report(self.on_menu_asked, key) + 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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 3593d7960..0509f3f58 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,10 +1,16 @@ from typing import Optional +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.ui.elements.layout.geometry import RowGeometry from sampletones_application.ui.elements.layout.well import well from sampletones_application.ui.elements.status import GUIStatusBar 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, @@ -16,6 +22,7 @@ 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, @@ -41,6 +48,7 @@ def __init__( *, prefix: str, layout: StemsListLayout, + glyphs: CommonGlyphs, language_manager: LanguageManager, status_bar: GUIStatusBar, offer: StemsListOffer, @@ -49,10 +57,14 @@ def __init__( self._layout = layout self._offer = offer self._view = StemsListViewModel.empty() + self._open_folders = OpenFolders() + self._geometry = RowGeometry.unmeasured(overscan=layout.window_overscan) + self._settling = False self._messages = StemsMessages( language_manager, offer=offer, + open_folders=self._open_folders, activatable=lambda: self.activatable, ) self._gestures = StemsGestures(self._tags, messages=self._messages, status_bar=status_bar) @@ -60,16 +72,27 @@ def __init__( 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._bands = LevelBands( self._tags, layout=layout, offer=offer, language_manager=language_manager, rows=self._rows, + folders=self._folders, + open_folders=self._open_folders, gestures=self._gestures, ) @@ -80,6 +103,7 @@ def __init__( 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._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) @@ -88,6 +112,8 @@ def __init__( 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_folder_toggled = self.toggle_folder @property def tags(self) -> StemsTags: @@ -119,16 +145,71 @@ def update_view(self, view_model: StemsListViewModel) -> None: """Take up a new reading of the setup: rebuild the bands where it reshapes them, repaint the rows either way.""" 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) - self._bands.rebuild_if_reshaped(view_model) + rebuilt = self._bands.rebuild_if_reshaped(view_model) for row in view_model.rows: self._rows.repaint(row, view_model, releasable=self._releasable) + self._folders.repaint(row, view_model) + + if rebuilt: + self._settle_soon() def row(self, key: str) -> Optional[StemRowViewModel]: """The row a gesture named, as the list last rendered it.""" return self._view.row(key) + 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 toggle_folder(self, key: str) -> None: + """Put a folder's recordings in view or away again, and draw the list as it now stands.""" + self._open_folders.toggle(key) + self.update_view(self._view) + + def _settle_soon(self) -> None: + """Ask to read the drawn rows back once the frame that placed them has been rendered. + + The list keeps this going for as long as a region it drew is following a scroll, so a + region refills itself rather than waiting on a frame hook an owner remembered to wire. + """ + if self._settling: + return + + 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 + self._measure_rows() + 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._open_folders: + self._settle_soon() + + def _measure_rows(self) -> None: + """Read what one row takes from the rows the list has drawn, so a folder opens knowing it. + + The reading is taken while the list stands as a plain run of rows, with no caption, strip + or open region among them, so what is measured is the rows' own room. A folder is then + opened against a reading the list took from its own rows and builds the handful it shows + rather than everything it holds; from there each region reads its own rows back. + """ + rows = self._view.row_count + if not rows or self._open_folders or not self._view.collapse_levels: + return + + if dpg.does_item_exist(self._tags.body): + self._geometry.take(block=float(dpg.get_item_rect_size(self._tags.body)[1]), rows=rows) + @property def _releasable(self) -> bool: """Whether a row may leave, which a list holding on to its last one answers by its count.""" diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py index 5025eadcf..336e6e88b 100644 --- a/src/sampletones_application/ui/elements/stems/messages.py +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -2,6 +2,7 @@ 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.offer import StemsListOffer from sampletones_application.view_model.shared.stems import ( StemRowViewModel, @@ -23,10 +24,12 @@ def __init__( language_manager: LanguageManager, *, offer: StemsListOffer, + open_folders: OpenFolders, activatable: Callable[[], bool], ) -> None: self._language_manager = language_manager self._offer = offer + self._open_folders = open_folders self._activatable = activatable self._view = StemsListViewModel.empty() self._msg_drag = language_manager["global.stems.message.drag_tooltip"] @@ -122,5 +125,19 @@ def remove(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: 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/row.py b/src/sampletones_application/ui/elements/stems/row.py index 92450ce08..de5559cd3 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -3,6 +3,7 @@ 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.layout.glyphs.common import CommonGlyphs from sampletones_application.tags.general import ( SUF_BUTTON, SUF_CHANNELS, @@ -10,6 +11,7 @@ SUF_GROUP, SUF_TEXT, SUF_TOOLTIP, + SUF_TWISTY, TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_STEMS_ROW, @@ -17,6 +19,7 @@ ) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry +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 @@ -50,6 +53,8 @@ def __init__( *, layout: StemsListLayout, offer: StemsListOffer, + glyphs: CommonGlyphs, + open_folders: OpenFolders, language_manager: LanguageManager, messages: StemsMessages, gestures: StemsGestures, @@ -57,6 +62,8 @@ def __init__( 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 @@ -81,7 +88,7 @@ def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: if self._offer.master_box: self._create_master(row) - self._create_name(row) + self._create_name(row, view_model) for channel_name in view_model.channels_in_play: self._create_channel(row, channel_name) @@ -134,27 +141,59 @@ def _create_master(self, row: StemRowViewModel) -> None: ) self._gestures.bind(master, SUF_CHECKBOX) - def _create_name(self, row: StemRowViewModel) -> None: - """The row itself: what names the source, what you drag it by, and what you drop onto.""" - name = dpg.add_selectable( - label=self._row_label(row), - tag=self._tags.row(row.key, SUF_TEXT), + def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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 where a list holds one every other row + opens the same width beside its name, so the names line up down the column. + """ + with dpg.group(horizontal=True): + self._create_disclosure(row, view_model) + name = dpg.add_selectable( + label=self._row_label(row), + tag=self._tags.row(row.key, SUF_TEXT), + 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.dragging: + 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 _create_disclosure(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + """The marker a folder opens by, and the room it takes beside every other row.""" + if not view_model.holds_folders: + return + + if not row.stands_for_a_folder: + dpg.add_spacer(width=self._layout.twisty_width) + return + + twisty = dpg.add_button( + label=self._twisty_glyph(row.key), + tag=self._tags.row(row.key, SUF_TWISTY), + width=self._layout.twisty_width, 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.dragging: - 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.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), + callback=self._gestures.on_twisty, ) + FontRegistry.bind_to_item(twisty, Font.ICON) + 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.""" diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index d40006056..bbc791c52 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -1,6 +1,7 @@ from dataclasses import dataclass from typing import FrozenSet, 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 @@ -12,6 +13,7 @@ class RowPlacement: key: str level: int offered: FrozenSet[ChannelName] + opened: bool @dataclass(frozen=True) @@ -27,13 +29,23 @@ class ListShape: rows: Tuple[RowPlacement, ...] @classmethod - def of(cls, view_model: StemsListViewModel) -> Self: - """The shape a view amounts to, which is what a list compares against what it drew.""" + 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. + + A folder opening or closing reshapes the list, since the region its recordings stand in + is built and taken down with it. + """ 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) for row in view_model.rows + RowPlacement( + key=row.key, + level=row.level, + offered=row.offered_channels, + opened=open_folders.stands_open(row.key), + ) + for row in view_model.rows ), ) diff --git a/src/sampletones_application/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py index 5101ab8ba..a5e2b416f 100644 --- a/src/sampletones_application/ui/elements/stems/tags.py +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -4,10 +4,12 @@ from sampletones_application.tags.general import ( SUF_CHANNELS, SUF_CHECKBOX, + SUF_FOLDER, SUF_GROUP, SUF_HANDLER_REGISTRY, SUF_LEVEL, SUF_PAYLOAD, + SUF_REGION, SUF_ROW, SUF_TABLE, SUF_WELL, @@ -40,6 +42,27 @@ 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, 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.""" diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 5cf026be0..b530e1316 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -3,7 +3,10 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.main import ConverterStemMoveElements +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.constants.conversion import MIN_CHANNEL_CAP @@ -44,7 +47,11 @@ 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.context_menu import ( + add_path_menu_items, + add_play_menu_item, + 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 @@ -90,6 +97,10 @@ class GUIConverterPanel(GUIPanel): 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. + + A folder stands as one row reaching everything below it. Its menu shows or puts away the + recordings it holds, takes the whole of it out of the conversion, and offers the folder's own + filesystem actions. """ def __init__( @@ -126,6 +137,7 @@ def __init__( 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.on_source_played: Optional[PathCallback] = None self._layout = layout self._input_width = inputs.default_width @@ -143,6 +155,7 @@ def __init__( self._stems_list = GUIStemsList( prefix=PRE_MAIN_CONVERTER_STEMS, layout=stems_layout, + glyphs=self._glyphs.common, language_manager=language_manager, status_bar=status_bar, offer=GATHERED_SOURCES, @@ -299,7 +312,8 @@ def _create_stems_list(self) -> None: self._stems_list.on_channel_toggled = self._on_folder_channel_toggled self._stems_list.on_remove_requested = self._on_source_removed self._stems_list.on_row_activated = self._on_row_selected - self._stems_list.on_menu_requested = self._show_row_menu + self._stems_list.on_menu_requested = self._show_menu + self._stems_list.on_row_opened = self._on_source_played self._stems_list.on_dropped_on_row = self._on_dropped_on_source self._stems_list.on_dropped_on_level = self._on_dropped_on_level @@ -365,17 +379,32 @@ def _on_dropped_on_source(self, key: str, target_key: str) -> None: 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.""" + def _on_source_played(self, key: str) -> None: + self.call(self.on_source_played, Path(key)) + + def _show_menu(self, key: str) -> None: + """Offer what the row a gesture landed on can do: a folder reaches everything below it, + a recording answers for itself.""" row = self._stems_list.row(key) if row is None: return + if row.stands_for_a_folder: + self._show_folder_menu(row) + return + + self._show_row_menu(row) + + def _show_row_menu(self, row: StemRowViewModel) -> None: + """A recording is played wherever it is met, so the menu leads with the same item the file + browser offers, then 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.""" with context_menu(): - header = dpg.add_text(row.name) - FontRegistry.bind_to_item(header, Font.MONO_BOLD) - dpg.add_separator() + self._menu_header(row.name) + add_play_menu_item( + self._language_manager["global.context.label.play"], + lambda: self.call(self.on_source_played, row.path), + ) for element, enabled, callback in self._row_moves(row): dpg.add_menu_item( label=self._label(element), @@ -385,6 +414,34 @@ def _show_row_menu(self, key: str) -> None: add_path_menu_items(self._language_manager, row.path) + def _show_folder_menu(self, row: StemRowViewModel) -> None: + """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.""" + key = row.key + opened = self._stems_list.stands_open(key) + with context_menu(): + self._menu_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._stems_list.toggle_folder(key), + ) + dpg.add_menu_item( + label=self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), + callback=lambda: self.call(self.on_folder_removed, row.path), + ) + add_path_menu_items(self._language_manager, row.path) + + @staticmethod + def _menu_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 _row_moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveElements, bool, VoidCallback]]: path = row.path return [ @@ -423,6 +480,9 @@ def _row_moves(self, row: StemRowViewModel) -> List[Tuple[ConverterStemMoveEleme 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] + 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) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 5362e875b..f73a03ac2 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -65,6 +65,7 @@ def __init__( self._stems_list = GUIStemsList( prefix=PRE_RECONSTRUCTION_STEMS, layout=stems_layout, + glyphs=self._glyphs.common, language_manager=language_manager, status_bar=status_bar, offer=RECORDED_ASSIGNMENT, diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 4159b212a..e1c63b9b1 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -22,12 +22,16 @@ class StemRowViewModel(BaseModel, frozen=True): 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. """ key: str kind: SourceKind path: Path - holds: int + held: Tuple["StemRowViewModel", ...] channels: FrozenSet[ChannelName] partial_channels: FrozenSet[ChannelName] offered_channels: FrozenSet[ChannelName] @@ -37,6 +41,11 @@ 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 name(self) -> str: """The source's own name, which is what the row reads as.""" @@ -122,6 +131,11 @@ def empty(cls) -> Self: 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.""" @@ -141,7 +155,8 @@ def boxes_of(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: @cached_property def _by_key(self) -> Dict[str, StemRowViewModel]: - return {row.key: row for row in self.rows} + """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)} @property def playing_count(self) -> int: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9ea18dadb..4d94b53c1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -269,7 +269,9 @@ 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 it holds." -global.stems.message.status_folder_row: "{name} holds {count} recordings." +global.stems.message.status_folder_row: "Open {name} to work on the {count} recordings in it, or right-click for more actions." +global.stems.message.status_folder_open: "Show the recordings in {name}." +global.stems.message.status_folder_close: "Put the recordings in {name} away." global.stems.label.remove: "x" 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." @@ -419,6 +421,9 @@ 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: "Put the recordings away" +main.converter.label.context_remove_folder: "Remove the folder from the conversion" # ============================================================================= # Main tab — Advanced diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index bdd85a517..af9189511 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -4,3 +4,7 @@ remove_button_width: 30 level_strip_height: 6 well_padding: 8 well_margin: 4 +twisty_width: 22 +folder_ceiling: 264 +folder_indent: 14 +window_overscan: 4 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..7f5627763 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -0,0 +1,160 @@ +from dataclasses import dataclass + +import pytest + +from sampletones_application.ui.elements.layout.geometry import ( + GEOMETRY_TOLERANCE, + RowGeometry, + Window, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +OVERSCAN = 2 +PITCH = 20.0 +REGION_HEIGHT = 100.0 + + +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) + + +class TestUnmeasuredGeometry(BaseTestSuite): + """A geometry that has yet to read a row holds the whole list in its window, so the region + draws everything and the next frame has something to measure.""" + + def test_it_reports_no_reading(self) -> None: + assert not RowGeometry.unmeasured(overscan=OVERSCAN).measured + + @pytest.mark.parametrize("total", (0, 1, 10, 10_000)) + def test_it_windows_no_list(self, total: int) -> None: + geometry = RowGeometry.unmeasured(overscan=OVERSCAN) + assert not geometry.windows(height=REGION_HEIGHT, total=total) + + @pytest.mark.parametrize("total", (0, 1, 10, 10_000)) + def test_its_slice_is_the_whole_list(self, total: int) -> None: + geometry = RowGeometry.unmeasured(overscan=OVERSCAN) + assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=total) == (0, total) + + def test_it_reserves_nothing(self) -> None: + assert RowGeometry.unmeasured(overscan=OVERSCAN).reserve(100) == 0 + + +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 follows the scroll position, held inside the list it slices.""" + + @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=100, expected=(0, 10)), + TestCase(label="a_scroll_carries_the_overscan_above_it", offset=200.0, total=100, expected=(8, 10)), + TestCase(label="the_bottom_holds_the_window_inside_the_list", offset=2000.0, total=100, expected=(90, 10)), + ) + + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_slice_follows_the_offset(self, test_case: TestCase) -> None: + geometry = measured() + window = geometry.slice_of(offset=test_case.offset, height=REGION_HEIGHT, total=test_case.total) + assert window == test_case.expected + + @pytest.mark.parametrize("offset", range(0, 2400, 37)) + def test_a_window_stays_inside_the_list_at_any_offset(self, offset: int) -> None: + total = 100 + geometry = measured() + start, count = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=total) + assert start >= 0 + assert start + count <= total + + def test_a_scrolled_window_covers_the_row_the_offset_reaches(self) -> None: + """The row a reader has scrolled to stands among the ones the window built.""" + total = 100 + geometry = measured() + for offset in range(0, int(total * PITCH), 17): + start, count = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=total) + reached = min(int(offset / PITCH), total - 1) + assert start <= reached < start + count + + +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_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.unmeasured(overscan=OVERSCAN) + 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.unmeasured(overscan=OVERSCAN) + 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( + ("block", "rows"), + ((200.0, 0), (200.0, -1), (0.0, 10), (-50.0, 10)), + ) + def test_a_block_with_nothing_to_read_leaves_the_reading_alone(self, block: float, rows: int) -> None: + geometry = measured() + assert not geometry.take(block=block, rows=rows) + 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..9c573077a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -0,0 +1,138 @@ +from typing import Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY +from sampletones_application.ui.elements.layout.geometry import RowGeometry +from sampletones_application.ui.elements.layout.region import WindowedRegion +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 +OVERSCAN = 2 +CEILING = 100 + + +@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), + ceiling=CEILING, + padding=0, + margin=0, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + return built + + +def draw(region: WindowedRegion, total: int) -> 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) + return asked + + +def reserves(region: WindowedRegion) -> Tuple[int, int]: + """The room standing above and below the rows the region drew.""" + children = dpg.get_item_children(region.body, 1) + return ( + int(dpg.get_item_configuration(children[0])["height"]), + int(dpg.get_item_configuration(children[-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 the list whole, which is what gives the next + frame something to measure.""" + + def test_it_builds_every_row(self, dpg_context: None) -> None: + built = WindowedRegion( + tag=REGION_TAG, + geometry=RowGeometry.unmeasured(overscan=OVERSCAN), + ceiling=CEILING, + padding=0, + margin=0, + ) + with dpg.window(tag=ROOT_TAG): + built.create(ROOT_TAG) + + assert draw(built, 500) == [(0, 500)] + + +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) 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..72dd9599b --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -0,0 +1,289 @@ +from pathlib import Path +from typing import Final, FrozenSet, Iterator, List, Tuple + +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.general import SUF_CHANNELS, SUF_CHECKBOX, SUF_TEXT, SUF_TWISTY +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.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 + +ROOT_TAG = "test_root" +PREFIX = "test.stems" +CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) +DOUBLE_CLICK_HANDLER: Final[str] = "mvAppItemType::mvDoubleClickedHandler" + + +@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, + 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(), + 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(), + offered_channels=frozenset(CHANNELS), + available=True, + level=0, + position=0, + level_size=1, + level_count=1, + ) + + +def view(*rows: StemRowViewModel) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=frozenset(), + live=True, + collapse_levels=True, + selected_key=None, + ) + + +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 f"{PREFIX}.row.{row.key}.{SUF_TWISTY}" + + +def region_of(row: StemRowViewModel) -> str: + return f"{PREFIX}.folder.{row.key}.region" + + +def name_of(row: StemRowViewModel) -> str: + return f"{PREFIX}.row.{row.key}.{SUF_TEXT}" + + +def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: + return f"{PREFIX}.row.{row.key}.{SUF_CHANNELS}.{channel_name}.{SUF_CHECKBOX}" + + +class TestAClosedFolder: + """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: + """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: + """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 TestDoubleClick: + """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(name_of(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(name_of(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(name_of(sources)) + + assert opened == [] + + +class TestAFolderThatLeaves: + """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(tag: str) -> None: + """Double-click a widget the way DearPyGui reports it, through the registry its kind shares.""" + registry = f"{PREFIX}.{SUF_TEXT}.handler.registry" + for handler in dpg.get_item_children(registry, 1): + if dpg.get_item_info(handler)["type"] == DOUBLE_CLICK_HANDLER: + dpg.get_item_callback(handler)(handler, (dpg.mvMouseButton_Left, dpg.get_alias_id(tag))) + return + + raise AssertionError("the list registers no double-click handler") 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 5feef7be8..ba44800f9 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -82,6 +82,7 @@ def build( stems_list = GUIStemsList( prefix=PREFIX, layout=layout_config.general.stems, + glyphs=layout_config.glyphs.common, language_manager=LanguageManager(LANG_EN), status_bar=GUIStatusBar(), offer=StemsListOffer( @@ -111,7 +112,7 @@ def row( path = Path(f"/audio/{name}.wav") return StemRowViewModel( kind=SourceKind.RECORDING, - holds=1, + held=(), partial_channels=frozenset(), key=str(path), path=path, @@ -169,7 +170,7 @@ def folder_row( key=str(path), kind=SourceKind.FOLDER, path=path, - holds=holds, + held=tuple(row(f"{name}/held_{index}") for index in range(holds)), channels=channels, partial_channels=partial_channels, offered_channels=frozenset(CHANNELS), 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 149c54a29..d7cf7c2ff 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 @@ -96,7 +96,7 @@ def _row( ) -> StemRowViewModel: return StemRowViewModel( kind=SourceKind.RECORDING, - holds=1, + held=(), partial_channels=frozenset(), key=str(stem_id), path=Path(f"/audio/{name}.wav"), 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 ff8ea0077..bf2a527d9 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -31,7 +31,7 @@ def _row( path = Path(f"/audio/{name}.wav") return StemRowViewModel( kind=SourceKind.RECORDING, - holds=1, + held=(), partial_channels=frozenset(), key=str(path), path=path, From 21eb76e8d1cef7bd83178892d40c1a69ab157bf5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 12:08:59 +0200 Subject: [PATCH 022/130] Held: a scrolling region to what it works out rather than what it measures back --- .../logic/main/sources/list.py | 12 +- .../ui/elements/layout/geometry.py | 48 +++++-- .../ui/elements/layout/region.py | 116 ++++++++++++----- .../ui/elements/stems/bands.py | 17 +-- .../ui/elements/stems/folder.py | 5 + .../ui/elements/stems/list.py | 53 +++++--- .../sampletones_application/test_startup.py | 46 +++++++ .../ui/elements/layout/test_geometry.py | 117 ++++++++++++------ .../ui/elements/layout/test_region.py | 21 +++- 9 files changed, 328 insertions(+), 107 deletions(-) diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 19b70a61e..5213a0710 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -58,7 +58,17 @@ def recording(self, path: Path) -> Optional[Recording]: return next((recording for recording in self.recordings if recording.path == path), None) def row(self, key: SourceKey) -> Optional[SourceRow]: - return next((row for row in self.rows if row.key == key), None) + """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. diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py index 0dd675031..f5e8702b5 100644 --- a/src/sampletones_application/ui/elements/layout/geometry.py +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -6,6 +6,7 @@ GEOMETRY_TOLERANCE: Final[float] = 1.0 UNMEASURED: Final[float] = 0.0 ONE_ROW: Final[int] = 1 +MINIMUM_ROW_PITCH: Final[float] = 8.0 @dataclass @@ -18,8 +19,9 @@ class RowGeometry: 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 holds every row in its window, which draws a list - whole and gives the next frame something to measure. + A geometry that has yet to read anything works from the least room a row can take, so a first + draw is generous rather than unbounded: it builds more rows than it needs, measures them, and + holds to that reading 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. @@ -35,30 +37,48 @@ def unmeasured(cls, *, overscan: int) -> "RowGeometry": @property def measured(self) -> bool: - """Whether a reading has been taken, which is what lets a region hold rows back.""" + """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 the least a row can take. + + ``MINIMUM_ROW_PITCH`` is a floor rather than a guess at the theme in force, so a window + taken before anything has been measured is wider than it needs to be and never narrower. + """ + return self.pitch if self.measured else MINIMUM_ROW_PITCH + 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.pitch) + ONE_ROW) + 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 self.measured and total > self.size(height) + return total > self.size(height) + + def slice_of(self, *, offset: float, extent: float, height: float, total: int) -> Window: + """The rows a scroll position reaches: where the window opens, and how many it holds. - 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.""" + Where the window opens follows how far through its travel the region is scrolled rather + than how many rows that offset counts out, so the top of the list is reachable at the top + and the end of it at the end however the reading of a row stands. ``extent`` is how far + the region can be scrolled, which is what the offset is read against. + """ if not self.windows(height=height, total=total): return (0, total) count = self.size(height) - reached = int(offset / self.pitch) - return (max(0, min(reached - self.overscan, total - count)), count) + last = total - count + if extent <= UNMEASURED: + return (0, count) + + return (max(0, min(round(offset / extent * last), last)), 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.pitch) + 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. @@ -66,11 +86,17 @@ def take(self, *, block: float, rows: int) -> bool: 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 is a region measured while its rows + stood clipped or unplaced rather than a row that small, so the reading in force stands. """ - if rows <= 0 or block <= UNMEASURED: + 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 index 853f9219b..07f3ce5d4 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -10,6 +10,7 @@ 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] @@ -23,12 +24,16 @@ class WindowedRegion: 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 opening a folder of thousands cost what - opening a folder of ten costs. + scrollbar proportional to the whole list and makes drawing a list of thousands cost what + drawing a list of ten costs. 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. + + 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 region standing at its natural height is what a reading is taken from. """ def __init__( @@ -67,6 +72,26 @@ 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 bool(self._total) and self._drawn[1] < self._total + + @property + def offset(self) -> float: + """How far the region has been scrolled, read from the region itself.""" + return self._scroll(dpg.get_y_scroll) + + @property + def extent(self) -> float: + """How far the region can be scrolled, worked out from the room its rows ask for. + + The travel follows from what the region reserved rather than from what it reports, since a + region asked to scroll reports its travel a frame late and would send the window to the + top of the list for that frame. + """ + return max(0.0, self._content() - self._height) + 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( @@ -83,43 +108,76 @@ def draw(self, total: int, build: SliceBuilder) -> None: ``build`` is handed where the window opens and how many rows it holds, and adds them to :attr:`body` between the two reserves. The scroll position is put back afterwards, so the rows a reader was looking at are the rows they keep looking at. + + 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. """ offset = self.offset - start, count = self._geometry.slice_of(offset=offset, height=self._height, total=total) + start, count = self._slice(offset, total) dpg_delete_children(self._body_tag) - self._reserve(self._above_tag, start) + measuring = not self._geometry.measured + self._reserve(self._above_tag, 0 if measuring else start) build(start, count) - self._reserve(self._below_tag, total - start - count) + self._reserve(self._below_tag, 0 if measuring else total - start - count) self._total = total self._drawn = (start, count) dpg.set_y_scroll(self._tag, offset) + def draw_whole(self, build: VoidCallback) -> 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. + """ + dpg_delete_children(self._body_tag) + build() + self._total = 0 + self._drawn = NO_ROWS + def settle(self) -> bool: - """Hold the region to its ceiling and read what a row takes, a frame after a draw. + """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. """ - self._hold() - moved = self._measure() - reached = self._geometry.slice_of(offset=self.offset, height=self._height, total=self._total) - return moved or reached != self._drawn - - @property - def offset(self) -> float: - """How far the region has been scrolled, read from the region itself.""" - if not dpg.does_item_exist(self._tag): - return 0.0 - - return float(dpg.get_y_scroll(self._tag)) + if not self._total: + self._stand_at_natural_height() + return False + + if not self._geometry.measured: + self._stand_at_natural_height() + return self._geometry.take(block=self._body_height(), rows=self._drawn[1]) + + self._hold_rows() + return self._slice(self.offset, self._total) != self._drawn + + def _slice(self, offset: float, total: int) -> Window: + """The rows the region's scroll position reaches, in the list it is a window onto.""" + return self._geometry.slice_of( + offset=offset, + extent=self.extent, + height=self._height, + total=total, + ) 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 _hold(self) -> None: - """Size the region to its rows up to its ceiling, and scroll them from there on.""" - content = self._content_height() + 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._content()) + + def _content(self) -> float: + """The room the region's whole list asks for, the margins above and below it included.""" + return float(self._geometry.reserve(self._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._height = self._body_height() + 2 * self._margin + dpg_configure_item(self._tag, height=AUTO_HEIGHT, auto_resize_y=True, no_scrollbar=True) + + def _size_to(self, content: float) -> None: within = content <= self._ceiling self._height = content if within else float(self._ceiling) dpg_configure_item( @@ -129,19 +187,15 @@ def _hold(self) -> None: no_scrollbar=within, ) - def _measure(self) -> bool: - """Read what one row takes from the block of rows the region last drew.""" - start, count = self._drawn - reserved = self._geometry.reserve(start) + self._geometry.reserve(self._total - start - count) - return self._geometry.take(block=self._body_height() - reserved, rows=count) - - def _content_height(self) -> float: - """The room the region's rows ask for, the margins it opens above and below included.""" - return self._body_height() + 2 * self._margin - 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/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 94ef10db8..3f2468a3b 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -61,21 +61,24 @@ def __init__( self._level_template = language_manager["global.stems.template.level_caption"] self._shape = ListShape.nothing() - def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> bool: - """Build the bands afresh where the view names a different shape than the one standing. + def reshaped(self, view_model: StemsListViewModel) -> bool: + """Whether the view names a different shape than the one standing, which asks for a rebuild. - Answers whether the bands were rebuilt, which is what tells a list its widgets are new. + The shape is taken up either way, 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) if shape == self._shape: return False self._shape = shape - self._folders.forget() - dpg.delete_item(self._tags.body, children_only=True) + return True + + 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 True + return for level_index in range(view_model.level_count): self._create_strip(level_index) @@ -89,8 +92,6 @@ def rebuild_if_reshaped(self, view_model: StemsListViewModel) -> bool: if view_model.level_count: self._create_strip(view_model.level_count) - return True - def _create_listing(self, view_model: StemsListViewModel) -> None: """Every row in one run, a folder breaking it so its own recordings stand below it.""" loose: List[StemRowViewModel] = [] diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index cf3545f5c..c17d1a5b8 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -61,6 +61,11 @@ def forget(self) -> None: """Let go of the regions a rebuild took down, so the next draw builds them afresh.""" self._regions.clear() + @property + def following(self) -> bool: + """An open folder holds back rows it has no room for, so a scroll asks it for others.""" + return any(region.windowing 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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 0509f3f58..d3f138e09 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Final, Optional import dearpygui.dearpygui as dpg @@ -6,7 +6,7 @@ from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.layout.glyphs.common import CommonGlyphs from sampletones_application.ui.elements.layout.geometry import RowGeometry -from sampletones_application.ui.elements.layout.well import well +from sampletones_application.ui.elements.layout.region import WindowedRegion from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.bands import LevelBands from sampletones_application.ui.elements.stems.expansion import OpenFolders @@ -30,6 +30,8 @@ from sampletones_shared.types.callback import StringCallback from sampletones_shared.utils.callbacks import CallbackMixin +NO_CEILING: Final[int] = 0 + class GUIStemsList(CallbackMixin): """The stems of one setup, as a table of rows banded by the levels they pick on. @@ -60,6 +62,13 @@ def __init__( self._open_folders = OpenFolders() self._geometry = RowGeometry.unmeasured(overscan=layout.window_overscan) self._settling = False + self._region = WindowedRegion( + tag=self._tags.well, + geometry=self._geometry, + ceiling=NO_CEILING, + padding=layout.well_padding, + margin=layout.well_margin, + ) self._messages = StemsMessages( language_manager, @@ -133,13 +142,7 @@ def activatable(self) -> bool: def create(self, parent: str, *, show: bool = True) -> None: """Build the list's recessed region and the handlers its rows share.""" self._gestures.create_handlers() - well( - parent, - self._tags.well, - padding=self._layout.well_padding, - margin=self._layout.well_margin, - show=show, - ) + self._region.create(parent, show=show) def update_view(self, view_model: StemsListViewModel) -> None: """Take up a new reading of the setup: rebuild the bands where it reshapes them, repaint @@ -148,14 +151,23 @@ def update_view(self, view_model: StemsListViewModel) -> None: self._open_folders.hold_to({row.key for row in view_model.rows}) self._messages.reads(view_model) self._gestures.reads(view_model) - rebuilt = self._bands.rebuild_if_reshaped(view_model) + if self._bands.reshaped(view_model): + self._rebuild(view_model) + 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() + self._region.draw_whole(lambda: self._bands.build(view_model)) + + def _repaint(self, view_model: StemsListViewModel) -> None: + """Draw what the rows in view currently hold onto the widgets they stand as.""" for row in view_model.rows: self._rows.repaint(row, view_model, releasable=self._releasable) self._folders.repaint(row, view_model) - if rebuilt: - self._settle_soon() - def row(self, key: str) -> Optional[StemRowViewModel]: """The row a gesture named, as the list last rendered it.""" return self._view.row(key) @@ -169,11 +181,17 @@ def toggle_folder(self, key: str) -> None: self._open_folders.toggle(key) self.update_view(self._view) + @property + def _following(self) -> bool: + """A region is holding rows back, so the list watches for the scroll that asks for them.""" + return self._folders.following + def _settle_soon(self) -> None: """Ask to read the drawn rows back once the frame that placed them has been rendered. - The list keeps this going for as long as a region it drew is following a scroll, so a - region refills itself rather than waiting on a frame hook an owner remembered to wire. + 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 @@ -186,13 +204,14 @@ def _settle(self) -> None: watching for as long as one of them holds rows it has yet to build.""" self._settling = False self._measure_rows() + self._region.settle() 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._open_folders: + if self._following: self._settle_soon() def _measure_rows(self) -> None: @@ -204,7 +223,7 @@ def _measure_rows(self) -> None: rather than everything it holds; from there each region reads its own rows back. """ rows = self._view.row_count - if not rows or self._open_folders or not self._view.collapse_levels: + if not rows or self._open_folders or not self._view.collapse_levels or not self._view.holds_folders: return if dpg.does_item_exist(self._tags.body): diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 607a591f7..1db677102 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -584,6 +584,52 @@ def test_a_clicked_row_is_what_the_settings_card_edits(self, app: Application, t 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) + + converter_logic = app._main_tab._converter_logic + converter_logic.set_output(OutputKind.PER_RECORDING) + converter_logic.gather_folder(root) + 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 + + _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_edits_what_a_recording_joins_with_where_nothing_is_picked( self, app: Application, diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py index 7f5627763..6e7be4695 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -4,6 +4,7 @@ from sampletones_application.ui.elements.layout.geometry import ( GEOMETRY_TOLERANCE, + MINIMUM_ROW_PITCH, RowGeometry, Window, ) @@ -13,6 +14,7 @@ OVERSCAN = 2 PITCH = 20.0 REGION_HEIGHT = 100.0 +TRAVEL = 1000.0 def measured(*, overscan: int = OVERSCAN, pitch: float = PITCH) -> RowGeometry: @@ -20,25 +22,38 @@ def measured(*, overscan: int = OVERSCAN, pitch: float = PITCH) -> RowGeometry: return RowGeometry(overscan=overscan, pitch=pitch) -class TestUnmeasuredGeometry(BaseTestSuite): - """A geometry that has yet to read a row holds the whole list in its window, so the region - draws everything and the next frame has something to measure.""" +class TestAnUnmeasuredGeometry(BaseTestSuite): + """A geometry that has yet to read a row works from the least room a row can take, so a first + draw is generous rather than unbounded.""" def test_it_reports_no_reading(self) -> None: assert not RowGeometry.unmeasured(overscan=OVERSCAN).measured - @pytest.mark.parametrize("total", (0, 1, 10, 10_000)) - def test_it_windows_no_list(self, total: int) -> None: - geometry = RowGeometry.unmeasured(overscan=OVERSCAN) - assert not geometry.windows(height=REGION_HEIGHT, total=total) + def test_it_works_from_the_floor(self) -> None: + assert RowGeometry.unmeasured(overscan=OVERSCAN).room == MINIMUM_ROW_PITCH + + def test_its_window_is_wider_than_a_measured_one(self) -> None: + """A floor no row goes under makes the first window larger than it needs to be, never + smaller, so the rows the region shows are among the ones it built.""" + unmeasured = RowGeometry.unmeasured(overscan=OVERSCAN) + assert unmeasured.size(REGION_HEIGHT) > measured().size(REGION_HEIGHT) + + def test_it_still_holds_a_long_list_back(self) -> None: + assert RowGeometry.unmeasured(overscan=OVERSCAN).windows(height=REGION_HEIGHT, total=10_000) - @pytest.mark.parametrize("total", (0, 1, 10, 10_000)) - def test_its_slice_is_the_whole_list(self, total: int) -> None: + def test_a_short_list_is_drawn_whole(self) -> None: geometry = RowGeometry.unmeasured(overscan=OVERSCAN) - assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=total) == (0, total) + assert geometry.slice_of(offset=0.0, extent=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_it_reserves_nothing(self) -> None: - assert RowGeometry.unmeasured(overscan=OVERSCAN).reserve(100) == 0 + 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): @@ -66,7 +81,8 @@ def test_size_counts_what_shows_and_the_overscan(self, test_case: TestCase) -> N class TestSliceOf(BaseTestSuite): - """Where a window opens follows the scroll position, held inside the list it slices.""" + """Where a window opens follows how far through its travel the region is scrolled, 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): @@ -78,32 +94,53 @@ class TestCase(BaseRegularTestCase): 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=100, expected=(0, 10)), - TestCase(label="a_scroll_carries_the_overscan_above_it", offset=200.0, total=100, expected=(8, 10)), - TestCase(label="the_bottom_holds_the_window_inside_the_list", offset=2000.0, total=100, expected=(90, 10)), + TestCase(label="the_middle_opens_halfway_down", offset=TRAVEL / 2, total=100, expected=(45, 10)), + TestCase(label="the_end_opens_at_the_last_rows", offset=TRAVEL, total=100, expected=(90, 10)), ) @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) - def test_the_slice_follows_the_offset(self, test_case: TestCase) -> None: - geometry = measured() - window = geometry.slice_of(offset=test_case.offset, height=REGION_HEIGHT, total=test_case.total) + def test_the_slice_follows_the_travel(self, test_case: TestCase) -> None: + window = measured().slice_of( + offset=test_case.offset, + extent=TRAVEL, + height=REGION_HEIGHT, + total=test_case.total, + ) assert window == test_case.expected - @pytest.mark.parametrize("offset", range(0, 2400, 37)) + def test_the_end_of_the_list_is_reachable(self) -> None: + """A region scrolled to the end of its travel builds the rows at the end of its list.""" + total = 5_000 + geometry = measured() + start, count = geometry.slice_of(offset=TRAVEL, extent=TRAVEL, height=REGION_HEIGHT, total=total) + assert start + count == total + + def test_a_region_with_no_travel_opens_at_the_top(self) -> None: + """A region measured before it has been laid out reports no travel, and the top of a list + is what stands until it reports some.""" + geometry = measured() + start, _ = geometry.slice_of(offset=500.0, extent=0.0, height=REGION_HEIGHT, total=100) + assert start == 0 + + @pytest.mark.parametrize("offset", range(0, 1001, 37)) def test_a_window_stays_inside_the_list_at_any_offset(self, offset: int) -> None: total = 100 - geometry = measured() - start, count = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=total) + start, count = measured().slice_of( + offset=float(offset), + extent=TRAVEL, + height=REGION_HEIGHT, + total=total, + ) assert start >= 0 assert start + count <= total - def test_a_scrolled_window_covers_the_row_the_offset_reaches(self) -> None: - """The row a reader has scrolled to stands among the ones the window built.""" - total = 100 + def test_the_window_only_moves_forward_as_the_region_scrolls(self) -> None: geometry = measured() - for offset in range(0, int(total * PITCH), 17): - start, count = geometry.slice_of(offset=float(offset), height=REGION_HEIGHT, total=total) - reached = min(int(offset / PITCH), total - 1) - assert start <= reached < start + count + previous = 0 + for offset in range(0, 1001, 13): + start, _ = geometry.slice_of(offset=float(offset), extent=TRAVEL, height=REGION_HEIGHT, total=500) + assert start >= previous + previous = start class TestReserve(BaseTestSuite): @@ -114,10 +151,15 @@ class TestReserve(BaseTestSuite): 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_the_floor(self) -> None: + rows = 100 + geometry = RowGeometry.unmeasured(overscan=OVERSCAN) + assert geometry.reserve(rows) == int(rows * MINIMUM_ROW_PITCH) + 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) + start, count = geometry.slice_of(offset=400.0, extent=TRAVEL, height=REGION_HEIGHT, total=total) spanned = geometry.reserve(start) + geometry.reserve(count) + geometry.reserve(total - start - count) assert spanned == geometry.reserve(total) @@ -150,11 +192,16 @@ def test_a_move_past_the_tolerance_is_worth_redrawing(self) -> None: drift = GEOMETRY_TOLERANCE * 2 assert geometry.take(block=(PITCH + drift) * 10, rows=10) - @pytest.mark.parametrize( - ("block", "rows"), - ((200.0, 0), (200.0, -1), (0.0, 10), (-50.0, 10)), - ) - def test_a_block_with_nothing_to_read_leaves_the_reading_alone(self, block: float, rows: int) -> None: + @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=rows) + 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 index 9c573077a..249c9c247 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -104,10 +104,11 @@ def test_the_reserves_and_the_slice_span_the_whole_list(self, region: WindowedRe class TestAnUnmeasuredRegion(BaseTestSuite): - """A region with no reading of a row yet builds the list whole, which is what gives the next - frame something to measure.""" + """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.""" - def test_it_builds_every_row(self, dpg_context: None) -> None: + @pytest.fixture + def unmeasured(self, dpg_context: None) -> WindowedRegion: built = WindowedRegion( tag=REGION_TAG, geometry=RowGeometry.unmeasured(overscan=OVERSCAN), @@ -118,7 +119,19 @@ def test_it_builds_every_row(self, dpg_context: None) -> None: with dpg.window(tag=ROOT_TAG): built.create(ROOT_TAG) - assert draw(built, 500) == [(0, 500)] + 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 TestRedrawing(BaseTestSuite): From 5c13bafc881990bf8d26a27a87e70775408c34e1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 12:17:35 +0200 Subject: [PATCH 023/130] Offered: every gathered recording to the question of what a mix holds --- docs/guide/interface.md | 6 +- .../ui/panels/dialogs/stem_selection.py | 37 +++- src/sampletones_config/lang/en.yaml | 2 +- .../ui/panels/dialogs/test_stem_selection.py | 165 ++++++++++++++++++ 4 files changed, 199 insertions(+), 11 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f428e1878..2c66dc0c0 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -62,8 +62,10 @@ into a tree mirroring that folder. Tick it and they mix into a single reconstruction instead. A mix reaches eight recordings, so ticking it with a longer list asks which ones -to mix. The folders give up the recordings they stood for, and what you pick is -what the mix converts. +to mix. The folders give up the recordings they stood for. The first eight arrive +ticked and every one is pickable, so swapping one for another is a click each; +the line above counts what you have picked, and **Add** settles the mix once the +pick fits. While mixing, 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 diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index b8751d5a6..50f6edebf 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -17,6 +17,7 @@ from sampletones_application.ui.elements.dialog import GUIDialogWindow from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.source import ShortcutSource @@ -24,11 +25,12 @@ class GUIStemSelectionWindow(GUIDialogWindow): - """A modal offering the recordings a folder holds, with the ones that fit already ticked. + """A modal offering the recordings gathered, with as many as a mix holds already ticked. - 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. + A list can hold more recordings than one mix has room for, so the reader is shown everything + gathered and picks which of it to mix: any recording is pickable, whichever ones arrived + ticked, so swapping the eighth for the ninth is one gesture. The line above reads what stands + picked against the room, and the mix is settled once the pick fits. """ def __init__( @@ -97,13 +99,13 @@ def create_window(self) -> None: ) def _create_candidate_rows(self) -> None: + """A box per recording gathered, the first ones the mix has room for arriving ticked.""" 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, + default_value=index < self._room, + callback=self._on_picked, ) @table_wrapper(columns=2) @@ -119,10 +121,26 @@ def _create_action_buttons(self) -> None: label=self._add_label, callback=self._add, width=-1, + 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._selected()), + total=len(self._candidates), + room=self._room, + ) + + def _on_picked(self, *_args: Any, **_kwargs: Any) -> None: + """Follow what stands picked: what the line reads, and whether the mix can be settled.""" + 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) + + @property + def _fits(self) -> bool: + """The pick is one a mix can be built from: at least one recording, and no more than fit.""" + picked = len(self._selected()) + return 0 < picked <= self._room def _selected(self) -> List[Path]: return [ @@ -132,6 +150,9 @@ def _selected(self) -> List[Path]: ] def _add(self) -> None: + if not self._fits: + return + selected = self._selected() self.hide() self.call(self.on_add, selected) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 4d94b53c1..76bd25b29 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -414,7 +414,7 @@ main.converter.message.status_stems_mode: "Mix the gathered recordings into one main.converter.title.discard_stems_dialog: "Replace the list?" main.converter.title.overwrite_target_dialog: "Write over it?" main.converter.title.stem_selection_dialog: "Pick recordings to mix" -main.converter.template.stem_selection_limit: "A mix holds {} of the {} recordings gathered." +main.converter.template.stem_selection_limit: "{picked} of {total} picked. A mix holds {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" 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..0319c71e5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -0,0 +1,165 @@ +from pathlib import Path +from typing import Final, List, 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.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.main import ( + PRE_MAIN_CONVERTER_CANDIDATE, + TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, + TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, +) +from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from tests.suite.base import BaseTestSuite +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +GATHERED: Final[int] = MAX_STEM_SOURCES + 4 + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIStemSelectionWindow: + return GUIStemSelectionWindow( + layout=layout_config.tabs.main.converter, + 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 candidates(count: int = GATHERED) -> List[Path]: + return [Path(f"/audio/take_{index}.wav") for index in range(count)] + + +def render(window: GUIStemSelectionWindow, offered: Sequence[Path]) -> None: + """Builds the widget tree for what was gathered, the way ``open`` does without a live frame.""" + window.open(offered, MAX_STEM_SOURCES) + + +def box_of(candidate: Path) -> str: + return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate)) + + +def pick(candidate: Path, *, picked: bool) -> None: + """Tick or untick one recording the way DearPyGui reports a checkbox.""" + tag = box_of(candidate) + dpg.set_value(tag, picked) + dpg.get_item_callback(tag)(tag, picked, dpg.get_item_user_data(tag)) + + +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 and pickable, 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 candidate in offered: + assert dpg.does_item_exist(box_of(candidate)) + + def test_the_ones_a_mix_holds_arrive_ticked(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + assert [dpg.get_value(box_of(candidate)) for candidate 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(candidate)) for candidate in offered[MAX_STEM_SOURCES:]) + + def test_a_recording_past_the_limit_is_pickable(self, window: GUIStemSelectionWindow) -> None: + """Swapping which recordings the mix is built from is what the question is for.""" + offered = candidates() + render(window, offered) + beyond = offered[MAX_STEM_SOURCES] + assert dpg.get_item_configuration(box_of(beyond))["enabled"] is True + + pick(beyond, picked=True) + + assert dpg.get_value(box_of(beyond)) 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]] = [] + window.on_add = answered.append + + render(window, offered) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [offered[:MAX_STEM_SOURCES]] + + def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + window.on_add = answered.append + + render(window, offered) + pick(offered[0], picked=False) + pick(offered[MAX_STEM_SOURCES], picked=True) + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [offered[1 : MAX_STEM_SOURCES + 1]] + + def test_a_pick_larger_than_a_mix_holds_waits(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + answered: List[List[Path]] = [] + window.on_add = answered.append + + render(window, offered) + pick(offered[MAX_STEM_SOURCES], picked=True) + + assert add_enabled() is False + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + assert answered == [] + + def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + for candidate in offered[:MAX_STEM_SOURCES]: + pick(candidate, picked=False) + + assert add_enabled() is False + + def test_letting_one_go_settles_again(self, window: GUIStemSelectionWindow) -> None: + offered = candidates() + render(window, offered) + pick(offered[MAX_STEM_SOURCES], picked=True) + assert add_enabled() is False + + pick(offered[0], picked=False) + + 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[MAX_STEM_SOURCES], picked=True) + + 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 From 69b32c05352585106ebf861a157300afef4e1ec7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 13:05:17 +0200 Subject: [PATCH 024/130] Named: the channels once above the rows that stand under them --- .../layout/general/stems.py | 7 + src/sampletones_application/tags/general.py | 7 + .../ui/elements/stems/bands.py | 40 +++++- .../ui/elements/stems/columns.py | 114 +++++++++++++++ .../ui/elements/stems/folder.py | 31 ++++- .../ui/elements/stems/heading.py | 131 ++++++++++++++++++ .../ui/elements/stems/list.py | 8 ++ .../ui/elements/stems/offer.py | 7 + .../ui/elements/stems/row.py | 77 +++++----- src/sampletones_config/lang/en.yaml | 3 + .../layout/general/stems.yaml | 9 +- .../theme/stems/slot_label.yaml | 9 ++ .../ui/elements/stems/test_list.py | 2 + 13 files changed, 401 insertions(+), 44 deletions(-) create mode 100644 src/sampletones_application/ui/elements/stems/columns.py create mode 100644 src/sampletones_application/ui/elements/stems/heading.py create mode 100644 src/sampletones_config/theme/stems/slot_label.yaml diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 3373cf25a..e05797a9a 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -4,11 +4,18 @@ 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 + column_gutter: int + cell_padding: int + name_height: int diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 754711ef5..579a3bd2d 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,6 +308,12 @@ Widget.THEME, "stems_row_inert", ) +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, @@ -791,6 +797,7 @@ SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment") 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") diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 3f2468a3b..88f8e9ebf 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -12,9 +12,11 @@ ) 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_RESERVE, 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 @@ -48,6 +50,7 @@ def __init__( language_manager: LanguageManager, rows: StemRowRenderer, folders: FolderRenderer, + heading: StemsHeading, open_folders: OpenFolders, gestures: StemsGestures, ) -> None: @@ -56,6 +59,7 @@ def __init__( 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"] @@ -75,7 +79,14 @@ def reshaped(self, view_model: StemsListViewModel) -> bool: return True def build(self, view_model: StemsListViewModel) -> None: - """Build the bands the view names, into whatever the list has cleared for them.""" + """Build the bands the view names, into whatever the list has cleared for them. + + The channels are named once above them all, so a cell below holds the box alone. + """ + columns = self.columns(view_model) + self._folders.reads(columns) + self._heading.create(self._tags.body, columns) + self._heading.render(view_model.muted_channels) if view_model.collapse_levels: self._create_listing(view_model) return @@ -150,13 +161,36 @@ def _create_table( rows: Sequence[StemRowViewModel], ) -> None: """One grid of rows, every band declaring the same columns so they line up across bands.""" + 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, ): - self._rows.declare_columns(view_model) + columns.declare() for row in rows: - self._rows.create(row, view_model) + 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 holds a scrollbar's width clear at its right end, so the columns + around a folder stand where the columns inside its own scrolling region stand. + """ + return StemsColumns( + layout=self._layout, + channels=view_model.channels_in_play, + master=self._offer.master_box, + removable=self._offer.removal, + bends=self._offer.bends, + reserve=self._reserve(view_model), + ) + + def _reserve(self, view_model: StemsListViewModel) -> int: + if not view_model.holds_folders: + return NO_RESERVE + + return self._layout.scrollbar_width + self._layout.column_gutter 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..446e55cea --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -0,0 +1,114 @@ +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 +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. + + ``reserve`` holds a strip clear at the right end of the grid, as wide as a scrollbar. A folder + draws its recordings inside a region of their own, which spends that width on its scrollbar; + holding the same width clear out here stands the columns of the grid around a folder where the + columns inside it stand. + """ + + layout: StemsListLayout + channels: Tuple[ChannelName, ...] + master: bool + removable: bool + bends: bool + reserve: int + + @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_width(self) -> int: + """The width the reserve column is declared at, so the room it holds is a scrollbar'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 + + 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.reserve_width > 0: + 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 + + 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) + + 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 0 + + return self._centered(int(measured[0])) + + 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) -> int: + """The indent standing something of this width in the middle of a channel's column.""" + return max(0, (self.channel_width - self.layout.cell_padding * 2 - span) // 2) diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index c17d1a5b8..6c9086796 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -1,3 +1,4 @@ +from dataclasses import replace from functools import partial from typing import Dict, Tuple @@ -7,6 +8,7 @@ from sampletones_application.tags.general import SUF_TABLE from sampletones_application.ui.elements.layout.geometry import RowGeometry from sampletones_application.ui.elements.layout.region import WindowedRegion +from sampletones_application.ui.elements.stems.columns import NO_RESERVE, 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 @@ -41,6 +43,18 @@ def __init__( self._open_folders = open_folders self._rows = rows self._regions: Dict[str, WindowedRegion] = {} + self._columns = StemsColumns( + layout=layout, + channels=(), + master=False, + removable=False, + bends=False, + reserve=NO_RESERVE, + ) + + 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 create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: """Draw the folder's own row, and the region its recordings stand in while it is open.""" @@ -50,9 +64,10 @@ def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: header_row=False, policy=dpg.mvTable_SizingFixedFit, resizable=False, + borders_innerV=True, ): - self._rows.declare_columns(view_model) - self._rows.create(row, view_model) + self._columns.declare() + self._rows.create(row, view_model, self._columns) if self._open_folders.stands_open(row.key): self._open(row, view_model) @@ -121,17 +136,23 @@ def _create_rows( start: int, count: int, ) -> None: - """One table of the recordings a region reaches, declaring the columns the list lines up on.""" + """One table of the recordings a region reaches, declaring the columns the list lines up on. + + The region spends a scrollbar's width of its own, which is the width the grid outside it + holds clear, so a box inside a folder stands in the column its neighbours stand in. + """ + held_columns = replace(self._columns, reserve=NO_RESERVE) with dpg.table( tag=self._tags.held(row.key), parent=region.body, header_row=False, policy=dpg.mvTable_SizingFixedFit, resizable=False, + borders_innerV=True, ): - self._rows.declare_columns(view_model) + held_columns.declare() for held in row.held[start : start + count]: - self._rows.create(held, view_model) + self._rows.create(held, view_model, held_columns) @staticmethod def _reached(region: WindowedRegion, row: StemRowViewModel) -> Tuple[StemRowViewModel, ...]: 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..7dd6fb524 --- /dev/null +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -0,0 +1,131 @@ +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 NO_RESERVE, 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, + reserve=NO_RESERVE, + ) + + @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.""" + 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() + + dpg.add_separator(parent=parent) + + 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/list.py b/src/sampletones_application/ui/elements/stems/list.py index d3f138e09..7566c9a01 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -18,6 +18,7 @@ KeyPairCallback, StemsGestures, ) +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 @@ -94,6 +95,12 @@ def __init__( 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, @@ -101,6 +108,7 @@ def __init__( language_manager=language_manager, rows=self._rows, folders=self._folders, + heading=self._heading, open_folders=self._open_folders, gestures=self._gestures, ) diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index d03472a7a..e838be5cd 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -8,12 +8,17 @@ class StemsListOffer: The converter's gathered recordings and a reconstruction's recorded assignment 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. """ master_box: bool removal: bool keeps_last_row: bool dragging: bool + bends: bool GATHERED_SOURCES: StemsListOffer = StemsListOffer( @@ -21,6 +26,7 @@ class StemsListOffer: removal=True, keeps_last_row=False, dragging=True, + bends=False, ) RECORDED_ASSIGNMENT: StemsListOffer = StemsListOffer( @@ -28,4 +34,5 @@ class StemsListOffer: removal=True, keeps_last_row=True, dragging=False, + bends=False, ) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index de5559cd3..0f1cebe28 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -1,6 +1,5 @@ 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.layout.glyphs.common import CommonGlyphs @@ -14,11 +13,13 @@ SUF_TWISTY, 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.stems.columns import 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 @@ -70,31 +71,27 @@ def __init__( self._lbl_remove = language_manager["global.stems.label.remove"] self._folder_template = language_manager["global.stems.template.folder_row"] - def declare_columns(self, view_model: StemsListViewModel) -> None: - """The columns every band holds to, so the rows line up across the bands.""" - if self._offer.master_box: - 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._offer.removal: - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.remove_button_width) - - def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: - """Build the widgets one row stands as, in the columns the bands were declared with.""" + 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.""" with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)): if self._offer.master_box: self._create_master(row) self._create_name(row, view_model) for channel_name in view_model.channels_in_play: - self._create_channel(row, channel_name) + self._create_channel(row, channel_name, columns) if self._offer.removal: self._create_remove(row) + if columns.reserve_width > 0: + dpg.add_spacer() + def repaint( self, row: StemRowViewModel, @@ -144,20 +141,21 @@ def _create_master(self, row: StemRowViewModel) -> None: def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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 where a list holds one every other row - opens the same width beside its name, so the names line up down the column. + A folder leads with the marker that opens it. 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, view_model) + 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, 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.dragging: + if self._draggable(view_model): with dpg.drag_payload(parent=name, drag_data=row.key, payload_type=self._tags.payload): dpg.add_text(row.name) @@ -169,13 +167,13 @@ def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> text_tag=self._tags.row(row.key, SUF_TOOLTIP), ) - def _create_disclosure(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: - """The marker a folder opens by, and the room it takes beside every other row.""" - if not view_model.holds_folders: - return + def _draggable(self, view_model: StemsListViewModel) -> bool: + """A row is dragged where the list bands its rows, which is what a drag rearranges.""" + return self._offer.dragging and not view_model.collapse_levels + def _create_disclosure(self, row: StemRowViewModel) -> None: + """The marker a folder opens by, which stands beside the folder's own name.""" if not row.stands_for_a_folder: - dpg.add_spacer(width=self._layout.twisty_width) return twisty = dpg.add_button( @@ -186,6 +184,7 @@ def _create_disclosure(self, row: StemRowViewModel, view_model: StemsListViewMod callback=self._gestures.on_twisty, ) FontRegistry.bind_to_item(twisty, Font.ICON) + ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_DROP_STRIP).bind_to_item(twisty) self._gestures.bind(twisty, SUF_TWISTY) def _twisty_glyph(self, key: str) -> str: @@ -202,24 +201,32 @@ def _row_label(self, row: StemRowViewModel) -> str: return self._folder_template.format(name=row.name, count=row.holds) - def _create_channel(self, row: StemRowViewModel, channel_name: ChannelName) -> None: + 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. - 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. + 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) - dpg.add_checkbox( - label=channel_label(self._language_manager, channel_name), - tag=checkbox_tag, - default_value=row.agreement_on(channel_name) is not Agreement.NONE, - user_data=(row.key, channel_name), - callback=self._gestures.on_channel_box, - ) + with dpg.group(horizontal=True, indent=columns.box_indent(channel_name)): + dpg.add_checkbox( + tag=checkbox_tag, + default_value=row.agreement_on(channel_name) is not Agreement.NONE, + user_data=(row.key, channel_name), + callback=self._gestures.on_channel_box, + ) + self._gestures.bind(checkbox_tag, SUF_CHANNELS) def _create_remove(self, row: StemRowViewModel) -> None: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 76bd25b29..8f5561533 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -273,6 +273,9 @@ global.stems.message.status_folder_row: "Open {name} to work on the {count} reco global.stems.message.status_folder_open: "Show the recordings in {name}." global.stems.message.status_folder_close: "Put the recordings in {name} away." global.stems.label.remove: "x" +global.stems.label.channel_on: "on" +global.stems.label.channel_bend: "bend" +global.stems.message.bend_tooltip: "Carry each note to the pitch the recording really sounds, a fraction of a step away from the note the channel names." 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." diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index af9189511..7588398f4 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,10 +1,17 @@ master_column_width: 26 -channel_column_width: 90 +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 +column_gutter: 6 +cell_padding: 4 +name_height: 30 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/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index ba44800f9..54a8c2486 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -78,6 +78,7 @@ def build( removal: bool = True, keeps_last_row: bool = False, master_box: bool = False, + bends: bool = False, ) -> GUIStemsList: stems_list = GUIStemsList( prefix=PREFIX, @@ -90,6 +91,7 @@ def build( removal=removal, keeps_last_row=keeps_last_row, dragging=dragging, + bends=bends, ), ) with dpg.window(tag=ROOT_TAG): From c4fa0ab070f93573e5d21358bfdf223cae57db21 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 13:29:34 +0200 Subject: [PATCH 025/130] Divided: the converter card into the sections it reads as --- docs/guide/interface.md | 30 +- .../coordinators/tabs/main.py | 2 +- .../logic/main/converter/logic.py | 11 +- .../logic/main/converter/messages.py | 46 +- src/sampletones_application/tags/main.py | 28 +- .../ui/elements/context_menu.py | 6 +- .../ui/panels/main/converter.py | 571 ------------------ .../ui/panels/main/converter/__init__.py | 0 .../ui/panels/main/converter/action.py | 111 ++++ .../ui/panels/main/converter/listing.py | 125 ++++ .../ui/panels/main/converter/menus.py | 165 +++++ .../ui/panels/main/converter/panel.py | 181 ++++++ .../ui/panels/main/converter/setup.py | 166 +++++ .../ui/panels/main/converter/summary.py | 105 ++++ .../view_model/main/converter.py | 16 + src/sampletones_config/lang/en.yaml | 20 +- .../logic/main/converter/test_messages.py | 108 ++-- .../logic/main/converter/texts.py | 8 +- .../sampletones_application/test_startup.py | 32 +- 19 files changed, 1028 insertions(+), 703 deletions(-) delete mode 100644 src/sampletones_application/ui/panels/main/converter.py create mode 100644 src/sampletones_application/ui/panels/main/converter/__init__.py create mode 100644 src/sampletones_application/ui/panels/main/converter/action.py create mode 100644 src/sampletones_application/ui/panels/main/converter/listing.py create mode 100644 src/sampletones_application/ui/panels/main/converter/menus.py create mode 100644 src/sampletones_application/ui/panels/main/converter/panel.py create mode 100644 src/sampletones_application/ui/panels/main/converter/setup.py create mode 100644 src/sampletones_application/ui/panels/main/converter/summary.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 2c66dc0c0..9b22fe7fa 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -13,14 +13,17 @@ The **Main** tab turns an audio file into a [reconstruction](../concepts/reconstruction.md). Most sessions start here. 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 +left, set up the conversion in the center, and click the button, which names the +run it is about to make: **Convert bass**, **Convert 6 recordings**, **Mix 5 +recordings**. 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. -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 +**Destination:** at the foot of the card names where a run writes — the document +a single conversion makes, or the folder a longer run fills — and while a run +goes on an **Input:** line above it names the recording being read. 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. @@ -56,12 +59,12 @@ it. ### One reconstruction each, or one from them all -**Mix into one** names what the run writes. Left clear, every recording in the -list gets a reconstruction of its own, and the ones a folder holds are written -into a tree mirroring that folder. Tick it and they mix into a single -reconstruction instead. +**Output**, at the head of the card, names what the run writes. On **One per +recording**, every recording in the list gets a reconstruction of its own, and +the ones a folder holds are written into a tree mirroring that folder. On **One +from all**, they mix into a single reconstruction instead. -A mix reaches eight recordings, so ticking it with a longer list asks which ones +A mix reaches eight recordings, so choosing it with a longer list asks which ones to mix. The folders give up the recordings they stood for. The first eight arrive ticked and every one is pickable, so swapping one for another is a click each; the line above counts what you have picked, and **Add** settles the mix once the @@ -76,9 +79,10 @@ recording's own actions — copy its name or path, or show the file in your file manager. **Order** sets how the levels take turns: round by round, or one level filled before the next picks. -**Channels per source** caps how many channels one recording may hold in a -single frame, and it applies to every conversion. Set to 1, each recording gets a -single voice. +**Channels per source**, below the list, caps how many channels one recording may +hold in a single frame, and it applies to every conversion. Set to 1, each +recording gets a single voice. It stands beside **Order** once there is a list to +answer for. Reconstructing a file or a folder from the browser converts that one thing, so it asks first where you have already gathered a list. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 486c832c7..f0ae37173 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -48,7 +48,7 @@ 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.utils.file_dialogs.api import select_directory_dialog diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 2f8159c88..ecaf1f84c 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -496,7 +496,7 @@ def _emit( self._state, phase=self._run.phase, status_text=status_text, - action_label=self._action_label(running_input), + action_label=self._action_label(), progress=progress, running_input=running_input, reconstructions_directory=self._config_manager.get_reconstructions_directory(), @@ -514,13 +514,10 @@ def _read_rows(self) -> Tuple[StemRowViewModel, ...]: """ return stem_rows(self._state.gathering, mixes=self.mixes) - def _action_label(self, running_input: Optional[Path]) -> str: - destination = self._state.destination - input_path = running_input if running_input is not None else destination.input_path + 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, - is_file=destination.is_file, - input_path=input_path, - playing=len(playing_sources(self._state)), + 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 index 6f5c5d0f0..ff29f8306 100644 --- a/src/sampletones_application/logic/main/converter/messages.py +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final, Optional +from typing import Dict, Final, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.services.conversion.result import ConversionItem @@ -9,6 +9,7 @@ from sampletones_core.reconstructions.stage import ReconstructionStage SINGLE_JOB: Final[int] = 1 +SINGLE_SOURCE: Final[int] = 1 class ConverterMessages: @@ -54,38 +55,29 @@ def action_label( *, phase: ConversionPhase, mixes: bool, - is_file: bool, - input_path: Optional[Path], - playing: int, + converted: Tuple[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.""" + """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 mixes: - return self._mix_label(playing) - - base = ( - self._language_manager["main.converter.label.convert_sample_button"] - if is_file - else self._language_manager["main.converter.label.convert_directory_button"] - ) - if input_path is None: - return base - - return self._named_label(base, input_path.name) - - def _mix_label(self, playing: int) -> str: - """The stems label, named after how many recordings take part.""" - base = self._language_manager["main.converter.label.convert_stems_button"] - if not playing: - return base + 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)) - return self._named_label(base, str(playing)) + if converted: + return self._language_manager["main.converter.template.convert_recording"].format(name=converted[0].stem) - def _named_label(self, base: str, subject: str) -> str: - return self._language_manager["main.converter.template.convert_label_template"].format(base, subject) + return self._language_manager["main.converter.label.convert_button"] def _run_text( self, diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 96b50bf13..d5d347363 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -236,12 +236,6 @@ Widget.GROUP, "summary", ) -TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT = TagName( - Page.MAIN, - Panel.CONVERTER, - Widget.TEXT, - "summary_hint", -) PRE_MAIN_RECONSTRUCTOR_SLOT = "slot" TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING = TagName( @@ -256,11 +250,23 @@ 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, @@ -274,11 +280,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, 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/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py deleted file mode 100644 index b530e1316..000000000 --- a/src/sampletones_application/ui/panels/main/converter.py +++ /dev/null @@ -1,571 +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 ( - ConverterFolderElements, - 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.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.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, - add_play_menu_item, - 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.elements.stems.offer import GATHERED_SOURCES -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. - - A folder stands as one row reaching everything below it. Its menu shows or puts away the - recordings it holds, takes the whole of it out of the conversion, and offers the folder'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_output_changed: Optional[Callable[[OutputKind], None]] = None - self.on_folder_removed: Optional[Callable[[Path], 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_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.on_source_played: Optional[PathCallback] = 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, - glyphs=self._glyphs.common, - language_manager=language_manager, - status_bar=status_bar, - offer=GATHERED_SOURCES, - ) - - 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_channel_toggled = self._on_folder_channel_toggled - self._stems_list.on_remove_requested = self._on_source_removed - self._stems_list.on_row_activated = self._on_row_selected - self._stems_list.on_menu_requested = self._show_menu - self._stems_list.on_row_opened = self._on_source_played - 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.mixes) - 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.mixes) - set_tooltip_visible(TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, view_model.mixes) - 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=True) - 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: - """The switch names what the run writes, which the box states as a mix or not.""" - self.call(self.on_output_changed, OutputKind.MIXED if value else OutputKind.PER_RECORDING) - - 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_row_selected(self, key: str) -> None: - """A clicked row is the one the settings card inspects, whichever kind it is.""" - row = self._stems_list.row(key) - if row is not None: - self.call(self.on_row_selected, Path(key), row.kind) - - 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_source_removed(self, key: str) -> None: - 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_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 _on_source_played(self, key: str) -> None: - self.call(self.on_source_played, Path(key)) - - def _show_menu(self, key: str) -> None: - """Offer what the row a gesture landed on can do: a folder reaches everything below it, - a recording answers for itself.""" - row = self._stems_list.row(key) - if row is None: - return - - if row.stands_for_a_folder: - self._show_folder_menu(row) - return - - self._show_row_menu(row) - - def _show_row_menu(self, row: StemRowViewModel) -> None: - """A recording is played wherever it is met, so the menu leads with the same item the file - browser offers, then 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.""" - with context_menu(): - self._menu_header(row.name) - add_play_menu_item( - self._language_manager["global.context.label.play"], - lambda: self.call(self.on_source_played, row.path), - ) - 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 _show_folder_menu(self, row: StemRowViewModel) -> None: - """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.""" - key = row.key - opened = self._stems_list.stands_open(key) - with context_menu(): - self._menu_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._stems_list.toggle_folder(key), - ) - dpg.add_menu_item( - label=self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), - callback=lambda: self.call(self.on_folder_removed, row.path), - ) - add_path_menu_items(self._language_manager, row.path) - - @staticmethod - def _menu_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 _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 _folder_label(self, element: ConverterFolderElements) -> 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..335c08cb3 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -0,0 +1,125 @@ +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.view_model.main.converter import ConverterViewModel +from sampletones_core.constants.enums import ChannelName +from sampletones_shared.types.callback import PathCallback, StringCallback +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. + """ + + def __init__( + self, + *, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._stems_list = GUIStemsList( + prefix=PRE_MAIN_CONVERTER_STEMS, + layout=stems_layout, + 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 + + @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 + + def update_view(self, view_model: ConverterViewModel) -> None: + """Draw the gathered recordings, with the hint standing while none are.""" + dpg_configure_item(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT, show=not 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, whichever kind it is.""" + row = self._stems_list.row(key) + if row is not None: + self.call(self.on_row_selected, Path(key), row.kind) + + 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..172555cb2 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -0,0 +1,165 @@ +from pathlib import Path +from typing import Callable, 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.view_model.shared.stems import StemRowViewModel +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin + +LEVEL_ABOVE: int = -1 +LEVEL_BELOW: int = 1 +POSITION_EARLIER: int = -1 +POSITION_LATER: 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, + ) -> None: + self._stems_list = stems_list + self._language_manager = language_manager + 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, + ) + for element, enabled, callback in self._moves(row, banded=banded): + dpg.add_menu_item(label=self._label(element), enabled=enabled, callback=callback) + + 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), + ) + dpg.add_menu_item( + label=self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), + callback=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 has no order to rearrange, so it offers the one move + that means something there: taking the recording out. + """ + path = row.path + removal = ( + ConverterStemMoveElements.CONTEXT_REMOVE_STEM, + True, + lambda: self.call(self.on_source_removed, path), + ) + if not banded: + return [removal] + + 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), + ), + removal, + ] + + @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..fb19229b3 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/converter/panel.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.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.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, + ) -> 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, + ) + self._menus = ConverterMenus( + stems_list=self._listing.stems_list, + language_manager=language_manager, + ) + 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_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_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/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index c82fefe79..ee8af0ed8 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -41,6 +41,7 @@ class ConverterAction(StrEnum): ConversionPhase.CANCELLING, } ) +SINGLE_SOURCE: Final[int] = 1 class ConverterViewModel(BaseModel, frozen=True): @@ -95,6 +96,21 @@ def has_input(self) -> bool: 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 row draws a box on, in the order the application names them. diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8f5561533..1629c31f5 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -366,8 +366,11 @@ 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 for each recording listed, 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." @@ -382,8 +385,7 @@ 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.progress_dialog: "Reconstruction progress" @@ -395,25 +397,23 @@ 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: "Mix into one" +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: "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 the gathered recordings into one reconstruction, each holding the channels you give it. Left clear, every recording gets a reconstruction of its own." 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 what this run converts." +main.converter.message.stems_empty_hint: "Click a recording in the browser to add it. Add a folder to convert everything under it." main.converter.message.discard_stems_prompt: "Converting this replaces the recordings you gathered. 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 mix." -main.converter.message.status_stems_mode: "Mix the gathered recordings into one reconstruction." main.converter.title.discard_stems_dialog: "Replace the list?" main.converter.title.overwrite_target_dialog: "Write over it?" main.converter.title.stem_selection_dialog: "Pick recordings to mix" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py index 939f8ac92..53cb64b9a 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_messages.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -1,5 +1,6 @@ +from dataclasses import dataclass from pathlib import Path -from typing import Final, Optional +from typing import Final, Optional, Tuple import pytest @@ -10,7 +11,9 @@ 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.unit.sampletones_application.logic.main.converter.texts import messages +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 @@ -37,7 +40,7 @@ def _item(stage: ReconstructionStage, completed: int) -> ConversionItem: ) -class TestProgressText: +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: @@ -62,66 +65,67 @@ def test_a_batch_counts_the_reconstruction_under_way_toward_its_files(self) -> N assert messages().progress_text(progress, "kick") == "Progress: 2/5 files - rendering 1100/1100" -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. +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. """ - def test_a_file_names_the_recording_it_would_convert(self) -> None: - label = messages().action_label( + @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, - is_file=True, - input_path=Path("/audio/kick.wav"), - playing=0, - ) - - assert label == "Convert sample: kick.wav" - - def test_a_directory_uses_the_directory_variant(self) -> None: - label = messages().action_label( + converted=(), + expected=TEXTS["main.converter.label.convert_button"], + ), + TestCase( + label="one_recording_names_its_document", phase=ConversionPhase.IDLE, mixes=False, - is_file=False, - input_path=Path("/audio/drums"), - playing=0, - ) - - assert label == "Convert directory: drums" - - def test_nothing_picked_reads_the_bare_convert_label(self) -> None: - label = messages().action_label( + 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, - is_file=True, - input_path=None, - playing=0, - ) - - assert label == "Convert sample" - - def test_a_mix_names_how_many_recordings_take_part(self) -> None: - label = messages().action_label( + 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, - is_file=True, - input_path=Path("/audio/kick.wav"), - playing=3, - ) - - assert label == "Convert stems: 3" + converted=(Path("/audio/kick.wav"), Path("/audio/snare.wav"), Path("/audio/hat.wav")), + expected=TEXTS["main.converter.template.mix_recordings"].format(count=3), + ), + ) - def test_a_mix_with_nobody_taking_part_reads_the_bare_label(self) -> None: + @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=ConversionPhase.IDLE, - mixes=True, - is_file=True, - input_path=None, - playing=0, + phase=test_case.phase, + mixes=test_case.mixes, + converted=test_case.converted, ) - assert label == "Convert stems" + assert label == test_case.expected @pytest.mark.parametrize( "phase", @@ -131,9 +135,7 @@ def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: Conv label = messages().action_label( phase=phase, mixes=False, - is_file=True, - input_path=Path("/audio/kick.wav"), - playing=0, + converted=(Path("/audio/kick.wav"),), ) - assert label == "Cancel" + assert label == TEXTS["main.converter.label.cancel_button"] diff --git a/tests/unit/sampletones_application/logic/main/converter/texts.py b/tests/unit/sampletones_application/logic/main/converter/texts.py index 673368226..cc59c2017 100644 --- a/tests/unit/sampletones_application/logic/main/converter/texts.py +++ b/tests/unit/sampletones_application/logic/main/converter/texts.py @@ -4,11 +4,11 @@ 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.convert_stems_button": "Convert stems", + "main.converter.label.convert_button": "Convert", "main.converter.label.cancel_button": "Cancel", - "main.converter.template.convert_label_template": "{}: {}", + "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}", diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 1db677102..89779e42d 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -23,6 +23,8 @@ TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.tags.main import ( + TAG_MAIN_CONVERTER_GROUP_CONTROLS, + TAG_MAIN_CONVERTER_GROUP_ORDER, TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, ) @@ -642,14 +644,34 @@ def test_the_card_edits_what_a_recording_joins_with_where_nothing_is_picked( assert ChannelName.PULSE2 in _row_of(app, joined).channels - 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 + 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 - converter_logic.set_output(OutputKind.MIXED) + 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_output(OutputKind.PER_RECORDING) + 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( From 9b15311584561eadbb8a81d7b47de3dc6267984c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 13:46:53 +0200 Subject: [PATCH 026/130] Held: the gathered list to a ceiling it scrolls inside --- docs/guide/interface.md | 10 +- src/sampletones_application/tags/general.py | 1 + .../ui/elements/layout/region.py | 103 +++++++++++++++--- .../ui/elements/stems/bands.py | 16 ++- .../ui/elements/stems/folder.py | 2 +- .../ui/elements/stems/list.py | 82 +++++++++----- .../ui/elements/layout/test_region.py | 73 +++++++++++-- .../ui/elements/stems/test_list.py | 60 ++++++++++ 8 files changed, 292 insertions(+), 55 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9b22fe7fa..c6d3b0b27 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -41,10 +41,12 @@ add it; right-click and choose **Add as stem**, or Ctrl-click, to do the same. Ctrl-click a folder — or use **Add folder as stems** — and the folder joins as one row standing for the recordings inside it. -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. **x** takes a row out; taking out a folder -takes everything it holds. +The channels are named once above the rows, and each row shows one recording and +a checkbox under every 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. **x** takes a row out; taking out a folder takes everything it holds. +The list grows with what you gather and scrolls once it fills the card, so the +cards below it stay where you left them. A folder's row names how many recordings it brought in, and its checkboxes read all three ways: ticked where every recording in it uses that channel, half-lit diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 579a3bd2d..cd7a055a7 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -807,6 +807,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") diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 07f3ce5d4..b1ab758f2 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -1,9 +1,10 @@ -from typing import Callable, Final +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, ) @@ -13,8 +14,10 @@ from sampletones_shared.types.callback import VoidCallback SliceBuilder = Callable[[int, int], None] +LeadBuilder = Callable[[str], None] NO_ROWS: Final[Window] = (0, 0) +NO_LEAD: Final[float] = 0.0 AUTO_HEIGHT: Final[int] = 0 @@ -27,13 +30,18 @@ class WindowedRegion: 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. 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 region standing at its natural height is what a reading is taken from. + 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. """ def __init__( @@ -52,9 +60,13 @@ def __init__( self._margin = margin 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._drawn: Window = NO_ROWS @property @@ -75,7 +87,12 @@ def window(self) -> Window: @property def windowing(self) -> bool: """The region holds back rows it has no room for, so a scroll asks it for different ones.""" - return bool(self._total) and self._drawn[1] < self._total + return self._windowed and self._drawn[1] < self._total + + @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: @@ -102,12 +119,13 @@ def create(self, parent: str, *, show: bool = True) -> None: show=show, ) - def draw(self, total: int, build: SliceBuilder) -> None: + 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. The scroll position is put back afterwards, so the - rows a reader was looking at are the rows they keep looking at. + :attr:`body` between the two reserves. ``lead`` builds the heading standing above them, + into the group it is handed. The scroll position is put back afterwards, so the rows a + reader was looking at are the rows they keep looking at. 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. @@ -116,22 +134,30 @@ def draw(self, total: int, build: SliceBuilder) -> None: start, count = self._slice(offset, 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) dpg.set_y_scroll(self._tag, offset) - def draw_whole(self, build: VoidCallback) -> None: + def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: int) -> 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. + so it stands as tall as what it holds and scrolls once that reaches its ceiling. + + ``rows`` is how many rows of one height the content is a plain run of, which is what a + reading of a row is taken from; content standing anything else among its rows is a run of + none. """ dpg_delete_children(self._body_tag) + self._build_lead(lead) build() - self._total = 0 + self._total = rows + self._windowed = False self._drawn = NO_ROWS def settle(self) -> bool: @@ -140,13 +166,15 @@ def settle(self) -> bool: Answers whether the rows standing are still the ones the region reaches, which is what asks an owner to draw it again. """ - if not self._total: - self._stand_at_natural_height() + self._take_lead() + if not self._windowed: + self._take_reading() + self._hold_content() return False if not self._geometry.measured: self._stand_at_natural_height() - return self._geometry.take(block=self._body_height(), rows=self._drawn[1]) + return self._take_reading() self._hold_rows() return self._slice(self.offset, self._total) != self._drawn @@ -160,26 +188,73 @@ def _slice(self, offset: float, total: int) -> Window: 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._content()) + 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 _content(self) -> float: - """The room the region's whole list asks for, the margins above and below it included.""" - return float(self._geometry.reserve(self._total) + 2 * self._margin) + """The room the region's whole list asks for: its heading, its rows, and its margins.""" + return float(self._lead + self._geometry.reserve(self._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._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) def _size_to(self, content: float) -> None: within = content <= self._ceiling self._height = content if within else float(self._ceiling) + self._natural = within dpg_configure_item( self._tag, height=AUTO_HEIGHT if within else self._ceiling, diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 88f8e9ebf..811c61d20 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -78,15 +78,23 @@ def reshaped(self, view_model: StemsListViewModel) -> bool: self._shape = shape return True - def build(self, view_model: StemsListViewModel) -> None: - """Build the bands the view names, into whatever the list has cleared for them. + 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 channels are named once above them all, so a cell below 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. """ columns = self.columns(view_model) self._folders.reads(columns) - self._heading.create(self._tags.body, columns) + self._heading.create(parent, columns) 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 diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 6c9086796..9fec4d64e 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -126,7 +126,7 @@ def _fill( row: StemRowViewModel, view_model: StemsListViewModel, ) -> None: - region.draw(row.holds, partial(self._create_rows, region, row, view_model)) + region.draw(row.holds, partial(self._create_rows, region, row, view_model), lead=None) def _create_rows( self, diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 7566c9a01..0dc3708c2 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,6 +1,5 @@ -from typing import Final, Optional - -import dearpygui.dearpygui as dpg +from functools import partial +from typing import Final, Optional, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.stems import StemsListLayout @@ -31,7 +30,7 @@ from sampletones_shared.types.callback import StringCallback from sampletones_shared.utils.callbacks import CallbackMixin -NO_CEILING: Final[int] = 0 +NO_ROWS: Final[int] = 0 class GUIStemsList(CallbackMixin): @@ -66,7 +65,7 @@ def __init__( self._region = WindowedRegion( tag=self._tags.well, geometry=self._geometry, - ceiling=NO_CEILING, + ceiling=layout.well_ceiling, padding=layout.well_padding, margin=layout.well_margin, ) @@ -168,14 +167,60 @@ def update_view(self, view_model: StemsListViewModel) -> None: 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() - self._region.draw_whole(lambda: self._bands.build(view_model)) + if self._windows(view_model): + self._draw_window(view_model) + return + + self._region.draw_whole( + partial(self._bands.build, view_model), + lead=partial(self._bands.build_heading, view_model), + rows=self._plain_rows(view_model), + ) + + 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 _windows(view_model: StemsListViewModel) -> bool: + """Whether the well draws a window over its rows rather than the whole run of them. + + 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 view_model.collapse_levels and not view_model.holds_folders + + def _plain_rows(self, view_model: StemsListViewModel) -> int: + """How many rows a whole-drawn well stands as a plain run of, which a reading counts by. + + A run broken by a caption, a strip or an open folder's region carries more than rows, so + it counts none and the reading in force stands. + """ + if not view_model.collapse_levels or self._open_folders: + return NO_ROWS + + return view_model.row_count def _repaint(self, view_model: StemsListViewModel) -> None: """Draw what the rows in view currently hold onto the widgets they stand as.""" - for row in view_model.rows: + for row in self._reached(view_model): self._rows.repaint(row, view_model, releasable=self._releasable) self._folders.repaint(row, view_model) + 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 + + start, count = self._region.window + return view_model.rows[start : start + count] + def row(self, key: str) -> Optional[StemRowViewModel]: """The row a gesture named, as the list last rendered it.""" return self._view.row(key) @@ -192,7 +237,7 @@ def toggle_folder(self, key: str) -> None: @property def _following(self) -> bool: """A region is holding rows back, so the list watches for the scroll that asks for them.""" - return self._folders.following + return self._region.windowing or self._folders.following def _settle_soon(self) -> None: """Ask to read the drawn rows back once the frame that placed them has been rendered. @@ -211,8 +256,10 @@ 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 - self._measure_rows() - self._region.settle() + if self._region.settle(): + 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) @@ -222,21 +269,6 @@ def _settle(self) -> None: if self._following: self._settle_soon() - def _measure_rows(self) -> None: - """Read what one row takes from the rows the list has drawn, so a folder opens knowing it. - - The reading is taken while the list stands as a plain run of rows, with no caption, strip - or open region among them, so what is measured is the rows' own room. A folder is then - opened against a reading the list took from its own rows and builds the handful it shows - rather than everything it holds; from there each region reads its own rows back. - """ - rows = self._view.row_count - if not rows or self._open_folders or not self._view.collapse_levels or not self._view.holds_folders: - return - - if dpg.does_item_exist(self._tags.body): - self._geometry.take(block=float(dpg.get_item_rect_size(self._tags.body)[1]), rows=rows) - @property def _releasable(self) -> bool: """Whether a row may leave, which a list holding on to its last one answers by its count.""" diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index 249c9c247..ee5678cc0 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -1,11 +1,11 @@ -from typing import Iterator, List, Tuple +from typing import Iterator, List, Optional, Tuple import dearpygui.dearpygui as dpg import pytest from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY from sampletones_application.ui.elements.layout.geometry import RowGeometry -from sampletones_application.ui.elements.layout.region import WindowedRegion +from sampletones_application.ui.elements.layout.region import LeadBuilder, WindowedRegion 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 @@ -17,6 +17,7 @@ PITCH = 20.0 OVERSCAN = 2 CEILING = 100 +HEADING_TEXT = "channels" @pytest.fixture @@ -47,7 +48,12 @@ def region(dpg_context: None) -> WindowedRegion: return built -def draw(region: WindowedRegion, total: int) -> List[Tuple[int, int]]: +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]] = [] @@ -56,16 +62,20 @@ def build(start: int, count: int) -> None: for index in range(start, start + count): dpg.add_text(f"row {index}", parent=region.body) - region.draw(total, build) + region.draw(total, build, lead=lead) return asked def reserves(region: WindowedRegion) -> Tuple[int, int]: """The room standing above and below the rows the region drew.""" - children = dpg.get_item_children(region.body, 1) + 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(children[0])["height"]), - int(dpg.get_item_configuration(children[-1])["height"]), + int(dpg.get_item_configuration(spacers[0])["height"]), + int(dpg.get_item_configuration(spacers[-1])["height"]), ) @@ -149,3 +159,52 @@ def test_a_shorter_list_reserves_less(self, region: WindowedRegion) -> None: 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 + + +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, rows=30 + ) + + 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, rows=0) + + 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, rows=0) + first = dpg.get_item_children(region.body, 1)[0] + + assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" 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 54a8c2486..a7aaec207 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -21,6 +21,7 @@ SUF_CHANNELS, SUF_CHECKBOX, SUF_HANDLER_REGISTRY, + SUF_HEADING, SUF_LEVEL, SUF_ROW, SUF_STRIP, @@ -49,6 +50,7 @@ PREFIX = "test.stems" CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DRAG_PAYLOAD_SLOT: Final[int] = 3 +LONG_LIST: Final[int] = 200 @pytest.fixture @@ -668,3 +670,61 @@ def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, la assert dpg.get_value(row_tag(lead, SUF_TEXT)) is True assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False + + +class TestTheHeading: + """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) -> 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) -> 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 TestTheWell: + """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) -> 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_a_short_run_of_recordings_builds_them_all(self, dpg_context: None, layout_config) -> 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) -> 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) -> 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) From d31227b3b1011a30059a87a9aae4740431a10745 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 14:22:35 +0200 Subject: [PATCH 027/130] Drew: the settings card as one row of the list's own grid --- docs/guide/configuration.md | 6 +- docs/guide/getting-started.md | 7 +- docs/guide/interface.md | 19 +- .../coordinators/tabs/main.py | 8 +- .../logic/main/converter/gathering.py | 4 + .../logic/main/converter/logic.py | 39 ++- .../logic/main/converter/view.py | 29 +- .../logic/main/sources/list.py | 31 ++ src/sampletones_application/tags/main.py | 18 + .../ui/elements/stems/row.py | 4 +- .../ui/panels/main/reconstructor/__init__.py | 0 .../ui/panels/main/reconstructor/grid.py | 183 ++++++++++ .../panel.py} | 165 ++++----- .../view_model/main/reconstructor.py | 40 ++- .../view_model/shared/agreement.py | 9 + src/sampletones_config/lang/en.yaml | 7 +- .../logic/main/converter/test_logic.py | 5 +- .../sampletones_application/test_startup.py | 48 ++- .../ui/elements/stems/test_list.py | 5 +- .../ui/panels/main/test_reconstructor.py | 329 +++++++++++++++--- 20 files changed, 739 insertions(+), 217 deletions(-) create mode 100644 src/sampletones_application/ui/panels/main/reconstructor/__init__.py create mode 100644 src/sampletones_application/ui/panels/main/reconstructor/grid.py rename src/sampletones_application/ui/panels/main/{reconstructor.py => reconstructor/panel.py} (54%) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index af5529661..82f415d95 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -10,10 +10,10 @@ you want to go deeper. ## From the interface The **Main** tab exposes the everyday settings (grouped under **General -settings**, **Reconstructor settings**, and **Advanced settings**): +settings**, **Reconstruction settings**, and **Advanced settings**): -- which **Channels** a recording takes when it joins a conversion, and the - **Drive** applied to them; +- which channels the recording you picked out of the converter's list takes, 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 diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b9b23eeb6..667caa3df 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -9,9 +9,10 @@ instruments, and building a whole song. Both assume it is already 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 +3. Optionally click the recording in the list and choose which channels it takes + under **Reconstruction settings**, and adjust **General settings**. At least + one channel must be enabled. +4. Click the button, which names the run it makes. 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 diff --git a/docs/guide/interface.md b/docs/guide/interface.md index c6d3b0b27..b668a3f8f 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -91,15 +91,16 @@ asks first where you have already gathered a list. ### Settings -A few settings are worth knowing before you convert. **Reconstructor settings** -edits whatever the list has picked out: click a row and the card shows that -recording's — or that folder's — **Channels** and **Bends**, and a folder whose -recordings differ shows the choice half-lit until one click settles them all. -With nothing picked, the card edits what every recording joins the list with, so -that is where you set the channels a new row starts from. **Drive** sets how hard -the channels are pushed and holds for the whole run. **General -settings** holds the analysis options: sample rate, NES frequency, generation -method, and feature scaling. The rest, including the worker count and the output +A few settings are worth knowing before you convert. **Drive** sets how hard the +channels are pushed and holds for the whole run, so it stands at the top of +**Reconstruction settings** whatever you are looking at. Below it the card names +the row you clicked in the converter's list — a folder reads how many recordings +it stands for — and gives that row a box under every channel: one for the channel +it takes, and one for the bend on it. A folder whose recordings differ reads clear +in a softer tone until one click settles them all. Press a channel's key to set +that channel across everything listed at once. **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. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index f0ae37173..7a64c2dcc 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -50,7 +50,7 @@ from sampletones_application.ui.panels.main.config import GUIConfigPanel 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.reconstructor.panel import GUIReconstructorPanel 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 @@ -216,9 +216,11 @@ def _build_cards( slots=self._converter_logic.settings_slots, inspected=None, drive=_config.generation.drive, + live=True, ), layout=layout.main.reconstructor, inputs=layout.inputs, + stems_layout=layout.stems, initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_RECONSTRUCTOR_PANEL), language_manager=language_manager, status_bar=status_bar, @@ -258,6 +260,7 @@ def _wire_settings(self, config_manager: ConfigManager) -> None: 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._reconstructor_panel.on_slot_toggled = self._converter_logic.toggle_slot + self._reconstructor_panel.on_channel_keyed = self._converter_logic.toggle_channel 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 @@ -508,8 +511,9 @@ def _update_reconstructor_panel_view(self) -> None: self._reconstructor_panel.update_view( ReconstructorPanelViewModel( slots=self._converter_logic.settings_slots, - inspected=self._converter_logic.inspected_name, + inspected=self._converter_logic.inspected_source, drive=self._config_manager.config.generation.drive, + live=self._converter_logic.live, ) ) diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py index 800299b86..36f2b2495 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -142,6 +142,10 @@ def settled( """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_throughout(self, slot: SettingsSlot, channel_name: ChannelName) -> Self: + """The setup with ``channel_name`` settled the one way on every recording listed.""" + return replace(self, sources=self.sources.toggled_throughout(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) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index ecaf1f84c..f6b3be585 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -27,8 +27,8 @@ from sampletones_application.logic.main.converter.state import ConverterState from sampletones_application.logic.main.converter.view import ( compose_view, - inspected_name, inspected_settings, + inspected_source, settings_slots, stem_rows, ) @@ -46,7 +46,10 @@ ConversionPhase, ConverterViewModel, ) -from sampletones_application.view_model.main.reconstructor import SettingsSlotViewModel +from sampletones_application.view_model.main.reconstructor 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 @@ -222,26 +225,40 @@ def settings_slots(self) -> Tuple[SettingsSlotViewModel, ...]: return settings_slots(self._state) @property - def inspected_name(self) -> Optional[str]: - """What the settings card is editing, where a reader picked a row out of the list.""" - return inspected_name(self._state) + 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``, wherever the settings card is pointed. + """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. With no row picked the gesture reaches the - settings a recording joins the list with, which is what the run hands out. + choice go, every other reading takes it up — so one gesture answers for a folder and for + a recording alike. """ - slot = SLOTS_BY_FIELD[field] - held = self._inspected_agreement(slot, channel_name).settles_to selected = self._state.selected if selected is None: - self._settle_joining(slot.settled(self._joining_settings, channel_name, held)) 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: + """Switches one channel across the whole list, which is what the channel's key reaches. + + The list answers as one group: where every listed recording already holds the channel it + goes from each, and otherwise it reaches the ones standing without it, so one press always + leaves the list agreeing. + """ + gathering = self._state.gathering.toggled_throughout(CHANNEL_SLOT, channel_name) + self._settle(self._state.with_gathering(gathering)) + 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) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 75fd36273..9da62471c 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -14,7 +14,10 @@ SettingsSlot, ) from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel -from sampletones_application.view_model.main.reconstructor import SettingsSlotViewModel +from sampletones_application.view_model.main.reconstructor 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 @@ -63,20 +66,20 @@ def compose_view( def settings_slots(state: ConverterState) -> Tuple[SettingsSlotViewModel, ...]: - """The choices the settings card edits, read from what the card is inspecting. + """The choices the settings card edits, read through the recordings the picked row stands for. - A picked row is read through the recordings it stands for; with none picked the card edits the - settings a recording joins the list with, which is what every new row starts from. + 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: a picked row's recordings, or the joining settings.""" + """The settings the card is editing, read from the recordings the picked row stands for.""" selected = state.selected if selected is None: - return (state.settings.joining,) + return () row = state.gathering.sources.row(selected) if row is None: @@ -85,13 +88,21 @@ def inspected_settings(state: ConverterState) -> Tuple[StemSettings, ...]: return tuple(recording.settings for recording in row.recordings) -def inspected_name(state: ConverterState) -> Optional[str]: - """What the card is editing, where a reader picked a row out of the list.""" +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 - return selected.path.name if selected.names_folder else selected.path.stem + 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( diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 5213a0710..65c8c15fd 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -156,6 +156,20 @@ def toggled( held = self.agreement(key, slot, channel_name).settles_to return self.settled(key, slot, channel_name, held) + def toggled_throughout( + self, + slot: SettingsSlot, + channel_name: ChannelName, + ) -> Self: + """The list one gesture reaching every row leaves behind. + + The whole list reads as one group: where every recording already makes the choice it goes + from each, and otherwise it reaches the ones standing without it, so one gesture always + leaves the list agreeing on that channel. + """ + held = Agreement.over(slot.holds(recording.settings, channel_name) for recording in self.recordings).settles_to + return replace(self, rows=tuple(self._throughout(slot, channel_name, held))) + def agreement( self, key: SourceKey, @@ -227,6 +241,23 @@ def _rewritten_rows( ) -> Tuple[SourceRow, ...]: return self._rows_with(key, lambda settings: slot.write(settings, channels)) + def _throughout( + self, + slot: SettingsSlot, + channel_name: ChannelName, + held: bool, + ) -> Tuple[SourceRow, ...]: + """Every row with ``channel_name`` settled the one way, folders carrying it to what they hold.""" + rows: Tuple[SourceRow, ...] = () + for row in self.rows: + changed = tuple( + recording.with_settings(slot.settled(recording.settings, channel_name, held)) + for recording in row.recordings + ) + rows += (Folder(root=row.key.path, recordings=changed),) if row.key.names_folder else changed + + return rows + def _rows_with( self, key: SourceKey, diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index d5d347363..be45de4cf 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -244,6 +244,24 @@ Widget.TEXT, "inspecting", ) +TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED = TagName( + Page.MAIN, + Panel.RECONSTRUCTOR, + Widget.TEXT, + "unpicked", +) +TAG_MAIN_RECONSTRUCTOR_GROUP_GRID = TagName( + Page.MAIN, + Panel.RECONSTRUCTOR, + Widget.GROUP, + "grid", +) +TAG_MAIN_RECONSTRUCTOR_TABLE_GRID = TagName( + Page.MAIN, + Panel.RECONSTRUCTOR, + Widget.TABLE, + "grid", +) TAG_MAIN_CONVERTER_GROUP_CONTROLS = TagName( Page.MAIN, Panel.CONVERTER, diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 0f1cebe28..53a80d839 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -110,7 +110,7 @@ def repaint( 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 is not Agreement.NONE) + 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) @@ -222,7 +222,7 @@ def _create_channel( with dpg.group(horizontal=True, indent=columns.box_indent(channel_name)): dpg.add_checkbox( tag=checkbox_tag, - default_value=row.agreement_on(channel_name) is not Agreement.NONE, + default_value=row.agreement_on(channel_name).reads_held, user_data=(row.key, channel_name), callback=self._gestures.on_channel_box, ) diff --git a/src/sampletones_application/ui/panels/main/reconstructor/__init__.py b/src/sampletones_application/ui/panels/main/reconstructor/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/main/reconstructor/grid.py b/src/sampletones_application/ui/panels/main/reconstructor/grid.py new file mode 100644 index 000000000..eb799b041 --- /dev/null +++ b/src/sampletones_application/ui/panels/main/reconstructor/grid.py @@ -0,0 +1,183 @@ +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_RECONSTRUCTOR_SLOT, + TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, + TAG_MAIN_RECONSTRUCTOR_TABLE_GRID, +) +from sampletones_application.ui.elements.stems.columns import NO_RESERVE, 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.reconstructor import ( + ReconstructorPanelViewModel, + SettingsSlotViewModel, +) +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_RECONSTRUCTOR_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, + reserve=NO_RESERVE, + ) + + 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_RECONSTRUCTOR_GROUP_GRID + + def create(self, view_model: ReconstructorPanelViewModel) -> None: + """Build the channel names and the one row of boxes standing under them.""" + with dpg.group(tag=TAG_MAIN_RECONSTRUCTOR_GROUP_GRID): + self._heading.create(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, self._columns) + self._heading.render(NO_MUTED_CHANNELS) + with dpg.table( + tag=TAG_MAIN_RECONSTRUCTOR_TABLE_GRID, + header_row=False, + policy=dpg.mvTable_SizingFixedFit, + resizable=False, + borders_innerV=True, + ): + self._columns.declare() + self._create_row(view_model) + + def render(self, view_model: ReconstructorPanelViewModel) -> 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: ReconstructorPanelViewModel) -> 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: ReconstructorPanelViewModel) -> 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_RECONSTRUCTOR_SLOT, field.value, channel_name.value) diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor/panel.py similarity index 54% rename from src/sampletones_application/ui/panels/main/reconstructor.py rename to src/sampletones_application/ui/panels/main/reconstructor/panel.py index 5ae685a85..41d79108e 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor/panel.py @@ -1,51 +1,50 @@ -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Any, Callable, Optional import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label 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.reconstructor import ReconstructorLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_HANDLER_REGISTRY +from sampletones_application.tags.general import ( + SUF_HANDLER_REGISTRY, + TAG_GLOBAL_THEME_SECTION_HEADER, +) from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_SLOT, TAG_MAIN_RECONSTRUCTOR_PANEL, TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, + TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, ) -from sampletones_application.ui.elements.field import labeled_field, subheader +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.themes.channels import ( - CHANNEL_THEME_TAGS, - PARTIAL_CHANNEL_THEME_TAGS, -) +from sampletones_application.ui.panels.main.reconstructor.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.reconstructor import ( + InspectedSourceViewModel, ReconstructorPanelViewModel, - SettingsSlotViewModel, ) from sampletones_application.view_model.main.updates import GenerationSettingsUpdate -from sampletones_application.view_model.shared.agreement import Agreement 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): - """The settings card: the per-recording choices a reader edits, and the drive the run holds to. + """The settings card: the drive a run holds to, and the choices the picked row is given. - The card is drawn from the slots the model declares rather than from a control per field, so a - further choice reaches the screen as one more slot and one more line naming it. What it edits - is whatever the list has picked out; with nothing picked it edits the settings a recording - joins the list with. + 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__( @@ -54,6 +53,7 @@ def __init__( *, layout: ReconstructorLayout, inputs: InputsLayout, + stems_layout: StemsListLayout, language_manager: LanguageManager, status_bar: GUIStatusBar, initial_collapsed: bool = False, @@ -64,15 +64,14 @@ def __init__( 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.reconstructor.message.nothing_picked"] + self._tpl_folder = language_manager["global.stems.template.folder_row"] + self._item_handler_tag = compose_tag(TAG_MAIN_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) + self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None self.on_slot_toggled: Optional[Callable[[SettingsField, ChannelName], None]] = None - self._slot_labels: Dict[SettingsField, str] = { - SettingsField.CHANNELS: language_manager["main.reconstructor.label.slot_channels"], - SettingsField.BENDS: language_manager["main.reconstructor.label.slot_bends"], - } - self._msg_joining = language_manager["main.reconstructor.message.inspecting_joining"] - self._tpl_inspecting = language_manager["main.reconstructor.template.inspecting_row"] - self._item_handler_tag = compose_tag(TAG_MAIN_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) + self.on_channel_keyed: Optional[Callable[[ChannelName], None]] = None super().__init__( tag=TAG_MAIN_RECONSTRUCTOR_PANEL, @@ -88,14 +87,34 @@ def create_panel(self, parent: str) -> None: glyph=self._glyphs.headers.reconstruction, width=self.width, ): - self._create_subject_line() - for slot in self._view.slots: - self._create_slot(slot) - - dpg.add_separator() 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: ReconstructorPanelViewModel) -> 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_RECONSTRUCTOR_SLIDER_DRIVE, view_model.drive) + dpg_set_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, self._subject_text(view_model.inspected)) + dpg_configure_item(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, show=view_model.inspecting) + dpg_configure_item(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, show=not view_model.inspecting) + dpg_configure_item(self._grid.tag, show=view_model.inspecting) + self._grid.render(view_model) + + def toggle_channel(self, channel: ChannelName) -> None: + """Switches one channel across the whole list, which is what its key reaches. + + A box on the card answers for the row a reader picked out; the key answers for the list, + so setting a channel on everything at once is one press rather than a row at a time. + """ + self.call(self.on_channel_keyed, channel) + 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) @@ -103,44 +122,27 @@ def _setup_handlers(self) -> None: dpg.add_item_edited_handler(callback=self._on_parameter_change) def _create_subject_line(self) -> None: - """What the card is editing, which the list settles by what a reader picks out of it.""" - text = dpg.add_text(self._subject_text(), tag=TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING) - FontRegistry.bind_to_item(text, Font.REGULAR_SMALL) - - def _create_slot(self, slot: SettingsSlotViewModel) -> None: - """One choice: its name, and a box on every channel it is put to a reader on.""" - subheader(self._slot_labels[slot.field]) - with dpg.group(tag=self._slot_tag(slot.field)): - for channel_name in ChannelName.items(): - self._create_slot_box(slot, channel_name) - - def _create_slot_box(self, slot: SettingsSlotViewModel, channel_name: ChannelName) -> None: - checkbox_tag = self._slot_checkbox_tag(slot.field, channel_name) - dpg.add_checkbox( - label=channel_label(self._language_manager, channel_name), - default_value=slot.agreement_on(channel_name) is not Agreement.NONE, - tag=checkbox_tag, - show=slot.offers(channel_name), - user_data=(slot.field, channel_name), - callback=self._on_slot_box, + """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_RECONSTRUCTOR_TEXT_INSPECTING, ) - ThemeRegistry.get(CHANNEL_THEME_TAGS[channel_name]).bind_to_item(checkbox_tag) + FontRegistry.bind_to_item(text, Font.BOLD) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(text) - def _subject_text(self) -> str: - inspected = self._view.inspected + 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_RECONSTRUCTOR_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 self._msg_joining + return "" - return self._tpl_inspecting.format(name=inspected) + if inspected.stands_for_a_folder: + return self._tpl_folder.format(name=inspected.name, count=inspected.holds) - def _on_slot_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) + return inspected.name def _create_drive_slider(self) -> None: with labeled_field(self._language_manager["main.reconstructor.label.slider_drive"], self._label_width): @@ -172,13 +174,8 @@ def _create_tooltips(self) -> None: self._language_manager["main.reconstructor.tooltip.tooltip_drive"], ) - def toggle_channel(self, channel: ChannelName) -> None: - """Switches one channel in or out of what the card is editing. - - This is the gesture a click on the channel's box makes, reached by the key the channel - answers to, so the panel reports the same choice either way. - """ - self.call(self.on_slot_toggled, SettingsField.CHANNELS, channel) + 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() @@ -188,35 +185,3 @@ def _report_generation_settings(self) -> None: drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)), ) 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) - dpg_set_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, self._subject_text()) - for slot in view_model.slots: - self._render_slot(slot) - - def _render_slot(self, slot: SettingsSlotViewModel) -> None: - """Draw what the choice currently stands at onto the boxes it already has.""" - for channel_name in ChannelName.items(): - checkbox_tag = self._slot_checkbox_tag(slot.field, channel_name) - agreement = slot.agreement_on(channel_name) - dpg_configure_item(checkbox_tag, show=slot.offers(channel_name)) - dpg_set_value(checkbox_tag, agreement is not Agreement.NONE) - ThemeRegistry.get(self._box_theme(channel_name, agreement)).bind_to_item(checkbox_tag) - - @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 _slot_tag(field: SettingsField) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value) - - @staticmethod - def _slot_checkbox_tag(field: SettingsField, channel: ChannelName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value, channel.value) diff --git a/src/sampletones_application/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/reconstructor.py index 41b542c68..ea744f26d 100644 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ b/src/sampletones_application/view_model/main/reconstructor.py @@ -2,7 +2,7 @@ from pydantic import BaseModel -from sampletones_application.constants.sources import SettingsField +from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.view_model.shared.agreement import Agreement from sampletones_core.constants.enums import ChannelName @@ -32,22 +32,38 @@ def agreement_on(self, channel_name: ChannelName) -> Agreement: 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 ReconstructorPanelViewModel(BaseModel, frozen=True): - """What the settings card shows: the choices it edits, and what it is editing them on. + """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 list; with none picked the card edits - the settings a recording joins the list with, which is what every new row starts from. + ``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[str] + inspected: Optional[InspectedSourceViewModel] drive: float + live: bool @property - def channels(self) -> FrozenSet[ChannelName]: - """The channels the run hands out, which is the first slot's own reading.""" - for slot in self.slots: - if slot.field is SettingsField.CHANNELS: - return slot.held_channels - - return frozenset() + 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/shared/agreement.py b/src/sampletones_application/view_model/shared/agreement.py index 06e305dc5..4f07cad42 100644 --- a/src/sampletones_application/view_model/shared/agreement.py +++ b/src/sampletones_application/view_model/shared/agreement.py @@ -33,3 +33,12 @@ def settles_to(self) -> bool: 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_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 1629c31f5..9ac2a55f1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -348,11 +348,8 @@ main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for # ============================================================================= # Main tab — Reconstructor panel # ============================================================================= -main.reconstructor.label.slot_channels: "Channels" -main.reconstructor.label.slot_bends: "Bends" -main.reconstructor.message.inspecting_joining: "Editing what every recording joins the list with." -main.reconstructor.template.inspecting_row: "Editing {name}." -main.reconstructor.label.section_settings: "Reconstructor settings" +main.reconstructor.message.nothing_picked: "Pick a recording or a folder in the Converter to set what it takes." +main.reconstructor.label.section_settings: "Reconstruction 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." diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 73f91ea04..8ac74eec2 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -8,6 +8,7 @@ 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 @@ -658,7 +659,9 @@ def test_a_folder_its_recordings_agree_on_reads_as_held( converter_logic: ConverterLogic, tmp_path: Path, ) -> None: - self._folder(converter_logic, tmp_path, ["a.wav", "b.wav"]) + """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] diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 89779e42d..db21b841c 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -14,6 +14,7 @@ from sampletones_application.constants.output import OutputKind from sampletones_application.constants.sources import SettingsField 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, @@ -23,13 +24,16 @@ TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.tags.main import ( + PRE_MAIN_RECONSTRUCTOR_SLOT, TAG_MAIN_CONVERTER_GROUP_CONTROLS, TAG_MAIN_CONVERTER_GROUP_ORDER, TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, + TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, + TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, + TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, ) from sampletones_application.ui.elements.stems.list import GUIStemsList -from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, @@ -372,12 +376,25 @@ 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_channel_a_recording_joins_with(self, app: Application) -> None: - joining = app.session_manager.converter_settings.channel_set + def test_the_main_tab_switches_the_channel_across_the_whole_list( + self, + app: Application, + tmp_path: Path, + ) -> None: + """The key is the gesture that answers for everything listed, a row at a time being the box.""" + 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) + held = ChannelName.TRIANGLE in _row_of(app, paths[0]).channels self._press(app, ChannelName.TRIANGLE, Tab.MAIN) - assert app.session_manager.converter_settings.channel_set == joining ^ {ChannelName.TRIANGLE} + for path in paths: + assert (ChannelName.TRIANGLE in _row_of(app, path).channels) is not held def test_the_sequencer_switches_its_mix(self, app: Application) -> None: self._press(app, ChannelName.NOISE, Tab.SEQUENCER) @@ -440,7 +457,7 @@ def _click_row(app: Application, path: Path) -> None: 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 = GUIReconstructorPanel._slot_checkbox_tag(field, channel_name) + box = compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value, channel_name.value) dpg.get_item_callback(box)(box, True, dpg.get_item_user_data(box)) @@ -632,17 +649,20 @@ def test_a_recording_inside_an_open_folder_is_what_the_card_edits( assert ChannelName.PULSE2 in _row_of(app, second).channels assert ChannelName.PULSE2 not in _row_of(app, first).channels - def test_the_card_edits_what_a_recording_joins_with_where_nothing_is_picked( - self, - app: Application, - tmp_path: Path, - ) -> None: - app._main_tab._converter_logic.set_output(OutputKind.MIXED) + 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"]) - _click_slot_box(SettingsField.CHANNELS, ChannelName.PULSE2) - joined = self._gather(app, tmp_path, ["a.wav"])[0] + assert dpg.get_item_configuration(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED)["show"] is True + assert dpg.get_item_configuration(TAG_MAIN_RECONSTRUCTOR_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 ChannelName.PULSE2 in _row_of(app, joined).channels + assert dpg.get_item_configuration(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID)["show"] is True + assert dpg.get_value(TAG_MAIN_RECONSTRUCTOR_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.""" 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 a7aaec207..ea2e3e144 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -205,11 +205,12 @@ def test_a_channel_every_recording_holds_reads_ticked(self, dpg_context: None, l assert dpg.get_value(channel_tag(sources, ChannelName.PULSE1)) is True - def test_a_channel_they_differ_on_reads_ticked_in_the_softer_tone( + def test_a_channel_they_differ_on_reads_clear_in_the_softer_tone( self, dpg_context: None, layout_config, ) -> 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", @@ -220,7 +221,7 @@ def test_a_channel_they_differ_on_reads_ticked_in_the_softer_tone( stems_list.update_view(view(sources)) box = channel_tag(sources, ChannelName.PULSE1) - assert dpg.get_value(box) is True + 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) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py index 13928c7e5..e72306a5a 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py @@ -1,78 +1,319 @@ -from typing import List, Tuple +from typing import FrozenSet, Iterator, List, Optional, Tuple +import dearpygui.dearpygui as dpg import pytest -from sampletones_application.constants.sources import SettingsField -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 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_TEXT +from sampletones_application.tags.main import ( + PRE_MAIN_RECONSTRUCTOR_SLOT, + TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, + TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, + TAG_MAIN_RECONSTRUCTOR_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.reconstructor.panel import GUIReconstructorPanel +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.reconstructor import ( + InspectedSourceViewModel, + ReconstructorPanelViewModel, + SettingsSlotViewModel, +) +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName +ROOT_TAG = "test_root" DRIVE = 1.5 +HELD_RECORDINGS = 1939 -class Harness: - """The panel over the gestures it reports, without a window to hold its widgets.""" +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) - def __init__(self, monkeypatch: pytest.MonkeyPatch) -> None: - self.toggled: List[Tuple[SettingsField, ChannelName]] = [] - self.reported: List[GenerationSettingsUpdate] = [] - monkeypatch.setattr(reconstructor_module, "clamp_widget_value", self._drive) +@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() - self.panel = GUIReconstructorPanel.__new__(GUIReconstructorPanel) - self.panel.on_slot_toggled = lambda field, channel: self.toggled.append((field, channel)) - 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 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, + ) -class TestTheChoicesTheCardReports: - """The card settles nothing itself: it names the choice a reader made and hands it on.""" +def view( + *slots: SettingsSlotViewModel, + inspected: Optional[InspectedSourceViewModel] = None, + live: bool = True, +) -> ReconstructorPanelViewModel: + return ReconstructorPanelViewModel( + slots=slots, + inspected=inspected, + drive=DRIVE, + live=live, + ) - @pytest.mark.parametrize("channel", list(ChannelName.items())) - def test_the_key_a_channel_answers_to_settles_its_own_choice( + +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: ReconstructorPanelViewModel, +) -> Tuple[GUIReconstructorPanel, List[Tuple[SettingsField, ChannelName]]]: + """The card as the application builds it, over the choices it reports.""" + panel = GUIReconstructorPanel( + initial, + layout=layout_config.tabs.main.reconstructor, + 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_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_TEXT_INSPECTING), 1) + drive = dpg.get_item_parent(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE) + + assert body.index(drive) < body.index(dpg.get_alias_id(TAG_MAIN_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_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_RECONSTRUCTOR_GROUP_GRID) + assert not shows(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED) + + +class TestTheGrid: + """The channels are named once above the row, and each cell holds the boxes it offers.""" + + 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_RECONSTRUCTOR_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, - channel: ChannelName, - monkeypatch: pytest.MonkeyPatch, + dpg_context: None, + layout_config: LayoutConfig, ) -> None: - harness = Harness(monkeypatch) + panel, _reported = build(layout_config, view()) - harness.panel.toggle_channel(channel) + 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 - assert harness.toggled == [(SettingsField.CHANNELS, channel)] - @pytest.mark.parametrize("field", list(SettingsField)) +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, - field: SettingsField, - monkeypatch: pytest.MonkeyPatch, + channel: ChannelName, + dpg_context: None, + layout_config: LayoutConfig, ) -> None: - harness = Harness(monkeypatch) + _panel, reported = build(layout_config, view(channels_slot(), inspected=recording("bass"))) - harness.panel._on_slot_box(None, True, (field, ChannelName.TRIANGLE)) + callback = dpg.get_item_callback(box_tag(SettingsField.CHANNELS, channel)) + callback(None, True, dpg.get_item_user_data(box_tag(SettingsField.CHANNELS, channel))) - assert harness.toggled == [(field, ChannelName.TRIANGLE)] + assert reported == [(SettingsField.CHANNELS, channel)] - def test_drive_reaches_the_configuration_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: - """Drive shapes every run, so it travels apart from the choices a row holds.""" - harness = Harness(monkeypatch) + @pytest.mark.parametrize("channel", list(ChannelName.items())) + def test_the_key_a_channel_answers_to_reaches_the_whole_list( + self, + channel: ChannelName, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A box answers for the picked row; the key answers for everything listed.""" + panel, _reported = build(layout_config, view(channels_slot(), inspected=recording("bass"))) + keyed: List[ChannelName] = [] + panel.on_channel_keyed = keyed.append - harness.panel._report_generation_settings() + panel.toggle_channel(channel) - assert harness.reported == [GenerationSettingsUpdate(drive=DRIVE)] + assert keyed == [channel] -class TestCheckboxTags: - def test_every_choice_and_channel_carries_a_tag_of_its_own(self) -> None: +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( - GUIReconstructorPanel._slot_checkbox_tag(field, channel) - for field in SettingsField + 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 From 2f1991f2173fa8cef9415ef5396bb9dca9c45818 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 14:45:09 +0200 Subject: [PATCH 028/130] Asked: which recordings to mix in the shape the card lists them --- docs/guide/interface.md | 9 +- src/sampletones_application/application.py | 4 + .../coordinators/tabs/main.py | 30 +++- .../logic/main/converter/logic.py | 36 +++-- .../logic/reconstruction/reconstruction.py | 1 + .../ui/elements/stems/bands.py | 6 +- .../ui/elements/stems/gestures.py | 5 + .../ui/elements/stems/list.py | 2 + .../ui/elements/stems/offer.py | 22 ++- .../ui/elements/stems/row.py | 32 ++++- .../ui/panels/dialogs/stem_selection.py | 122 ++++++++++------ .../view_model/main/converter.py | 1 + .../view_model/shared/stems.py | 30 ++++ .../coordinators/tabs/test_main.py | 50 ++++++- .../ui/elements/stems/test_folder.py | 1 + .../ui/elements/stems/test_list.py | 4 + .../ui/panels/dialogs/test_stem_selection.py | 136 ++++++++++++++---- .../panels/reconstruction/test_stems_panel.py | 1 + .../reconstruction/test_reconstruction.py | 1 + 19 files changed, 392 insertions(+), 101 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index b668a3f8f..181e83697 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -67,10 +67,11 @@ the ones a folder holds are written into a tree mirroring that folder. On **One from all**, they mix into a single reconstruction instead. A mix reaches eight recordings, so choosing it with a longer list asks which ones -to mix. The folders give up the recordings they stood for. The first eight arrive -ticked and every one is pickable, so swapping one for another is a click each; -the line above counts what you have picked, and **Add** settles the mix once the -pick fits. +to mix, and so does adding a folder that overflows what is left. The question +shows the same rows the card does — folders open onto what they hold, and one +click answers for a whole folder. The first eight arrive ticked and every one is +pickable, so swapping one for another is a click each; the line above counts what +you have picked, and **Add** settles the mix once the pick fits. While mixing, 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 diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 201ce49fb..14295e0e9 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -312,6 +312,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"], diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7a64c2dcc..e91404e09 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -457,12 +457,11 @@ def _request_output(self, output: OutputKind) -> None: 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. Every other switch takes effect straight away. """ - candidates = self._converter_logic.gathered_paths - if not output.mixes or len(candidates) <= MAX_STEM_SOURCES: + if not output.mixes or len(self._converter_logic.gathered_paths) <= MAX_STEM_SOURCES: self._converter_logic.set_output(output) return - self._stem_selection_window.open(candidates, MAX_STEM_SOURCES) + self._stem_selection_window.open(self._converter_logic.gathered_rows, MAX_STEM_SOURCES) def _can_add_stems(self) -> bool: """The converter is free to gather recordings into a stems conversion.""" @@ -476,12 +475,35 @@ def _on_file_add_requested(self, filepath: Path) -> None: self._converter_logic.gather_recordings([filepath]) def _on_directory_add_requested(self, directory_path: Path) -> None: - """Gathers a folder into the setup, standing for the recordings found below it.""" + """Gathers a folder into the setup, standing for the recordings found below it. + + A mix reaches a fixed number of recordings, so a folder overflowing it raises the same + question the output switch raises: which of what is now offered to mix. + """ if self._hooks.is_operation_active(): return + if self._mixing_beyond_room(directory_path): + return + self._converter_logic.gather_folder(directory_path) + def _mixing_beyond_room(self, directory_path: Path) -> bool: + """Whether the folder overflows the mix, which is a question rather than a gathering. + + Answering it settles the mix on what the reader picked, so the folder joins by the same + route a longer list does. + """ + if not self._converter_logic.mixes: + return False + + rows = self._converter_logic.rows_gathering(directory_path) + if sum(len(row.recordings) for row in rows) <= MAX_STEM_SOURCES: + return False + + self._stem_selection_window.open(rows, MAX_STEM_SOURCES) + return True + def _request_cancel_confirmation(self) -> None: self._dialogs.show_confirmation( TAG_MAIN_CONVERTER_DIALOG_CANCEL, diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index f6b3be585..507f5e4ce 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -137,9 +137,22 @@ def room_for_sources(self) -> int: @property def gathered_paths(self) -> Tuple[Path, ...]: - """Every gathered recording, which is what a reader picking a mix is offered.""" + """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_gathering(self, root: Path) -> Tuple[StemRowViewModel, ...]: + """The rows the list would stand as with ``root`` gathered, folders standing as folders. + + A mix reaches a fixed number of recordings, so a folder overflowing what is left is put to + a reader as the same question the output switch asks: which of these to mix. + """ + return stem_rows(self._gathering_folder(root), mixes=False) + @property def is_active(self) -> bool: """A conversion is occupying resources, from the request until it settles.""" @@ -174,16 +187,11 @@ def gather_folder(self, root: Path) -> None: 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. """ - recordings = [self._gathered(path) for path in top_level_audio_files(root)] - if not recordings: - return - if self.mixes: - self.gather_recordings([recording.path for recording in recordings]) + self.gather_recordings([recording.path for recording in self._folder_recordings(root)]) return - folder = Folder(root=root, recordings=tuple(recordings)) - self._settle(self._state.with_gathering(self._state.gathering.listing_folder(folder))) + self._settle(self._state.with_gathering(self._gathering_folder(root))) def convert_path(self, path: Path) -> None: """Converts exactly what the reader named, which is what a Reconstruct asks for. @@ -402,6 +410,18 @@ 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 _folder_recordings(self, root: Path) -> Tuple[Recording, ...]: + """The recordings a folder brings in, each joining with the settings a new row starts from.""" + return tuple(self._gathered(path) for path in top_level_audio_files(root)) + + def _gathering_folder(self, root: Path) -> Gathering: + """The setup with ``root`` standing as one row, or as it stands where the folder is empty.""" + recordings = self._folder_recordings(root) + 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) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index edb8939ad..85a4e2b47 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -380,6 +380,7 @@ 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(), live=True, collapse_levels=False, selected_key=None, diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 811c61d20..222299196 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -82,10 +82,14 @@ 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. + 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) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 0c85172ea..e0eb185aa 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -54,6 +54,7 @@ def __init__( 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 @property def activatable(self) -> bool: @@ -130,6 +131,10 @@ def on_master_box(self, _sender: Sender, value: bool, user_data: str) -> None: 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) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 0dc3708c2..21fa66587 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -120,6 +120,7 @@ def __init__( 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._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) @@ -129,6 +130,7 @@ def __init__( 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 diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index e838be5cd..ae8a6ac1e 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -5,13 +5,17 @@ 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 and a reconstruction's recorded assignment 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. + 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 @@ -19,6 +23,7 @@ class StemsListOffer: keeps_last_row: bool dragging: bool bends: bool + picking: bool GATHERED_SOURCES: StemsListOffer = StemsListOffer( @@ -27,6 +32,7 @@ class StemsListOffer: keeps_last_row=False, dragging=True, bends=False, + picking=False, ) RECORDED_ASSIGNMENT: StemsListOffer = StemsListOffer( @@ -35,4 +41,14 @@ class StemsListOffer: keeps_last_row=True, dragging=False, bends=False, + picking=False, +) + +PICKED_SOURCES: 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 index 53a80d839..570a9cb15 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -80,7 +80,7 @@ def create( """Build the widgets one row stands as, in the columns its grid was declared with.""" with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)): if self._offer.master_box: - self._create_master(row) + self._create_master(row, view_model) self._create_name(row, view_model) for channel_name in view_model.channels_in_play: @@ -122,21 +122,39 @@ def repaint( if self._offer.master_box: master_tag = self._tags.row(row.key, SUF_CHECKBOX) - dpg_configure_item(master_tag, enabled=live and row.offers_channels) - dpg_set_value(master_tag, row.takes_part) + dpg_configure_item(master_tag, enabled=live and (self._offer.picking or row.offers_channels)) + 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 releasable) - def _create_master(self, row: StemRowViewModel) -> None: - """The box moving every channel the row offers at once.""" + def _create_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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), - default_value=row.takes_part, + default_value=self._master_value(row, view_model), user_data=row.key, - callback=self._gestures.on_master_box, + 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_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: + """Soften a picking box where the folder it stands for is picked only in part.""" + if not self._offer.picking: + return + + agreement = view_model.picking_of(row) + theme = TAG_GLOBAL_THEME_STEMS_ROW_INERT if agreement is Agreement.SOME else TAG_GLOBAL_THEME_STEMS_ROW + ThemeRegistry.get(theme).bind_to_item(self._tags.row(row.key, SUF_CHECKBOX)) def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: """The row itself: what names the source, what you drag it by, and what you drop onto. diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index 50f6edebf..6d947f22c 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,28 +17,44 @@ ) 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, +) ADD_FOCUS_STOP: Final[int] = 1 +NO_PICK: Final[int] = 0 class GUIStemSelectionWindow(GUIDialogWindow): - """A modal offering the recordings gathered, with as many as a mix holds 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 list can hold more recordings than one mix has room for, so the reader is shown everything - gathered and picks which of it to mix: any recording is pickable, whichever ones arrived - ticked, so swapping the eighth for the ninth is one gesture. The line above reads what stands - picked against the room, and the mix is settled once the pick fits. + 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. """ def __init__( self, *, layout: ConverterLayout, + stems_layout: StemsListLayout, + glyphs: CommonGlyphs, + language_manager: LanguageManager, + status_bar: GUIStatusBar, title: str, message: str, limit_template: str, @@ -50,9 +68,19 @@ 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, + glyphs=glyphs, + language_manager=language_manager, + status_bar=status_bar, + offer=PICKED_SOURCES, + ) + self._list.on_row_picked = self._on_picked self.on_add: Optional[Callable[[List[Path]], None]] = None @@ -64,14 +92,15 @@ 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) -> None: + """Shows the rows gathered, picking as many recordings as the mix has room for.""" + self._rows = tuple(rows) self._room = room + 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): @@ -84,11 +113,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), @@ -98,16 +128,6 @@ def create_window(self) -> None: initial_index=ADD_FOCUS_STOP, ) - def _create_candidate_rows(self) -> None: - """A box per recording gathered, the first ones the mix has room for arriving ticked.""" - for index, candidate in enumerate(self._candidates): - dpg.add_checkbox( - label=candidate.name, - tag=self._candidate_tag(candidate), - default_value=index < self._room, - callback=self._on_picked, - ) - @table_wrapper(columns=2) def _create_action_buttons(self) -> None: GUIButton( @@ -124,39 +144,51 @@ def _create_action_buttons(self) -> None: 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, + 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( - picked=len(self._selected()), - total=len(self._candidates), + picked=len(self._picked), + total=len(self._view().recordings), room=self._room, ) - def _on_picked(self, *_args: Any, **_kwargs: Any) -> None: - """Follow what stands picked: what the line reads, and whether the mix can be settled.""" - 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 _on_picked(self, key: str) -> None: + """Picks the recordings a row stands for, or lets them go where they all stand picked.""" + view_model = self._view() + row = view_model.row(key) + if row is None: + return + + keys = frozenset(recording.key for recording in row.recordings) + self._picked = self._picked | keys if view_model.picking_of(row).settles_to else self._picked - keys + 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.""" - picked = len(self._selected()) - return 0 < picked <= 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)) - ] + return NO_PICK < len(self._picked) <= self._room def _add(self) -> None: if not self._fits: return - selected = self._selected() + picked = list(self._view().picked_paths) self.hide() - self.call(self.on_add, selected) - - @staticmethod - def _candidate_tag(candidate: Path) -> str: - return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate)) + self.call(self.on_add, picked) diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index ee8af0ed8..a08735796 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -127,6 +127,7 @@ def stems_list(self) -> StemsListViewModel: rows=self.stem_sources, channels_in_play=self.channels_in_play, muted_channels=frozenset(), + picked_keys=frozenset(), live=not self.is_active, collapse_levels=not self.mixes, selected_key=self.selected_key, diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index e1c63b9b1..792b20c15 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -46,6 +46,15 @@ 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 name(self) -> str: """The source's own name, which is what the row reads as.""" @@ -106,11 +115,13 @@ class StemsListViewModel(BaseModel, frozen=True): 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. """ rows: Tuple[StemRowViewModel, ...] channels_in_play: Tuple[ChannelName, ...] muted_channels: FrozenSet[ChannelName] + picked_keys: FrozenSet[str] live: bool collapse_levels: bool selected_key: Optional[str] @@ -122,6 +133,7 @@ def empty(cls) -> Self: rows=(), channels_in_play=(), muted_channels=frozenset(), + picked_keys=frozenset(), live=True, collapse_levels=False, selected_key=None, @@ -149,6 +161,24 @@ 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 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) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index e5f5e81d8..8ef6f59c3 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -192,6 +192,7 @@ def _stems_coordinator( operation_active: bool = False, mixes: bool = True, gathered: Tuple[Path, ...] = (), + folder_rows: Tuple[MagicMock, ...] = (), room: int = MAX_STEM_SOURCES, ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) @@ -204,6 +205,7 @@ def _stems_coordinator( coordinator._converter_logic.gathered_paths = gathered coordinator._converter_logic.source_count = len(gathered) coordinator._converter_logic.room_for_sources = room + coordinator._converter_logic.rows_gathering.return_value = folder_rows coordinator._stem_selection_window = MagicMock() return coordinator @@ -234,8 +236,8 @@ def test_a_list_longer_than_a_mix_holds_asks_which_to_mix(self) -> None: coordinator._request_output(OutputKind.MIXED) coordinator._converter_logic.set_output.assert_not_called() - candidates, room = coordinator._stem_selection_window.open.call_args.args - assert candidates == gathered + rows, room = coordinator._stem_selection_window.open.call_args.args + assert rows == coordinator._converter_logic.gathered_rows assert room == MAX_STEM_SOURCES def test_a_list_a_mix_holds_takes_effect_at_once(self) -> None: @@ -374,3 +376,47 @@ def test_declining_converts_nothing(self, tmp_path: Path) -> None: 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)) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.gather_folder.assert_called_once_with(tmp_path) + 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)) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.gather_folder.assert_called_once_with(tmp_path) + 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) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.gather_folder.assert_not_called() + offered, room = coordinator._stem_selection_window.open.call_args.args + assert offered == rows + assert room == MAX_STEM_SOURCES + + 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)) + + coordinator._on_directory_add_requested(tmp_path) + + coordinator._converter_logic.gather_folder.assert_not_called() + coordinator._stem_selection_window.open.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 72dd9599b..6df241d8c 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -113,6 +113,7 @@ def view(*rows: StemRowViewModel) -> StemsListViewModel: rows=rows, channels_in_play=CHANNELS, muted_channels=frozenset(), + picked_keys=frozenset(), live=True, collapse_levels=True, selected_key=None, 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 ea2e3e144..a1bba18ca 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -81,6 +81,7 @@ def build( keeps_last_row: bool = False, master_box: bool = False, bends: bool = False, + picking: bool = False, ) -> GUIStemsList: stems_list = GUIStemsList( prefix=PREFIX, @@ -94,6 +95,7 @@ def build( keeps_last_row=keeps_last_row, dragging=dragging, bends=bends, + picking=picking, ), ) with dpg.window(tag=ROOT_TAG): @@ -134,6 +136,7 @@ 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, ) -> StemsListViewModel: @@ -142,6 +145,7 @@ def view( rows=rows, channels_in_play=CHANNELS, muted_channels=muted_channels, + picked_keys=picked_keys, live=live, collapse_levels=collapse_levels, ) 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 index 0319c71e5..29acf7065 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -6,17 +6,21 @@ 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 +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_ROW 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.panels.dialogs.stem_selection import 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.shortcuts import shipped_source @@ -28,6 +32,10 @@ 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"], @@ -38,24 +46,63 @@ def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIStemSel ) -def candidates(count: int = GATHERED) -> List[Path]: +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(), + 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(), + 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 render(window: GUIStemSelectionWindow, offered: Sequence[Path]) -> None: +def candidates(count: int = GATHERED) -> List[StemRowViewModel]: + return [recording_row(path) for path in paths(count)] + + +def render(window: GUIStemSelectionWindow, offered: Sequence[StemRowViewModel]) -> None: """Builds the widget tree for what was gathered, the way ``open`` does without a live frame.""" window.open(offered, MAX_STEM_SOURCES) -def box_of(candidate: Path) -> str: - return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, str(candidate)) +def box_of(row: StemRowViewModel) -> str: + return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, SUF_ROW, row.key, SUF_CHECKBOX) -def pick(candidate: Path, *, picked: bool) -> None: - """Tick or untick one recording the way DearPyGui reports a checkbox.""" - tag = box_of(candidate) - dpg.set_value(tag, picked) - dpg.get_item_callback(tag)(tag, picked, dpg.get_item_user_data(tag)) +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 add_enabled() -> bool: @@ -68,20 +115,18 @@ class TestWhatIsOffered(BaseTestSuite): def test_every_recording_gathered_gets_a_box(self, window: GUIStemSelectionWindow) -> None: offered = candidates() render(window, offered) - for candidate in offered: - assert dpg.does_item_exist(box_of(candidate)) + 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(candidate)) for candidate in offered[:MAX_STEM_SOURCES]] == [ - True - ] * MAX_STEM_SOURCES + 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(candidate)) for candidate in offered[MAX_STEM_SOURCES:]) + assert not any(dpg.get_value(box_of(row)) for row in offered[MAX_STEM_SOURCES:]) def test_a_recording_past_the_limit_is_pickable(self, window: GUIStemSelectionWindow) -> None: """Swapping which recordings the mix is built from is what the question is for.""" @@ -90,7 +135,7 @@ def test_a_recording_past_the_limit_is_pickable(self, window: GUIStemSelectionWi beyond = offered[MAX_STEM_SOURCES] assert dpg.get_item_configuration(box_of(beyond))["enabled"] is True - pick(beyond, picked=True) + pick(beyond) assert dpg.get_value(box_of(beyond)) is True @@ -106,7 +151,7 @@ def test_a_pick_that_fits_settles(self, window: GUIStemSelectionWindow) -> None: render(window, offered) dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() - assert answered == [offered[:MAX_STEM_SOURCES]] + assert answered == [paths()[:MAX_STEM_SOURCES]] def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelectionWindow) -> None: offered = candidates() @@ -114,11 +159,11 @@ def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelecti window.on_add = answered.append render(window, offered) - pick(offered[0], picked=False) - pick(offered[MAX_STEM_SOURCES], picked=True) + pick(offered[0]) + pick(offered[MAX_STEM_SOURCES]) dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() - assert answered == [offered[1 : MAX_STEM_SOURCES + 1]] + assert answered == [paths()[1 : MAX_STEM_SOURCES + 1]] def test_a_pick_larger_than_a_mix_holds_waits(self, window: GUIStemSelectionWindow) -> None: offered = candidates() @@ -126,7 +171,7 @@ def test_a_pick_larger_than_a_mix_holds_waits(self, window: GUIStemSelectionWind window.on_add = answered.append render(window, offered) - pick(offered[MAX_STEM_SOURCES], picked=True) + pick(offered[MAX_STEM_SOURCES]) assert add_enabled() is False dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() @@ -135,18 +180,18 @@ def test_a_pick_larger_than_a_mix_holds_waits(self, window: GUIStemSelectionWind def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: offered = candidates() render(window, offered) - for candidate in offered[:MAX_STEM_SOURCES]: - pick(candidate, picked=False) + for row in offered[:MAX_STEM_SOURCES]: + pick(row) assert add_enabled() is False def test_letting_one_go_settles_again(self, window: GUIStemSelectionWindow) -> None: offered = candidates() render(window, offered) - pick(offered[MAX_STEM_SOURCES], picked=True) + pick(offered[MAX_STEM_SOURCES]) assert add_enabled() is False - pick(offered[0], picked=False) + pick(offered[0]) assert add_enabled() is True @@ -155,7 +200,7 @@ def test_the_line_reads_what_stands_picked(self, window: GUIStemSelectionWindow) render(window, offered) opening = dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) - pick(offered[MAX_STEM_SOURCES], picked=True) + pick(offered[MAX_STEM_SOURCES]) assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) != opening @@ -163,3 +208,40 @@ def test_a_list_a_mix_already_holds_arrives_settling(self, window: GUIStemSelect 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_what_it_holds_is_what_the_mix_takes(self, window: GUIStemSelectionWindow) -> None: + held = paths(3) + answered: List[List[Path]] = [] + window.on_add = answered.append + render(window, [folder_row(Path("/audio/takes"), held)]) + + dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() + + assert answered == [held] 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 d7cf7c2ff..ffa870199 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 @@ -122,6 +122,7 @@ def _view_model( rows=rows, channels_in_play=CHANNELS if rows else (), muted_channels=muted_channels, + picked_keys=frozenset(), live=True, collapse_levels=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 9d7eaf24d..cebaa7af5 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -23,6 +23,7 @@ rows=(), channels_in_play=(), muted_channels=frozenset(), + picked_keys=frozenset(), live=True, collapse_levels=False, ) From 5833bf0bb0cd37d589c9b82dcbad210281bef3a0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 15:13:42 +0200 Subject: [PATCH 029/130] Carried: the run's shape between launches and the whole tree below a folder --- docs/guide/interface.md | 5 +- .../config/managers/application.py | 26 +++++ .../config/managers/session.py | 26 +++++ .../config/session/application/converter.py | 41 ++++++- .../coordinators/tabs/instructions.py | 4 +- .../coordinators/tabs/main.py | 8 +- .../coordinators/tabs/reconstruction.py | 6 +- .../tabs/sequencer/coordinator.py | 6 +- .../logic/main/converter/logic.py | 36 +++++-- .../logic/shared/file_playback.py | 56 ++++++++++ .../logic/shared/tree.py | 49 ++------- .../converter/paths/__init__.py | 2 - .../reconstructions/converter/paths/utils.py | 15 --- .../logic/main/converter/test_logic.py | 101 +++++++++++++++++- .../logic/shared/test_file_playback.py | 89 +++++++++++++++ .../logic/shared/test_tree.py | 49 +-------- .../converter/paths/test_utils.py | 23 ---- 17 files changed, 388 insertions(+), 154 deletions(-) create mode 100644 src/sampletones_application/logic/shared/file_playback.py create mode 100644 tests/unit/sampletones_application/logic/shared/test_file_playback.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 181e83697..c3b2f27a6 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -39,7 +39,7 @@ you can rerun it to carry on where you stopped. The card holds a list of what a run converts. Click a recording in the browser to add it; right-click and choose **Add as stem**, or Ctrl-click, to do the same. Ctrl-click a folder — or use **Add folder as stems** — and the folder joins as -one row standing for the recordings inside it. +one row standing for every recording below it, however deep the tree goes. The channels are named once above the rows, and each row shows one recording and a checkbox under every channel it may use. Untick them all and the row grays out: @@ -85,7 +85,8 @@ filled before the next picks. **Channels per source**, below the list, caps how many channels one recording may hold in a single frame, and it applies to every conversion. Set to 1, each recording gets a single voice. It stands beside **Order** once there is a list to -answer for. +answer for. The output switch, the cap and the order are remembered, so the app +opens on the run you last set up. Reconstructing a file or a folder from the browser converts that one thing, so it asks first where you have already gathered a list. diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index b2112eb7c..999559875 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -2,9 +2,11 @@ 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 @@ -162,6 +164,30 @@ def converter_settings(self) -> StemSettings: 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/session.py b/src/sampletones_application/config/managers/session.py index 2a942e0ee..c33e712b2 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -7,9 +7,11 @@ 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 @@ -93,6 +95,30 @@ def converter_settings(self) -> StemSettings: 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/converter.py b/src/sampletones_application/config/session/application/converter.py index 128ec7a1a..5873ff725 100644 --- a/src/sampletones_application/config/session/application/converter.py +++ b/src/sampletones_application/config/session/application/converter.py @@ -1,6 +1,13 @@ from pydantic import BaseModel, ConfigDict, Field, field_serializer -from sampletones_core.constants.enums import DEFAULT_CHANNELS, bending_channels +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 @@ -13,11 +20,14 @@ def _starting_settings() -> StemSettings: class ConverterConfig(BaseModel): - """What the converter starts a recording from, carried between runs. + """How the converter opens, carried between runs. - A recording joins the conversion holding these settings, and the reader then says otherwise for - it alone. Keeping them as one value is what lets a further per-recording choice reach the + 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) @@ -27,7 +37,30 @@ class ConverterConfig(BaseModel): 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/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 e91404e09..193005460 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -15,6 +15,7 @@ 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.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 @@ -159,9 +160,10 @@ def _build_explorer( language_manager=language_manager, open_directories=session_manager.expanded_directories, ) + self._file_playback: FilePlayback = FilePlayback(audio_device_manager) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, - audio_device_manager, + self._file_playback, scheduling=layout.scheduling, ) self._explorer_panel: GUIExplorerPanel = GUIExplorerPanel( @@ -176,7 +178,7 @@ def _build_explorer( 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, @@ -333,7 +335,7 @@ def _wire_converter( 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_source_played = self._explorer_tree_logic.play_path + self._converter_panel.on_source_played = self._file_playback.play self._stem_selection_window.on_add = self._converter_logic.mix_only def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 4e8891363..df46e1f7c 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( diff --git a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 686f46146..41fd51796 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 diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 507f5e4ce..3480400f5 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -53,10 +53,9 @@ 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.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.converter import ConversionPlan -from sampletones_core.reconstructions.converter.paths import top_level_audio_files +from sampletones_core.reconstructions.converter.paths import get_audio_files from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger @@ -91,9 +90,9 @@ def __init__( self._state = ConverterState( settings=RunSettings( joining=session_manager.converter_settings, - output=OutputKind.PER_RECORDING, - channel_cap=len(ChannelName), - hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, + 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(), @@ -398,9 +397,7 @@ def _settings(self) -> RunSettings: def _settle_joining(self, joining: StemSettings) -> None: """Takes up the settings a recording joins the list with, and writes them down.""" - settings = self._settings.with_joining(joining) - self._session_manager.set_converter_settings(settings.joining) - self._settle(self._state.with_settings(settings)) + self._settle(self._state.with_settings(self._settings.with_joining(joining))) def _inspected_agreement(self, slot: SettingsSlot, channel_name: ChannelName) -> Agreement: """How the settings the card is editing read on ``channel_name`` in ``slot``.""" @@ -411,8 +408,12 @@ def _gathered(self, path: Path) -> Recording: return Recording(path=path, settings=self._joining_settings) def _folder_recordings(self, root: Path) -> Tuple[Recording, ...]: - """The recordings a folder brings in, each joining with the settings a new row starts from.""" - return tuple(self._gathered(path) for path in top_level_audio_files(root)) + """Every recording below a folder, each joining with the settings a new row starts from. + + The walk goes as deep as the folder does, so a folder of folders stands for what its whole + tree holds and a run writing one reconstruction apiece mirrors that tree. + """ + return tuple(self._gathered(path) for path in get_audio_files(root, sort=True)) def _gathering_folder(self, root: Path) -> Gathering: """The setup with ``root`` standing as one row, or as it stands where the folder is empty.""" @@ -449,12 +450,27 @@ def _settle(self, state: ConverterState) -> None: shows follows every gesture; a settled run returns to idle, since the setup it reported on is no longer the one on screen. """ + 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 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 7aaf3626f..bf7100d61 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -1,17 +1,12 @@ import threading -from pathlib import Path from typing import Callable, Optional from sampletones_application.config.managers.session import SessionManager from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.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 @@ -20,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 @@ -38,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: @@ -90,26 +84,16 @@ def cancel_autoplay(self) -> None: self._pending_autoplay_node = None def play_node(self, node: FileSystemNode) -> None: - """Play the file a browser node stands for, where it is one this logic can sound.""" + """Play the file a browser node stands for, where it is one the player can sound.""" if node.node_type == NodeType.FILE: - self.play_path(node.filepath) - - def play_path(self, path: Path) -> None: - """Play a file on demand, preempting the auxiliary preview and the players. - - The session autoplay flag holds for what a selection sounds on its own; asking for a file - by name is a deliberate action, so it plays at ``NORMAL`` priority and outranks the - reconstruction and sequencer players. - """ - self._play_file(path, PlaybackPriority.NORMAL) + 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: @@ -117,24 +101,9 @@ def _execute_autoplay(self) -> None: self._pending_autoplay_node = None def _autoplay_file(self, node: FileSystemNode) -> None: + """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._play_file(node.filepath, PlaybackPriority.PREVIEW) - - def _play_file(self, path: Path, priority: PlaybackPriority) -> None: - match path.suffix.lower(): - case extensions.EXT_FILE_RECONSTRUCTION: - try: - reconstruction = Reconstruction.load(path) - 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: {path}") - self.call(self.on_autoplay_error, exception) - case suffix if suffix in extensions.EXT_FILES_AUDIO: - self._audio_device_manager.play_file(path, update=False, priority=priority) + 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_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 973210975..bb5d6e2fb 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -9,7 +9,6 @@ get_relative_path, group_output_path, holds_audio_files, - top_level_audio_files, ) __all__ = [ @@ -21,5 +20,4 @@ "get_relative_path", "group_output_path", "holds_audio_files", - "top_level_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 291d1508b..e226c553c 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -109,21 +109,6 @@ def holds_audio_files( return any(path.is_file() and path.suffix.lower() in extensions for path in input_directory.rglob("*")) -def top_level_audio_files( - input_directory: Path, - extensions: Tuple[str, ...] = EXT_FILES_AUDIO, -) -> List[Path]: - """The audio files sitting directly in a directory, in name order. - - 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 - - def filter_files( audio_files: List[Path], base_directory: Path, diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 8ac74eec2..9f6012e0c 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -21,6 +21,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.converter import GroupConversion +from tests.suite.base import BaseTestSuite from tests.suite.language import FakeLanguageManager from tests.unit.sampletones_application.logic.main.converter.texts import TEXTS @@ -34,6 +35,14 @@ def _config_writing_under(reconstructions_directory: Path) -> Config: 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.""" @@ -60,9 +69,7 @@ def converter_logic( 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 + config_manager = _config_manager_writing_under(reconstructions_directory) scheduling = MagicMock( priorities=MagicMock(schedule=0), delays=MagicMock(schedule=0, cancel=0), @@ -783,3 +790,91 @@ def _aimed_at_a_recording(converter_logic: ConverterLogic, tmp_path: Path) -> Pa 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) + + 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) + + 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/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_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_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index a3d71b136..ad341fb65 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -11,7 +11,6 @@ get_output_path, get_relative_path, group_output_path, - top_level_audio_files, ) from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @@ -120,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) == [] From d0d5318bc7c70ddb46c9e1c752bed66ac484d939 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 15:40:16 +0200 Subject: [PATCH 030/130] Held: the converter card to a surface test and the bands to a mix of two --- docs/guide/interface.md | 6 +- .../ui/elements/stems/bands.py | 3 + .../ui/elements/stems/list.py | 6 + .../ui/elements/stems/messages.py | 22 +- .../view_model/main/converter.py | 8 +- src/sampletones_config/lang/en.yaml | 1 + .../sampletones_application/test_startup.py | 9 +- .../ui/panels/main/test_converter.py | 308 ++++++++++++++++++ 8 files changed, 353 insertions(+), 10 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/main/test_converter.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index c3b2f27a6..353069194 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -73,9 +73,9 @@ click answers for a whole folder. The first eight arrive ticked and every one is pickable, so swapping one for another is a click each; the line above counts what you have picked, and **Add** settles the mix once the pick fits. -While mixing, 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 +From the second recording of a mix, 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 diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 222299196..4be91f0e5 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -8,6 +8,7 @@ SUF_STRIP, SUF_TABLE, SUF_TEXT, + TAG_GLOBAL_THEME_SECTION_HEADER, TAG_GLOBAL_THEME_STEMS_DROP_STRIP, ) from sampletones_application.ui.elements.fonts.font import Font @@ -159,12 +160,14 @@ def _create_strip(self, position: int) -> None: 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.MONO_SMALL) + ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(caption) def _create_table( self, diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 21fa66587..f305ee277 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -75,6 +75,7 @@ def __init__( offer=offer, open_folders=self._open_folders, activatable=lambda: self.activatable, + playable=lambda: self.playable, ) self._gestures = StemsGestures(self._tags, messages=self._messages, status_bar=status_bar) self._rows = StemRowRenderer( @@ -148,6 +149,11 @@ def activatable(self) -> bool: """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" return self.on_row_activated is not None + @property + 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 + def create(self, parent: str, *, show: bool = True) -> None: """Build the list's recessed region and the handlers its rows share.""" self._gestures.create_handlers() diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py index 336e6e88b..d67b13c6c 100644 --- a/src/sampletones_application/ui/elements/stems/messages.py +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -26,11 +26,13 @@ def __init__( offer: StemsListOffer, open_folders: OpenFolders, activatable: Callable[[], bool], + playable: Callable[[], bool], ) -> None: self._language_manager = language_manager self._offer = offer self._open_folders = open_folders self._activatable = activatable + self._playable = playable self._view = StemsListViewModel.empty() self._msg_drag = language_manager["global.stems.message.drag_tooltip"] self._msg_inert = language_manager["global.stems.message.inert_tooltip"] @@ -61,6 +63,7 @@ def row_explanation(self, row: StemRowViewModel) -> str: 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 "" @@ -71,13 +74,24 @@ def name(self, *_args: Any, user_data: str, **_kwargs: Any) -> str: 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.dragging: - return self._language_manager["global.stems.message.status_row_drag"].format(name=row.name) + lines += (self._language_manager["global.stems.message.status_row_drag"].format(name=row.name),) + elif self._activatable(): + lines += (self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name),) - if self._activatable(): - return self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name) + if self._playable(): + lines += (self._language_manager["global.stems.message.status_row_play"].format(name=row.name),) - return row.name + return lines or (row.name,) def channel( self, diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index a08735796..8361e3ee3 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -122,14 +122,18 @@ def channels_in_play(self) -> Tuple[ChannelName, ...]: @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(), live=not self.is_active, - collapse_levels=not self.mixes, + collapse_levels=not self.mixes_several, selected_key=self.selected_key, ) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9ac2a55f1..82a854316 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -281,6 +281,7 @@ 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}." diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index db21b841c..1c9a73c5b 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -566,9 +566,16 @@ def test_a_level_draws_its_own_band(self, app: Application, tmp_path: Path) -> N ) 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).tags.row(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"]) 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..fe2afe806 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -0,0 +1,308 @@ +from pathlib import Path +from typing import Iterator, List, Optional, 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.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.panel import GUIConverterPanel +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.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 + +ROOT_TAG = "test_root" +LANGUAGE_MANAGER = LanguageManager(LANG_EN) +ACTION_LABEL = "Convert 2 recordings" +STATUS_TEXT = "No tasks in progress." + + +@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) -> 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(), + 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, +) -> 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=None, + ) + + +def build(layout_config: LayoutConfig) -> 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(), + ) + 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 + + +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) + + +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=Path("/audio/kick.wav"))) + + 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=Path("/audio/kick.wav"), + ) + ) + + assert shows(TAG_MAIN_CONVERTER_GROUP_INPUT) + assert str(panel.input_path_text.path) == "/audio/kick.wav" From b2821972d56658e47f3763dc884df19a0d651dd3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 17:43:17 +0200 Subject: [PATCH 031/130] Held: the gathering of a folder to the gesture that asks for it --- .../coordinators/tabs/main.py | 5 -- .../ui/panels/main/explorer.py | 11 ++-- .../sampletones_application/test_startup.py | 62 ++++++++++++++++++- 3 files changed, 67 insertions(+), 11 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 193005460..128870ed6 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -273,7 +273,6 @@ 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, @@ -363,10 +362,6 @@ def _on_wave_file_clicked(self, filepath: Path) -> None: if not self._hooks.is_operation_active(): self._converter_logic.gather_recordings([filepath]) - def _on_directory_clicked(self, directory_path: Path) -> None: - if not self._hooks.is_operation_active(): - self._converter_logic.gather_folder(directory_path) - def _request_reconstruct_file(self, filepath: Path) -> None: if self._notify_converter_running(): return diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 01f690bc4..4323cb8e0 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -93,7 +93,6 @@ def __init__( 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 @@ -349,10 +348,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 as stems** + 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 +365,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 diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 1c9a73c5b..7ac765ffb 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 from unittest.mock import PropertyMock, patch import dearpygui.dearpygui as dpg @@ -34,7 +34,9 @@ TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, ) from sampletones_application.ui.elements.stems.list import GUIStemsList +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, @@ -48,9 +50,11 @@ 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.structures.tree import FileSystemNode, NodeType 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", @@ -488,6 +492,62 @@ def _reports_running(app: Application, status_text: str, progress: float) -> Non app._main_tab._on_converter_view_changed(running) +class TestBrowserGathering: + """What a gesture in the browser gathers: a click opens a folder, and Ctrl brings it in. + + Reading every recording below a folder is work a reader asks for, so it answers the gathering + gesture alone. A plain click on a folder walks the browser and leaves the conversion as it is, + which is what keeps navigating into a large tree from gathering it. + """ + + @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.""" + 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) + + 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_it(self, app: Application, tmp_path: Path) -> None: + """A recording is one path, so naming it costs nothing and a click is enough.""" + directory = self._tree(tmp_path) + panel = app._main_tab._explorer_panel + recording = directory / "one.wav" + + with patch.object(explorer_module, "capture_modifiers", return_value=frozenset()): + panel._audio_node_clicked(FileSystemNode(recording.name, node_type=NodeType.FILE, filepath=recording)) + + assert app._main_tab._converter_logic.gathered_paths == (recording,) + + class TestConverterStemsCard: """Gathering recordings paints the converter card: a row each, carrying what the reader set.""" From 8e7796c1741592aa4c87e29a64556d87c4fdd2b5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 18:03:53 +0200 Subject: [PATCH 032/130] Moved: the reconstruction card below the list whose row it reads --- .../coordinators/tabs/main.py | 25 +++++--- src/sampletones_application/tags/main.py | 6 +- .../sampletones_application/test_startup.py | 61 ++++++++++++++++++- 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 128870ed6..6262af4fd 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -30,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, @@ -41,7 +42,6 @@ TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, TAG_MAIN_EXPLORER_PANEL, TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, ) from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns from sampletones_application.ui.elements.layout.responsive import expanded_side_width @@ -599,7 +599,12 @@ def create_tab(self) -> None: self._sync_explorer_width() 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, @@ -610,23 +615,23 @@ def _build_center(self, parent: str) -> None: build=self._config_panel.create_panel, ), ColumnSpec( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL_RECONSTRUCTOR_CELL, - build=self._reconstructor_panel.create_panel, + tag=TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, + build=self._advanced_settings_panel.create_panel, ), ], ) self._sync_config_row_height() dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) - self._advanced_settings_panel.create_panel(parent) - 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._reconstructor_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._reconstructor_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: @@ -658,13 +663,13 @@ 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 + both_collapsed = self._config_panel.collapsed and self._advanced_settings_panel.collapsed height = 0 if both_collapsed else self._config_height dpg_configure_item(TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, height=height) diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index be45de4cf..48d4b7f5a 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, diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 7ac765ffb..0a19db6de 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, FrozenSet, 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 @@ -25,11 +25,16 @@ ) from sampletones_application.tags.main import ( PRE_MAIN_RECONSTRUCTOR_SLOT, + TAG_MAIN_ADVANCED_PANEL, + TAG_MAIN_CONFIG_PANEL, + TAG_MAIN_CONFIG_TABLE_CONFIG_ROW, TAG_MAIN_CONVERTER_GROUP_CONTROLS, TAG_MAIN_CONVERTER_GROUP_ORDER, + TAG_MAIN_CONVERTER_PANEL, TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, + TAG_MAIN_RECONSTRUCTOR_PANEL, TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, ) @@ -492,6 +497,60 @@ def _reports_running(app: Application, status_text: str, progress: float) -> Non app._main_tab._on_converter_view_changed(running) +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_RECONSTRUCTOR_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 + + class TestBrowserGathering: """What a gesture in the browser gathers: a click opens a folder, and Ctrl brings it in. From eeaa5824d6cc38994f667198ddad5f46a8f522da Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 18:24:36 +0200 Subject: [PATCH 033/130] Held: a rebuilt region at the position the reader scrolled it to --- .../ui/elements/layout/region.py | 50 +++++++++++-- .../ui/elements/layout/well.py | 14 ++-- .../ui/elements/stems/folder.py | 19 +++-- .../ui/elements/stems/list.py | 4 +- .../ui/elements/stems/shape.py | 11 ++- .../ui/elements/layout/test_region.py | 72 +++++++++++++++++++ .../ui/elements/stems/test_folder.py | 51 ++++++++++++- 7 files changed, 202 insertions(+), 19 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index b1ab758f2..f9c9b364f 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -19,6 +19,8 @@ NO_ROWS: Final[Window] = (0, 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: @@ -42,6 +44,12 @@ class WindowedRegion: 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. + + Where the reader had scrolled to is held between a draw and the frame that renders it. A scroll + written while the rows it applies to are being rebuilt lands against the layout of the frame + before, so the region reads a position it never asked for and picks a different slice from it, + which asks for another rebuild. The offset is therefore remembered as the rows come down and + put back once the frame that placed them has been drawn. """ def __init__( @@ -52,12 +60,14 @@ def __init__( ceiling: int, padding: int, margin: int, + indent: Optional[int] = None, ) -> None: self._tag = tag self._geometry = geometry self._ceiling = ceiling self._padding = padding self._margin = margin + 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) @@ -68,6 +78,8 @@ def __init__( self._windowed = False self._natural = True self._drawn: Window = NO_ROWS + self._resting = NO_SCROLL + self._restoring = False @property def tag(self) -> str: @@ -116,6 +128,7 @@ def create(self, parent: str, *, show: bool = True) -> None: self._tag, padding=self._padding, margin=self._margin, + indent=self._indent, show=show, ) @@ -124,14 +137,15 @@ def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) ``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 scroll position is put back afterwards, so the rows a - reader was looking at are the rows they keep looking at. + into the group it is handed. Where the reader stands is taken up before the rows come down + and put back by :meth:`settle`, so the rows they were looking at are the rows they keep + looking at. 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. """ - offset = self.offset - start, count = self._slice(offset, total) + self._remember() + start, count = self._slice(self._resting, total) dpg_delete_children(self._body_tag) measuring = not self._geometry.measured self._build_lead(lead) @@ -141,7 +155,6 @@ def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) self._total = total self._windowed = True self._drawn = (start, count) - dpg.set_y_scroll(self._tag, offset) def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: int) -> None: """Build the region's contents entire, for content that is more than a run of rows. @@ -153,6 +166,7 @@ def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: reading of a row is taken from; content standing anything else among its rows is a run of none. """ + self._remember() dpg_delete_children(self._body_tag) self._build_lead(lead) build() @@ -170,15 +184,39 @@ def settle(self) -> bool: if not self._windowed: self._take_reading() self._hold_content() - return False + return self._restore() if not self._geometry.measured: self._stand_at_natural_height() return self._take_reading() self._hold_rows() + if self._restore(): + return False + return self._slice(self.offset, self._total) != self._drawn + def _remember(self) -> None: + """Take up where the reader stands, which the rows about to be built are chosen for.""" + self._resting = self.offset + self._restoring = True + + 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.""" return self._geometry.slice_of( diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index c83d88c68..262eb0220 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,6 +16,7 @@ def well( *, padding: int, margin: int, + indent: Optional[int] = None, height: int = 0, show: bool = True, ) -> str: @@ -24,9 +27,12 @@ def well( card. Alongside ``card()`` this is where the recessed depth theme is bound; the region sizes itself to its rows unless ``height`` reserves a footprint. - 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 keeps ``padding`` clear at the right + and ``indent`` at the left, the two being the same width unless a caller nests the body inside + something. 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. """ body_tag = compose_tag(tag, SUF_GROUP) with dpg.child_window( @@ -40,7 +46,7 @@ def well( show=show, ): dpg.add_spacer(height=margin) - dpg.add_group(tag=body_tag, indent=padding, width=-padding) + dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=-padding) dpg.add_spacer(height=margin) ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag) diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 9fec4d64e..dd7120299 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -98,14 +98,24 @@ def redraw(self, key: str, view_model: StemsListViewModel) -> None: 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.""" + def repaint( + self, + row: StemRowViewModel, + view_model: StemsListViewModel, + *, + releasable: bool, + ) -> 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, releasable=False) + self._rows.repaint(held, view_model, releasable=releasable) 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.""" @@ -113,8 +123,9 @@ def _open(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: tag=self._tags.region(row.key), geometry=self._geometry, ceiling=self._layout.folder_ceiling, - padding=self._layout.well_padding + self._layout.folder_indent, + padding=self._layout.well_padding, margin=self._layout.well_margin, + indent=self._layout.well_padding + self._layout.folder_indent, ) region.create(self._tags.body) self._regions[row.key] = region diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index f305ee277..ae6b78ab9 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -219,7 +219,7 @@ def _repaint(self, view_model: StemsListViewModel) -> None: """Draw what the rows in view currently hold onto the widgets they stand as.""" for row in self._reached(view_model): self._rows.repaint(row, view_model, releasable=self._releasable) - self._folders.repaint(row, view_model) + self._folders.repaint(row, view_model, releasable=self._releasable) def _reached(self, view_model: StemsListViewModel) -> Tuple[StemRowViewModel, ...]: """The rows the well has widgets for, which are the ones a repaint reaches.""" @@ -272,7 +272,7 @@ def _settle(self) -> None: self._folders.redraw(key, self._view) row = self._view.row(key) if row is not None: - self._folders.repaint(row, self._view) + self._folders.repaint(row, self._view, releasable=self._releasable) if self._following: self._settle_soon() diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index bbc791c52..f57384697 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -8,12 +8,17 @@ @dataclass(frozen=True) class RowPlacement: - """Where one row stands: what it is, which band holds it, and which boxes it draws.""" + """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 reshapes the list + the way a loose row leaving does and the region it stood in is drawn again. + """ key: str level: int offered: FrozenSet[ChannelName] opened: bool + held: Tuple[str, ...] @dataclass(frozen=True) @@ -33,7 +38,8 @@ 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. A folder opening or closing reshapes the list, since the region its recordings stand in - is built and taken down with it. + is built and taken down with it, and so does a recording leaving the folder, since the + region then holds a row for something the list no longer stands for. """ return cls( columns=view_model.channels_in_play, @@ -44,6 +50,7 @@ def of(cls, view_model: StemsListViewModel, open_folders: OpenFolders) -> Self: 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 ), diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index ee5678cc0..cf51c0854 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -1,4 +1,5 @@ from typing import Iterator, List, Optional, Tuple +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest @@ -18,6 +19,8 @@ OVERSCAN = 2 CEILING = 100 HEADING_TEXT = "channels" +STANDING_OFFSET = 300.0 +NO_OFFSET = 0.0 @pytest.fixture @@ -208,3 +211,72 @@ def test_it_carries_its_heading_too(self, region: WindowedRegion) -> None: first = dpg.get_item_children(region.body, 1)[0] assert dpg.get_item_type(first) == "mvAppItemType::mvGroup" + + +class TestWhereTheReaderStands(BaseTestSuite): + """A rebuild leaves the reader where they were, and asks DearPyGui for nothing while it draws. + + A scroll written while the rows it applies to are coming down lands against the layout of the + frame before, so the region reads back a position it never asked for. Every draw therefore + takes the offset up first and hands it back only once the rows have been placed. + """ + + 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_is_chosen_from_where_the_reader_stood(self, region: WindowedRegion) -> None: + """The offset is read once, so a position DearPyGui reports mid-rebuild reaches nothing.""" + draw(region, 40) + region.settle() + + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET) as get_y_scroll: + asked = draw(region, 40) + + assert get_y_scroll.call_count == 1 + assert asked[0][0] > 0 + + def test_the_reader_is_put_back_once_the_rows_are_placed(self, region: WindowedRegion) -> None: + draw(region, 40) + + with ( + patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), + patch.object(dpg, "set_y_scroll") as set_y_scroll, + ): + region.settle() + + set_y_scroll.assert_not_called() + + def test_a_position_the_rebuild_moved_is_restored(self, region: WindowedRegion) -> None: + """Where the rows come down and go back changed height, the offset is handed back.""" + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + draw(region, 40) + + with ( + patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), + 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_it_is_handed_back_once(self, region: WindowedRegion) -> None: + """A restored position is where the reader stands, so the next frame writes nothing.""" + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): + draw(region, 40) + + with ( + patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), + patch.object(dpg, "set_y_scroll"), + ): + region.settle() + + with ( + patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), + patch.object(dpg, "set_y_scroll") as set_y_scroll, + ): + region.settle() + + set_y_scroll.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 6df241d8c..9087ad01d 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -15,7 +15,13 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) -from sampletones_application.tags.general import SUF_CHANNELS, SUF_CHECKBOX, SUF_TEXT, SUF_TWISTY +from sampletones_application.tags.general import ( + SUF_BUTTON, + SUF_CHANNELS, + SUF_CHECKBOX, + SUF_TEXT, + SUF_TWISTY, +) 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 @@ -141,6 +147,12 @@ def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: return f"{PREFIX}.row.{row.key}.{SUF_CHANNELS}.{channel_name}.{SUF_CHECKBOX}" +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: """A folder arrives closed, standing as one row that names how many recordings it brought in.""" @@ -288,3 +300,40 @@ def double_click(tag: str) -> None: return raise AssertionError("the list registers no double-click handler") + + +class TestARecordingInsideAFolder: + """A recording standing inside an open folder answers the same gestures a loose one does.""" + + @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 = f"{PREFIX}.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(f"{PREFIX}.row.{leaving.key}.{SUF_TEXT}") + + 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(f"{PREFIX}.row.{held.key}.{SUF_TEXT}") From e0701f97c7f1ae7ef789fe8feb5ca5fedb23307e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 18:35:26 +0200 Subject: [PATCH 034/130] Capped: the pick a mix is built from at the room it has --- .../logic/reconstruction/reconstruction.py | 1 + .../ui/elements/stems/row.py | 9 ++- .../ui/panels/dialogs/stem_selection.py | 10 +++- .../view_model/main/converter.py | 1 + .../view_model/shared/stems.py | 52 +++++++++++++++++- .../ui/elements/stems/test_folder.py | 1 + .../ui/elements/stems/test_list.py | 1 + .../ui/panels/dialogs/test_stem_selection.py | 55 ++++++++++++++----- .../panels/reconstruction/test_stems_panel.py | 1 + .../reconstruction/test_reconstruction.py | 1 + 10 files changed, 111 insertions(+), 21 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 85a4e2b47..fcc570869 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -381,6 +381,7 @@ def _build_stems_view_model( 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, diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 570a9cb15..05ca49049 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -122,7 +122,7 @@ def repaint( if self._offer.master_box: master_tag = self._tags.row(row.key, SUF_CHECKBOX) - dpg_configure_item(master_tag, enabled=live and (self._offer.picking or row.offers_channels)) + 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) @@ -140,6 +140,13 @@ def _create_master(self, row: StemRowViewModel, view_model: StemsListViewModel) 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: diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index 6d947f22c..8a5286823 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -151,6 +151,7 @@ def _view(self) -> StemsListViewModel: channels_in_play=(), muted_channels=frozenset(), picked_keys=self._picked, + picking_room=self._room, live=True, collapse_levels=True, selected_key=None, @@ -170,14 +171,17 @@ def _limit_text(self) -> str: ) def _on_picked(self, key: str) -> None: - """Picks the recordings a row stands for, or lets them go where they all stand picked.""" + """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 - keys = frozenset(recording.key for recording in row.recordings) - self._picked = self._picked | keys if view_model.picking_of(row).settles_to else self._picked - keys + self._picked = view_model.picking_settled(row) self._render() @property diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 8361e3ee3..920dc5b5f 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -132,6 +132,7 @@ def stems_list(self) -> StemsListViewModel: channels_in_play=self.channels_in_play, muted_channels=frozenset(), picked_keys=frozenset(), + picking_room=None, live=not self.is_active, collapse_levels=not self.mixes_several, selected_key=self.selected_key, diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 792b20c15..2e92df111 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -1,6 +1,6 @@ from functools import cached_property from pathlib import Path -from typing import Dict, FrozenSet, Optional, Self, Tuple +from typing import Dict, FrozenSet, Iterator, Optional, Self, Tuple from pydantic import BaseModel @@ -115,13 +115,16 @@ class StemsListViewModel(BaseModel, frozen=True): 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. + ``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] @@ -134,6 +137,7 @@ def empty(cls) -> Self: channels_in_play=(), muted_channels=frozenset(), picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=False, selected_key=None, @@ -174,6 +178,50 @@ def picking_of(self, row: StemRowViewModel) -> Agreement: """ 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 picked_paths(self) -> Tuple[Path, ...]: """The recordings standing picked, in the order the list draws them.""" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 9087ad01d..3d4c790dc 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -120,6 +120,7 @@ def view(*rows: StemRowViewModel) -> StemsListViewModel: channels_in_play=CHANNELS, muted_channels=frozenset(), picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=True, selected_key=None, 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 a1bba18ca..b6c8a51ee 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -146,6 +146,7 @@ def view( channels_in_play=CHANNELS, muted_channels=muted_channels, picked_keys=picked_keys, + picking_room=None, live=live, collapse_levels=collapse_levels, ) 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 index 29acf7065..2051754fe 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -110,7 +110,7 @@ def add_enabled() -> bool: class TestWhatIsOffered(BaseTestSuite): - """Everything gathered is offered and pickable, with as many as a mix holds arriving ticked.""" + """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() @@ -128,16 +128,19 @@ def test_the_rest_arrive_clear(self, window: GUIStemSelectionWindow) -> None: 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_limit_is_pickable(self, window: GUIStemSelectionWindow) -> None: - """Swapping which recordings the mix is built from is what the question is for.""" + 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) - beyond = offered[MAX_STEM_SOURCES] - assert dpg.get_item_configuration(box_of(beyond))["enabled"] is True - pick(beyond) + assert dpg.get_item_configuration(box_of(offered[MAX_STEM_SOURCES]))["enabled"] is False - assert dpg.get_value(box_of(beyond)) is True + 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): @@ -165,17 +168,16 @@ def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelecti assert answered == [paths()[1 : MAX_STEM_SOURCES + 1]] - def test_a_pick_larger_than_a_mix_holds_waits(self, window: GUIStemSelectionWindow) -> None: + def test_a_pick_past_the_room_leaves_the_mix_as_it_was(self, window: GUIStemSelectionWindow) -> None: offered = candidates() answered: List[List[Path]] = [] window.on_add = answered.append render(window, offered) pick(offered[MAX_STEM_SOURCES]) - - assert add_enabled() is False dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() - assert answered == [] + + assert answered == [paths()[:MAX_STEM_SOURCES]] def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: offered = candidates() @@ -185,14 +187,16 @@ def test_a_pick_of_nothing_waits(self, window: GUIStemSelectionWindow) -> None: assert add_enabled() is False - def test_letting_one_go_settles_again(self, window: GUIStemSelectionWindow) -> None: + def test_letting_one_go_opens_the_room_for_another(self, window: GUIStemSelectionWindow) -> None: offered = candidates() render(window, offered) - pick(offered[MAX_STEM_SOURCES]) - assert add_enabled() is False + 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: @@ -200,7 +204,7 @@ def test_the_line_reads_what_stands_picked(self, window: GUIStemSelectionWindow) render(window, offered) opening = dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) - pick(offered[MAX_STEM_SOURCES]) + pick(offered[0]) assert dpg.get_value(TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT) != opening @@ -236,6 +240,27 @@ def test_one_click_lets_the_whole_folder_go(self, window: GUIStemSelectionWindow 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]] = [] + window.on_add = answered.append + held = paths(GATHERED) + folder = folder_row(Path("/audio/takes"), held) + render(window, [folder]) + 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]] = [] 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 ffa870199..1235d8044 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 @@ -123,6 +123,7 @@ def _view_model( channels_in_play=CHANNELS if rows else (), muted_channels=muted_channels, picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=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 cebaa7af5..dddb170cd 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -24,6 +24,7 @@ channels_in_play=(), muted_channels=frozenset(), picked_keys=frozenset(), + picking_room=None, live=True, collapse_levels=False, ) From 81556832e97e6415133bc1c6922491910a49b75a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 18:54:26 +0200 Subject: [PATCH 035/130] Gathered: the recordings a reader picks out of an overflowing folder --- .../coordinators/tabs/main.py | 25 +++--- .../logic/main/converter/logic.py | 15 ++-- .../ui/panels/dialogs/stem_selection.py | 22 ++++-- .../coordinators/tabs/test_main.py | 10 ++- .../sampletones_application/test_startup.py | 76 +++++++++++++++++++ .../ui/panels/dialogs/test_stem_selection.py | 33 ++++---- 6 files changed, 143 insertions(+), 38 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6262af4fd..da940fdd3 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -335,7 +335,6 @@ def _wire_converter( 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_source_played = self._file_playback.play - self._stem_selection_window.on_add = self._converter_logic.mix_only 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.""" @@ -452,13 +451,20 @@ def _request_output(self, output: OutputKind) -> None: """Answers the output switch, asking which recordings to mix where the list overflows one. 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. Every other switch takes effect straight away. + 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 not output.mixes or len(self._converter_logic.gathered_paths) <= MAX_STEM_SOURCES: self._converter_logic.set_output(output) return - self._stem_selection_window.open(self._converter_logic.gathered_rows, MAX_STEM_SOURCES) + self._converter_logic.refresh_view() + self._stem_selection_window.open( + self._converter_logic.gathered_rows, + MAX_STEM_SOURCES, + self._converter_logic.mix_only, + ) def _can_add_stems(self) -> bool: """The converter is free to gather recordings into a stems conversion.""" @@ -486,19 +492,20 @@ def _on_directory_add_requested(self, directory_path: Path) -> None: self._converter_logic.gather_folder(directory_path) def _mixing_beyond_room(self, directory_path: Path) -> bool: - """Whether the folder overflows the mix, which is a question rather than a gathering. + """Whether the folder brings in more than the mix has room for, which is a question. - Answering it settles the mix on what the reader picked, so the folder joins by the same - route a longer list does. + The answer names the recordings to gather, so it reaches the same gathering a click in the + browser reaches and the setup stands as it was until the reader gives one. """ if not self._converter_logic.mixes: return False - rows = self._converter_logic.rows_gathering(directory_path) - if sum(len(row.recordings) for row in rows) <= MAX_STEM_SOURCES: + offered = self._converter_logic.rows_offered_by(directory_path) + room = self._converter_logic.room_for_sources + if sum(len(row.recordings) for row in offered) <= room: return False - self._stem_selection_window.open(rows, MAX_STEM_SOURCES) + self._stem_selection_window.open(offered, room, self._converter_logic.gather_recordings) return True def _request_cancel_confirmation(self) -> None: diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 3480400f5..f5ce2a8b5 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -35,6 +35,7 @@ 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, @@ -144,13 +145,17 @@ 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_gathering(self, root: Path) -> Tuple[StemRowViewModel, ...]: - """The rows the list would stand as with ``root`` gathered, folders standing as folders. + def rows_offered_by(self, root: Path) -> Tuple[StemRowViewModel, ...]: + """The recordings below ``root`` the setup has yet to gather, as one row apiece. - A mix reaches a fixed number of recordings, so a folder overflowing what is left is put to - a reader as the same question the output switch asks: which of these to mix. + 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 rather than what it would leave the list standing as, + since the answer names the recordings to gather. """ - return stem_rows(self._gathering_folder(root), mixes=False) + standing = frozenset(self.gathered_paths) + offered = tuple(recording for recording in self._folder_recordings(root) if recording.path not in standing) + return stem_rows(Gathering(sources=SourceList(rows=offered), levels=MixLevels()), mixes=False) @property def is_active(self) -> bool: diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index 8a5286823..f6a2c5bb0 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -30,6 +30,8 @@ StemsListViewModel, ) +Answer = Callable[[List[Path]], None] + ADD_FOCUS_STOP: Final[int] = 1 NO_PICK: Final[int] = 0 @@ -44,7 +46,8 @@ class GUIStemSelectionWindow(GUIDialogWindow): against the room, and the mix is settled once the pick fits. 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. + list, and gathering a folder that overflows what is left. Each opening names what its own + answer reaches, since one narrows a list already gathered and the other gathers what it names. """ def __init__( @@ -82,7 +85,7 @@ def __init__( ) self._list.on_row_picked = self._on_picked - self.on_add: Optional[Callable[[List[Path]], None]] = None + self._answer: Optional[Answer] = None super().__init__( tag=TAG_MAIN_CONVERTER_WINDOW_STEM_SELECTION, @@ -92,10 +95,19 @@ def __init__( shortcut_source=shortcut_source, ) - def open(self, rows: Sequence[StemRowViewModel], room: int) -> None: - """Shows the rows gathered, picking as many recordings as the mix has room for.""" + 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() @@ -195,4 +207,4 @@ def _add(self) -> None: picked = list(self._view().picked_paths) self.hide() - self.call(self.on_add, picked) + self.call(self._answer, picked) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 8ef6f59c3..635cd573f 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -205,7 +205,7 @@ def _stems_coordinator( coordinator._converter_logic.gathered_paths = gathered coordinator._converter_logic.source_count = len(gathered) coordinator._converter_logic.room_for_sources = room - coordinator._converter_logic.rows_gathering.return_value = folder_rows + coordinator._converter_logic.rows_offered_by.return_value = folder_rows coordinator._stem_selection_window = MagicMock() return coordinator @@ -236,9 +236,10 @@ def test_a_list_longer_than_a_mix_holds_asks_which_to_mix(self) -> None: coordinator._request_output(OutputKind.MIXED) coordinator._converter_logic.set_output.assert_not_called() - rows, room = coordinator._stem_selection_window.open.call_args.args + rows, room, answer = coordinator._stem_selection_window.open.call_args.args assert rows == coordinator._converter_logic.gathered_rows assert room == MAX_STEM_SOURCES + assert answer == coordinator._converter_logic.mix_only 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)) @@ -409,9 +410,10 @@ def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> coordinator._on_directory_add_requested(tmp_path) coordinator._converter_logic.gather_folder.assert_not_called() - offered, room = coordinator._stem_selection_window.open.call_args.args + offered, room, answer = coordinator._stem_selection_window.open.call_args.args assert offered == rows - assert room == MAX_STEM_SOURCES + assert room == coordinator._converter_logic.room_for_sources + assert answer == coordinator._converter_logic.gather_recordings 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)) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 0a19db6de..02f3d4185 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -10,6 +10,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile +from sampletones_application.constants.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 @@ -31,6 +32,7 @@ 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_RECONSTRUCTOR_GROUP_GRID, @@ -497,6 +499,80 @@ def _reports_running(app: Application, status_text: str, progress: float) -> Non app._main_tab._on_converter_view_changed(running) +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, the way the browser reports the gathering gesture.""" + 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 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 + + class TestMainTabReadingOrder: """The tab reads in one direction: what a run is set up with, what it gathers, what a row takes. 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 index 2051754fe..480e5c4b1 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, List, Sequence +from typing import Final, List, Optional, Sequence import dearpygui.dearpygui as dpg import pytest @@ -17,7 +17,7 @@ TAG_MAIN_CONVERTER_TEXT_STEM_SELECTION_LIMIT, ) from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.panels.dialogs.stem_selection import GUIStemSelectionWindow +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 @@ -90,9 +90,17 @@ def candidates(count: int = GATHERED) -> List[StemRowViewModel]: return [recording_row(path) for path in paths(count)] -def render(window: GUIStemSelectionWindow, offered: Sequence[StemRowViewModel]) -> None: - """Builds the widget tree for what was gathered, the way ``open`` does without a live frame.""" - window.open(offered, MAX_STEM_SOURCES) +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: @@ -149,9 +157,8 @@ class TestSettlingTheMix(BaseTestSuite): def test_a_pick_that_fits_settles(self, window: GUIStemSelectionWindow) -> None: offered = candidates() answered: List[List[Path]] = [] - window.on_add = answered.append - render(window, offered) + 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]] @@ -159,9 +166,8 @@ def test_a_pick_that_fits_settles(self, window: GUIStemSelectionWindow) -> None: def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelectionWindow) -> None: offered = candidates() answered: List[List[Path]] = [] - window.on_add = answered.append - render(window, offered) + 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))() @@ -171,9 +177,8 @@ def test_swapping_one_for_another_keeps_it_settling(self, window: GUIStemSelecti def test_a_pick_past_the_room_leaves_the_mix_as_it_was(self, window: GUIStemSelectionWindow) -> None: offered = candidates() answered: List[List[Path]] = [] - window.on_add = answered.append - render(window, offered) + render(window, offered, answered.append) pick(offered[MAX_STEM_SOURCES]) dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() @@ -246,10 +251,9 @@ def test_a_folder_larger_than_the_room_takes_as_many_as_fit( ) -> None: """A folder settles either way, so it lets go of a full mix and takes what fits again.""" answered: List[List[Path]] = [] - window.on_add = answered.append held = paths(GATHERED) folder = folder_row(Path("/audio/takes"), held) - render(window, [folder]) + render(window, [folder], answered.append) assert add_enabled() is True pick(folder) @@ -264,8 +268,7 @@ def test_a_folder_larger_than_the_room_takes_as_many_as_fit( def test_what_it_holds_is_what_the_mix_takes(self, window: GUIStemSelectionWindow) -> None: held = paths(3) answered: List[List[Path]] = [] - window.on_add = answered.append - render(window, [folder_row(Path("/audio/takes"), held)]) + render(window, [folder_row(Path("/audio/takes"), held)], answered.append) dpg.get_item_callback(compose_tag(TAG_MAIN_CONVERTER_BUTTON_ADD_STEMS, SUF_BUTTON))() From 7a1b834ab818aded90a37ba62354ad87dca2d54e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 19:35:34 +0200 Subject: [PATCH 036/130] Read: a folder beside the interface, saying how far it has got --- .../coordinators/tabs/main.py | 65 ++++++++-- .../layout/tabs/main/converter.py | 1 + .../logic/main/converter/logic.py | 55 ++++---- .../logic/main/sources/list.py | 29 +++-- .../logic/main/sources/scan.py | 82 ++++++++++++ src/sampletones_application/tags/main.py | 24 ++++ .../ui/elements/window.py | 7 +- .../ui/panels/dialogs/scanning.py | 120 +++++++++++++++++ src/sampletones_config/lang/en.yaml | 4 + .../layout/tabs/main/converter.yaml | 3 + .../converter/paths/__init__.py | 2 + .../reconstructions/converter/paths/utils.py | 18 ++- .../coordinators/tabs/test_main.py | 56 ++++++-- .../logic/main/converter/test_logic.py | 11 +- .../logic/main/sources/test_scan.py | 121 ++++++++++++++++++ .../sampletones_application/test_startup.py | 23 +++- 16 files changed, 547 insertions(+), 74 deletions(-) create mode 100644 src/sampletones_application/logic/main/sources/scan.py create mode 100644 src/sampletones_application/ui/panels/dialogs/scanning.py create mode 100644 tests/unit/sampletones_application/logic/main/sources/test_scan.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index da940fdd3..d18a01f8b 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Tuple import dearpygui.dearpygui as dpg @@ -15,6 +15,7 @@ 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 @@ -46,6 +47,7 @@ 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 @@ -161,6 +163,7 @@ def _build_explorer( 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, self._file_playback, @@ -175,6 +178,9 @@ def _build_explorer( 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 @@ -242,6 +248,11 @@ def _build_cards( status_bar=status_bar, path_colors=layout.path_colors, ) + self._scan_window: GUIScanWindow = GUIScanWindow( + layout=layout.main.converter, + language_manager=language_manager, + ) + self._scan_window.on_stop = self._folder_scan.stop self._converter_panel: GUIConverterPanel = GUIConverterPanel( layout=layout.main.converter, stems_layout=layout.stems, @@ -478,20 +489,46 @@ def _on_file_add_requested(self, filepath: Path) -> None: self._converter_logic.gather_recordings([filepath]) def _on_directory_add_requested(self, directory_path: Path) -> None: - """Gathers a folder into the setup, standing for the recordings found below it. + """Reads what a folder holds, and gathers it once the reading is done. - A mix reaches a fixed number of recordings, so a folder overflowing it raises the same - question the output switch raises: which of what is now offered to mix. + 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._hooks.is_operation_active(): return - if self._mixing_beyond_room(directory_path): + 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 self._mixing_beyond_room(directory_path, found): return - self._converter_logic.gather_folder(directory_path) + self._converter_logic.gather_folder(directory_path, found) - def _mixing_beyond_room(self, directory_path: Path) -> bool: + 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, directory_path: Path, found: Tuple[Path, ...]) -> bool: """Whether the folder brings in more than the mix has room for, which is a question. The answer names the recordings to gather, so it reaches the same gathering a click in the @@ -500,7 +537,7 @@ def _mixing_beyond_room(self, directory_path: Path) -> bool: if not self._converter_logic.mixes: return False - offered = self._converter_logic.rows_offered_by(directory_path) + offered = self._converter_logic.rows_offered(found) room = self._converter_logic.room_for_sources if sum(len(row.recordings) for row in offered) <= room: return False @@ -690,8 +727,16 @@ def refresh_converter_view(self) -> None: self._converter_logic.refresh_view() def convert_path(self, path: Path) -> None: - """Converts exactly what a Reconstruct named, replacing whatever the reader gathered.""" - self._converter_logic.convert_path(path) + """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.""" diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index 003338189..e5977a466 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -8,3 +8,4 @@ class ConverterLayout(BaseModel, extra="forbid", frozen=True): button_height: int stem_selection: Dimensions stem_selection_footer: int + scan: Dimensions diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index f5ce2a8b5..dbeb10d2d 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -56,7 +56,6 @@ 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.converter.paths import get_audio_files from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings from sampletones_shared.exceptions import NoFilesToProcessError from sampletones_shared.logger import logger @@ -145,8 +144,8 @@ 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_by(self, root: Path) -> Tuple[StemRowViewModel, ...]: - """The recordings below ``root`` the setup has yet to gather, as one row apiece. + 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. @@ -154,7 +153,7 @@ def rows_offered_by(self, root: Path) -> Tuple[StemRowViewModel, ...]: since the answer names the recordings to gather. """ standing = frozenset(self.gathered_paths) - offered = tuple(recording for recording in self._folder_recordings(root) if recording.path not in standing) + 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 @@ -185,35 +184,39 @@ def gather_recordings(self, paths: Sequence[Path]) -> None: self._settle(self._state.with_gathering(gathering)) - def gather_folder(self, root: Path) -> None: - """Gathers a folder, standing for every recording found directly below it. + 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. + 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([recording.path for recording in self._folder_recordings(root)]) + self.gather_recordings(found) return - self._settle(self._state.with_gathering(self._gathering_folder(root))) + self._settle(self._state.with_gathering(self._gathering_folder(root, found))) - def convert_path(self, path: Path) -> None: - """Converts exactly what the reader named, which is what a Reconstruct asks for. + 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() - The setup becomes that one source — a recording, or a folder standing for the recordings - below it — and the run starts, writing one reconstruction apiece. - """ + 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() ) ) - if path.is_dir(): - self.gather_folder(path) - else: - self.gather_recordings([path]) - - self.start_conversion() def select_row(self, path: Path, kind: SourceKind) -> None: """Names the row a reader is inspecting, which the settings card edits.""" @@ -412,17 +415,9 @@ 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 _folder_recordings(self, root: Path) -> Tuple[Recording, ...]: - """Every recording below a folder, each joining with the settings a new row starts from. - - The walk goes as deep as the folder does, so a folder of folders stands for what its whole - tree holds and a run writing one reconstruction apiece mirrors that tree. - """ - return tuple(self._gathered(path) for path in get_audio_files(root, sort=True)) - - def _gathering_folder(self, root: Path) -> Gathering: + 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 = self._folder_recordings(root) + recordings = tuple(self._gathered(path) for path in found) if not recordings: return self._state.gathering diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 65c8c15fd..6e79e5d55 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -1,4 +1,5 @@ from dataclasses import dataclass, replace +from functools import cached_property from pathlib import Path from typing import Callable, Dict, FrozenSet, Optional, Self, Tuple @@ -28,15 +29,29 @@ class SourceList: rows: Tuple[SourceRow, ...] = () - @property + @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. + 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) @@ -52,10 +67,10 @@ def row_count(self) -> int: return len(self.rows) def holds(self, path: Path) -> bool: - return any(recording.path == path for recording in self.recordings) + return path in self._by_path def recording(self, path: Path) -> Optional[Recording]: - return next((recording for recording in self.recordings if recording.path == path), None) + return self._by_path.get(path) def row(self, key: SourceKey) -> Optional[SourceRow]: """The row a key names, wherever it stands. @@ -76,11 +91,7 @@ def folder_root_of(self, path: Path) -> Optional[Path]: A recording the reader named answers with nothing, and its reconstruction sits directly in the directory the run's settings are named after. """ - for row in self.rows: - if row.key.names_folder and any(recording.path == path for recording in row.recordings): - return row.key.path - - return None + 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.""" 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..6a81be61a --- /dev/null +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -0,0 +1,82 @@ +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 walk_audio_files +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 +NOTHING_FOUND: 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._answer: Optional[FoundCallback] = None + + 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 belongs to the asking rather than to the scan, so the same walk serves a + gathering and a conversion. A walk already under way stands, so a second folder waits for + the one being read. + """ + if self.running: + return + + self._answer = answer + self._stopping.clear() + self._running.set() + self.call(self.on_started, root) + self._walk(root) + + 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) -> None: + found: List[Path] = [] + for path in walk_audio_files(root): + if self._stopping.is_set(): + self._settled(self.on_stopped) + return + + found.append(path) + if len(found) % REPORT_EVERY == NOTHING_FOUND: + self.call(self.on_progress, len(found)) + + self._running.clear() + self.call(self._answer, root, tuple(sorted(found))) + + def _settled(self, report: Optional[VoidCallback]) -> None: + """Lets the walk go and says how it ended, in that order, so a next one may start.""" + self._running.clear() + self.call(report) diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 48d4b7f5a..5e6dd5f8c 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -340,6 +340,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/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/scanning.py b/src/sampletones_application/ui/panels/dialogs/scanning.py new file mode 100644 index 000000000..c37eb88a7 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/scanning.py @@ -0,0 +1,120 @@ +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.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.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, + ) + + def _stop(self) -> None: + self._rolling = False + 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_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 82a854316..5be7f0df8 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -386,6 +386,10 @@ main.converter.message.status_input_label: "Input:" 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: "{count} recordings so far in {name}" +main.converter.message.scan_opening: "Looking through {name}..." +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?" diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index 3f558a268..b9a18c698 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -4,3 +4,6 @@ stem_selection: width: 420 height: 360 stem_selection_footer: 44 +scan: + width: 420 + height: 150 diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index bb5d6e2fb..d9fc07e8a 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -9,6 +9,7 @@ get_relative_path, group_output_path, holds_audio_files, + walk_audio_files, ) __all__ = [ @@ -20,4 +21,5 @@ "get_relative_path", "group_output_path", "holds_audio_files", + "walk_audio_files", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index e226c553c..a485a1348 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import AbstractSet, List, Tuple +from typing import AbstractSet, Iterator, List, Tuple from sampletones_core.configs import Config from sampletones_core.constants.enums import ChannelName @@ -85,12 +85,26 @@ def group_output_path( return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) +def walk_audio_files( + input_directory: Path, + extensions: Tuple[str, ...] = EXT_FILES_AUDIO, +) -> Iterator[Path]: + """The recordings below a directory, reported as the walk meets them. + + 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 input_directory.rglob("*"): + if path.is_file() and path.suffix.lower() in extensions: + yield path + + def get_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] + audio_files = list(walk_audio_files(input_directory, extensions)) if sort: audio_files.sort() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 635cd573f..d00cf2d29 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -9,12 +9,14 @@ from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.coordinators.tabs.main import MainTabCoordinator 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" @@ -205,11 +207,33 @@ def _stems_coordinator( coordinator._converter_logic.gathered_paths = gathered coordinator._converter_logic.source_count = len(gathered) coordinator._converter_logic.room_for_sources = room - coordinator._converter_logic.rows_offered_by.return_value = folder_rows + coordinator._converter_logic.rows_offered.return_value = folder_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 +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() + + return root + + +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() + + class TestOutputSwitch: """A mix reaches a fixed number of recordings, so a longer list is put to the reader first.""" @@ -255,11 +279,14 @@ class TestDirectoryAdd: """Ctrl-clicking a folder gathers it, standing for the recordings found below it.""" def test_a_folder_joins_the_setup(self, tmp_path: Path) -> None: - coordinator = _stems_coordinator() + coordinator = _stems_coordinator(mixes=False) + root = _folder_of(tmp_path, 2) - coordinator._on_directory_add_requested(tmp_path) + _add_folder(coordinator, root) - coordinator._converter_logic.gather_folder.assert_called_once_with(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"} def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: coordinator = _stems_coordinator(operation_active=True) @@ -390,24 +417,24 @@ class TestGatheringAFolder: 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)) - coordinator._on_directory_add_requested(tmp_path) + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES + 5)) - coordinator._converter_logic.gather_folder.assert_called_once_with(tmp_path) + 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)) - coordinator._on_directory_add_requested(tmp_path) + _add_folder(coordinator, _folder_of(tmp_path, MAX_STEM_SOURCES)) - coordinator._converter_logic.gather_folder.assert_called_once_with(tmp_path) + 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) - coordinator._on_directory_add_requested(tmp_path) + _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 @@ -415,10 +442,19 @@ def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> assert room == coordinator._converter_logic.room_for_sources assert answer == coordinator._converter_logic.gather_recordings + 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)) - coordinator._on_directory_add_requested(tmp_path) + _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/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 9f6012e0c..78517b934 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -21,6 +21,7 @@ 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 @@ -316,7 +317,7 @@ def test_a_folder_starts_without_asking( sources = tmp_path / "sources" sources.mkdir() (sources / "song.wav").touch() - converter_logic.gather_folder(sources) + converter_logic.gather_folder(sources, get_audio_files(sources, sort=True)) on_target_exists = MagicMock() converter_logic.on_target_exists = on_target_exists @@ -383,7 +384,7 @@ def test_one_folder_names_the_tree_it_mirrors( sources.mkdir() (sources / "song.wav").touch() - converter_logic.gather_folder(sources) + 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) @@ -646,7 +647,7 @@ def _folder(self, converter_logic: ConverterLogic, tmp_path: Path, names: List[s for name in names: (root / name).touch() - converter_logic.gather_folder(root) + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) return root def test_a_folder_draws_one_row_naming_what_it_holds( @@ -812,14 +813,14 @@ def test_every_recording_below_it_is_gathered( ) -> None: root = self._tree(tmp_path) - converter_logic.gather_folder(root) + 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) + converter_logic.gather_folder(root, get_audio_files(root, sort=True)) rows = _view(converter_logic).stem_sources assert len(rows) == 1 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..8c3e07ea9 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -0,0 +1,121 @@ +from pathlib import Path +from typing import List, Tuple + +import pytest + +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 diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 02f3d4185..265d77557 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -57,6 +57,7 @@ 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 REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} @@ -499,6 +500,14 @@ def _reports_running(app: Application, status_text: str, progress: float) -> Non 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) + + class TestGatheringAFolderIntoAMix: """A folder bringing in more than a mix holds is a question, and the answer reaches the mix. @@ -517,11 +526,9 @@ def _folder(tmp_path: Path, count: int) -> Path: @staticmethod def _ask(app: Application, directory: Path) -> None: - """Ctrl-clicks the folder, the way the browser reports the gathering gesture.""" - 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) + """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) @@ -649,11 +656,13 @@ def _tree(tmp_path: Path) -> Path: 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.""" + """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) @@ -817,7 +826,7 @@ def _gather_folder(self, app: Application, tmp_path: Path, names: List[str]) -> converter_logic = app._main_tab._converter_logic converter_logic.set_output(OutputKind.PER_RECORDING) - converter_logic.gather_folder(root) + 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( From 85ec6db652123488a137f859249f9a28b01799c1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 19:39:37 +0200 Subject: [PATCH 037/130] Toned: a channel's name the colour its boxes are drawn in --- src/sampletones_config/theme/channels/muted.yaml | 5 +++++ src/sampletones_config/theme/channels/noise.yaml | 5 +++++ src/sampletones_config/theme/channels/pulse1.yaml | 5 +++++ src/sampletones_config/theme/channels/pulse2.yaml | 5 +++++ src/sampletones_config/theme/channels/triangle.yaml | 5 +++++ 5 files changed, 25 insertions(+) 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/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/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/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 From 1486b752caed7d86e4f355881b164c1483b4a00a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 19:48:11 +0200 Subject: [PATCH 038/130] Answered: a folder holding no recordings when the reading comes back --- .../coordinators/tabs/main.py | 18 ++++++++++++++++++ .../logic/main/sources/list.py | 6 +++++- src/sampletones_application/tags/main.py | 6 ++++++ .../logic/main/sources/test_list.py | 17 +++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index d18a01f8b..9158c4ad6 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -41,6 +41,7 @@ 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, ) @@ -519,11 +520,28 @@ def _convert_folder_read(self, directory_path: Path, found: Tuple[Path, ...]) -> def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: self._scan_window.close() + if not found: + self._nothing_below(directory_path) + return + if self._mixing_beyond_room(directory_path, found): return 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.status_no_files"], + 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) diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 6e79e5d55..6029fd8c4 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -105,12 +105,16 @@ def add_folder(self, folder: Folder) -> Self: 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. + 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),)) diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 5e6dd5f8c..44b83815a 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -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, diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py index f76b60954..40a5181ce 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_list.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -68,6 +68,23 @@ def test_a_recording_the_reader_named_belongs_to_no_folder(self) -> None: assert sources.folder_root_of(Path("/audio/a.wav")) is None +class TestAFolderStandingForNothing: + """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: def test_a_folder_goes_with_everything_it_holds(self) -> None: gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) From b4ac51be91c140c25e94dbf03c7bcf831680d808 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 19:54:09 +0200 Subject: [PATCH 039/130] Stated: the crossing the render-thread helper actually answers --- docs/development/architecture.md | 2 +- docs/development/bugs-and-todos.md | 6 +++++ .../utils/gui/render_thread.py | 25 +++++++++---------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index b9684f851..f2c97852c 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -71,7 +71,7 @@ The thread that created the DearPyGui context is the only one that may build, co **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. -**A gesture that rebuilds widgets crosses through `on_render_thread`.** DearPyGui invokes a widget's callback on a thread of its own, so a panel that rebuilds itself straight from a gesture creates and drops widgets while the render thread walks them, and a callback 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 callback that reads a value or sets one on a standing widget runs where it is called; one that creates or deletes items goes through the helper. +**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. **Work that needs a drawn frame is scheduled through `FrameCallbackManager`.** 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` names the frame the work is picked up on, and is how a callback waits for one. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index d2c005fcc..9abaec396 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -133,6 +133,12 @@ again. `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 behaviour cannot see a hook left unset. Building the object in that file is what closes it. +* Principle 6 named a widget's callback as arriving on a thread of DearPyGui's own. It does not + here: `manual_callback_management` is never enabled, so a callback runs inside + `render_dearpygui_frame` and `on_render_thread` reaches it as a direct call. The principle and + the helper now state the hazard they answer — work arriving from a worker of our own. Whether to + enable manual callback management is a separate question: it would let a gesture's own work be + spread across frames, at the cost of every callback becoming a queued one. * `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 diff --git a/src/sampletones_application/utils/gui/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py index 7c2ba776a..d85e370d4 100644 --- a/src/sampletones_application/utils/gui/render_thread.py +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -4,19 +4,19 @@ from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_shared.types.callback import Callback -_render_thread: Optional[int] = None +_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() + 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 + global _RENDER_THREAD # pylint: disable=global-statement + _RENDER_THREAD = None def is_render_thread() -> bool: @@ -26,7 +26,7 @@ def is_render_thread() -> bool: 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 + return _RENDER_THREAD is None or threading.get_ident() == _RENDER_THREAD def on_render_thread( @@ -37,14 +37,13 @@ def on_render_thread( ) -> None: """Runs ``work`` where DearPyGui's context belongs: the thread drawing the frames. - DearPyGui invokes a widget's callback on a thread of its own, so a panel that rebuilds itself - straight from a gesture creates and drops widgets while the render thread walks them, and a - callback 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 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 callback that reads a value or sets one on a standing widget runs where it is called; one - that creates or deletes items comes through here. + A widget's own callback runs on the render thread, since DearPyGui calls it inside the frame + being drawn, and reaches this as a direct call. """ if is_render_thread(): work(*args, **kwargs) From 584bc7f18b20116efafa1a32824ba0f935f7dffd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 20:20:58 +0200 Subject: [PATCH 040/130] Drew: the bend a recording was converted with beside its channel --- .../logic/main/converter/view.py | 2 + .../logic/reconstruction/reconstruction.py | 2 + src/sampletones_application/tags/general.py | 1 + .../ui/elements/stems/offer.py | 2 +- .../ui/elements/stems/row.py | 21 +++++++ .../ui/elements/stems/tags.py | 11 ++++ .../view_model/shared/stems.py | 15 ++++- .../ui/elements/stems/test_folder.py | 2 + .../ui/elements/stems/test_list.py | 2 + .../ui/panels/dialogs/test_stem_selection.py | 2 + .../ui/panels/main/test_converter.py | 1 + .../panels/reconstruction/test_stems_panel.py | 58 +++++++++++++++++++ .../view_model/main/test_converter.py | 1 + 13 files changed, 118 insertions(+), 2 deletions(-) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 9da62471c..e82253acc 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -172,6 +172,7 @@ def _row(placement: _Placement) -> StemRowViewModel: 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, @@ -195,6 +196,7 @@ def _held(placement: _Placement) -> Tuple[StemRowViewModel, ...]: 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, diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index fcc570869..cd7f4c196 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -352,6 +352,7 @@ 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( @@ -361,6 +362,7 @@ def _build_stems_view_model( 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, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index cd7a055a7..4d4d7f011 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -795,6 +795,7 @@ 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" diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index ae8a6ac1e..97612cee9 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -40,7 +40,7 @@ class StemsListOffer: removal=True, keeps_last_row=True, dragging=False, - bends=False, + bends=True, picking=False, ) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 05ca49049..22cb3a51b 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -251,9 +251,30 @@ def _create_channel( 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, diff --git a/src/sampletones_application/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py index a5e2b416f..a86b62d3f 100644 --- a/src/sampletones_application/ui/elements/stems/tags.py +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -2,6 +2,7 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_BENDS, SUF_CHANNELS, SUF_CHECKBOX, SUF_FOLDER, @@ -89,3 +90,13 @@ def channel(self, key: str, channel_name: ChannelName) -> str: SUF_CHANNELS, compose_tag(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 compose_tag( + self.prefix, + SUF_ROW, + key, + SUF_BENDS, + compose_tag(channel_name, SUF_CHECKBOX), + ) diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 2e92df111..75e5ecd25 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -6,7 +6,7 @@ from sampletones_application.constants.sources import SourceKind from sampletones_application.view_model.shared.agreement import Agreement -from sampletones_core.constants.enums import ChannelName +from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName class StemRowViewModel(BaseModel, frozen=True): @@ -26,6 +26,9 @@ class StemRowViewModel(BaseModel, frozen=True): ``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 @@ -34,6 +37,7 @@ class StemRowViewModel(BaseModel, frozen=True): held: Tuple["StemRowViewModel", ...] channels: FrozenSet[ChannelName] partial_channels: FrozenSet[ChannelName] + bends: FrozenSet[ChannelName] offered_channels: FrozenSet[ChannelName] available: bool level: int @@ -55,6 +59,15 @@ def recordings(self) -> Tuple["StemRowViewModel", ...]: """ 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 source's own name, which is what the row reads as.""" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 3d4c790dc..ffd68b813 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -87,6 +87,7 @@ def recording(path: Path, *, channels: FrozenSet[ChannelName] = frozenset(CHANNE held=(), channels=channels, partial_channels=frozenset(), + bends=frozenset(), offered_channels=frozenset(CHANNELS), available=True, level=0, @@ -105,6 +106,7 @@ def folder(name: str, *, holds: int) -> StemRowViewModel: 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, 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 b6c8a51ee..47825d1d5 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -120,6 +120,7 @@ def row( kind=SourceKind.RECORDING, held=(), partial_channels=frozenset(), + bends=frozenset(), key=str(path), path=path, channels=channels, @@ -182,6 +183,7 @@ def folder_row( 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, 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 index 480e5c4b1..e7f4366c4 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -55,6 +55,7 @@ def recording_row(path: Path) -> StemRowViewModel: held=(), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), + bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=0, @@ -73,6 +74,7 @@ def folder_row(root: Path, held: Sequence[Path]) -> StemRowViewModel: 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, diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index fe2afe806..4f9e654ff 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -85,6 +85,7 @@ def row(name: str) -> StemRowViewModel: held=(), channels=frozenset({ChannelName.PULSE1}), partial_channels=frozenset(), + bends=frozenset(), offered_channels=frozenset({ChannelName.PULSE1}), available=True, level=0, 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 1235d8044..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 @@ -88,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, @@ -98,6 +99,7 @@ def _row( kind=SourceKind.RECORDING, held=(), partial_channels=frozenset(), + bends=bends, key=str(stem_id), path=Path(f"/audio/{name}.wav"), channels=channels, @@ -309,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/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index bf2a527d9..7d1084efa 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -33,6 +33,7 @@ def _row( kind=SourceKind.RECORDING, held=(), partial_channels=frozenset(), + bends=frozenset(), key=str(path), path=path, channels=channels, From d65b2f8b7c800a7fac0f62c5349fc2b9221970ec Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 20:27:36 +0200 Subject: [PATCH 041/130] Recorded: the reconstructions an earlier build left unreadable --- docs/development/bugs-and-todos.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 9abaec396..ace904315 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -152,6 +152,13 @@ again. ## Bugs +* A reconstruction written by an earlier 0.3.2 build cannot be opened. The stems 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 the 2.2 upgrade step does not run on a file already stamped 2.2. + Files from v0.3.1 (data version 2.1) are unaffected — they carry no stems record and the step + synthesizes one in the current shape. Twenty-one files on the development machine are stranded + this way, nine of them true stems reconstructions. + * No refreshing after library generation * Misaligned dialog boxes sizes at initialization * Audible noise instructions when matching near-silent samples for FFT γ0 From dbcaa2c58c43747d926c58fa8400252dbcd4be74 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 21:44:12 +0200 Subject: [PATCH 042/130] Settled: what a record stamped with the pending data version means --- docs/development/bugs-and-todos.md | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ace904315..aa465ccbc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -152,12 +152,16 @@ again. ## Bugs -* A reconstruction written by an earlier 0.3.2 build cannot be opened. The stems 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 the 2.2 upgrade step does not run on a file already stamped 2.2. - Files from v0.3.1 (data version 2.1) are unaffected — they carry no stems record and the step - synthesizes one in the current shape. Twenty-one files on the development machine are stranded - this way, nine of them true stems reconstructions. +* 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. * No refreshing after library generation * Misaligned dialog boxes sizes at initialization From 6f5afe6b792a08880896bb1509b1cdbcbe5a4a2c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 5 Sep 2026 21:59:59 +0200 Subject: [PATCH 043/130] Cleared: the dead declarations and the readings that hid what they answered --- .../coordinators/tabs/main.py | 6 +++--- .../logic/main/converter/destination.py | 20 ------------------- .../logic/main/sources/row.py | 3 +-- .../ui/elements/layout/geometry.py | 5 +++-- .../ui/elements/stems/list.py | 9 +++++---- 5 files changed, 12 insertions(+), 31 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 9158c4ad6..bf3038d17 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -524,7 +524,7 @@ def _gather_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: self._nothing_below(directory_path) return - if self._mixing_beyond_room(directory_path, found): + if self._mixing_beyond_room(found): return self._converter_logic.gather_folder(directory_path, found) @@ -546,8 +546,8 @@ 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, directory_path: Path, found: Tuple[Path, ...]) -> bool: - """Whether the folder brings in more than the mix has room for, which is a question. + 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. The answer names the recordings to gather, so it reaches the same gathering a click in the browser reaches and the setup stands as it was until the reader gives one. diff --git a/src/sampletones_application/logic/main/converter/destination.py b/src/sampletones_application/logic/main/converter/destination.py index 6a8d81aae..40a9755ce 100644 --- a/src/sampletones_application/logic/main/converter/destination.py +++ b/src/sampletones_application/logic/main/converter/destination.py @@ -8,7 +8,6 @@ from sampletones_core.reconstructions.converter import BatchEntry from sampletones_core.reconstructions.converter.paths import ( config_directory_path, - get_output_path, group_output_path, ) @@ -40,25 +39,6 @@ def reconstruction_name(self) -> str: return self.input_path.stem if self.input_path is not None else "" - def aimed_at( - self, - config: Config, - input_path: Path, - channels: AbstractSet[ChannelName], - ) -> Self: - """The destination a newly picked recording or directory names. - - Raises: - FileNotFoundError: The path names nothing on disk. - OSError: The path cannot be read. - """ - return replace( - self, - input_path=input_path, - output_path=get_output_path(config, input_path, channels), - is_file=input_path.is_file(), - ) - def aimed_at_mix( self, config: Config, diff --git a/src/sampletones_application/logic/main/sources/row.py b/src/sampletones_application/logic/main/sources/row.py index 5c26fc381..bb465db0e 100644 --- a/src/sampletones_application/logic/main/sources/row.py +++ b/src/sampletones_application/logic/main/sources/row.py @@ -1,10 +1,9 @@ -from typing import Protocol, Tuple, runtime_checkable +from typing import Protocol, Tuple from sampletones_application.logic.main.sources.key import SourceKey from sampletones_application.logic.main.sources.recording import Recording -@runtime_checkable class SourceRow(Protocol): """One row of the list a run is set up in. diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py index f5e8702b5..7567eaf2d 100644 --- a/src/sampletones_application/ui/elements/layout/geometry.py +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -87,8 +87,9 @@ def take(self, *, block: float, rows: int) -> bool: 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 is a region measured while its rows - stood clipped or unplaced rather than a row that small, so the reading in force stands. + 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 diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index ae6b78ab9..67507bb78 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -205,10 +205,11 @@ def _windows(view_model: StemsListViewModel) -> bool: return view_model.collapse_levels and not view_model.holds_folders def _plain_rows(self, view_model: StemsListViewModel) -> int: - """How many rows a whole-drawn well stands as a plain run of, which a reading counts by. + """How many rows of one height the well holds, which a reading of a row is counted from. - A run broken by a caption, a strip or an open folder's region carries more than rows, so - it counts none and the reading in force stands. + A well standing rows alone answers with its whole count. One standing a caption, a strip + or an open folder's region among them answers with none, since a block measured across + those would read a row as taller than it is. """ if not view_model.collapse_levels or self._open_folders: return NO_ROWS @@ -264,7 +265,7 @@ 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(): + if self._region.settle() and self._windows(self._view): self._draw_window(self._view) self._repaint(self._view) From d347c9df53293ff2b0f44d20e91174bd51b161a5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 00:49:02 +0200 Subject: [PATCH 044/130] Fixed: a folder's region writing back the scroll it had just read --- .../ui/elements/layout/region.py | 59 ++++++++++------- .../ui/elements/stems/folder.py | 11 +++- .../ui/elements/layout/test_region.py | 63 ++++++++++--------- .../ui/elements/stems/test_folder.py | 56 ++++++++++++++++- 4 files changed, 132 insertions(+), 57 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index f9c9b364f..1099d6484 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -45,11 +45,12 @@ class WindowedRegion: 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. - Where the reader had scrolled to is held between a draw and the frame that renders it. A scroll - written while the rows it applies to are being rebuilt lands against the layout of the frame - before, so the region reads a position it never asked for and picks a different slice from it, - which asks for another rebuild. The offset is therefore remembered as the rows come down and - put back once the frame that placed them has been drawn. + 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__( @@ -119,7 +120,16 @@ def extent(self) -> float: region asked to scroll reports its travel a frame late and would send the window to the top of the list for that frame. """ - return max(0.0, self._content() - self._height) + return self._travel(self._total) + + 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.""" @@ -137,15 +147,13 @@ def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) ``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. Where the reader stands is taken up before the rows come down - and put back by :meth:`settle`, so the rows they were looking at are the rows they keep - looking at. + 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. """ - self._remember() - start, count = self._slice(self._resting, total) + start, count = self._slice(self._reading, total) dpg_delete_children(self._body_tag) measuring = not self._geometry.measured self._build_lead(lead) @@ -166,7 +174,6 @@ def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: reading of a row is taken from; content standing anything else among its rows is a run of none. """ - self._remember() dpg_delete_children(self._body_tag) self._build_lead(lead) build() @@ -196,10 +203,10 @@ def settle(self) -> bool: return self._slice(self.offset, self._total) != self._drawn - def _remember(self) -> None: - """Take up where the reader stands, which the rows about to be built are chosen for.""" - self._resting = self.offset - self._restoring = True + @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. @@ -218,14 +225,22 @@ def _restore(self) -> bool: 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 rows the region's scroll position reaches, in the list it is a window onto. + + The travel is worked out from the list being drawn rather than the one standing, so the + first draw of a region opens on the rows its position names. + """ return self._geometry.slice_of( offset=offset, - extent=self.extent, + extent=self._travel(total), height=self._height, total=total, ) + def _travel(self, total: int) -> float: + """How far a list of this length can be scrolled inside the region.""" + return max(0.0, self._room_for(total) - self._height) + 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: @@ -268,7 +283,7 @@ def _standing(self) -> int: 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._content()) + 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. @@ -279,9 +294,9 @@ def _hold_content(self) -> None: """ self._size_to(self._body_height() + 2 * self._margin) - def _content(self) -> float: - """The room the region's whole list asks for: its heading, its rows, and its margins.""" - return float(self._lead + self._geometry.reserve(self._total) + 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.""" diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index dd7120299..72ab1fc1d 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -7,7 +7,7 @@ from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.tags.general import SUF_TABLE from sampletones_application.ui.elements.layout.geometry import RowGeometry -from sampletones_application.ui.elements.layout.region import WindowedRegion +from sampletones_application.ui.elements.layout.region import NO_SCROLL, WindowedRegion from sampletones_application.ui.elements.stems.columns import NO_RESERVE, StemsColumns from sampletones_application.ui.elements.stems.expansion import OpenFolders from sampletones_application.ui.elements.stems.row import StemRowRenderer @@ -43,6 +43,7 @@ def __init__( self._open_folders = open_folders self._rows = rows self._regions: Dict[str, WindowedRegion] = {} + self._resting: Dict[str, float] = {} self._columns = StemsColumns( layout=layout, channels=(), @@ -73,7 +74,12 @@ def create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: self._open(row, view_model) def forget(self) -> None: - """Let go of the regions a rebuild took down, so the next draw builds them afresh.""" + """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 @@ -128,6 +134,7 @@ def _open(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: 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) diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index cf51c0854..3d6a3d127 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -20,7 +20,6 @@ CEILING = 100 HEADING_TEXT = "channels" STANDING_OFFSET = 300.0 -NO_OFFSET = 0.0 @pytest.fixture @@ -214,11 +213,13 @@ def test_it_carries_its_heading_too(self, region: WindowedRegion) -> None: class TestWhereTheReaderStands(BaseTestSuite): - """A rebuild leaves the reader where they were, and asks DearPyGui for nothing while it draws. + """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. - A scroll written while the rows it applies to are coming down lands against the layout of the - frame before, so the region reads back a position it never asked for. Every draw therefore - takes the offset up first and hands it back only once the rows have been placed. + 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: @@ -227,56 +228,58 @@ def test_a_draw_writes_no_scroll(self, region: WindowedRegion) -> None: set_y_scroll.assert_not_called() - def test_the_window_is_chosen_from_where_the_reader_stood(self, region: WindowedRegion) -> None: - """The offset is read once, so a position DearPyGui reports mid-rebuild reaches nothing.""" + 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) as get_y_scroll: + with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): asked = draw(region, 40) - assert get_y_scroll.call_count == 1 assert asked[0][0] > 0 - def test_the_reader_is_put_back_once_the_rows_are_placed(self, region: WindowedRegion) -> None: + 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=NO_OFFSET), + 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() - def test_a_position_the_rebuild_moved_is_restored(self, region: WindowedRegion) -> None: - """Where the rows come down and go back changed height, the offset is handed back.""" - with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): - draw(region, 40) - with ( - patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), - patch.object(dpg, "set_y_scroll") as set_y_scroll, - ): +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_it_is_handed_back_once(self, region: WindowedRegion) -> None: """A restored position is where the reader stands, so the next frame writes nothing.""" - with patch.object(dpg, "get_y_scroll", return_value=STANDING_OFFSET): - draw(region, 40) - - with ( - patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), - patch.object(dpg, "set_y_scroll"), - ): + region.opens_at(STANDING_OFFSET) + draw(region, 40) + with patch.object(dpg, "set_y_scroll"): region.settle() - with ( - patch.object(dpg, "get_y_scroll", return_value=NO_OFFSET), - patch.object(dpg, "set_y_scroll") as set_y_scroll, - ): + with patch.object(dpg, "set_y_scroll") as set_y_scroll: region.settle() set_y_scroll.assert_not_called() diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index ffd68b813..069f372c4 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -1,5 +1,6 @@ from pathlib import Path -from typing import Final, FrozenSet, Iterator, List, Tuple +from typing import Callable, Final, FrozenSet, Iterator, List, Tuple +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest @@ -40,6 +41,9 @@ PREFIX = "test.stems" CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DOUBLE_CLICK_HANDLER: Final[str] = "mvAppItemType::mvDoubleClickedHandler" +DEEP_FOLDER: Final[int] = 200 +STANDING_OFFSET: Final[float] = 700.0 +NO_OFFSET: Final[float] = 0.0 @pytest.fixture @@ -305,8 +309,8 @@ def double_click(tag: str) -> None: raise AssertionError("the list registers no double-click handler") -class TestARecordingInsideAFolder: - """A recording standing inside an open folder answers the same gestures a loose one does.""" +class TestARecordingThatLeavesAFolder: + """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: @@ -340,3 +344,49 @@ def test_the_ones_that_stay_are_still_drawn(self, stems_list: GUIStemsList) -> N for held in sources.held[1:]: assert dpg.does_item_exist(f"{PREFIX}.row.{held.key}.{SUF_TEXT}") + + +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: + """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) From 9aa3c831b036fd9a6770d5b4a828c13a4a78a4c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 00:49:27 +0200 Subject: [PATCH 045/130] Drew: a level caption in the weight a section header takes --- src/sampletones_application/ui/elements/stems/bands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 4be91f0e5..484812a6c 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -166,7 +166,7 @@ def _create_caption(self, level_index: int) -> None: tag=self._tags.level(level_index, SUF_TEXT), parent=self._tags.body, ) - FontRegistry.bind_to_item(caption, Font.MONO_SMALL) + FontRegistry.bind_to_item(caption, Font.BOLD) ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(caption) def _create_table( From 627fd97d264dbaa7c70b4837205d10042f61dd5f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 00:49:41 +0200 Subject: [PATCH 046/130] Fixed: a stems list repainting the widgets a second opening took down --- .../ui/elements/stems/bands.py | 4 +++ .../ui/elements/stems/list.py | 10 ++++++- .../ui/panels/dialogs/test_stem_selection.py | 26 +++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 484812a6c..947919cd6 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -79,6 +79,10 @@ def reshaped(self, view_model: StemsListViewModel) -> bool: self._shape = shape return True + 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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 67507bb78..63e6980df 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -155,7 +155,15 @@ def playable(self) -> bool: return self.on_row_opened is not None def create(self, parent: str, *, show: bool = True) -> None: - """Build the list's recessed region and the handlers its rows share.""" + """Build the list's recessed region and the handlers its rows share. + + 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) 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 index e7f4366c4..268c98264 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -275,3 +275,29 @@ def test_what_it_holds_is_what_the_mix_takes(self, window: GUIStemSelectionWindo 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]] From 5d1751371215aadf177f008ba2535aabccf7a664 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 03:15:33 +0200 Subject: [PATCH 047/130] Fixed: an open folder cutting off the rows at its end --- .../ui/elements/layout/geometry.py | 17 ++-- .../ui/elements/layout/region.py | 25 +----- .../ui/elements/stems/list.py | 10 ++- .../ui/elements/layout/test_geometry.py | 88 +++++++++++++------ 4 files changed, 78 insertions(+), 62 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py index 7567eaf2d..99f31a8ae 100644 --- a/src/sampletones_application/ui/elements/layout/geometry.py +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -58,23 +58,20 @@ 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, extent: float, height: float, total: int) -> Window: + 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. - Where the window opens follows how far through its travel the region is scrolled rather - than how many rows that offset counts out, so the top of the list is reachable at the top - and the end of it at the end however the reading of a row stands. ``extent`` is how far - the region can be scrolled, which is what the offset is read against. + 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) - last = total - count - if extent <= UNMEASURED: - return (0, count) - - return (max(0, min(round(offset / extent * last), last)), count) + 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.""" diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 1099d6484..94356f74e 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -112,16 +112,6 @@ def offset(self) -> float: """How far the region has been scrolled, read from the region itself.""" return self._scroll(dpg.get_y_scroll) - @property - def extent(self) -> float: - """How far the region can be scrolled, worked out from the room its rows ask for. - - The travel follows from what the region reserved rather than from what it reports, since a - region asked to scroll reports its travel a frame late and would send the window to the - top of the list for that frame. - """ - return self._travel(self._total) - def opens_at(self, offset: float) -> None: """Open the region where the reader had scrolled the one it stands in place of. @@ -227,19 +217,10 @@ def _restore(self) -> bool: 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 travel is worked out from the list being drawn rather than the one standing, so the - first draw of a region opens on the rows its position names. + 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, - extent=self._travel(total), - height=self._height, - total=total, - ) - - def _travel(self, total: int) -> float: - """How far a list of this length can be scrolled inside the region.""" - return max(0.0, self._room_for(total) - self._height) + 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.""" diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 63e6980df..33a5ae03e 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -215,11 +215,13 @@ def _windows(view_model: StemsListViewModel) -> bool: def _plain_rows(self, view_model: StemsListViewModel) -> int: """How many rows of one height the well holds, which a reading of a row is counted from. - A well standing rows alone answers with its whole count. One standing a caption, a strip - or an open folder's region among them answers with none, since a block measured across - those would read a row as taller than it is. + A well standing recordings alone answers with its whole count. One standing a caption, a + strip or a folder answers with none: a folder is a table of its own with a region under + it, so a block measured across those carries a table's chrome per folder and reads a row + as taller than it is. The reading a folder's rows are reserved by is then the one that + folder's own region takes, from the run of rows it draws. """ - if not view_model.collapse_levels or self._open_folders: + if not view_model.collapse_levels or view_model.holds_folders: return NO_ROWS return view_model.row_count diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py index 6e7be4695..bd5e28d62 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Tuple import pytest @@ -14,7 +15,8 @@ OVERSCAN = 2 PITCH = 20.0 REGION_HEIGHT = 100.0 -TRAVEL = 1000.0 +TOTAL_ROWS = 100 +READING_STEPS = 40 def measured(*, overscan: int = OVERSCAN, pitch: float = PITCH) -> RowGeometry: @@ -43,7 +45,7 @@ def test_it_still_holds_a_long_list_back(self) -> None: def test_a_short_list_is_drawn_whole(self) -> None: geometry = RowGeometry.unmeasured(overscan=OVERSCAN) - assert geometry.slice_of(offset=0.0, extent=0.0, height=REGION_HEIGHT, total=3) == (0, 3) + assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=3) == (0, 3) class TestAMeasuredGeometry(BaseTestSuite): @@ -81,8 +83,8 @@ def test_size_counts_what_shows_and_the_overscan(self, test_case: TestCase) -> N class TestSliceOf(BaseTestSuite): - """Where a window opens follows how far through its travel the region is scrolled, so the top - of the list is reachable at the top and the end of it at the end.""" + """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): @@ -93,56 +95,90 @@ class TestCase(BaseRegularTestCase): 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=100, expected=(0, 10)), - TestCase(label="the_middle_opens_halfway_down", offset=TRAVEL / 2, total=100, expected=(45, 10)), - TestCase(label="the_end_opens_at_the_last_rows", offset=TRAVEL, total=100, expected=(90, 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_travel(self, test_case: TestCase) -> None: + def test_the_slice_follows_the_rooms_the_offset_counts_out(self, test_case: TestCase) -> None: window = measured().slice_of( offset=test_case.offset, - extent=TRAVEL, height=REGION_HEIGHT, total=test_case.total, ) assert window == test_case.expected def test_the_end_of_the_list_is_reachable(self) -> None: - """A region scrolled to the end of its travel builds the rows at the end of its list.""" + """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=TRAVEL, extent=TRAVEL, height=REGION_HEIGHT, total=total) + start, count = geometry.slice_of(offset=total * PITCH, height=REGION_HEIGHT, total=total) assert start + count == total - def test_a_region_with_no_travel_opens_at_the_top(self) -> None: - """A region measured before it has been laid out reports no travel, and the top of a list - is what stands until it reports some.""" - geometry = measured() - start, _ = geometry.slice_of(offset=500.0, extent=0.0, height=REGION_HEIGHT, total=100) - assert start == 0 - - @pytest.mark.parametrize("offset", range(0, 1001, 37)) + @pytest.mark.parametrize("offset", range(0, 2001, 37)) def test_a_window_stays_inside_the_list_at_any_offset(self, offset: int) -> None: - total = 100 start, count = measured().slice_of( offset=float(offset), - extent=TRAVEL, height=REGION_HEIGHT, - total=total, + total=TOTAL_ROWS, ) assert start >= 0 - assert start + count <= total + 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, 1001, 13): - start, _ = geometry.slice_of(offset=float(offset), extent=TRAVEL, height=REGION_HEIGHT, total=500) + 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.""" @@ -159,7 +195,7 @@ def test_an_unmeasured_geometry_reserves_by_the_floor(self) -> None: 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, extent=TRAVEL, height=REGION_HEIGHT, total=total) + 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) From d9425dfdddd395b36339d87409e0d06c000bae80 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 03:21:52 +0200 Subject: [PATCH 048/130] Measured: what a converter holding ten thousand recordings costs --- Makefile | 2 +- tests/benchmarks/test_converter_load.py | 319 ++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 1 deletion(-) create mode 100644 tests/benchmarks/test_converter_load.py diff --git a/Makefile b/Makefile index 18874d3e0..859910d79 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py new file mode 100644 index 000000000..e42f68f34 --- /dev/null +++ b/tests/benchmarks/test_converter_load.py @@ -0,0 +1,319 @@ +from itertools import count +from pathlib import Path +from time import process_time +from typing import Callable, Final, Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.logic.main.converter.gathering import Gathering +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.list import GUIStemsList +from sampletones_application.ui.elements.stems.offer import GATHERED_SOURCES +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.enums import ChannelName +from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings +from tests.suite.base import BaseTestSuite + +SMALL_FOLDER: Final[int] = 1_000 +LARGE_FOLDER: Final[int] = 10_000 +REPEATS: Final[int] = 3 +GROWTH_ALLOWANCE: Final[float] = 1.6 +REGION_HEIGHT: Final[float] = 264.0 +ROW_PITCH: Final[float] = 36.0 +OVERSCAN: Final[int] = 4 +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 seconds(work: Callable[[], object]) -> float: + """The best of several runs, which is the reading least disturbed by other load.""" + readings: List[float] = [] + for _ in range(REPEATS): + started = process_time() + work() + readings.append(process_time() - started) + + return min(readings) + + +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.""" + one = seconds(small) + many = seconds(large) + ratio = many / one if one > 0 else float("inf") + 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.""" + 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 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 + + +class TestWhatTheListDraws(BaseTestSuite): + """What a region builds is what a reader can see, however long the list behind it is. + + This is the claim the folder rests on: opening ten thousand recordings costs what opening ten + costs, because the rows outside the window stand as reserved room rather than as widgets. + """ + + def test_the_window_holds_the_same_rows_however_long_the_list(self) -> None: + geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) + _, few = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=SMALL_FOLDER) + _, many = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=LARGE_FOLDER) + + assert few == many + + def test_the_room_it_reserves_stands_for_the_whole_list(self) -> None: + geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) + + assert geometry.reserve(LARGE_FOLDER) == int(LARGE_FOLDER * ROW_PITCH) + + def test_the_end_of_a_long_list_is_reachable(self) -> None: + geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) + start, count = geometry.slice_of( + offset=LARGE_FOLDER * ROW_PITCH, + height=REGION_HEIGHT, + total=LARGE_FOLDER, + ) + + assert start + count == LARGE_FOLDER + + +@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, + glyphs=layout_config.glyphs.common, + language_manager=LanguageManager(LANG_EN), + status_bar=GUIStatusBar(), + offer=GATHERED_SOURCES, + ) + built.create(ROOT_TAG) + return built + + +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] + return sum(1 for held in folder_row.held if dpg.does_item_exist(f"{prefix}.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.unmeasured(overscan=layout.window_overscan).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.""" + stems_list = list_drawn_as(prefix, layout_config) + stems_list.update_view(listing) + stems_list.toggle_folder(listing.rows[0].key) + return rows_on_screen(prefix, listing) From 032a8313d5ca76835338abb122ff2343a8414a95 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 03:26:18 +0200 Subject: [PATCH 049/130] Recorded: the deallocation on a stateless thread the crash lands in --- docs/development/bugs-and-todos.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index aa465ccbc..94371c69e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -163,6 +163,17 @@ again. releases moves inside the pending step, and a build writing the pending version writes the shape that step produces. +* An occasional segmentation fault, the same one each time. The kernel records it as + `segfault at 10 ip 000000000180314d ... in python3.13`, and that address disassembles to + `method_dealloc+0x8d`: the instruction that loads the current thread state out of thread-local + storage and reads a field of it. The pointer is null, so a bound method — a callback — is being + freed on a thread the interpreter holds no state for. It has been recorded on 22 August, twice on + 4 September and twice on 6 September, each time at that same instruction, so it predates the + converter rebuild and is deterministic in whatever reaches it rather than a race between threads. + The route that reaches it is still open: it was reported while opening a reconstruction, and + opening one by every route the interface offers has yet to reproduce it. A session started with + `PYTHONFAULTHANDLER=1` prints the Python frames at the fault, which is what would name the owner. + * No refreshing after library generation * Misaligned dialog boxes sizes at initialization * Audible noise instructions when matching near-silent samples for FFT γ0 From ed17429b72b44411e8f3901b0dbbb68d08e146e8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 03:37:05 +0200 Subject: [PATCH 050/130] Recorded: the render loop racing the thread DearPyGui calls back on --- docs/development/bugs-and-todos.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 94371c69e..be7186345 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -163,16 +163,22 @@ again. releases moves inside the pending step, and a build writing the pending version writes the shape that step produces. -* An occasional segmentation fault, the same one each time. The kernel records it as - `segfault at 10 ip 000000000180314d ... in python3.13`, and that address disassembles to - `method_dealloc+0x8d`: the instruction that loads the current thread state out of thread-local - storage and reads a field of it. The pointer is null, so a bound method — a callback — is being - freed on a thread the interpreter holds no state for. It has been recorded on 22 August, twice on - 4 September and twice on 6 September, each time at that same instruction, so it predates the - converter rebuild and is deterministic in whatever reaches it rather than a race between threads. - The route that reaches it is still open: it was reported while opening a reconstruction, and - opening one by every route the interface offers has yet to reproduce it. A session started with - `PYTHONFAULTHANDLER=1` prints the Python frames at the fault, which is what would name the owner. +* Opening a reconstruction segfaults the render loop. A faulthandler traceback names both sides: + the main thread is inside `render_dearpygui_frame`, and another thread is partway through + `filter_approximations`, reached from `tree.py::double_click_callback` through the browser, the + reconstruction coordinator and `display_reconstruction`. That second thread is DearPyGui's own: + a probe with a global mouse handler reports the callback on one thread identifier and the render + loop on another, so **DearPyGui invokes a widget's callback on a thread of its own**. Loading a + reconstruction therefore tears the whole tab down and rebuilds it — a plot series of nine million + points among it — while the renderer walks the items it is dropping. + + The premise `architecture.md` states is the opposite of what the probe reads: it says manual + callback management is off and so a callback runs inside the frame, which would make + `on_render_thread` a direct call everywhere. It is not, and the sites that read as deferrals are + crossings that nothing performs. Either the gestures that rebuild widgets cross through + `on_render_thread`, or `manual_callback_management` is turned on and the frame drains DearPyGui's + own queue, which makes the document true and closes the class rather than this one instance. The + record: the same fault on 22 August, twice on 4 September and twice on 6 September. * No refreshing after library generation * Misaligned dialog boxes sizes at initialization From f753511b7bf81a21b5a4f6ffa5606eb2a8be10f0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 04:10:15 +0200 Subject: [PATCH 051/130] Held: a widget's callback for the frame that drew it --- docs/development/architecture.md | 4 + docs/development/bugs-and-todos.md | 30 +--- src/sampletones_application/application.py | 8 + src/sampletones_application/shell.py | 2 + .../utils/file_dialogs/api.py | 45 +++-- .../utils/gui/callbacks.py | 43 +++++ .../utils/gui/render_thread.py | 37 +++- .../utils/gui/test_callbacks.py | 161 ++++++++++++++++++ 8 files changed, 292 insertions(+), 38 deletions(-) create mode 100644 src/sampletones_application/utils/gui/callbacks.py create mode 100644 tests/unit/sampletones_application/utils/gui/test_callbacks.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index f2c97852c..04054c086 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -73,6 +73,10 @@ The thread that created the DearPyGui context is the only one that may build, co **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 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 is scheduled through `FrameCallbackManager`.** 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` names the frame the work is picked up on, and is how a callback waits for one. ### 7. Construction flows from the composition root diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index be7186345..94a4d32c2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -133,12 +133,13 @@ again. `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 behaviour cannot see a hook left unset. Building the object in that file is what closes it. -* Principle 6 named a widget's callback as arriving on a thread of DearPyGui's own. It does not - here: `manual_callback_management` is never enabled, so a callback runs inside - `render_dearpygui_frame` and `on_render_thread` reaches it as a direct call. The principle and - the helper now state the hazard they answer — work arriving from a worker of our own. Whether to - enable manual callback management is a separate question: it would let a gesture's own work be - spread across frames, at the cost of every callback becoming a queued one. +* 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 @@ -163,23 +164,6 @@ again. releases moves inside the pending step, and a build writing the pending version writes the shape that step produces. -* Opening a reconstruction segfaults the render loop. A faulthandler traceback names both sides: - the main thread is inside `render_dearpygui_frame`, and another thread is partway through - `filter_approximations`, reached from `tree.py::double_click_callback` through the browser, the - reconstruction coordinator and `display_reconstruction`. That second thread is DearPyGui's own: - a probe with a global mouse handler reports the callback on one thread identifier and the render - loop on another, so **DearPyGui invokes a widget's callback on a thread of its own**. Loading a - reconstruction therefore tears the whole tab down and rebuilds it — a plot series of nine million - points among it — while the renderer walks the items it is dropping. - - The premise `architecture.md` states is the opposite of what the probe reads: it says manual - callback management is off and so a callback runs inside the frame, which would make - `on_render_thread` a direct call everywhere. It is not, and the sites that read as deferrals are - crossings that nothing performs. Either the gestures that rebuild widgets cross through - `on_render_thread`, or `manual_callback_management` is turned on and the frame drains DearPyGui's - own queue, which makes the document true and closes the class rather than this one instance. The - record: the same fault on 22 August, twice on 4 September and twice on 6 September. - * 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/src/sampletones_application/application.py b/src/sampletones_application/application.py index 14295e0e9..60d19bb82 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -133,6 +133,7 @@ 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 @@ -1649,6 +1650,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( 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/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/render_thread.py b/src/sampletones_application/utils/gui/render_thread.py index d85e370d4..5c12531bb 100644 --- a/src/sampletones_application/utils/gui/render_thread.py +++ b/src/sampletones_application/utils/gui/render_thread.py @@ -1,9 +1,17 @@ import threading -from typing import Any, Optional +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 @@ -42,11 +50,34 @@ def on_render_thread( 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 runs on the render thread, since DearPyGui calls it inside the frame - being drawn, and reaches this as a direct call. + 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/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 From 90a517dbfe24d098d409ee9bb499a4c4a8b0954d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 15:01:08 +0200 Subject: [PATCH 052/130] Held: a stems list to the room its owner gives it --- .../layout/tabs/main/converter.py | 1 + .../ui/elements/layout/region.py | 18 +++++- .../ui/elements/stems/folder.py | 4 +- .../ui/elements/stems/list.py | 11 +++- .../ui/panels/dialogs/stem_selection.py | 1 + .../ui/panels/main/converter/listing.py | 1 + .../ui/panels/reconstruction/stems.py | 1 + .../layout/tabs/main/converter.yaml | 5 +- tests/benchmarks/test_converter_load.py | 1 + .../ui/elements/layout/test_region.py | 63 ++++++++++++++++++- .../ui/elements/stems/test_folder.py | 1 + .../ui/elements/stems/test_list.py | 1 + 12 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/sampletones_application/layout/tabs/main/converter.py b/src/sampletones_application/layout/tabs/main/converter.py index e5977a466..c2428a231 100644 --- a/src/sampletones_application/layout/tabs/main/converter.py +++ b/src/sampletones_application/layout/tabs/main/converter.py @@ -8,4 +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/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 94356f74e..6605f9b34 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -43,7 +43,8 @@ class WindowedRegion: 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. + 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 @@ -78,6 +79,7 @@ def __init__( self._total = 0 self._windowed = False self._natural = True + self._reading_to_hold = False self._drawn: Window = NO_ROWS self._resting = NO_SCROLL self._restoring = False @@ -102,6 +104,16 @@ 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 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. Either + way what stands now is not what the region comes to rest as. + """ + return self.windowing or self._reading_to_hold + @property def natural(self) -> bool: """The region stands at the height of what it holds, so what it holds measures true.""" @@ -185,7 +197,8 @@ def settle(self) -> bool: if not self._geometry.measured: self._stand_at_natural_height() - return self._take_reading() + self._reading_to_hold = self._take_reading() + return self._reading_to_hold self._hold_rows() if self._restore(): @@ -289,6 +302,7 @@ def _size_to(self, content: float) -> None: within = content <= self._ceiling self._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, diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 72ab1fc1d..04eaf3f9f 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -84,8 +84,8 @@ def forget(self) -> None: @property def following(self) -> bool: - """An open folder holds back rows it has no room for, so a scroll asks it for others.""" - return any(region.windowing for region in self._regions.values()) + """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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 33a5ae03e..3b7d38e61 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -43,6 +43,10 @@ class GUIStemsList(CallbackMixin): 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__( @@ -50,6 +54,7 @@ def __init__( *, prefix: str, layout: StemsListLayout, + ceiling: int, glyphs: CommonGlyphs, language_manager: LanguageManager, status_bar: GUIStatusBar, @@ -65,7 +70,7 @@ def __init__( self._region = WindowedRegion( tag=self._tags.well, geometry=self._geometry, - ceiling=layout.well_ceiling, + ceiling=ceiling, padding=layout.well_padding, margin=layout.well_margin, ) @@ -255,8 +260,8 @@ def toggle_folder(self, key: str) -> None: @property def _following(self) -> bool: - """A region is holding rows back, so the list watches for the scroll that asks for them.""" - return self._region.windowing or self._folders.following + """A region stands as something other than it will, so the list settles it once more.""" + return self._region.settling or self._folders.following def _settle_soon(self) -> None: """Ask to read the drawn rows back once the frame that placed them has been rendered. diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index f6a2c5bb0..bc00653a6 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -78,6 +78,7 @@ def __init__( 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, diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 335c08cb3..31d835cb2 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -50,6 +50,7 @@ def __init__( 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, diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index f73a03ac2..73c93aba5 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -65,6 +65,7 @@ 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, diff --git a/src/sampletones_config/layout/tabs/main/converter.yaml b/src/sampletones_config/layout/tabs/main/converter.yaml index b9a18c698..8bdfbcc8d 100644 --- a/src/sampletones_config/layout/tabs/main/converter.yaml +++ b/src/sampletones_config/layout/tabs/main/converter.yaml @@ -1,9 +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/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index e42f68f34..72a69b66c 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -255,6 +255,7 @@ def list_drawn_as(prefix: str, layout_config: LayoutConfig) -> GUIStemsList: 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(), diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index 3d6a3d127..da99a796e 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -1,4 +1,4 @@ -from typing import Iterator, List, Optional, Tuple +from typing import Any, Iterator, List, Optional, Tuple from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -68,6 +68,11 @@ def build(start: int, count: int) -> None: return asked +def block_of(height: float) -> Any: + """Stands in for the rows a frame placed, which is what a reading of a row is taken from.""" + return patch.object(dpg, "get_item_rect_size", return_value=[0, height]) + + def reserves(region: WindowedRegion) -> Tuple[int, int]: """The room standing above and below the rows the region drew.""" spacers = [ @@ -146,6 +151,62 @@ def test_a_short_list_is_still_built_whole(self, unmeasured: WindowedRegion) -> 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.unmeasured(overscan=OVERSCAN), + ceiling=CEILING, + padding=0, + margin=0, + ) + 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_with_nothing_to_read_asks_for_nothing(self, unmeasured: WindowedRegion) -> None: + """A region drawn where no frame has placed its rows measures nothing, and waits.""" + draw(unmeasured, 4) + unmeasured.settle() + + assert not unmeasured.settling + + class TestRedrawing(BaseTestSuite): """A region drawn again replaces what it held, so its rows stand once however often it is rebuilt.""" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 069f372c4..72c2d6fbf 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -72,6 +72,7 @@ def stems_list(dpg_context: None, layout_config: LayoutConfig) -> GUIStemsList: 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(), 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 47825d1d5..8452a39b2 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -86,6 +86,7 @@ def build( 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(), From 584620cee301e89860d28f64131e8fcfe5d1cfee Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 15:01:20 +0200 Subject: [PATCH 053/130] Widened: the column a row's own box stands in --- src/sampletones_config/layout/general/stems.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index 7588398f4..b2f75945d 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -1,4 +1,4 @@ -master_column_width: 26 +master_column_width: 44 channel_column_width: 78 channel_solo_width: 62 channel_box_width: 29 From 5737a62a2c8fbf43dd30fac65e79a972e528eb8f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 15:01:31 +0200 Subject: [PATCH 054/130] Filled: a half-held box in its own tone --- docs/guide/interface.md | 7 ++++--- src/sampletones_application/tags/general.py | 12 ++++++++++++ .../ui/elements/stems/row.py | 11 +++++++++-- src/sampletones_config/palettes/dark.yaml | 5 +++++ src/sampletones_config/palettes/light.yaml | 5 +++++ src/sampletones_config/palettes/studio.yaml | 5 +++++ .../theme/channels/noise_partial.yaml | 9 +++++++++ .../theme/channels/pulse1_partial.yaml | 9 +++++++++ .../theme/channels/pulse2_partial.yaml | 9 +++++++++ .../theme/channels/triangle_partial.yaml | 9 +++++++++ src/sampletones_config/theme/stems/pick.yaml | 15 +++++++++++++++ .../theme/stems/pick_partial.yaml | 15 +++++++++++++++ 12 files changed, 106 insertions(+), 5 deletions(-) create mode 100644 src/sampletones_config/theme/stems/pick.yaml create mode 100644 src/sampletones_config/theme/stems/pick_partial.yaml diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 353069194..814cfd47f 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -69,9 +69,10 @@ from all**, they mix into a single reconstruction instead. A mix reaches eight recordings, so choosing it with a longer list asks which ones to mix, and so does adding a folder that overflows what is left. The question shows the same rows the card does — folders open onto what they hold, and one -click answers for a whole folder. The first eight arrive ticked and every one is -pickable, so swapping one for another is a click each; the line above counts what -you have picked, and **Add** settles the mix once the pick fits. +click answers for a whole folder, its box filling where only some of what it +holds is picked. The first eight arrive ticked and every one is pickable, so +swapping one for another is a click each; the line above counts what you have +picked, and **Add** settles the mix once the pick fits. From the second recording of a mix, 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 diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 4d4d7f011..a9abf981d 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,6 +308,18 @@ 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, diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 22cb3a51b..09285590a 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -14,6 +14,8 @@ TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_DANGER_BUTTON, TAG_GLOBAL_THEME_STEMS_DROP_STRIP, + TAG_GLOBAL_THEME_STEMS_PICK, + TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL, TAG_GLOBAL_THEME_STEMS_ROW, TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) @@ -155,12 +157,17 @@ def _master_value(self, row: StemRowViewModel, view_model: StemsListViewModel) - return row.takes_part def _tone_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: - """Soften a picking box where the folder it stands for is picked only in part.""" + """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_ROW_INERT if agreement is Agreement.SOME else TAG_GLOBAL_THEME_STEMS_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) -> None: diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index cf951bb7e..1e495f47a 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: "#4fa6ff55" 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: "#f0925655" + channel_pulse2_partial: "#f2d15f55" + channel_triangle_partial: "#7fc3f255" + channel_noise_partial: "#b4b8c055" tab: "#232325" tab_hovered: "#2d2d31" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 04d5d7927..e6307361a 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: "#0b4c8c55" 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: "#a8410a55" + channel_pulse2_partial: "#7a5c0055" + channel_triangle_partial: "#0f528855" + channel_noise_partial: "#4a4f5955" tab: "#b9bfca" tab_hovered: "#a9b0bd" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 3c67dc74b..c1e58da66 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: "#b98af355" 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: "#f0925655" + channel_pulse2_partial: "#f2d15f55" + channel_triangle_partial: "#8cc1ed55" + channel_noise_partial: "#bbb8c255" tab: "#24283a" tab_hovered: "#2f3a56" diff --git a/src/sampletones_config/theme/channels/noise_partial.yaml b/src/sampletones_config/theme/channels/noise_partial.yaml index 231088762..3db9aa5d5 100644 --- a/src/sampletones_config/theme/channels/noise_partial.yaml +++ b/src/sampletones_config/theme/channels/noise_partial.yaml @@ -4,6 +4,15 @@ 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 diff --git a/src/sampletones_config/theme/channels/pulse1_partial.yaml b/src/sampletones_config/theme/channels/pulse1_partial.yaml index 0d97b4bf4..0da399f59 100644 --- a/src/sampletones_config/theme/channels/pulse1_partial.yaml +++ b/src/sampletones_config/theme/channels/pulse1_partial.yaml @@ -4,6 +4,15 @@ 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 diff --git a/src/sampletones_config/theme/channels/pulse2_partial.yaml b/src/sampletones_config/theme/channels/pulse2_partial.yaml index 8efba9549..4fd482c45 100644 --- a/src/sampletones_config/theme/channels/pulse2_partial.yaml +++ b/src/sampletones_config/theme/channels/pulse2_partial.yaml @@ -4,6 +4,15 @@ 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 diff --git a/src/sampletones_config/theme/channels/triangle_partial.yaml b/src/sampletones_config/theme/channels/triangle_partial.yaml index e041e271d..9ac8ade9b 100644 --- a/src/sampletones_config/theme/channels/triangle_partial.yaml +++ b/src/sampletones_config/theme/channels/triangle_partial.yaml @@ -4,6 +4,15 @@ 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 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 From 8580aa5ca66c17caa19a04d95d34cdaadd257073 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 15:01:44 +0200 Subject: [PATCH 055/130] Offered: a folder beside the recordings a mix already holds --- docs/guide/interface.md | 7 +-- .../coordinators/tabs/main.py | 15 ++++--- .../logic/main/converter/gathering.py | 26 +++++------ .../logic/main/converter/logic.py | 30 ++++++++++--- .../ui/panels/dialogs/stem_selection.py | 5 ++- .../coordinators/tabs/test_main.py | 18 ++++++-- .../logic/main/converter/test_gathering.py | 22 +++++----- .../logic/main/converter/test_logic.py | 43 +++++++++++++++++++ 8 files changed, 125 insertions(+), 41 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 814cfd47f..09cec255e 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -70,9 +70,10 @@ A mix reaches eight recordings, so choosing it with a longer list asks which one to mix, and so does adding a folder that overflows what is left. The question shows the same rows the card does — folders open onto what they hold, and one click answers for a whole folder, its box filling where only some of what it -holds is picked. The first eight arrive ticked and every one is pickable, so -swapping one for another is a click each; the line above counts what you have -picked, and **Add** settles the mix once the pick fits. +holds is picked. Adding a folder stands the recordings the mix is already built +from beside the ones the folder offers, so letting one go is how you make room +for another. As many as a mix holds arrive ticked, the line above counts what you +have picked, and **Add** settles the mix once the pick fits. From the second recording of a mix, 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 diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index bf3038d17..78d81df45 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -549,18 +549,23 @@ def _convert_read(self, directory_path: Path, found: Tuple[Path, ...]) -> None: 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. - The answer names the recordings to gather, so it reaches the same gathering a click in the - browser reaches and the setup stands as it was until the reader gives one. + 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) - room = self._converter_logic.room_for_sources - if sum(len(row.recordings) for row in offered) <= room: + if sum(len(row.recordings) for row in offered) <= self._converter_logic.room_for_sources: return False - self._stem_selection_window.open(offered, room, self._converter_logic.gather_recordings) + self._stem_selection_window.open( + self._converter_logic.gathered_rows + offered, + MAX_STEM_SOURCES, + self._converter_logic.mix_only, + ) return True def _request_cancel_confirmation(self) -> None: diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py index 36f2b2495..4a62023d1 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -50,9 +50,14 @@ def room(self) -> int: @property def paths(self) -> Tuple[Path, ...]: - """Every gathered recording, in the order the list holds it.""" + """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 + @property def mixed_paths(self) -> Tuple[Path, ...]: """The recordings a mix converts, in the order they pick in.""" @@ -150,22 +155,19 @@ 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, paths: Tuple[Path, ...]) -> Self: - """The setup a mix runs from: exactly ``paths``, loose, each keeping what it was given. + 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 turning to a mix leaves behind — the folders give up the recordings they - stood for, and what the reader did not pick goes with them. + 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. """ - recordings = {recording.path: recording for recording in self.sources.recordings} sources = SourceList() levels = MixLevels() - for path in paths: - recording = recordings.get(path) - if recording is None: - continue - + for recording in recordings: sources = sources.add_recording(recording) - levels = levels.add(path) + levels = levels.add(recording.path) return replace(self, sources=sources, levels=levels) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index dbeb10d2d..245689d4a 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -149,8 +149,8 @@ def rows_offered(self, found: Sequence[Path]) -> Tuple[StemRowViewModel, ...]: 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 rather than what it would leave the list standing as, - since the answer names the recordings to gather. + 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) @@ -321,13 +321,26 @@ def set_output(self, output: OutputKind) -> None: return gathering = self._state.gathering - settled = gathering.mixing_only(gathering.paths[:MAX_STEM_SOURCES]) if output.mixes else gathering.unmixed() + settled = ( + gathering.mixing_only(gathering.recordings[:MAX_STEM_SOURCES]) 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, which is what a reader answers a full mix with.""" - gathering = self._state.gathering.mixing_only(tuple(paths)[:MAX_STEM_SOURCES]) - self._settle(self._state.with_settings(self._settings.with_output(OutputKind.MIXED)).with_gathering(gathering)) + """Names the recordings a mix converts, gathering the ones the list does not hold yet. + + 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)[:MAX_STEM_SOURCES]) + self._settle( + self._state.with_settings(self._settings.with_output(OutputKind.MIXED)).with_gathering( + gathering.mixing_only(mixed) + ) + ) def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: """Names the channels a recording holds when it joins the list, carried between runs. @@ -415,6 +428,11 @@ 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) diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index bc00653a6..412d95c40 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -46,8 +46,9 @@ class GUIStemSelectionWindow(GUIDialogWindow): against the room, and the mix is settled once the pick fits. 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. Each opening names what its own - answer reaches, since one narrows a list already gathered and the other gathers what it names. + 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. """ def __init__( diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index d00cf2d29..517f9ee4e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -438,9 +438,21 @@ def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> coordinator._converter_logic.gather_folder.assert_not_called() offered, room, answer = coordinator._stem_selection_window.open.call_args.args - assert offered == rows - assert room == coordinator._converter_logic.room_for_sources - assert answer == coordinator._converter_logic.gather_recordings + 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, room=0) + coordinator._converter_logic.gathered_rows = _rows_holding(*[1] * MAX_STEM_SOURCES) + + _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.""" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py index 3ecb09197..018a91c33 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -174,28 +174,30 @@ class TestTurningToAMix: """A mix converts loose recordings and holds a fixed number of them.""" def test_the_recordings_picked_stand_alone_and_in_order(self) -> None: - gathering = Gathering.empty().listing_folder( - folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) - ) + held = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) + gathering = Gathering.empty().listing_folder(held) - gathering = gathering.mixing_only((Path("/audio/b.wav"),)) + 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_stood_with(self) -> None: - gathering = Gathering.empty().listing(recording("/audio/a.wav", [ChannelName.NOISE])) + def test_a_recording_keeps_the_settings_it_is_named_with(self) -> None: + gathering = Gathering.empty().listing(recording("/audio/a.wav")) - settled = gathering.mixing_only((Path("/audio/a.wav"),)).recording(Path("/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_path_the_list_never_gathered_takes_no_part(self) -> None: - gathering = _listed("a").mixing_only((Path("/audio/a.wav"), Path("/audio/stranger.wav"))) + 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"] + assert _names(gathering) == ["a", "stranger"] + assert _mixed_names(gathering) == ["a", "stranger"] class TestTurningAwayFromAMix: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 78517b934..7231b01c1 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -572,6 +572,49 @@ def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLo ] +class TestAnsweringWhichRecordingsToMix: + """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: """What the converter asks the service to run, once a reader has set the mix up.""" From 42909278ce020644bdd93a5ec3aafd209608a8c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 23:19:59 +0200 Subject: [PATCH 056/130] Divided: the settings row among the cards standing in it --- .../coordinators/tabs/main.py | 57 ++++--- src/sampletones_application/tags/general.py | 2 + .../ui/elements/layout/columns.py | 71 +++++++-- .../sampletones_application/test_startup.py | 27 ++++ .../ui/elements/layout/test_columns.py | 144 ++++++++++++++++++ 5 files changed, 274 insertions(+), 27 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/layout/test_columns.py diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 78d81df45..ab9adf83f 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -665,6 +665,20 @@ 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 settings cards side by side, then the converter and the card reading its list. @@ -676,18 +690,9 @@ def _build_center(self, parent: str) -> None: 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_ADVANCED_PANEL_ADVANCED_CELL, - build=self._advanced_settings_panel.create_panel, - ), - ], + columns=self._config_columns, ) - self._sync_config_row_height() + 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) @@ -735,9 +740,10 @@ def _on_config_row_collapse_changed(self, card_tag: str, collapsed: bool) -> Non 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._advanced_settings_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: @@ -773,11 +779,28 @@ def toggle_channel(self, channel: ChannelName) -> None: self._reconstructor_panel.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/tags/general.py b/src/sampletones_application/tags/general.py index a9abf981d..b29188b72 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -830,6 +830,8 @@ 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") 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/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 265d77557..cf611e657 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -21,13 +21,16 @@ 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_RECONSTRUCTOR_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, @@ -633,6 +636,30 @@ def test_the_row_holds_its_height_until_both_cards_collapse(self, app: Applicati 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 click opens a folder, and Ctrl brings it in. 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 From 164643b0881f96687dc5553aade3afca36520b5d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 23:20:05 +0200 Subject: [PATCH 057/130] Matched: the general settings card to the advanced one --- src/sampletones_config/layout/tabs/main/config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From e6acb70509e3b40e9bb5b581069fae6e49d79128 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 23:20:13 +0200 Subject: [PATCH 058/130] Moved: gathering a recording onto the double-click --- docs/guide/getting-started.md | 5 +-- docs/guide/interface.md | 10 +++--- .../coordinators/tabs/main.py | 5 --- .../ui/panels/main/explorer.py | 32 +++++++++---------- src/sampletones_config/lang/en.yaml | 6 ++-- .../sampletones_application/test_startup.py | 32 ++++++++++++++----- .../ui/panels/main/test_explorer_controls.py | 32 +++++++++++++++---- 7 files changed, 77 insertions(+), 45 deletions(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 667caa3df..e119d0fba 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,8 +7,9 @@ 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. +2. In the **Filesystem** browser on the left, 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 takes under **Reconstruction settings**, and adjust **General settings**. At least one channel must be enabled. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 09cec255e..f9cd57df8 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -36,10 +36,12 @@ you can rerun it to carry on where you stopped. ### What to convert -The card holds a list of what a run converts. Click a recording in the browser to -add it; right-click and choose **Add as stem**, or Ctrl-click, to do the same. -Ctrl-click a folder — or use **Add folder as stems** — and the folder joins as -one row standing for every recording below it, however deep the tree goes. +The card holds a list of what a run converts. Double-click a recording in the +browser to add it; right-click and choose **Add as stem**, or Ctrl-click, to do +the same. A plain click plays the recording, so you can listen through a folder +before you take anything from it. Ctrl-click a folder — or use **Add folder as +stems** — and the folder joins as one row standing for every recording below it, +however deep the tree goes. The channels are named once above the rows, and each row shows one recording and a checkbox under every channel it may use. Untick them all and the row grays out: diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index ab9adf83f..9e0ae56d3 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -284,7 +284,6 @@ def _wire_settings(self, config_manager: ConfigManager) -> None: 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_add_requested=self._on_directory_add_requested, on_file_add_requested=self._on_file_add_requested, can_add_stems=self._can_add_stems, @@ -369,10 +368,6 @@ def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._update_reconstructor_panel_view() self._hooks.on_busy_state_changed() - def _on_wave_file_clicked(self, filepath: Path) -> None: - if not self._hooks.is_operation_active(): - self._converter_logic.gather_recordings([filepath]) - def _request_reconstruct_file(self, filepath: Path) -> None: if self._notify_converter_running(): return diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 4323cb8e0..428940d52 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -92,7 +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_add_requested: Optional[PathCallback] = None self.on_file_add_requested: Optional[PathCallback] = None self.can_add_stems: Optional[Callable[[], bool]] = None @@ -283,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, @@ -309,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) @@ -382,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_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5be7f0df8..d4a8612d7 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -326,8 +326,8 @@ main.explorer.label.context_add_stem: "Add as stem" 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" @@ -412,7 +412,7 @@ main.converter.label.add_stems_button: "Add" main.converter.label.overwrite_target_button: "Convert anyway" 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 a recording in the browser to add it. Add a folder to convert everything under it." +main.converter.message.stems_empty_hint: "Double-click a recording in the browser to add it. Add a folder to convert everything under it." main.converter.message.discard_stems_prompt: "Converting this replaces the recordings you gathered. 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 mix." diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index cf611e657..a00eda7c4 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -662,11 +662,12 @@ def test_the_advanced_card_leaves_the_row_and_comes_back_to_its_half(self, app: class TestBrowserGathering: - """What a gesture in the browser gathers: a click opens a folder, and Ctrl brings it in. + """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 on a folder walks the browser and leaves the conversion as it is, - which is what keeps navigating into a large tree from gathering it. + 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 @@ -707,17 +708,32 @@ def test_ctrl_gathers_the_whole_tree_below_it(self, app: Application, tmp_path: directory / "deeper" / "two.wav", } - def test_a_plain_click_on_a_recording_gathers_it(self, app: Application, tmp_path: Path) -> None: - """A recording is one path, so naming it costs nothing and a click is enough.""" - directory = self._tree(tmp_path) + 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 - recording = directory / "one.wav" with patch.object(explorer_module, "capture_modifiers", return_value=frozenset()): - panel._audio_node_clicked(FileSystemNode(recording.name, node_type=NodeType.FILE, filepath=recording)) + 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.""" 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..9d28f4444 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 @@ -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 == [] From 50cd1ce5a0a0e6a6e7ef66289c296ebea02524b5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 6 Sep 2026 23:20:18 +0200 Subject: [PATCH 059/130] Brightened: a half-held box against the box beside it --- src/sampletones_config/palettes/dark.yaml | 10 +++++----- src/sampletones_config/palettes/light.yaml | 10 +++++----- src/sampletones_config/palettes/studio.yaml | 10 +++++----- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 1e495f47a..aaf25054a 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -32,7 +32,7 @@ colors: accent_hover: "#6fb8ff" accent_active: "#3585d6" accent_muted: "#3a5570" - accent_partial: "#4fa6ff55" + accent_partial: "#4fa6ffaa" on_accent: "#0b1520" primary: "#0e639c" @@ -63,10 +63,10 @@ colors: channel_pulse2_soft: "#dcd3a4" channel_triangle_soft: "#aecadd" channel_noise_soft: "#c4c6cc" - channel_pulse1_partial: "#f0925655" - channel_pulse2_partial: "#f2d15f55" - channel_triangle_partial: "#7fc3f255" - channel_noise_partial: "#b4b8c055" + 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 e6307361a..4644a9d8d 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -32,7 +32,7 @@ colors: accent_hover: "#1163ac" accent_active: "#073a6c" accent_muted: "#9dbcd9" - accent_partial: "#0b4c8c55" + accent_partial: "#0b4c8caa" on_accent: "#ffffff" primary: "#0b4c8c" @@ -63,10 +63,10 @@ colors: channel_pulse2_soft: "#75683c" channel_triangle_soft: "#456c8c" channel_noise_soft: "#767b85" - channel_pulse1_partial: "#a8410a55" - channel_pulse2_partial: "#7a5c0055" - channel_triangle_partial: "#0f528855" - channel_noise_partial: "#4a4f5955" + 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 c1e58da66..8e8aa09cd 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -32,7 +32,7 @@ colors: accent_hover: "#a180ce" accent_active: "#7b629e" accent_muted: "#685386" - accent_partial: "#b98af355" + accent_partial: "#b98af3aa" on_accent: "#17131f" primary: "#8f6fc0" @@ -63,10 +63,10 @@ colors: channel_pulse2_soft: "#dfd6a8" channel_triangle_soft: "#b9cedf" channel_noise_soft: "#cbcace" - channel_pulse1_partial: "#f0925655" - channel_pulse2_partial: "#f2d15f55" - channel_triangle_partial: "#8cc1ed55" - channel_noise_partial: "#bbb8c255" + channel_pulse1_partial: "#f0925688" + channel_pulse2_partial: "#f2d15f88" + channel_triangle_partial: "#8cc1ed88" + channel_noise_partial: "#bbb8c288" tab: "#24283a" tab_hovered: "#2f3a56" From a21c46fade719f219904abf66912c494a10e73fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:12:27 +0200 Subject: [PATCH 060/130] Named: every reconstruction an overwrite would replace --- .../coordinators/tabs/main.py | 25 ++++++++---- .../logic/main/converter/logic.py | 23 +++++------ src/sampletones_config/lang/en.yaml | 2 + src/sampletones_shared/types/callback.py | 3 +- .../coordinators/tabs/test_main.py | 20 ++++++++-- .../logic/main/converter/test_logic.py | 38 ++++++++++++++++++- 6 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 9e0ae56d3..5a619beda 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -404,19 +404,30 @@ 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: diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 245689d4a..7a6e08865 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -59,7 +59,7 @@ 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, VoidCallback +from sampletones_shared.types.callback import PathCallback, PathsCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -111,7 +111,7 @@ def __init__( 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_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 @@ -378,9 +378,9 @@ def start_conversion(self, confirmed: bool = False) -> None: self.call(self.on_no_generators) return - standing_target = self._standing_target(plan) - if standing_target is not None and not confirmed: - self.call(self.on_target_exists, standing_target) + standing_targets = self._standing_targets(plan) + if standing_targets and not confirmed: + self.call(self.on_target_exists, standing_targets) return self._run.wait() @@ -499,14 +499,15 @@ def _redirected(self, state: ConverterState) -> ConverterState: return state.with_destination(destination.aimed_at_batch(config, batch_entries(state))) - def _standing_target(self, plan: ConversionPlan) -> Optional[Path]: - """The reconstruction ``plan`` would write over, where one stands. + def _standing_targets(self, plan: ConversionPlan) -> Tuple[Path, ...]: + """Every reconstruction ``plan`` would write over, in the order the run reaches them. - A batch converts what is still to be written and keeps the rest, so it puts nothing to - the reader; a run writing one document asks about that document. + 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. """ - targets = plan.existing_targets(self._config_manager.config) - return targets[0] if targets else None + return plan.existing_targets(self._config_manager.config) def _wait_for_library_and_start(self) -> None: if self._run.phase != ConversionPhase.WAITING: diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index d4a8612d7..6c2768171 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -415,9 +415,11 @@ main.converter.message.hierarchy_mode_tooltip: "Round robin gives every level a main.converter.message.stems_empty_hint: "Double-click a recording in the browser to add it. Add a folder to convert everything under it." main.converter.message.discard_stems_prompt: "Converting this replaces the recordings you gathered. Continue?" main.converter.message.overwrite_target_prompt: "A reconstruction of this name already stands here. Converting writes over it." +main.converter.message.overwrite_targets_prompt: "{count} reconstructions already stand where this run writes. Converting writes over 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: "Write over it?" +main.converter.title.overwrite_targets_dialog: "Write over them?" main.converter.title.stem_selection_dialog: "Pick recordings to mix" main.converter.template.stem_selection_limit: "{picked} of {total} picked. A mix holds {room}." main.converter.label.context_move_up: "Move up" 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/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 517f9ee4e..59d9764e7 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -331,6 +331,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: @@ -340,7 +342,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 @@ -348,10 +350,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) @@ -359,7 +373,7 @@ 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() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 7231b01c1..2fd08e384 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -269,10 +269,46 @@ def test_a_standing_target_is_put_to_the_reader_and_nothing_starts( with patch(SCHEDULING): converter_logic.start_conversion() - on_target_exists.assert_called_once_with(target) + 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, From 0decf2801da6fecb9913028dcb685d939024bfcd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:12:27 +0200 Subject: [PATCH 061/130] Carried: a folder walk's answer with the walk that earns it --- .../logic/main/sources/scan.py | 59 +++++++++++------- .../converter/paths/__init__.py | 4 ++ .../reconstructions/converter/paths/utils.py | 18 +++++- .../logic/main/sources/test_scan.py | 61 +++++++++++++++++++ 4 files changed, 119 insertions(+), 23 deletions(-) diff --git a/src/sampletones_application/logic/main/sources/scan.py b/src/sampletones_application/logic/main/sources/scan.py index 6a81be61a..037b15d47 100644 --- a/src/sampletones_application/logic/main/sources/scan.py +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -3,7 +3,7 @@ from typing import Callable, Final, List, Optional, Tuple from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.reconstructions.converter.paths import walk_audio_files +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 @@ -11,7 +11,7 @@ FoundCallback = Callable[[Path, Tuple[Path, ...]], None] REPORT_EVERY: Final[int] = 64 -NOTHING_FOUND: Final[int] = 0 +REPORT_DUE: Final[int] = 0 class FolderScan(CallbackMixin): @@ -30,8 +30,6 @@ def __init__(self) -> None: self._stopping = threading.Event() self._running = threading.Event() - self._answer: Optional[FoundCallback] = None - self.on_started: Optional[PathCallback] = None self.on_progress: Optional[CountCallback] = None self.on_stopped: Optional[VoidCallback] = None @@ -44,39 +42,58 @@ def running(self) -> bool: def start(self, root: Path, answer: FoundCallback) -> None: """Reads what ``root`` holds and hands it to ``answer``, counting as the walk goes. - The answer belongs to the asking rather than to the scan, so the same walk serves a - gathering and a conversion. A walk already under way stands, so a second folder waits for - the one being read. + 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._answer = answer self._stopping.clear() self._running.set() self.call(self.on_started, root) - self._walk(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) -> None: + def _walk(self, root: Path, answer: FoundCallback) -> None: + """Reads the tree, lets the walk go, and reports how it ended, in that order. + + The walk is let go whatever becomes of it, so a reading that fails partway leaves the next + folder free to be asked for. + """ + try: + found = self._gather(root) + stopped = self._stopping.is_set() + finally: + self._running.clear() + + if stopped: + self.call(self.on_stopped) + return + + self.call(answer, root, tuple(sorted(found))) + + 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_audio_files(root): + for path in walk_entries(root): if self._stopping.is_set(): - self._settled(self.on_stopped) - return + return found + + if not is_audio_file(path): + continue found.append(path) - if len(found) % REPORT_EVERY == NOTHING_FOUND: + if len(found) % REPORT_EVERY == REPORT_DUE: self.call(self.on_progress, len(found)) - self._running.clear() - self.call(self._answer, root, tuple(sorted(found))) - - def _settled(self, report: Optional[VoidCallback]) -> None: - """Lets the walk go and says how it ended, in that order, so a next one may start.""" - self._running.clear() - self.call(report) + return found diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index d9fc07e8a..540550621 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -9,7 +9,9 @@ get_relative_path, group_output_path, holds_audio_files, + is_audio_file, walk_audio_files, + walk_entries, ) __all__ = [ @@ -21,5 +23,7 @@ "get_relative_path", "group_output_path", "holds_audio_files", + "is_audio_file", "walk_audio_files", + "walk_entries", ] diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index a485a1348..3988e19c4 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -85,6 +85,20 @@ def group_output_path( return Path((output_directory / f"{derive_name(sources)}{suffix}").absolute()) +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, @@ -94,8 +108,8 @@ def walk_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 input_directory.rglob("*"): - if path.is_file() and path.suffix.lower() in extensions: + for path in walk_entries(input_directory): + if is_audio_file(path, extensions): yield path diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py index 8c3e07ea9..a20850f12 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_scan.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -3,6 +3,7 @@ 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 @@ -119,3 +120,63 @@ def test_a_walk_that_ended_leaves_the_next_free_to_start( 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 == [] From a0b9f57f175c0f40d1a9a22ce458de151e25c114 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:12:27 +0200 Subject: [PATCH 062/130] Held: a region to the body it still has --- .../ui/elements/layout/region.py | 21 ++++++++++++++++--- .../ui/elements/layout/test_region.py | 19 +++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 6605f9b34..dee5fca4a 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -104,15 +104,21 @@ 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. Either - way what stands now is not what the region comes to rest as. + 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.windowing or self._reading_to_hold + return self.standing and (self.windowing or self._reading_to_hold) @property def natural(self) -> bool: @@ -155,6 +161,9 @@ def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) 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 @@ -176,6 +185,9 @@ def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: reading of a row is taken from; content standing anything else among its rows is a run of none. """ + if not self.standing: + return + dpg_delete_children(self._body_tag) self._build_lead(lead) build() @@ -187,8 +199,11 @@ 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. + 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._take_lead() if not self._windowed: self._take_reading() diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index da99a796e..d7743d954 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -344,3 +344,22 @@ def test_it_is_handed_back_once(self, region: WindowedRegion) -> None: 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) == [] From b010ef35de202894762e9d8a2af6e2ca02a92f90 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:20:02 +0200 Subject: [PATCH 063/130] Reworded: the converter's messages in plain English --- src/sampletones_config/lang/en.yaml | 30 ++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6c2768171..5f5a3415a 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -268,14 +268,14 @@ 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 it holds." -global.stems.message.status_folder_row: "Open {name} to work on the {count} recordings in it, or right-click for more actions." +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: "Put the recordings in {name} away." +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: "Carry each note to the pitch the recording really sounds, a fraction of a step away from the note the channel names." +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." @@ -349,7 +349,7 @@ main.config.tooltip.tooltip_nes_frequency: "Set the NES refresh rate (in Hz) for # ============================================================================= # Main tab — Reconstructor panel # ============================================================================= -main.reconstructor.message.nothing_picked: "Pick a recording or a folder in the Converter to set what it takes." +main.reconstructor.message.nothing_picked: "Pick a recording or a folder in the Converter to change its settings." main.reconstructor.label.section_settings: "Reconstruction 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." @@ -364,7 +364,7 @@ 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.message.mode_tooltip: "One reconstruction for each recording listed, or one reconstruction mixing them all." +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}" @@ -387,7 +387,7 @@ 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: "{count} recordings so far in {name}" +main.converter.template.scan_progress: "Found {count} recordings in {name}" main.converter.message.scan_opening: "Looking through {name}..." main.converter.label.stop_scan_button: "Stop" main.converter.title.progress_dialog: "Reconstruction progress" @@ -412,16 +412,16 @@ main.converter.label.add_stems_button: "Add" main.converter.label.overwrite_target_button: "Convert anyway" 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: "Double-click a recording in the browser to add it. Add a folder to convert everything under it." -main.converter.message.discard_stems_prompt: "Converting this replaces the recordings you gathered. Continue?" -main.converter.message.overwrite_target_prompt: "A reconstruction of this name already stands here. Converting writes over it." -main.converter.message.overwrite_targets_prompt: "{count} reconstructions already stand where this run writes. Converting writes over them." +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: "Write over it?" -main.converter.title.overwrite_targets_dialog: "Write over them?" +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 {room}." +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" @@ -429,7 +429,7 @@ 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: "Put the recordings away" +main.converter.label.context_close_folder: "Hide the recordings" main.converter.label.context_remove_folder: "Remove the folder from the conversion" # ============================================================================= From d113363f213ebb082da307a7f58daf6d3160e5a8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:32:17 +0200 Subject: [PATCH 064/130] Removed: the converter methods nothing calls --- .../logic/main/converter/gathering.py | 24 ----------- .../logic/main/converter/logic.py | 13 ------ .../logic/main/converter/settings.py | 29 +++----------- .../logic/main/sources/levels.py | 5 --- .../logic/main/sources/list.py | 8 ---- .../view_model/main/converter.py | 5 --- .../view_model/shared/stems.py | 5 --- .../converter/paths/__init__.py | 2 - .../reconstructions/converter/paths/utils.py | 12 ------ .../logic/main/converter/test_gathering.py | 40 +------------------ .../logic/main/converter/test_logic.py | 2 +- .../logic/main/converter/test_settings.py | 13 +----- .../logic/main/sources/test_levels.py | 3 -- .../logic/main/sources/test_list.py | 26 ------------ .../view_model/main/test_converter.py | 2 - 15 files changed, 8 insertions(+), 181 deletions(-) diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py index 4a62023d1..a051183cf 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -58,11 +58,6 @@ def recordings(self) -> Tuple[Recording, ...]: """Every gathered recording, in the order the list holds it.""" return self.sources.recordings - @property - def mixed_paths(self) -> Tuple[Path, ...]: - """The recordings a mix converts, in the order they pick in.""" - return self.levels.paths - def recording(self, path: Path) -> Optional[Recording]: """The gathered recording at ``path``, where the list holds one.""" return self.sources.recording(path) @@ -118,25 +113,6 @@ def written( return replace(self, sources=self.sources.written(recording.key, slot, value)) - def written_among( - self, - path: Path, - slot: SettingsSlot, - value: FrozenSet[ChannelName], - offered: FrozenSet[ChannelName], - ) -> Self: - """The setup with one recording's slot settled to ``value``, among the channels ``offered``. - - A channel left out of the run reaches no checkbox, so the recording keeps whatever it was - given for it and gets that choice back when the channel returns. - """ - recording = self.recording(path) - if recording is None: - return self - - held = slot.read(recording.settings) - return self.written(path, slot, (held - offered) | value) - def settled( self, key: SourceKey, diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 7a6e08865..19efe0619 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -342,15 +342,6 @@ def mix_only(self, paths: Sequence[Path]) -> None: ) ) - def set_joining_channels(self, channels: FrozenSet[ChannelName]) -> None: - """Names the channels a recording holds when it joins the list, carried between runs. - - A run hands out what a recording joins with, so narrowing this narrows every gathered - recording to the channels still named; each keeps the choice it was given for a channel - left out and gets it back when that channel returns. - """ - self._settle_joining(CHANNEL_SLOT.write(self._joining_settings, channels)) - 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))) @@ -416,10 +407,6 @@ def cleanup(self) -> None: def _settings(self) -> RunSettings: return self._state.settings - def _settle_joining(self, joining: StemSettings) -> None: - """Takes up the settings a recording joins the list with, and writes them down.""" - self._settle(self._state.with_settings(self._settings.with_joining(joining))) - 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)) diff --git a/src/sampletones_application/logic/main/converter/settings.py b/src/sampletones_application/logic/main/converter/settings.py index cf4276854..dee87cfa5 100644 --- a/src/sampletones_application/logic/main/converter/settings.py +++ b/src/sampletones_application/logic/main/converter/settings.py @@ -1,9 +1,8 @@ from dataclasses import dataclass, replace -from typing import FrozenSet, Self +from typing import Self from sampletones_application.constants.conversion import MIN_CHANNEL_CAP from sampletones_application.constants.output import OutputKind -from sampletones_application.logic.main.sources.slots import CHANNEL_SLOT from sampletones_core.constants.enums import ChannelName, HierarchyMode from sampletones_core.reconstructions.reconstructor.stems.configs.settings import StemSettings @@ -12,11 +11,10 @@ class RunSettings: """The choices a run holds to, whatever it converts. - ``joining`` is what a recording is given when it joins the setup, and a run hands out the - channels it names: every gathered recording is narrowed to them, so this one value settles - both what a new row starts from and what the whole run reaches. 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`` 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 @@ -24,11 +22,6 @@ class RunSettings: channel_cap: int hierarchy_mode: HierarchyMode - @property - def enabled_channels(self) -> FrozenSet[ChannelName]: - """The channels a run hands out, which is what a joining recording holds.""" - return self.joining.channel_set - @property def max_channel_cap(self) -> int: """The highest cap there is, which is one recording holding every channel in a frame.""" @@ -39,18 +32,6 @@ 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) - def with_joining(self, joining: StemSettings) -> Self: - """The settings a recording joins the list with, as a reader settled them.""" - return replace(self, joining=joining) - - def with_joining_channels(self, channels: FrozenSet[ChannelName]) -> Self: - """The settings a recording joins with, holding exactly ``channels``. - - A bend the recording carried on a channel left out goes with it, which is what keeps the - joining settings a value the core accepts. - """ - return replace(self, joining=CHANNEL_SLOT.write(self.joining, channels)) - @property def mixes(self) -> bool: """Several recordings are being gathered into one reconstruction.""" diff --git a/src/sampletones_application/logic/main/sources/levels.py b/src/sampletones_application/logic/main/sources/levels.py index 98313a8a2..e00899fde 100644 --- a/src/sampletones_application/logic/main/sources/levels.py +++ b/src/sampletones_application/logic/main/sources/levels.py @@ -87,11 +87,6 @@ def remove(self, path: Path) -> Self: """Lets a recording go, together with the level it emptied.""" return self.of(self._without(path)) - def keep_first(self) -> Self: - """Keeps the recording that picks first, which is the one a single-source run carries.""" - paths = self.paths - return self.of([[paths[0]]]) if paths 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 ties first.""" if not self.holds(path): diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 6029fd8c4..4d64fd5b2 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -198,14 +198,6 @@ def agreement( return Agreement.over(slot.holds(recording.settings, channel_name) for recording in row.recordings) - def flattened(self) -> Self: - """The same recordings as loose rows, in the order they stand. - - A mix converts recordings alone, so a folder standing in the list contributes what it - holds and stops standing for them. - """ - return replace(self, rows=self.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() diff --git a/src/sampletones_application/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index 920dc5b5f..e0fdd16bd 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -143,11 +143,6 @@ 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: """Another recording would reach the run, which a full mix answers no to.""" diff --git a/src/sampletones_application/view_model/shared/stems.py b/src/sampletones_application/view_model/shared/stems.py index 75e5ecd25..364f1011f 100644 --- a/src/sampletones_application/view_model/shared/stems.py +++ b/src/sampletones_application/view_model/shared/stems.py @@ -248,8 +248,3 @@ def boxes_of(self, row: StemRowViewModel) -> Tuple[ChannelName, ...]: 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)} - - @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) diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py index 540550621..700793310 100644 --- a/src/sampletones_core/reconstructions/converter/paths/__init__.py +++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py @@ -8,7 +8,6 @@ get_output_path, get_relative_path, group_output_path, - holds_audio_files, is_audio_file, walk_audio_files, walk_entries, @@ -22,7 +21,6 @@ "get_output_path", "get_relative_path", "group_output_path", - "holds_audio_files", "is_audio_file", "walk_audio_files", "walk_entries", diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 3988e19c4..f974267b7 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -125,18 +125,6 @@ def get_audio_files( return audio_files -def holds_audio_files( - input_directory: Path, - extensions: Tuple[str, ...] = EXT_FILES_AUDIO, -) -> bool: - """Whether a batch of this folder would find anything to convert. - - A batch reaches every recording below the folder, so the walk goes as deep and stops at the - first one it meets, which is what makes the answer cheap enough for a gesture to ask for it. - """ - return any(path.is_file() and path.suffix.lower() in extensions for path in input_directory.rglob("*")) - - def filter_files( audio_files: List[Path], base_directory: Path, diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py index 018a91c33..11ddd2881 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -32,7 +32,7 @@ def _names(gathering: Gathering) -> List[str]: def _mixed_names(gathering: Gathering) -> List[str]: - return [path.stem for path in gathering.mixed_paths] + return [path.stem for path in gathering.levels.paths] class TestGatheringRecordings: @@ -101,44 +101,6 @@ def test_settling_a_recording_the_setup_never_gathered_changes_nothing(self) -> assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering -class TestSettlingAmongTheChannelsOffered: - """A reader answers for the channels the run enables, and the rest stands as it was.""" - - def _held(self, gathering: Gathering) -> FrozenSet[ChannelName]: - settled = gathering.recording(Path("/audio/bass.wav")) - assert settled is not None - return settled.settings.channel_set - - def test_a_channel_left_out_of_the_run_keeps_the_choice_it_was_given(self) -> None: - gathering = Gathering.empty().listing(recording("/audio/bass.wav", [ChannelName.PULSE1, ChannelName.NOISE])) - - gathering = gathering.written_among( - Path("/audio/bass.wav"), - CHANNEL_SLOT, - frozenset(), - frozenset({ChannelName.PULSE1}), - ) - - assert self._held(gathering) == {ChannelName.NOISE} - - def test_a_channel_the_reader_answered_for_settles_to_the_answer(self) -> None: - gathering = Gathering.empty().listing(recording("/audio/bass.wav", [ChannelName.PULSE1])) - - gathering = gathering.written_among( - Path("/audio/bass.wav"), - CHANNEL_SLOT, - frozenset({ChannelName.PULSE2}), - frozenset({ChannelName.PULSE1, ChannelName.PULSE2}), - ) - - assert self._held(gathering) == {ChannelName.PULSE2} - - def test_a_recording_the_setup_never_gathered_changes_nothing(self) -> None: - gathering = _mixed("bass") - - assert gathering.written_among(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset(), frozenset()) == gathering - - class TestTheListAPerRecordingRunConverts: """A run writing one reconstruction apiece converts whatever the list holds, unbounded.""" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 2fd08e384..fa8d97486 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -197,8 +197,8 @@ def test_no_generators_notifies_and_does_not_start( self, converter_logic: ConverterLogic, ) -> None: - converter_logic.set_joining_channels(frozenset()) _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 diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py index e39ae12b6..92c2aeab1 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_settings.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -23,17 +23,6 @@ def _joining(channels: List[ChannelName]) -> StemSettings: return StemSettings(channels=channels, bends=bending_channels(channels)) -class TestTheChannelsARunHandsOut: - def test_the_channels_a_recording_joins_with_are_what_the_run_enables(self) -> None: - assert _settings(TONES).enabled_channels == frozenset(TONES) - - def test_narrowing_the_joining_channels_takes_the_bends_they_carried(self) -> None: - narrowed = _settings(TONES).with_joining_channels(frozenset({ChannelName.PULSE1})) - - assert narrowed.joining.channels == [ChannelName.PULSE1] - assert ChannelName.TRIANGLE not in narrowed.joining.bends - - class TestTheCapARunHoldsTo: def test_a_cap_beyond_the_channels_there_are_is_held_to_them(self) -> None: settings = _settings(TONES).with_channel_cap(len(ChannelName) + 5) @@ -45,7 +34,7 @@ def test_a_cap_below_one_channel_is_refused(self) -> None: 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(TONES).with_channel_cap(3).with_joining_channels(frozenset({ChannelName.PULSE1})) + settings = _settings([ChannelName.PULSE1]).with_channel_cap(3) assert settings.effective_channel_cap == 3 diff --git a/tests/unit/sampletones_application/logic/main/sources/test_levels.py b/tests/unit/sampletones_application/logic/main/sources/test_levels.py index cef43f414..4ff37452e 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_levels.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_levels.py @@ -35,9 +35,6 @@ def test_a_recording_already_gathered_changes_nothing(self) -> None: 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_keeping_the_first_leaves_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) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py index 40a5181ce..46510ea4e 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_list.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -185,29 +185,3 @@ def test_a_bend_settles_the_same_way_a_channel_does(self) -> None: ) sources = SourceList().add_folder(gathered).toggled(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) assert sources.agreement(gathered.key, BEND_SLOT, ChannelName.TRIANGLE) == Agreement.ALL - - -class TestFlatteningTheList: - def test_a_folder_gives_up_the_recordings_it_stood_for(self) -> None: - gathered = folder("/audio", [recording("/audio/a.wav"), recording("/audio/b.wav")]) - sources = SourceList().add_folder(gathered).flattened() - - assert sources.row_count == 2 - assert sources.folder_root_of(Path("/audio/a.wav")) is None - - def test_the_recordings_keep_the_order_they_stood_in(self) -> None: - sources = SourceList().add_recording(recording("/other/a.wav")) - sources = sources.add_folder(folder("/audio", [recording("/audio/b.wav"), recording("/audio/c.wav")])) - - assert sources.flattened().paths == ( - Path("/other/a.wav"), - Path("/audio/b.wav"), - Path("/audio/c.wav"), - ) - - def test_the_recordings_keep_the_settings_they_stood_with(self) -> None: - gathered = folder("/audio", [recording("/audio/a.wav", [ChannelName.NOISE])]) - loose = SourceList().add_folder(gathered).flattened().recording(Path("/audio/a.wav")) - - assert loose is not None - assert loose.settings.channel_set == {ChannelName.NOISE} 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 7d1084efa..3f42a348d 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -68,7 +68,6 @@ def _view_model( other_operation_active=other_operation_active, 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, @@ -212,7 +211,6 @@ def test_a_row_holding_no_channel_offers_nothing_to_convert(self) -> None: ) assert view_model.has_input is False - assert view_model.playing_count == 0 assert view_model.convert_button_enabled is False From aef4c971ab4996f30223c0ebda3dedab3c293e64 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:43:27 +0200 Subject: [PATCH 065/130] Asked: the model for the ceiling a mix holds to --- .../coordinators/tabs/main.py | 7 +++---- .../logic/main/converter/gathering.py | 14 +++++++++++++ .../logic/main/converter/logic.py | 21 ++++++++++++------- .../logic/main/converter/view.py | 3 +-- .../logic/main/sources/levels.py | 7 ++++++- .../coordinators/tabs/test_main.py | 3 +++ .../logic/main/converter/test_gathering.py | 7 +++++++ 7 files changed, 48 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 5a619beda..7e75b4959 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -6,7 +6,6 @@ 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.conversion import MAX_STEM_SOURCES from sampletones_application.constants.output import OutputKind from sampletones_application.coordinators.tabs.hooks import MainTabHooks from sampletones_application.logic.instruction.library_manager import ( @@ -473,14 +472,14 @@ def _request_output(self, output: OutputKind) -> None: holds while the question stands, since the run is what the reader is being asked about. Every other switch takes effect straight away. """ - if not output.mixes or len(self._converter_logic.gathered_paths) <= MAX_STEM_SOURCES: + if not output.mixes or self._converter_logic.list_fits_a_mix: self._converter_logic.set_output(output) return self._converter_logic.refresh_view() self._stem_selection_window.open( self._converter_logic.gathered_rows, - MAX_STEM_SOURCES, + self._converter_logic.mix_ceiling, self._converter_logic.mix_only, ) @@ -569,7 +568,7 @@ def _mixing_beyond_room(self, found: Tuple[Path, ...]) -> bool: self._stem_selection_window.open( self._converter_logic.gathered_rows + offered, - MAX_STEM_SOURCES, + self._converter_logic.mix_ceiling, self._converter_logic.mix_only, ) return True diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py index a051183cf..9bdfecc19 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -43,11 +43,21 @@ 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.""" @@ -123,6 +133,10 @@ def settled( """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 toggled_throughout(self, slot: SettingsSlot, channel_name: ChannelName) -> Self: """The setup with ``channel_name`` settled the one way on every recording listed.""" return replace(self, sources=self.sources.toggled_throughout(slot, channel_name)) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 19efe0619..6e2a3d27c 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -4,7 +4,6 @@ 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.conversion import MAX_STEM_SOURCES from sampletones_application.constants.output import OutputKind from sampletones_application.constants.sources import SettingsField, SourceKind from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior @@ -134,6 +133,16 @@ 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.""" @@ -285,10 +294,8 @@ def toggle_folder_channel(self, root: Path, channel_name: ChannelName) -> None: 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 - key = SourceKey.folder(root) - held = gathering.sources.agreement(key, CHANNEL_SLOT, channel_name).settles_to - self._settle(self._state.with_gathering(gathering.settled(key, CHANNEL_SLOT, channel_name, held))) + 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.""" @@ -322,7 +329,7 @@ def set_output(self, output: OutputKind) -> None: gathering = self._state.gathering settled = ( - gathering.mixing_only(gathering.recordings[:MAX_STEM_SOURCES]) if output.mixes else gathering.unmixed() + 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)) @@ -335,7 +342,7 @@ def mix_only(self, paths: Sequence[Path]) -> None: 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)[:MAX_STEM_SOURCES]) + 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) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index e82253acc..8863094cc 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -2,7 +2,6 @@ from pathlib import Path from typing import FrozenSet, Optional, Tuple -from sampletones_application.constants.conversion import MAX_STEM_SOURCES 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 @@ -60,7 +59,7 @@ def compose_view( channel_cap=settings.effective_channel_cap, max_channel_cap=settings.max_channel_cap, hierarchy_mode=settings.hierarchy_mode, - max_sources=MAX_STEM_SOURCES, + max_sources=state.gathering.ceiling, selected_key=_selected_key(state), ) diff --git a/src/sampletones_application/logic/main/sources/levels.py b/src/sampletones_application/logic/main/sources/levels.py index e00899fde..4d5c6fc96 100644 --- a/src/sampletones_application/logic/main/sources/levels.py +++ b/src/sampletones_application/logic/main/sources/levels.py @@ -43,10 +43,15 @@ def count(self) -> int: 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 MAX_STEM_SOURCES - self.count + return self.ceiling - self.count def holds(self, path: Path) -> bool: return path in self.paths diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 59d9764e7..ce116c039 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -196,6 +196,7 @@ def _stems_coordinator( gathered: Tuple[Path, ...] = (), folder_rows: Tuple[MagicMock, ...] = (), room: int = MAX_STEM_SOURCES, + ceiling: int = MAX_STEM_SOURCES, ) -> MainTabCoordinator: coordinator = MainTabCoordinator.__new__(MainTabCoordinator) coordinator._hooks = _hooks(operation_active=operation_active) @@ -207,6 +208,8 @@ def _stems_coordinator( 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._stem_selection_window = MagicMock() coordinator._scan_window = MagicMock() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py index 11ddd2881..7d15abd70 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -80,6 +80,13 @@ def test_a_recording_arriving_at_a_full_setup_reaches_neither_side(self) -> None 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: def test_a_slot_settles_on_the_recording_named(self) -> None: From 547608e8e907dea2db0058dc03968eb1d6a8a35b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 11:52:30 +0200 Subject: [PATCH 066/130] Stated: four docstrings by what the code does --- .../logic/main/converter/logic.py | 6 +++--- .../logic/main/converter/run.py | 2 +- .../ui/elements/layout/geometry.py | 4 ++-- src/sampletones_application/ui/elements/stems/offer.py | 7 ++++--- src/sampletones_application/ui/elements/stems/shape.py | 2 +- .../ui/panels/main/converter/menus.py | 10 +++++----- 6 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 6e2a3d27c..a08ac8c7d 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -334,7 +334,7 @@ def set_output(self, output: OutputKind) -> None: 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 the ones the list does not hold yet. + """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. @@ -459,8 +459,8 @@ 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 setup it reported on - is no longer the one on screen. + 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)) diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py index af4c60e42..7b2d53eb2 100644 --- a/src/sampletones_application/logic/main/converter/run.py +++ b/src/sampletones_application/logic/main/converter/run.py @@ -140,7 +140,7 @@ def cancel(self) -> None: self._service.cancel() def abandon(self) -> None: - """Gives up a request that never reached the service, which is a cancellation all the same.""" + """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: diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py index 99f31a8ae..e38b2a853 100644 --- a/src/sampletones_application/ui/elements/layout/geometry.py +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Final, Tuple +from typing import Final, Self, Tuple Window = Tuple[int, int] @@ -31,7 +31,7 @@ class RowGeometry: pitch: float @classmethod - def unmeasured(cls, *, overscan: int) -> "RowGeometry": + def unmeasured(cls, *, overscan: int) -> Self: """The reading a list starts from, before it has drawn a row to measure.""" return cls(overscan=overscan, pitch=UNMEASURED) diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index 97612cee9..9c979015d 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Final @dataclass(frozen=True) @@ -26,7 +27,7 @@ class StemsListOffer: picking: bool -GATHERED_SOURCES: StemsListOffer = StemsListOffer( +GATHERED_SOURCES: Final[StemsListOffer] = StemsListOffer( master_box=False, removal=True, keeps_last_row=False, @@ -35,7 +36,7 @@ class StemsListOffer: picking=False, ) -RECORDED_ASSIGNMENT: StemsListOffer = StemsListOffer( +RECORDED_ASSIGNMENT: Final[StemsListOffer] = StemsListOffer( master_box=True, removal=True, keeps_last_row=True, @@ -44,7 +45,7 @@ class StemsListOffer: picking=False, ) -PICKED_SOURCES: StemsListOffer = StemsListOffer( +PICKED_SOURCES: Final[StemsListOffer] = StemsListOffer( master_box=True, removal=False, keeps_last_row=False, diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index f57384697..16770b3a0 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -39,7 +39,7 @@ def of(cls, view_model: StemsListViewModel, open_folders: OpenFolders) -> Self: A folder opening or closing reshapes the list, since the region its recordings stand in is built and taken down with it, and so does a recording leaving the folder, since the - region then holds a row for something the list no longer stands for. + region draws a row for each one the list still stands for. """ return cls( columns=view_model.channels_in_play, diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index 172555cb2..5fcbfcd66 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, List, Optional, Tuple +from typing import Callable, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -21,10 +21,10 @@ from sampletones_shared.types.callback import PathCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -LEVEL_ABOVE: int = -1 -LEVEL_BELOW: int = 1 -POSITION_EARLIER: int = -1 -POSITION_LATER: int = 1 +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] From 160a9da6a3eabe63b3486b6ce0722f7f46229077 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 12:20:34 +0200 Subject: [PATCH 067/130] Tightened: the tests that read their own mocks back --- .../coordinators/tabs/test_main.py | 15 +++-- .../logic/main/converter/test_destination.py | 5 +- .../logic/main/converter/test_gathering.py | 13 +++-- .../logic/main/converter/test_logic.py | 28 ++++----- .../logic/main/converter/test_run.py | 7 ++- .../logic/main/converter/test_settings.py | 5 +- .../logic/main/converter/test_setup.py | 5 +- .../logic/main/sources/test_derive.py | 7 ++- .../logic/main/sources/test_levels.py | 13 +++-- .../logic/main/sources/test_list.py | 15 ++--- .../logic/main/sources/test_slots.py | 9 +-- .../ui/elements/stems/test_folder.py | 15 ++--- .../ui/elements/stems/test_list.py | 58 ++++++++++--------- 13 files changed, 108 insertions(+), 87 deletions(-) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index ce116c039..8fadc6757 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -195,6 +195,7 @@ def _stems_coordinator( 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: @@ -211,6 +212,7 @@ def _stems_coordinator( 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 @@ -258,13 +260,14 @@ def test_turning_away_from_a_mix_takes_effect_at_once(self) -> None: 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)) - coordinator = _stems_coordinator(mixes=False, gathered=gathered) + listed = _rows_holding(*[1] * len(gathered)) + coordinator = _stems_coordinator(mixes=False, gathered=gathered, gathered_rows=listed) coordinator._request_output(OutputKind.MIXED) coordinator._converter_logic.set_output.assert_not_called() rows, room, answer = coordinator._stem_selection_window.open.call_args.args - assert rows == coordinator._converter_logic.gathered_rows + assert rows == listed assert room == MAX_STEM_SOURCES assert answer == coordinator._converter_logic.mix_only @@ -462,8 +465,12 @@ def test_a_folder_overflowing_the_mix_asks_which_to_mix(self, tmp_path: Path) -> 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, room=0) - coordinator._converter_logic.gathered_rows = _rows_holding(*[1] * MAX_STEM_SOURCES) + 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)) diff --git a/tests/unit/sampletones_application/logic/main/converter/test_destination.py b/tests/unit/sampletones_application/logic/main/converter/test_destination.py index 9f18beff8..98757f3d4 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_destination.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_destination.py @@ -3,9 +3,10 @@ from sampletones_application.logic.main.converter.destination import Destination from sampletones_core.configs import Config +from tests.suite.base import BaseTestSuite -class TestTheDocumentARunIsMaking: +class TestTheDocumentARunIsMaking(BaseTestSuite): """A run of one names itself after the reconstruction it writes.""" def test_the_output_names_the_reconstruction(self) -> None: @@ -32,7 +33,7 @@ def test_a_completed_run_names_what_it_wrote(self) -> None: assert (destination.output_path, destination.reconstruction_name) == (written, "mixed") -class TestWhereAMixWrites: +class TestWhereAMixWrites(BaseTestSuite): def test_a_mix_with_nobody_taking_part_stands_where_it_was(self) -> None: destination = Destination.unset() sources: Tuple[Path, ...] = () diff --git a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py index 7d15abd70..2daad7abc 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_gathering.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_gathering.py @@ -6,6 +6,7 @@ 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 @@ -35,7 +36,7 @@ def _mixed_names(gathering: Gathering) -> List[str]: return [path.stem for path in gathering.levels.paths] -class TestGatheringRecordings: +class TestGatheringRecordings(BaseTestSuite): def test_a_recording_stands_in_the_list_and_on_a_level(self) -> None: gathering = _mixed("bass") @@ -66,7 +67,7 @@ def test_a_recording_leaves_both_sides_of_the_setup(self) -> None: assert gathering.recording(Path("/audio/bass.wav")) is None -class TestTheCeilingAMixHoldsTo: +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: @@ -88,7 +89,7 @@ def test_a_list_past_the_ceiling_does_not(self) -> None: assert _listed(*[f"source{index}" for index in range(MAX_STEM_SOURCES + 1)]).fits_a_mix is False -class TestSettlingOneRecording: +class TestSettlingOneRecording(BaseTestSuite): def test_a_slot_settles_on_the_recording_named(self) -> None: gathering = _mixed("bass", "lead").written( Path("/audio/bass.wav"), @@ -108,7 +109,7 @@ def test_settling_a_recording_the_setup_never_gathered_changes_nothing(self) -> assert gathering.written(Path("/audio/stranger.wav"), CHANNEL_SLOT, frozenset()) == gathering -class TestTheListAPerRecordingRunConverts: +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: @@ -139,7 +140,7 @@ def test_a_folder_goes_with_everything_it_stands_for(self) -> None: assert gathering.count == 0 -class TestTurningToAMix: +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: @@ -169,7 +170,7 @@ def test_a_recording_the_list_never_gathered_joins_it(self) -> None: assert _mixed_names(gathering) == ["a", "stranger"] -class TestTurningAwayFromAMix: +class TestTurningAwayFromAMix(BaseTestSuite): def test_the_list_stands_and_the_picking_order_goes(self) -> None: gathering = _mixed("bass", "lead").unmixed() diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index fa8d97486..055808db8 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -126,7 +126,7 @@ def _started_plan(converter_logic: ConverterLogic, service: MagicMock) -> GroupC return plan -class TestCancelDuringLibraryGeneration: +class TestCancelDuringLibraryGeneration(BaseTestSuite): """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.""" @@ -190,7 +190,7 @@ def test_wait_poll_does_not_emit_a_zero_progress_view( scheduled.assert_called_once() -class TestNoChannelsGuard: +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( @@ -209,7 +209,7 @@ def test_no_generators_notifies_and_does_not_start( assert _phase(converter_logic) == ConversionPhase.IDLE -class TestNothingToConvertGuard: +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( @@ -236,7 +236,7 @@ def test_a_mix_runs_without_a_recording_ever_being_picked( assert plan.sources == (Path("/audio/a.wav"), Path("/audio/b.wav")) -class TestOverwriteGuard: +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 @@ -364,7 +364,7 @@ def test_a_folder_starts_without_asking( assert _phase(converter_logic) == ConversionPhase.WAITING -class TestStartConversionGate: +class TestStartConversionGate(BaseTestSuite): """A conversion refuses to start while another exclusive operation is active, so two heavy processes cannot run at once.""" @@ -397,7 +397,7 @@ def test_proceeds_when_nothing_is_active( assert _phase(converter_logic) == ConversionPhase.WAITING -class TestWhatTheSetupNamesItselfBy: +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( @@ -431,7 +431,7 @@ def test_several_rows_name_none_of_them(self, converter_logic: ConverterLogic) - assert _view(converter_logic).input_path is None -class TestWhatACompletedConversionLeaves: +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.""" @@ -480,7 +480,7 @@ def test_a_batch_loads_the_folder_and_one_file_loads_itself( converter_logic.on_load_directory.assert_called_once_with() -class TestFailureReturnsToIdle: +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.""" @@ -500,7 +500,7 @@ def test_failure_schedules_return_to_idle_and_reports( converter_logic.on_error.assert_called_once() -class TestActivePhases: +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.""" @@ -532,7 +532,7 @@ def test_a_converter_that_has_run_nothing_is_idle(self, converter_logic: Convert assert (_phase(converter_logic), converter_logic.is_active) == (ConversionPhase.IDLE, False) -class TestGatheringRecordings: +class TestGatheringRecordings(BaseTestSuite): """The rows a reader gathers, as the panel reads them back.""" def _names(self, converter_logic: ConverterLogic) -> List[str]: @@ -608,7 +608,7 @@ def test_a_row_reports_the_level_it_landed_on(self, converter_logic: ConverterLo ] -class TestAnsweringWhichRecordingsToMix: +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.""" @@ -651,7 +651,7 @@ def test_the_run_it_leaves_is_a_mix(self, converter_logic: ConverterLogic) -> No assert converter_logic.mixes -class TestWhatTheGatheredRecordingsRun: +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( @@ -717,7 +717,7 @@ def test_the_configuration_reaches_the_service_with_the_plan( assert started_config == converter_logic._config_manager.config -class TestAFolderInTheList: +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: @@ -820,7 +820,7 @@ def test_turning_to_a_mix_gives_up_the_folder( assert [row.stands_for_a_folder for row in rows] == [False, False] -class TestTheStemsView: +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: diff --git a/tests/unit/sampletones_application/logic/main/converter/test_run.py b/tests/unit/sampletones_application/logic/main/converter/test_run.py index 6cb54ac08..2df1cc299 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_run.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -21,6 +21,7 @@ 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"),) @@ -63,7 +64,7 @@ def driver() -> Driver: return Driver() -class TestWhereARunStands: +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: @@ -111,7 +112,7 @@ def test_a_settled_run_holds_nothing( assert (driver.run.phase, driver.run.is_active) == (phase, False) -class TestWhatARunReports: +class TestWhatARunReports(BaseTestSuite): def test_a_request_says_it_is_waiting(self, driver: Driver) -> None: driver.run.wait() @@ -167,7 +168,7 @@ def test_library_progress_once_the_run_is_under_way_reports_nothing(self, driver assert len(driver.reports) == reported -class TestWhatACompletedRunHandsOver: +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.""" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_settings.py b/tests/unit/sampletones_application/logic/main/converter/test_settings.py index 92c2aeab1..e2cca879d 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_settings.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_settings.py @@ -6,6 +6,7 @@ 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] @@ -23,7 +24,7 @@ def _joining(channels: List[ChannelName]) -> StemSettings: return StemSettings(channels=channels, bends=bending_channels(channels)) -class TestTheCapARunHoldsTo: +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) @@ -39,7 +40,7 @@ def test_the_cap_stands_whatever_a_row_holds(self) -> None: assert settings.effective_channel_cap == 3 -class TestTheShapeOfTheRun: +class TestTheShapeOfTheRun(BaseTestSuite): def test_the_run_is_named_as_a_mix(self) -> None: assert _settings(TONES).with_output(OutputKind.MIXED).mixes is True diff --git a/tests/unit/sampletones_application/logic/main/converter/test_setup.py b/tests/unit/sampletones_application/logic/main/converter/test_setup.py index baa305601..794fd292c 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_setup.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_setup.py @@ -17,6 +17,7 @@ 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] @@ -59,7 +60,7 @@ def _state( ) -class TestWhatAPerRecordingRunConverts: +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: @@ -94,7 +95,7 @@ def test_a_setup_holding_nothing_names_no_plan(self) -> None: assert conversion_plan(_state(output=OutputKind.PER_RECORDING)) is None -class TestWhatAMixRuns: +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"])) diff --git a/tests/unit/sampletones_application/logic/main/sources/test_derive.py b/tests/unit/sampletones_application/logic/main/sources/test_derive.py index f9e22f3ab..144969185 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_derive.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_derive.py @@ -5,6 +5,7 @@ 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 @@ -25,7 +26,7 @@ def _gathered( return sources, MixLevels.of([[_path(name) for name in level] for level in levels]) -class TestWhatEachRecordingBringsToTheSetup: +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]) @@ -54,7 +55,7 @@ def test_a_bend_the_recording_carries_reaches_the_entry(self) -> None: assert setup.stems.entries[0].settings.bends == [ChannelName.TRIANGLE] -class TestTheSetupTheLevelsAmountTo: +class TestTheSetupTheLevelsAmountTo(BaseTestSuite): def test_a_recordings_position_is_its_stem_id(self) -> None: sources, levels = _gathered(["a", "b"]) @@ -104,7 +105,7 @@ def test_the_cap_and_the_mode_travel_with_the_setup(self) -> None: assert (setup.stems.channel_cap, setup.stems.hierarchy.mode) == (2, HierarchyMode.ROUND_ROBIN) -class TestARecordingThatTakesNoPart: +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]: diff --git a/tests/unit/sampletones_application/logic/main/sources/test_levels.py b/tests/unit/sampletones_application/logic/main/sources/test_levels.py index 4ff37452e..7a8c88718 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_levels.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_levels.py @@ -5,6 +5,7 @@ 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: @@ -19,7 +20,7 @@ def _shape(levels: MixLevels) -> List[List[str]]: return [[path.stem for path in level] for level in levels.levels] -class TestGathering: +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: @@ -44,7 +45,7 @@ def test_asking_after_a_recording_that_was_never_gathered_fails(self) -> None: _levels(["bass"]).level_of(_path("lead")) -class TestTheCeilingAMixHolds: +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: @@ -62,7 +63,7 @@ def test_a_recording_arriving_at_a_full_mix_leaves_it_as_it_stands(self) -> None assert levels.add(_path("one_more")).count == MAX_STEM_SOURCES -class TestMovesWithinALevel: +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: @@ -73,7 +74,7 @@ def test_a_move_off_the_end_of_a_level_changes_nothing(self) -> None: assert _shape(levels.move_within_level(_path("bass"), -1)) == _shape(levels) -class TestMovesBetweenLevels: +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"]] @@ -96,7 +97,7 @@ def test_a_recording_already_alone_stays_where_it_is(self) -> None: assert _shape(levels.isolate(_path("bass"))) == _shape(levels) -class TestDropOntoARow: +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"] @@ -107,7 +108,7 @@ def test_dropping_a_recording_on_itself_changes_nothing(self) -> None: assert _shape(levels.move_onto(_path("bass"), _path("bass"))) == _shape(levels) -class TestDropOntoAStrip: +class TestDropOntoAStrip(BaseTestSuite): """A strip is the gap between two bands, counted from the one above the first level.""" @pytest.mark.parametrize( diff --git a/tests/unit/sampletones_application/logic/main/sources/test_list.py b/tests/unit/sampletones_application/logic/main/sources/test_list.py index 46510ea4e..4f4e5bd38 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_list.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_list.py @@ -5,10 +5,11 @@ 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: +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"),) @@ -25,7 +26,7 @@ def test_a_path_already_standing_leaves_the_list_as_it_is(self) -> None: assert sources.rows == (first,) -class TestGatheringAFolder: +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) @@ -68,7 +69,7 @@ def test_a_recording_the_reader_named_belongs_to_no_folder(self) -> None: assert sources.folder_root_of(Path("/audio/a.wav")) is None -class TestAFolderStandingForNothing: +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: @@ -85,7 +86,7 @@ def test_a_folder_whose_recordings_another_holds_leaves_it_as_it_is(self) -> Non assert sources.row_count == 1 -class TestLettingSourcesGo: +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) @@ -109,7 +110,7 @@ def test_a_recording_the_reader_named_goes_on_its_own(self) -> None: assert sources.paths == (Path("/audio/b.wav"),) -class TestSettlingWhatARowStandsFor: +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) @@ -135,7 +136,7 @@ def test_settling_a_folder_leaves_the_rows_beside_it_alone(self) -> None: assert untouched.settings.channel_set == {ChannelName.PULSE1} -class TestHowAFolderReads: +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) @@ -161,7 +162,7 @@ def test_a_row_the_list_has_none_of_reads_as_none(self) -> None: ) -class TestOneGestureOnARow: +class TestOneGestureOnARow(BaseTestSuite): def test_a_folder_its_recordings_disagree_on_settles_on_all_of_them(self) -> None: gathered = folder( "/audio", diff --git a/tests/unit/sampletones_application/logic/main/sources/test_slots.py b/tests/unit/sampletones_application/logic/main/sources/test_slots.py index 4308e1353..c1e692043 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_slots.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_slots.py @@ -4,10 +4,11 @@ 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: +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: @@ -24,7 +25,7 @@ def test_a_bend_reaches_no_channel_the_recording_leaves_alone(self) -> None: assert BEND_SLOT.offered(settings([])) == frozenset() -class TestSettlingTheChannelsARecordingOccupies: +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} @@ -45,7 +46,7 @@ def test_a_channel_settled_off_takes_the_bend_it_carried(self) -> None: assert settled.bends == [] -class TestSettlingTheChannelsARecordingBends: +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) @@ -66,7 +67,7 @@ def test_settling_a_bend_leaves_the_channels_as_they_stand(self) -> None: assert settled.channels == held.channels -class TestTheSlotsARecordingOffers: +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)) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 72c2d6fbf..02585607f 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -36,6 +36,7 @@ StemsListViewModel, ) from sampletones_core.constants.enums import ChannelName +from tests.suite.base import BaseTestSuite ROOT_TAG = "test_root" PREFIX = "test.stems" @@ -161,7 +162,7 @@ def folder_without(row: StemRowViewModel, leaving: StemRowViewModel) -> StemRowV return row.model_copy(update={"held": held}) -class TestAClosedFolder: +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: @@ -186,7 +187,7 @@ def test_a_recording_carries_no_marker(self, stems_list: GUIStemsList) -> None: assert not dpg.does_item_exist(twisty_of(bass)) -class TestOpeningAFolder: +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: @@ -232,7 +233,7 @@ def test_a_folder_stays_open_across_a_new_reading(self, stems_list: GUIStemsList assert dpg.does_item_exist(region_of(sources)) -class TestARecordingInsideAFolder: +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: @@ -257,7 +258,7 @@ def test_its_box_reports_the_recording_it_belongs_to(self, stems_list: GUIStemsL assert settled == [(held.key, frozenset({ChannelName.TRIANGLE}))] -class TestDoubleClick: +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: @@ -287,7 +288,7 @@ def test_a_double_clicked_folder_sounds_nothing(self, stems_list: GUIStemsList) assert opened == [] -class TestAFolderThatLeaves: +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: @@ -310,7 +311,7 @@ def double_click(tag: str) -> None: raise AssertionError("the list registers no double-click handler") -class TestARecordingThatLeavesAFolder: +class TestARecordingThatLeavesAFolder(BaseTestSuite): """A recording taken out from inside an open folder leaves it the way a loose one leaves.""" @staticmethod @@ -358,7 +359,7 @@ def read(_tag: str) -> float: return read -class TestWhereAnOpenFolderStands: +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.""" 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 8452a39b2..429b6f187 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -35,6 +35,7 @@ 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 StemsListOffer +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 @@ -45,9 +46,11 @@ ) from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.callback import Callback +from tests.suite.base import BaseTestSuite 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 @@ -155,17 +158,16 @@ def view( 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(dpg.get_item_children(TAGS.handlers(suffix), 1)[-1]) def folder_row( @@ -194,16 +196,17 @@ def folder_row( ) -class TestFolderRows: +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) -> 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)) == "sources (3)" + 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) -> None: stems_list = build(layout_config) @@ -253,7 +256,7 @@ def test_a_folders_box_reports_the_channel_it_settles(self, dpg_context: None, l assert toggled == [(sources.key, ChannelName.PULSE1)] -class TestRows: +class TestRows(BaseTestSuite): def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass") @@ -315,7 +318,7 @@ def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layou assert stems_list.row("nothing") is None -class TestLevels: +class TestLevels(BaseTestSuite): def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) rows = ( @@ -325,8 +328,9 @@ 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_draggable_list_opens_a_strip_above_each_level_and_below_the_last( self, dpg_context: None, layout_config @@ -340,10 +344,10 @@ 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, @@ -367,7 +371,7 @@ def test_a_list_without_dragging_carries_no_payload_and_no_strip( 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, removal=True) @@ -386,7 +390,7 @@ 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, @@ -427,7 +431,7 @@ 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: +class TestGestures(BaseTestSuite): def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None: reported: List[Tuple[str, FrozenSet[ChannelName]]] = [] stems_list = build(layout_config) @@ -454,7 +458,7 @@ def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_confi assert removed == [bass.key] -class TestBusyState: +class TestBusyState(BaseTestSuite): def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass") @@ -477,7 +481,7 @@ 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: @@ -506,7 +510,7 @@ 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: +class TestOfferedChannels(BaseTestSuite): def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass", channels=frozenset({ChannelName.PULSE1}), offered_channels=frozenset({ChannelName.PULSE1})) @@ -534,7 +538,7 @@ 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: +class TestMasterCheckbox(BaseTestSuite): 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_box=True) playing = row("bass") @@ -594,7 +598,7 @@ 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: +class TestMutedChannels(BaseTestSuite): def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) bass = row("bass") @@ -626,7 +630,7 @@ 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: +class TestCollapsedLevels(BaseTestSuite): def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config, dragging=False) rows = ( @@ -637,7 +641,7 @@ 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.tags.table) - assert not dpg.does_item_exist(compose_tag(PREFIX, SUF_LEVEL, "0", SUF_TEXT)) + 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)) @@ -652,11 +656,11 @@ 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.tags.table) - 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 dpg.does_item_exist(TAGS.level(0, SUF_TEXT)) + assert dpg.does_item_exist(TAGS.level(1, SUF_TEXT)) -class TestActivation: +class TestActivation(BaseTestSuite): def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> None: activated: List[str] = [] stems_list = build(layout_config, dragging=False) @@ -681,7 +685,7 @@ def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, la assert dpg.get_value(row_tag(bass, SUF_TEXT)) is False -class TestTheHeading: +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) -> None: @@ -699,7 +703,7 @@ def test_a_banded_list_names_them_too(self, dpg_context: None, layout_config) -> assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) -class TestTheWell: +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.""" From b1a3f4400bc64b91ea4ba3b1cac3c8451b26d2eb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 12:25:40 +0200 Subject: [PATCH 068/130] Moved: the render-thread crossings into a document of their own --- docs/development/architecture.md | 12 +--- docs/development/render-thread.md | 63 +++++++++++++++++++ docs/index.md | 1 + .../utils/gui/frame.py | 9 +++ 4 files changed, 74 insertions(+), 11 deletions(-) create mode 100644 docs/development/render-thread.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 04054c086..a11f583c8 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -67,17 +67,7 @@ This decouples widget construction (which happens during `create_panel()`) from ### 6. DearPyGui's context belongs to the render thread -The thread that created the DearPyGui context is the only one that may build, configure, or destroy an item, so work reaching the interface from anywhere else arrives on that thread first. Two directions cross it. - -**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 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 is scheduled through `FrameCallbackManager`.** 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` names the frame the work is picked up on, and is how a callback waits for one. +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 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/index.md b/docs/index.md index f6443d609..d799199dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -64,6 +64,7 @@ The [**development**](development/) section is for contributors. - [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/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() From 3c47f5672670c503d287bbe5a97479ab317c9b54 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 12:28:16 +0200 Subject: [PATCH 069/130] Stated: each fact in the document that owns it --- docs/development/architecture.md | 6 ++---- docs/development/keyboard.md | 21 ++++++++++----------- docs/development/palette.md | 11 +++++------ docs/development/vocabularies.md | 6 +----- 4 files changed, 18 insertions(+), 26 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index a11f583c8..e6d8e067a 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -108,7 +108,7 @@ DearPyGui gives every key handler the same global reach, so priority and consume 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. -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 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). +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 @@ -140,9 +140,7 @@ Two mechanisms keep the codebase aligned with this document. 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. - -**A contract and the code that meets it change together.** A change that alters a contract this document states lands with the document edit that states it, and a deviation it knowingly leaves behind lands with a ledger entry — `guidelines.md` § Documents holds the general rule. Every branch therefore leaves an updated contract, a recorded deviation, or both, which is what a later reader has to go on. +**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. --- diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md index fd245d41c..126d9f06b 100644 --- a/docs/development/keyboard.md +++ b/docs/development/keyboard.md @@ -14,13 +14,14 @@ mechanism behind both. ## The dispatcher -DearPyGui delivers a press to every registered key handler with the same global reach, so priority -and consume semantics exist where the application builds them. A single `KeyRouter` +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 consume mechanism the framework leaves -available. +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 @@ -70,8 +71,8 @@ lifetime, and the built-in `MODAL` scope routes each press to the top of the sta outranks the panel and shortcut scopes, every scope beneath it reads the keyboard as though the application held no dialogs at all. -The router is constructed at the composition root and injected into every consumer (architecture -principle 7); its one global handler is bound in `shell.py` once the DPG context exists. +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). --- @@ -81,11 +82,9 @@ One key table (`utils/gui/keyboard/keys.py`) reads a key both ways — the name code a press carries — and one combination type, `KeyCombination`, 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. +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 diff --git a/docs/development/palette.md b/docs/development/palette.md index 266d2796e..854cf7785 100644 --- a/docs/development/palette.md +++ b/docs/development/palette.md @@ -11,12 +11,11 @@ token, resolved where it is drawn. This document holds the mechanism. ## A color is a token -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. +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 diff --git a/docs/development/vocabularies.md b/docs/development/vocabularies.md index 95c7797b0..9814868d7 100644 --- a/docs/development/vocabularies.md +++ b/docs/development/vocabularies.md @@ -64,10 +64,6 @@ language_manager[ ## Widget tags -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 @@ -75,7 +71,7 @@ it. Each part is lowercased and its whitespace runs become single underscores, s 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 -from the joiner, so a fragment reads as the segment it names and either end composes onto it. +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` From 7d23db09f14e9bfc6ba00d908f5d7f057fc6fac0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 12:30:09 +0200 Subject: [PATCH 070/130] Collapsed: three test rules into the one they are facets of --- docs/development/guidelines.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 8d9713b89..cb1affd0f 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 @@ -94,9 +94,9 @@ These rules govern the Python in this repository. They complement 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. 1. Prefer fixtures over factories, and define shared fixtures in an appropriate place. -1. **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, 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. Two mechanics follow: + - **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. + - **A literal shipped value needs a stated reason.** Write one only where the value itself is the contract — a file format's constant, a value another system reads back, an interoperability requirement — and say so in the case. Asserting against the named constant that defines the value (`DEFAULT_MAX_FPS`, `DEFAULT_SCHEME_NAME`) states where the value comes from and is welcome; a bare literal standing for the same thing is the pin this rule forbids. 1. Values that must match by contract are asserted to match, never hardcoded — e.g. project metadata at creation or after a save/load round-trip is held against its source, never against a version string. 1. Unit tests may mock system boundaries (file I/O, external services, IPC channels), but must not mock the domain logic that is the subject of the test. Integration tests must exercise real computation pipelines against real (synthetically built) data. 1. When a test expectation diverges from the production code's actual 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. From 2e177750a7c4df138eab95a4f557601d11c284f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 12:35:15 +0200 Subject: [PATCH 071/130] Recorded: four places the branch stands apart from the contract --- docs/development/bugs-and-todos.md | 26 ++++++++++++++ tests/benchmarks/test_converter_load.py | 45 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 94a4d32c2..7ee83cb41 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -144,6 +144,32 @@ again. 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. Measured by `tests/benchmarks/test_converter_load.py` on a folder of ten + thousand: 38 ms of row reading and 53 ms of entry derivation, so a click on a channel box spends + about a tenth of a second on model work before a widget is touched — all of it repeated, 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` diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 72a69b66c..01736caab 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -7,9 +7,14 @@ 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 @@ -34,6 +39,7 @@ 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 @@ -64,6 +70,21 @@ def gathering_of(root: Path, count: int) -> Gathering: 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 seconds(work: Callable[[], object]) -> float: """The best of several runs, which is the reading least disturbed by other load.""" readings: List[float] = [] @@ -146,6 +167,30 @@ def test_it_reads_a_row_for_every_recording_a_folder_holds(self) -> None: 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. From 281fab6da958fbdfc529a29f77c1fcc4dd4c69f3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 15:56:00 +0200 Subject: [PATCH 072/130] Held: a folder walk's claim until its answer has gone out --- .../logic/main/sources/scan.py | 19 ++++++----- .../logic/main/sources/test_scan.py | 32 +++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/sampletones_application/logic/main/sources/scan.py b/src/sampletones_application/logic/main/sources/scan.py index 037b15d47..43d08b7b7 100644 --- a/src/sampletones_application/logic/main/sources/scan.py +++ b/src/sampletones_application/logic/main/sources/scan.py @@ -61,23 +61,22 @@ def stop(self) -> None: @concurrent(wait=False) def _walk(self, root: Path, answer: FoundCallback) -> None: - """Reads the tree, lets the walk go, and reports how it ended, in that order. + """Reads the tree, reports how it ended, and lets the walk go, in that order. - The walk is let go whatever becomes of it, so a reading that fails partway leaves the next - folder free to be asked for. + 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) - stopped = self._stopping.is_set() + if self._stopping.is_set(): + self.call(self.on_stopped) + return + + self.call(answer, root, tuple(sorted(found))) finally: self._running.clear() - if stopped: - self.call(self.on_stopped) - return - - self.call(answer, root, tuple(sorted(found))) - def _gather(self, root: Path) -> List[Path]: """The recordings met below ``root``, giving up at the entry the reader stops the walk on. diff --git a/tests/unit/sampletones_application/logic/main/sources/test_scan.py b/tests/unit/sampletones_application/logic/main/sources/test_scan.py index a20850f12..4464866cb 100644 --- a/tests/unit/sampletones_application/logic/main/sources/test_scan.py +++ b/tests/unit/sampletones_application/logic/main/sources/test_scan.py @@ -180,3 +180,35 @@ def test_a_folder_asked_for_while_one_is_read_is_turned_away( 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] From 98012bd05473133b45514f924a5a77f015702f3d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 15:56:05 +0200 Subject: [PATCH 073/130] Closed: the scan window on the gesture that stops the walk --- .../ui/panels/dialogs/scanning.py | 8 +- .../ui/panels/dialogs/test_scanning.py | 89 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py diff --git a/src/sampletones_application/ui/panels/dialogs/scanning.py b/src/sampletones_application/ui/panels/dialogs/scanning.py index c37eb88a7..e190a81ef 100644 --- a/src/sampletones_application/ui/panels/dialogs/scanning.py +++ b/src/sampletones_application/ui/panels/dialogs/scanning.py @@ -102,7 +102,13 @@ def create_window(self) -> None: ) def _stop(self) -> None: - self._rolling = False + """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: 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..06bbe2b2b --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py @@ -0,0 +1,89 @@ +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 +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 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 From 9d143fb158cc5abca213d2c2f85a7822086a4095 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 15:56:10 +0200 Subject: [PATCH 074/130] Named: what a folder holding no recordings answers with --- .../coordinators/tabs/main.py | 2 +- src/sampletones_config/lang/en.yaml | 3 ++- .../coordinators/tabs/test_main.py | 12 ++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7e75b4959..7af627079 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -543,7 +543,7 @@ def _nothing_below(self, directory_path: Path) -> None: logger.info(f"No recordings below {directory_path}.") self._dialogs.show_info( TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, - self._language_manager["main.converter.message.status_no_files"], + self._language_manager["main.converter.message.scan_nothing_below"], self._language_manager["main.converter.title.scan_dialog"], ) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5f5a3415a..7c2e308d9 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -371,7 +371,7 @@ 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..." @@ -389,6 +389,7 @@ 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" diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 8fadc6757..926a538b4 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -27,6 +27,7 @@ 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: @@ -301,6 +302,17 @@ def test_a_busy_application_ignores_the_gesture(self, tmp_path: Path) -> None: coordinator._converter_logic.gather_folder.assert_not_called() + 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) + + _add_folder(coordinator, root) + + 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 the setup, whichever run it names.""" From 36838cdba749532ebffa5bee930e34974ae5e827 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 15:56:15 +0200 Subject: [PATCH 075/130] Read: the benchmark's growth without the collector's share in it --- docs/development/bugs-and-todos.md | 15 ++++++----- tests/benchmarks/test_converter_load.py | 36 +++++++++++++++++++------ 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 7ee83cb41..bf0cd4dee 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -131,7 +131,7 @@ again. `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 behaviour cannot see a hook left unset. Building + 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 @@ -165,11 +165,14 @@ again. 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. Measured by `tests/benchmarks/test_converter_load.py` on a folder of ten - thousand: 38 ms of row reading and 53 ms of entry derivation, so a click on a channel box spends - about a tenth of a second on model work before a widget is touched — all of it repeated, 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. + 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` diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 01736caab..3379e5692 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -1,3 +1,4 @@ +import gc from itertools import count from pathlib import Path from time import process_time @@ -47,7 +48,7 @@ SMALL_FOLDER: Final[int] = 1_000 LARGE_FOLDER: Final[int] = 10_000 REPEATS: Final[int] = 3 -GROWTH_ALLOWANCE: Final[float] = 1.6 +GROWTH_ALLOWANCE: Final[float] = 2.0 REGION_HEIGHT: Final[float] = 264.0 ROW_PITCH: Final[float] = 36.0 OVERSCAN: Final[int] = 4 @@ -86,12 +87,24 @@ def state_of(root: Path, count: int) -> ConverterState: def seconds(work: Callable[[], object]) -> float: - """The best of several runs, which is the reading least disturbed by other load.""" - readings: List[float] = [] - for _ in range(REPEATS): - started = process_time() - work() - readings.append(process_time() - started) + """The best of several runs, taken with the collector held off so the reading is the work's. + + The collector runs on how much is live rather than on what the work does, so a run building ten + times the objects meets it more often and reads as more than ten times the cost — enough to + swallow the growth these bounds are about. Held off for the reading, what is left is how the + work itself follows the length of the list, and the objects are collected once it comes back. + """ + collecting = gc.isenabled() + gc.disable() + try: + readings: List[float] = [] + for _ in range(REPEATS): + started = process_time() + work() + readings.append(process_time() - started) + finally: + if collecting: + gc.enable() return min(readings) @@ -111,7 +124,14 @@ def growth(small: Callable[[], object], large: Callable[[], object]) -> Tuple[fl def linear(one: float) -> float: - """The most a reading may cost while the work it does still follows the list's length.""" + """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 From 2e7f4a221973e1fa81bc84b2860e29cbbcc8809c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 15:56:20 +0200 Subject: [PATCH 076/130] Corrected: the guide and the documents where the code moved past them --- docs/development/progress.md | 3 +- docs/guide/interface.md | 39 ++++++++++--------- .../ui/panels/main/explorer.py | 8 ++-- 3 files changed, 27 insertions(+), 23 deletions(-) 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/guide/interface.md b/docs/guide/interface.md index f9cd57df8..f2e0bb14e 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -28,20 +28,22 @@ 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. -Converting to one reconstruction 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. 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. +A recording you added by name is written every time you convert, so where a +reconstruction of that name already stands the app asks first — naming the one it +is about to replace, or counting them where a run would replace several — and +**Convert anyway** goes ahead. Recordings that came in with a folder are left +where their reconstruction already stands, so rerunning a folder carries on from +where you stopped. ### What to convert The card holds a list of what a run converts. Double-click a recording in the browser to add it; right-click and choose **Add as stem**, or Ctrl-click, to do the same. A plain click plays the recording, so you can listen through a folder -before you take anything from it. Ctrl-click a folder — or use **Add folder as -stems** — and the folder joins as one row standing for every recording below it, -however deep the tree goes. +before you take anything from it. Ctrl-click a folder — or use **Add folder** — +and the folder joins as one row standing for every recording below it, however +deep the tree goes. Reading a large tree takes a moment, so a window names the +folder and counts what it has found, with a **Stop** if you picked the wrong one. The channels are named once above the rows, and each row shows one recording and a checkbox under every channel it may use. Untick them all and the row grays out: @@ -51,8 +53,9 @@ The list grows with what you gather and scrolls once it fills the card, so the cards below it stay where you left them. A folder's row names how many recordings it brought in, and its checkboxes read -all three ways: ticked where every recording in it uses that channel, half-lit -where they differ, clear where none does. One click settles the whole folder. +all three ways: ticked where every recording in it uses that channel, filled in +that channel's own color where they differ, and empty where none does. One click +settles the whole folder. A folder arrives closed. Click the marker beside its name — or double-click the name, or use **Show the recordings** in its menu — and it opens onto the @@ -102,14 +105,14 @@ channels are pushed and holds for the whole run, so it stands at the top of **Reconstruction settings** whatever you are looking at. Below it the card names the row you clicked in the converter's list — a folder reads how many recordings it stands for — and gives that row a box under every channel: one for the channel -it takes, and one for the bend on it. A folder whose recordings differ reads clear -in a softer tone until one click settles them all. Press a channel's key to set -that channel across everything listed at once. **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. +it takes, and one for the bend on it. A folder whose recordings differ on a +channel fills that box in the channel's own color and leaves it unticked, until +one click settles them all. Press a channel's key to set that channel across +everything listed at once. **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 diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 428940d52..fc012c541 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -357,10 +357,10 @@ def _directory_node_clicked( """Answers a click on a folder: Ctrl gathers its recordings, and 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 as stems** - 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. + 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: From 6c505b151884a7c3de376d1d52e6b5200e77e3d6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 16:55:02 +0200 Subject: [PATCH 077/130] Held: a case's path expectations to the platform running them --- docs/development/guidelines.md | 5 ++--- .../ui/panels/main/test_converter.py | 7 ++++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index cb1affd0f..067fd9582 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -94,9 +94,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. Two mechanics follow: - - **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. - - **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/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 4f9e654ff..6208bc2b4 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -49,6 +49,7 @@ LANGUAGE_MANAGER = LanguageManager(LANG_EN) ACTION_LABEL = "Convert 2 recordings" STATUS_TEXT = "No tasks in progress." +RECORDING = Path("/audio/kick.wav") @pytest.fixture @@ -286,7 +287,7 @@ def test_it_stands_with_nothing_listed(self, dpg_context: None, layout_config: L 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=Path("/audio/kick.wav"))) + panel.update_view(view(row("kick"), input_path=RECORDING)) assert not shows(TAG_MAIN_CONVERTER_GROUP_INPUT) @@ -301,9 +302,9 @@ def test_the_input_line_names_the_recording_a_run_is_reading( view( row("kick"), phase=ConversionPhase.RUNNING, - input_path=Path("/audio/kick.wav"), + input_path=RECORDING, ) ) assert shows(TAG_MAIN_CONVERTER_GROUP_INPUT) - assert str(panel.input_path_text.path) == "/audio/kick.wav" + assert panel.input_path_text.path == RECORDING From 7daab2a6973c1033097dbfcad4e324bc8b1b6911 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 17:40:57 +0200 Subject: [PATCH 078/130] Checked: that no case holds rendered text against a spelled-out literal --- .pre-commit-config.yaml | 8 + Makefile | 5 +- docs/development/architecture.md | 3 +- scripts/checks/rendered_literals.py | 142 ++++++++++++++++++ .../scripts/checks/test_rendered_literals.py | 120 +++++++++++++++ 5 files changed, 276 insertions(+), 2 deletions(-) create mode 100755 scripts/checks/rendered_literals.py create mode 100644 tests/unit/scripts/checks/test_rendered_literals.py 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 859910d79..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) @@ -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/development/architecture.md b/docs/development/architecture.md index e6d8e067a..448388210 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -128,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 | |------|--------|---------------| @@ -137,6 +137,7 @@ 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. 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/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 From 0e45ae97aaa636b930df717dc8394a038f4beeb5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 18:26:10 +0200 Subject: [PATCH 079/130] Named: the settings card after the source it edits --- docs/guide/configuration.md | 2 +- docs/guide/getting-started.md | 2 +- docs/guide/interface.md | 2 +- .../categories/hierarchy.py | 2 +- .../coordinators/tabs/main.py | 38 ++++++------ .../layout/tabs/main/__init__.py | 4 +- .../tabs/main/{reconstructor.py => source.py} | 2 +- .../logic/main/converter/logic.py | 2 +- .../logic/main/converter/view.py | 2 +- src/sampletones_application/tags/main.py | 28 ++++----- .../{reconstructor => source}/__init__.py | 0 .../main/{reconstructor => source}/grid.py | 30 ++++----- .../main/{reconstructor => source}/panel.py | 62 +++++++++---------- .../main/{reconstructor.py => source.py} | 2 +- src/sampletones_config/lang/en.yaml | 10 +-- .../main/{reconstructor.yaml => source.yaml} | 0 .../test_application_channels.py | 2 +- .../sampletones_application/test_startup.py | 22 +++---- .../{test_reconstructor.py => test_source.py} | 56 ++++++++--------- 19 files changed, 133 insertions(+), 135 deletions(-) rename src/sampletones_application/layout/tabs/main/{reconstructor.py => source.py} (51%) rename src/sampletones_application/ui/panels/main/{reconstructor => source}/__init__.py (100%) rename src/sampletones_application/ui/panels/main/{reconstructor => source}/grid.py (88%) rename src/sampletones_application/ui/panels/main/{reconstructor => source}/panel.py (75%) rename src/sampletones_application/view_model/main/{reconstructor.py => source.py} (97%) rename src/sampletones_config/layout/tabs/main/{reconstructor.yaml => source.yaml} (100%) rename tests/unit/sampletones_application/ui/panels/main/{test_reconstructor.py => test_source.py} (86%) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 82f415d95..76a179912 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -10,7 +10,7 @@ you want to go deeper. ## From the interface The **Main** tab exposes the everyday settings (grouped under **General -settings**, **Reconstruction settings**, and **Advanced settings**): +settings**, **Source settings**, and **Advanced settings**): - which channels the recording you picked out of the converter's list takes, and the **Drive** applied to them; diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index e119d0fba..09e6ae656 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -11,7 +11,7 @@ instruments, and building a whole song. Both assume it is already 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 takes - under **Reconstruction settings**, and adjust **General settings**. At least + under **Source settings**, and adjust **General settings**. At least one channel must be enabled. 4. Click the button, which names the run it makes. The first time you use a given set of settings, the diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f2e0bb14e..2b63329eb 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -102,7 +102,7 @@ asks first where you have already gathered a list. A few settings are worth knowing before you convert. **Drive** sets how hard the channels are pushed and holds for the whole run, so it stands at the top of -**Reconstruction settings** whatever you are looking at. Below it the card names +**Source settings** whatever you are looking at. Below it the card names the row you clicked in the converter's list — a folder reads how many recordings it stands for — and gives that row a box under every channel: one for the channel it takes, and one for the bend on it. A folder whose recordings differ on a 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/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7af627079..f8421c1f8 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -42,7 +42,7 @@ TAG_MAIN_EXPLORER_DIALOG_CONVERTER_RUNNING, TAG_MAIN_EXPLORER_DIALOG_NOTHING_BELOW, TAG_MAIN_EXPLORER_PANEL, - TAG_MAIN_RECONSTRUCTOR_PANEL, + 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 @@ -53,7 +53,7 @@ from sampletones_application.ui.panels.main.config import GUIConfigPanel 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.panel 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 @@ -65,8 +65,8 @@ ) 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 @@ -219,17 +219,17 @@ def _build_cards( language_manager=language_manager, is_operation_active=self._hooks.is_operation_active, ) - self._reconstructor_panel: GUIReconstructorPanel = GUIReconstructorPanel( - ReconstructorPanelViewModel( + 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, stems_layout=layout.stems, - initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_RECONSTRUCTOR_PANEL), + initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_SOURCE_PANEL), language_manager=language_manager, status_bar=status_bar, ) @@ -266,14 +266,14 @@ def _build_cards( 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._reconstructor_panel.on_slot_toggled = self._converter_logic.toggle_slot - self._reconstructor_panel.on_channel_keyed = self._converter_logic.toggle_channel + self._source_panel.on_generation_settings_changed = config_manager.apply_generation_settings + self._source_panel.on_slot_toggled = self._converter_logic.toggle_slot + self._source_panel.on_channel_keyed = self._converter_logic.toggle_channel 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 @@ -364,7 +364,7 @@ def _on_converter_view_changed(self, view_model: ConverterViewModel) -> None: def _repaint_converter(self, view_model: ConverterViewModel) -> None: self._converter_panel.update_view(view_model) - self._update_reconstructor_panel_view() + self._update_source_panel_view() self._hooks.on_busy_state_changed() def _request_reconstruct_file(self, filepath: Path) -> None: @@ -594,13 +594,13 @@ def _update_config_panel_view(self) -> None: ) ) - def _update_reconstructor_panel_view(self) -> None: + 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._reconstructor_panel.update_view( - ReconstructorPanelViewModel( + 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, @@ -701,14 +701,14 @@ def _build_center(self, parent: str) -> None: 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._reconstructor_panel.create_panel(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._advanced_settings_panel.set_collapse_handler(self._on_config_row_collapse_changed) - self._reconstructor_panel.set_collapse_handler(self._on_card_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: @@ -781,7 +781,7 @@ def refresh_browser(self) -> None: 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) + self._source_panel.toggle_channel(channel) def toggle_advanced_settings(self) -> None: """Puts the advanced card away or stands it back beside the general one.""" 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/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/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index a08ac8c7d..79f08a928 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -46,7 +46,7 @@ ConversionPhase, ConverterViewModel, ) -from sampletones_application.view_model.main.reconstructor import ( +from sampletones_application.view_model.main.source import ( InspectedSourceViewModel, SettingsSlotViewModel, ) diff --git a/src/sampletones_application/logic/main/converter/view.py b/src/sampletones_application/logic/main/converter/view.py index 8863094cc..16e5a149d 100644 --- a/src/sampletones_application/logic/main/converter/view.py +++ b/src/sampletones_application/logic/main/converter/view.py @@ -13,7 +13,7 @@ SettingsSlot, ) from sampletones_application.view_model.main.converter import ConversionPhase, ConverterViewModel -from sampletones_application.view_model.main.reconstructor import ( +from sampletones_application.view_model.main.source import ( InspectedSourceViewModel, SettingsSlotViewModel, ) diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 44b83815a..5d33914a4 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -98,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", ) @@ -243,28 +243,28 @@ "summary", ) -PRE_MAIN_RECONSTRUCTOR_SLOT = "slot" -TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING = TagName( +PRE_MAIN_SOURCE_SLOT = "slot" +TAG_MAIN_SOURCE_TEXT_INSPECTING = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.TEXT, "inspecting", ) -TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED = TagName( +TAG_MAIN_SOURCE_TEXT_UNPICKED = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.TEXT, "unpicked", ) -TAG_MAIN_RECONSTRUCTOR_GROUP_GRID = TagName( +TAG_MAIN_SOURCE_GROUP_GRID = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.GROUP, "grid", ) -TAG_MAIN_RECONSTRUCTOR_TABLE_GRID = TagName( +TAG_MAIN_SOURCE_TABLE_GRID = TagName( Page.MAIN, - Panel.RECONSTRUCTOR, + Panel.SOURCE, Widget.TABLE, "grid", ) diff --git a/src/sampletones_application/ui/panels/main/reconstructor/__init__.py b/src/sampletones_application/ui/panels/main/source/__init__.py similarity index 100% rename from src/sampletones_application/ui/panels/main/reconstructor/__init__.py rename to src/sampletones_application/ui/panels/main/source/__init__.py diff --git a/src/sampletones_application/ui/panels/main/reconstructor/grid.py b/src/sampletones_application/ui/panels/main/source/grid.py similarity index 88% rename from src/sampletones_application/ui/panels/main/reconstructor/grid.py rename to src/sampletones_application/ui/panels/main/source/grid.py index eb799b041..45cfa9e45 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor/grid.py +++ b/src/sampletones_application/ui/panels/main/source/grid.py @@ -7,9 +7,9 @@ from sampletones_application.layout.general.stems import StemsListLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_SLOT, - TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, - TAG_MAIN_RECONSTRUCTOR_TABLE_GRID, + PRE_MAIN_SOURCE_SLOT, + TAG_MAIN_SOURCE_GROUP_GRID, + TAG_MAIN_SOURCE_TABLE_GRID, ) from sampletones_application.ui.elements.stems.columns import NO_RESERVE, StemsColumns from sampletones_application.ui.elements.stems.heading import StemsHeading @@ -19,9 +19,9 @@ ) 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.reconstructor import ( - ReconstructorPanelViewModel, +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 @@ -51,7 +51,7 @@ def __init__( ) -> None: self._layout = layout self._heading = StemsHeading( - prefix=TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, + prefix=TAG_MAIN_SOURCE_GROUP_GRID, layout=layout, language_manager=language_manager, bends=True, @@ -70,15 +70,15 @@ def __init__( @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_RECONSTRUCTOR_GROUP_GRID + return TAG_MAIN_SOURCE_GROUP_GRID - def create(self, view_model: ReconstructorPanelViewModel) -> None: + def create(self, view_model: SourceSettingsPanelViewModel) -> None: """Build the channel names and the one row of boxes standing under them.""" - with dpg.group(tag=TAG_MAIN_RECONSTRUCTOR_GROUP_GRID): - self._heading.create(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, self._columns) + 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_RECONSTRUCTOR_TABLE_GRID, + tag=TAG_MAIN_SOURCE_TABLE_GRID, header_row=False, policy=dpg.mvTable_SizingFixedFit, resizable=False, @@ -87,14 +87,14 @@ def create(self, view_model: ReconstructorPanelViewModel) -> None: self._columns.declare() self._create_row(view_model) - def render(self, view_model: ReconstructorPanelViewModel) -> None: + 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: ReconstructorPanelViewModel) -> None: + 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() @@ -157,7 +157,7 @@ def _fields_on(self, channel_name: ChannelName) -> Tuple[SettingsField, ...]: return SETTINGS_FIELDS[: self._columns.slots(channel_name)] @staticmethod - def _boxes(view_model: ReconstructorPanelViewModel) -> Dict[SettingsField, SettingsSlotViewModel]: + 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} @@ -180,4 +180,4 @@ def _box_theme(channel_name: ChannelName, agreement: Agreement) -> str: @staticmethod def _box_tag(field: SettingsField, channel_name: ChannelName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value, channel_name.value) + return compose_tag(PRE_MAIN_SOURCE_SLOT, field.value, channel_name.value) diff --git a/src/sampletones_application/ui/panels/main/reconstructor/panel.py b/src/sampletones_application/ui/panels/main/source/panel.py similarity index 75% rename from src/sampletones_application/ui/panels/main/reconstructor/panel.py rename to src/sampletones_application/ui/panels/main/source/panel.py index 41d79108e..b37487b9d 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor/panel.py +++ b/src/sampletones_application/ui/panels/main/source/panel.py @@ -6,31 +6,31 @@ 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.reconstructor import ReconstructorLayout +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_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, - TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, + 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.reconstructor.grid import SettingsGrid +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.reconstructor import ( +from sampletones_application.view_model.main.source import ( InspectedSourceViewModel, - ReconstructorPanelViewModel, + SourceSettingsPanelViewModel, ) from sampletones_application.view_model.main.updates import GenerationSettingsUpdate from sampletones_core.constants.algorithm import MAX_DRIVE @@ -38,8 +38,8 @@ from sampletones_shared.types.application import Sender -class GUIReconstructorPanel(GUIPanel): - """The settings card: the drive a run holds to, and the choices the picked row is given. +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 @@ -49,9 +49,9 @@ class GUIReconstructorPanel(GUIPanel): def __init__( self, - initial_view: ReconstructorPanelViewModel, + initial_view: SourceSettingsPanelViewModel, *, - layout: ReconstructorLayout, + layout: SourceSettingsLayout, inputs: InputsLayout, stems_layout: StemsListLayout, language_manager: LanguageManager, @@ -65,16 +65,16 @@ def __init__( 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.reconstructor.message.nothing_picked"] + 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_RECONSTRUCTOR_PANEL, SUF_HANDLER_REGISTRY) + 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 self.on_channel_keyed: Optional[Callable[[ChannelName], None]] = None super().__init__( - tag=TAG_MAIN_RECONSTRUCTOR_PANEL, + tag=TAG_MAIN_SOURCE_PANEL, height=layout.height, ) self._enable_vertical_collapse(initial_collapsed=initial_collapsed) @@ -83,7 +83,7 @@ def create_panel(self, parent: str) -> None: self._setup_handlers() with self._collapsible_card( parent, - self._language_manager["main.reconstructor.label.section_settings"], + self._language_manager["main.source.label.section_settings"], glyph=self._glyphs.headers.reconstruction, width=self.width, ): @@ -97,13 +97,13 @@ def create_panel(self, parent: str) -> None: self._grid.on_slot_toggled = self._on_slot_toggled self.update_view(self._view) - def update_view(self, view_model: ReconstructorPanelViewModel) -> None: + 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_RECONSTRUCTOR_SLIDER_DRIVE, view_model.drive) - dpg_set_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, self._subject_text(view_model.inspected)) - dpg_configure_item(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, show=view_model.inspecting) - dpg_configure_item(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, show=not view_model.inspecting) + 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) @@ -125,14 +125,14 @@ 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_RECONSTRUCTOR_TEXT_INSPECTING, + 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_RECONSTRUCTOR_TEXT_UNPICKED, wrap=self.width) + 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: @@ -145,9 +145,9 @@ def _subject_text(self, inspected: Optional[InspectedSourceViewModel]) -> str: return inspected.name def _create_drive_slider(self) -> None: - with labeled_field(self._language_manager["main.reconstructor.label.slider_drive"], self._label_width): + with labeled_field(self._language_manager["main.source.label.slider_drive"], self._label_width): dpg.add_slider_float( - tag=TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + tag=TAG_MAIN_SOURCE_SLIDER_DRIVE, min_value=0.0, max_value=MAX_DRIVE, default_value=self._view.drive, @@ -156,22 +156,22 @@ def _create_drive_slider(self) -> None: ) dpg.bind_item_handler_registry( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + TAG_MAIN_SOURCE_SLIDER_DRIVE, self._item_handler_tag, ) self._status_bar.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + TAG_MAIN_SOURCE_SLIDER_DRIVE, self._language_manager["global.status.message.input"], ) FontRegistry.bind_to_item( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + TAG_MAIN_SOURCE_SLIDER_DRIVE, Font.MONO, ) def _create_tooltips(self) -> None: show_tooltip( - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - self._language_manager["main.reconstructor.tooltip.tooltip_drive"], + TAG_MAIN_SOURCE_SLIDER_DRIVE, + self._language_manager["main.source.tooltip.tooltip_drive"], ) def _on_slot_toggled(self, field: SettingsField, channel_name: ChannelName) -> None: @@ -182,6 +182,6 @@ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None: def _report_generation_settings(self) -> None: generation_update = GenerationSettingsUpdate( - drive=float(clamp_widget_value(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE)), + 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/view_model/main/reconstructor.py b/src/sampletones_application/view_model/main/source.py similarity index 97% rename from src/sampletones_application/view_model/main/reconstructor.py rename to src/sampletones_application/view_model/main/source.py index ea744f26d..ef80e1add 100644 --- a/src/sampletones_application/view_model/main/reconstructor.py +++ b/src/sampletones_application/view_model/main/source.py @@ -49,7 +49,7 @@ def stands_for_a_folder(self) -> bool: return self.kind is SourceKind.FOLDER -class ReconstructorPanelViewModel(BaseModel, frozen=True): +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 diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7c2e308d9..9bc6b66a6 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -347,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.message.nothing_picked: "Pick a recording or a folder in the Converter to change its settings." -main.reconstructor.label.section_settings: "Reconstruction 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 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/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 a00eda7c4..21cf729b7 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -26,7 +26,7 @@ TAG_GLOBAL_THEME_STEMS_ROW_INERT, ) from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_SLOT, + PRE_MAIN_SOURCE_SLOT, TAG_MAIN_ADVANCED_PANEL, TAG_MAIN_ADVANCED_PANEL_ADVANCED_CELL, TAG_MAIN_CONFIG_PANEL, @@ -38,10 +38,10 @@ TAG_MAIN_CONVERTER_RADIO_MODE, TAG_MAIN_CONVERTER_TOOLTIP_HIERARCHY_MODE, TAG_MAIN_CONVERTER_WINDOW_STEMS, - TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, - TAG_MAIN_RECONSTRUCTOR_PANEL, - TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, - TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, + 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.panels.main import explorer as explorer_module @@ -472,7 +472,7 @@ def _click_row(app: Application, path: Path) -> None: 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_RECONSTRUCTOR_SLOT, field.value, channel_name.value) + 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)) @@ -616,7 +616,7 @@ def _stands_within(cls, tag: str, ancestor: str) -> bool: 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_RECONSTRUCTOR_PANEL) + card_parent, card_place = self._place(TAG_MAIN_SOURCE_PANEL) assert card_parent == converter_parent assert card_place > converter_place @@ -907,16 +907,16 @@ def test_the_card_names_the_gesture_that_gives_it_a_row(self, app: Application, """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_RECONSTRUCTOR_TEXT_UNPICKED)["show"] is True - assert dpg.get_item_configuration(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID)["show"] is False + 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_RECONSTRUCTOR_GROUP_GRID)["show"] is True - assert dpg.get_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING) == path.stem + 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.""" diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py similarity index 86% rename from tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py rename to tests/unit/sampletones_application/ui/panels/main/test_source.py index e72306a5a..cdf8bc738 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -17,24 +17,24 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HEADING, SUF_TEXT from sampletones_application.tags.main import ( - PRE_MAIN_RECONSTRUCTOR_SLOT, - TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, - TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, - TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING, - TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED, + PRE_MAIN_SOURCE_SLOT, + TAG_MAIN_SOURCE_GROUP_GRID, + TAG_MAIN_SOURCE_SLIDER_DRIVE, + 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.reconstructor.panel import GUIReconstructorPanel +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.reconstructor import ( +from sampletones_application.view_model.main.source import ( InspectedSourceViewModel, - ReconstructorPanelViewModel, SettingsSlotViewModel, + SourceSettingsPanelViewModel, ) from sampletones_core.constants.enums import TONE_CHANNELS, ChannelName @@ -87,8 +87,8 @@ def view( *slots: SettingsSlotViewModel, inspected: Optional[InspectedSourceViewModel] = None, live: bool = True, -) -> ReconstructorPanelViewModel: - return ReconstructorPanelViewModel( +) -> SourceSettingsPanelViewModel: + return SourceSettingsPanelViewModel( slots=slots, inspected=inspected, drive=DRIVE, @@ -110,12 +110,12 @@ def channels_slot(*, held: FrozenSet[ChannelName] = frozenset()) -> SettingsSlot def build( layout_config: LayoutConfig, - initial: ReconstructorPanelViewModel, -) -> Tuple[GUIReconstructorPanel, List[Tuple[SettingsField, ChannelName]]]: + initial: SourceSettingsPanelViewModel, +) -> Tuple[GUISourceSettingsPanel, List[Tuple[SettingsField, ChannelName]]]: """The card as the application builds it, over the choices it reports.""" - panel = GUIReconstructorPanel( + panel = GUISourceSettingsPanel( initial, - layout=layout_config.tabs.main.reconstructor, + layout=layout_config.tabs.main.source, inputs=layout_config.general.inputs, stems_layout=layout_config.general.stems, language_manager=LanguageManager(LANG_EN), @@ -130,7 +130,7 @@ def build( def box_tag(field: SettingsField, channel_name: ChannelName) -> str: - return compose_tag(PRE_MAIN_RECONSTRUCTOR_SLOT, field.value, channel_name.value) + return compose_tag(PRE_MAIN_SOURCE_SLOT, field.value, channel_name.value) def shows(tag: str) -> bool: @@ -143,14 +143,14 @@ class TestDrive: 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_RECONSTRUCTOR_SLIDER_DRIVE) == pytest.approx(DRIVE) + 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_RECONSTRUCTOR_TEXT_INSPECTING), 1) - drive = dpg.get_item_parent(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE) + 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_RECONSTRUCTOR_TEXT_INSPECTING)) + assert body.index(drive) < body.index(dpg.get_alias_id(TAG_MAIN_SOURCE_TEXT_INSPECTING)) class TestNothingPicked: @@ -159,17 +159,17 @@ class TestNothingPicked: def test_the_hint_stands(self, dpg_context: None, layout_config: LayoutConfig) -> None: build(layout_config, view()) - assert shows(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED) + 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_RECONSTRUCTOR_GROUP_GRID) + 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_RECONSTRUCTOR_TEXT_INSPECTING) + assert not shows(TAG_MAIN_SOURCE_TEXT_INSPECTING) class TestAPickedRow: @@ -180,14 +180,14 @@ def test_a_recording_reads_its_own_name(self, dpg_context: None, layout_config: panel.update_view(view(channels_slot(), inspected=recording("bass"))) - assert dpg.get_value(TAG_MAIN_RECONSTRUCTOR_TEXT_INSPECTING) == "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_RECONSTRUCTOR_TEXT_INSPECTING) + named = dpg.get_value(TAG_MAIN_SOURCE_TEXT_INSPECTING) assert named.startswith("VEH2 Loops") assert str(HELD_RECORDINGS) in named @@ -196,8 +196,8 @@ def test_the_grid_comes_with_it(self, dpg_context: None, layout_config: LayoutCo panel.update_view(view(channels_slot(), inspected=recording("bass"))) - assert shows(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID) - assert not shows(TAG_MAIN_RECONSTRUCTOR_TEXT_UNPICKED) + assert shows(TAG_MAIN_SOURCE_GROUP_GRID) + assert not shows(TAG_MAIN_SOURCE_TEXT_UNPICKED) class TestTheGrid: @@ -207,9 +207,7 @@ def test_every_channel_is_named(self, dpg_context: None, layout_config: LayoutCo build(layout_config, view()) for channel_name in ChannelName.items(): - assert dpg.does_item_exist( - compose_tag(TAG_MAIN_RECONSTRUCTOR_GROUP_GRID, SUF_HEADING, channel_name, SUF_TEXT) - ) + 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()) From 7f17b45f6bf2f369d1a6e807ca41fee2307cf24b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 19:56:37 +0200 Subject: [PATCH 080/130] Rewrote: the interface guide --- docs/guide/interface.md | 484 ++++++++++++++++++---------------------- 1 file changed, 217 insertions(+), 267 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 2b63329eb..4d99bc462 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -1,275 +1,225 @@ # 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. - -This page covers the **Main**, **Instructions**, and **Reconstructions** tabs -and the menus around them. The **Sequencer** has [its own page](sequencer.md). - -## Main - -The **Main** tab turns an audio file into a -[reconstruction](../concepts/reconstruction.md). Most sessions start here. - -Pick an audio file — or a whole folder — in the **Filesystem** browser on the -left, set up the conversion in the center, and click the button, which names the -run it is about to make: **Convert bass**, **Convert 6 recordings**, **Mix 5 -recordings**. 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. - -**Destination:** at the foot of the card names where a run writes — the document -a single conversion makes, or the folder a longer run fills — and while a run -goes on an **Input:** line above it names the recording being read. 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. - -A recording you added by name is written every time you convert, so where a -reconstruction of that name already stands the app asks first — naming the one it -is about to replace, or counting them where a run would replace several — and -**Convert anyway** goes ahead. Recordings that came in with a folder are left -where their reconstruction already stands, so rerunning a folder carries on from -where you stopped. - -### What to convert - -The card holds a list of what a run converts. Double-click a recording in the -browser to add it; right-click and choose **Add as stem**, or Ctrl-click, to do -the same. A plain click plays the recording, so you can listen through a folder -before you take anything from it. Ctrl-click a folder — or use **Add folder** — -and the folder joins as one row standing for every recording below it, however -deep the tree goes. Reading a large tree takes a moment, so a window names the -folder and counts what it has found, with a **Stop** if you picked the wrong one. - -The channels are named once above the rows, and each row shows one recording and -a checkbox under every 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. **x** takes a row out; taking out a folder takes everything it holds. -The list grows with what you gather and scrolls once it fills the card, so the -cards below it stay where you left them. - -A folder's row names how many recordings it brought in, and its checkboxes read -all three ways: ticked where every recording in it uses that channel, filled in -that channel's own color where they differ, and empty where none does. One click -settles the whole folder. - -A folder arrives closed. Click the marker beside its name — or double-click the -name, or use **Show the recordings** in its menu — and it opens onto the -recordings it holds, in a panel of its own that scrolls once there are more than -it can show. Each of those recordings has its own checkboxes, so you can answer -for one of them without breaking the folder up. Double-clicking a recording plays -it. - -### One reconstruction each, or one from them all - -**Output**, at the head of the card, names what the run writes. On **One per -recording**, every recording in the list gets a reconstruction of its own, and -the ones a folder holds are written into a tree mirroring that folder. On **One -from all**, they mix into a single reconstruction instead. - -A mix reaches eight recordings, so choosing it with a longer list asks which ones -to mix, and so does adding a folder that overflows what is left. The question -shows the same rows the card does — folders open onto what they hold, and one -click answers for a whole folder, its box filling where only some of what it -holds is picked. Adding a folder stands the recordings the mix is already built -from beside the ones the folder offers, so letting one go is how you make room -for another. As many as a mix holds arrive ticked, the line above counts what you -have picked, and **Add** settles the mix once the pick fits. - -From the second recording of a mix, 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. **Order** sets how the levels take turns: round by round, or one level -filled before the next picks. - -**Channels per source**, below the list, caps how many channels one recording may -hold in a single frame, and it applies to every conversion. Set to 1, each -recording gets a single voice. It stands beside **Order** once there is a list to -answer for. The output switch, the cap and the order are remembered, so the app -opens on the run you last set up. - -Reconstructing a file or a folder from the browser converts that one thing, so it -asks first where you have already gathered a list. - -### Settings - -A few settings are worth knowing before you convert. **Drive** sets how hard the -channels are pushed and holds for the whole run, so it stands at the top of -**Source settings** whatever you are looking at. Below it the card names -the row you clicked in the converter's list — a folder reads how many recordings -it stands for — and gives that row a box under every channel: one for the channel -it takes, and one for the bend on it. A folder whose recordings differ on a -channel fills that box in the channel's own color and leaves it unticked, until -one click settles them all. Press a channel's key to set that channel across -everything listed at once. **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 +_SampleToNES_ has four tabs, and `F1` to `F4` switch between them: + +- **Main** (`F1`) — turn audio files into [reconstructions](../concepts/reconstruction.md). +- **Reconstruction** (`F2`) — listen to a reconstruction, edit its instruments, and export it. +- **Sequencer** (`F3`) — arrange reconstructions into a song. It has [its own page](sequencer.md). +- **Instructions** (`F4`) — build and browse the [instruction + library](../concepts/instruction-library.md) a conversion draws from. + +Work moves through them in that order. You convert on **Main**, and when the run +finishes **Load** opens the result on **Reconstruction**. From there **Add to +Sequencer** hands it to a song. The **Instructions** tab is optional: converting +builds the library your settings need, so you come here only to build one ahead +of time or to look at what one holds. + +Two things sit outside the tabs. Everything a reconstruction exports to is on the +**Reconstruction** menu rather than on a tab. And the **Edit** and **Voice** +menus rebuild themselves around whatever your cursor is on, so what they offer +depends on where you are working. + +## Choosing what to convert + +The **Converter** card on the **Main** tab holds a list of what a run converts. +Fill it from the **Filesystem** browser: + +- Double-click an audio file, or Ctrl-click it, to add it. Right-click ▸ **Add as + stem** does the same. +- Ctrl-click a folder, or right-click ▸ **Add folder**, to add every recording + below it, however deep the tree goes. + +Reading a large folder takes a moment, so **Reading the folder** appears while +the scan runs, counting what it has found, with a **Stop** if you picked the +wrong one. A folder holding no audio says so instead of joining the list. + +A single click plays a recording while **Playback ▸ Autoplay** (`Ctrl+P`) is on, +so you can listen through a folder before taking anything from it. With Autoplay +off, use right-click ▸ **Play**. + +**x** takes a row out of the list, and taking out a folder takes everything it +holds. + +## Choosing which channels a recording uses + +The NES has four sound channels — **Pulse 1**, **Pulse 2**, **Triangle**, and +**Noise** — and every recording in the list carries a checkbox under each of +them. Tick the ones that recording may use. Untick them all and the row grays +out: it stays in the list and sits out of the conversion, so you can bring it +back. Pressing `1` to `4` switches one channel across every recording at once. + +A folder is a single row standing for the recordings below it, so its checkboxes +read three ways: ticked where every recording in it uses that channel, filled in +the channel's own color where they differ, and empty where none does. One click +settles the whole folder. To answer for one recording on its own, open the folder +— click the marker beside its name, or double-click the name. + +**Source settings** holds two more things. **Drive** sets how hard the channels +are pushed and applies to the whole run. Below it, the card names the row you +clicked in the list and gives it a box per channel, with a **bend** box beside it +on **Pulse 1**, **Pulse 2**, and **Triangle** that tunes each note to the +recording's exact pitch. Noise takes no bend. + +**Channels per source** caps how many channels one recording may hold in a single +frame, from 1 to 4. Set it to 1 and each recording gets a single voice. + +## One reconstruction each, or one mix from all + +**Output**, at the head of the **Converter** card, decides what the run writes. + +- **One per recording** gives every recording in the list a reconstruction of its + own. The ones that came in with a folder are written into a tree mirroring that + folder. +- **One from all** mixes them into a single reconstruction. Folders are flattened + into the recordings they hold. + +A mix holds up to eight recordings. Switching to **One from all** with more than +that listed asks which to mix, and so does adding a folder that overflows the +room left. The question lists the same rows the card does, counts what you have +picked, and **Add** settles it once the pick fits. Adding one more recording to a +mix that is already full does nothing, so let one go first. + +From the second recording of a mix, rows sit in **level** bands. A level is a +turn to choose: everything on level 1 picks its channels before anything on level +2, so a lead can take what it needs before a pad does. Drag a row onto another to +share its level, or into a gap to give it a level of its own; right-clicking a +row lists the same moves. **Order** decides how the levels take turns — **Round +robin** gives every level a turn each round, **Strict** fills one level before the +next picks. + +## Running a conversion + +Click the button under **Output** to start. It names what it is about to do, and +only one conversion runs at a time; while one does, the button reads **Cancel**. + +**Destination:** says where the run writes — the document a single conversion +makes, or the folder a longer run fills. Click the path to open it in your file +manager. If converting would replace a reconstruction that already exists, the +app asks first. + +When the run finishes, **Load** opens the result on the **Reconstruction** tab; +after a run of several, the button reads **Open** instead. The first conversion +with a given set of settings builds the [instruction +library](../concepts/instruction-library.md) it needs, which takes a while; later +runs on the same settings reuse it. + +## Listening to a reconstruction + +Open a saved reconstruction from the **Browser** on the **Reconstruction** tab. +It groups them two ways in one tree: **By configuration**, by the settings they +were made with, and **By sample**, gathering every version of one source audio +together. If what you have open has unsaved edits, you are asked whether to save +it first. + +To keep the ones you return to within reach, right-click a reconstruction — or a +whole folder — and choose **Mark as favorite**, then tick **Favorites only** to +narrow the tree to them. + +The **Source** card switches playback between **Reconstruction** and **Original +audio**, so you can hear the two against each other. The **Waveform** card draws +the channels with a checkbox for each; `1` to `4` flip the same boxes. + +## Hearing what each recording contributed + +The **Stems** card lists the recordings a reconstruction was built from, under +the level each was picked on. Every row carries a box for each channel that +recording took, and a box at the front that moves all of them together. + +Untick one and those frames fall silent everywhere — in the waveform, in +playback, in the original audio, and in a WAV export — which is how you hear what +one recording contributed, channel by channel. These boxes are listening state +only and change nothing that is saved. **Collapse levels** reads the whole list +as one table. + +**x** at the end of a row removes that recording from the reconstruction and asks +first: its frames fall silent and its row disappears, and the change is written +only when you save. One recording always stays, so the last row's **x** is +disabled. + +## Editing instruments + +The **Instruments** panel shows what each channel plays, as sequences you edit by +dragging the bars or typing values. Which sequences a channel has depends on the +channel: **Pulse 1** and **Pulse 2** carry volume, arpeggio, pitch, hi-pitch, and +duty cycle; **Triangle** the same without duty cycle; **Noise** carries volume, +arpeggio, and duty cycle. + +Typing `|` before a value marks where that sequence repeats from while a note is +held, so `15 14 | 12 10` attacks and then circles the last two. Clearing a +sequence hands that dimension back to the channel, so an instrument with its +volume cleared plays at whatever volume the channel is set to. Each channel +states how many bytes its instrument takes on the NES, so you can see what an +edit costs. + +A hand-written **instrument** — a voice with no recording behind it, 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 +**Audition** takes the place of the pitch steppers: pick **Pulse**, **Triangle**, +or **Noise** and the note keys play the instrument on that generator. + +## Exporting + +Everything a reconstruction exports to is on the **Reconstruction** menu. +**Export instruments ▸ FamiTracker instruments...** writes one `.fti` per +channel, **Bitphase presets...** writes the same as `.json`, and **NSF +program...** writes a single `.nsf` that plays the whole reconstruction on a NES. +**Export to WAV...** renders the audio, honoring the channel and stem boxes you +have set. + +**Export instrument...** in the **Instruments** panel writes the one channel you +are looking at, in whichever format the save dialog is set to. See [where your +files live](files.md#exported-files). + +To use a reconstruction in a song, right-click it and choose **Add to +Sequencer**. + +## The instruction library 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 +library](../concepts/instruction-library.md), the catalog of NES tones a +conversion searches. Converting builds the one your settings need, so you rarely +need to come here. + +It is useful for building a library ahead of a long session, and for exploring +what a configuration can produce: pick an instruction and its **Waveform** and +**Spectrum** appear, so you can see and hear a single NES tone on its own. +**Generate library** builds one for your current settings, and reads +**Regenerate instructions** once a library is loaded. + +## The menus + +Each menu covers one kind of work: + +- **File** — projects, the module and program a song exports to, and rendering a + song to audio. +- **Edit** — undo and redo, then the actions of whatever your cursor is on. +- **Reconstruction** — making, opening and saving reconstructions, and every + export of one. +- **Voice** — the ways a voice comes into the sequencer, and the actions of the + one you picked. +- **Playback** — playing, autoplay, following the song, and muting channels. +- **View** — advanced settings, favorites, the display, and the shortcuts. +- **Help** — **About**. + +Two items are easy to miss. **View ▸ Show advanced settings** reveals **Advanced +settings** on the **Main** tab, which holds the generation method, the feature +scaling, the worker count, and the library and output folders. +[Configuration](configuration.md) explains each one. **Playback ▸ Audio +settings...** picks the device, sample rate, and buffer size you listen through, +which are separate from the **Sample rate** and **NES frequency** on the **Main** +tab that decide how audio is reconstructed. + +Project properties belong to a project and are covered in the [sequencer +guide](sequencer.md). + +## 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**. +keys you want. If another action already holds them, the app names it and asks +whether to hand them over. **Reset to defaults** puts everything back, and your +changes take effect when you press **OK**. -On macOS the shortcuts use Command where other platforms use Control. What you -change is saved with your settings and is there the next time you start. - -Project properties belong to a project and are covered in the [sequencer -guide](sequencer.md). +`Space` plays and pauses and `Esc` stops, wherever you are. 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. From 183c0e80e68f776011eb2819e2182b5dd45ed984 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 19:58:20 +0200 Subject: [PATCH 081/130] Stated: the channel choice plainly in the configuration guide --- docs/guide/configuration.md | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 76a179912..fe91074a1 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -2,24 +2,28 @@ _SampleToNES_ reconstructs according to a **generation configuration** — the sample rate, the NES frequency, how the audio is analyzed, and how candidates are -scored. Which channels a conversion uses travels with the conversion itself, so -each recording says which of them it may take. 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. +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. + +Which channels a recording may use is not one of them. The NES has four sound +channels — **Pulse 1**, **Pulse 2**, **Triangle**, and **Noise** — and you pick +which of them each recording takes in the converter's list, every time you set up +a conversion. See [choosing which channels a recording +uses](interface.md#choosing-which-channels-a-recording-uses). ## From the interface -The **Main** tab exposes the everyday settings (grouped under **General -settings**, **Source settings**, and **Advanced settings**): - -- which channels the recording you picked out of the converter's list takes, 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. +Three cards on the **Main** tab hold the everyday settings: + +- **General settings** — **Normalize audio** and **Quantize audio**, and the + **Sample rate** and **NES frequency** a library is built for. +- **Source settings** — the **Drive** a run is pushed with, and the channels and + bends of the recording you picked out of the converter's list. +- **Advanced settings** — the **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. **View ▸ Show advanced settings** + reveals this card. Changing any of these updates your configuration, which is saved to `config.json` (see [Where your files live](files.md)). From 53bbe210754a6a25975f9e630e941dca7b38342c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 19:59:14 +0200 Subject: [PATCH 082/130] Corrected: the first steps and the tab they open --- docs/guide/getting-started.md | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 09e6ae656..b72b4f5d2 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -7,25 +7,24 @@ 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, double-click an audio file (WAV, - MP3, FLAC, OGG, AIFF, or AU) — or Ctrl-click a folder, to reconstruct every - audio file inside it. +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 takes under **Source settings**, and adjust **General settings**. At least one channel must be enabled. -4. Click the button, which names the run it makes. 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** +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. 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** +The [interface guide](interface.md) covers the **Main** and **Reconstruction** tabs in full. ## Build a song and export a module @@ -33,9 +32,9 @@ tabs in full. 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**. +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; assign a sample to a channel with the cell's right-click **Set voice**. 5. Arrange the piece in the **Order** grid, and set **Rows**, **Tempo**, **Speed**, From c512eda76783971d84c40884c4e955bd0f59018b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 19:59:45 +0200 Subject: [PATCH 083/130] Added: the rules a guide page is held to --- docs/development/guidelines.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 067fd9582..895008325 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -85,6 +85,12 @@ These rules govern the Python in this repository. They complement 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. +## 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 catalogues 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. + ## Tests 1. A test file mirrors the ownership of the code it exercises. From 0960a1fb24c666b92d8d9b6b1bbca75ab2456d27 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 20:01:53 +0200 Subject: [PATCH 084/130] Named: the Reconstruction tab as the application labels it --- docs/development/browser.md | 2 +- docs/guide/sequencer.md | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index ce82c113e..41bce5535 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 diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 601abd5d6..d568b11ce 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -26,17 +26,17 @@ 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 +hear it straight away; give it the sound you want on the **Reconstruction** tab (right-click ▸ **Edit**). See [editing instruments](interface.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 From 5fc1bdd0609a1e3777081ed81b7ea6dd56b64019 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 20:42:48 +0200 Subject: [PATCH 085/130] Removed: the opening paragraph that said twice what its sections say --- docs/guide/interface.md | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 4d99bc462..08cb4d68c 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -10,14 +10,7 @@ _SampleToNES_ has four tabs, and `F1` to `F4` switch between them: Work moves through them in that order. You convert on **Main**, and when the run finishes **Load** opens the result on **Reconstruction**. From there **Add to -Sequencer** hands it to a song. The **Instructions** tab is optional: converting -builds the library your settings need, so you come here only to build one ahead -of time or to look at what one holds. - -Two things sit outside the tabs. Everything a reconstruction exports to is on the -**Reconstruction** menu rather than on a tab. And the **Edit** and **Voice** -menus rebuild themselves around whatever your cursor is on, so what they offer -depends on where you are working. +Sequencer** hands it to a song. The **Instructions** tab is optional and allows you to explore single _instructions_ — unit blocks the NES sound processor produces. ## Choosing what to convert @@ -159,7 +152,7 @@ or **Noise** and the note keys play the instrument on that generator. ## Exporting -Everything a reconstruction exports to is on the **Reconstruction** menu. +You export a reconstruction from the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase presets...** writes the same as `.json`, and **NSF program...** writes a single `.nsf` that plays the whole reconstruction on a NES. @@ -192,11 +185,12 @@ Each menu covers one kind of work: - **File** — projects, the module and program a song exports to, and rendering a song to audio. -- **Edit** — undo and redo, then the actions of whatever your cursor is on. -- **Reconstruction** — making, opening and saving reconstructions, and every - export of one. +- **Edit** — undo and redo, then the actions of whatever you have selected, so + its lower half changes with what you are working on. +- **Reconstruction** — making, opening and saving reconstructions, and exporting + them. - **Voice** — the ways a voice comes into the sequencer, and the actions of the - one you picked. + voice you picked. - **Playback** — playing, autoplay, following the song, and muting channels. - **View** — advanced settings, favorites, the display, and the shortcuts. - **Help** — **About**. From c2681e7db68d3127b52ab77d835f1f5620bd5c5c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 22:19:04 +0200 Subject: [PATCH 086/130] Improved: documentation --- docs/development/guidelines.md | 1 + docs/guide/configuration.md | 52 ++++---- docs/guide/converting.md | 76 ++++++++++++ docs/guide/getting-started.md | 21 ++-- docs/guide/interface.md | 220 +++------------------------------ docs/guide/reconstruction.md | 62 ++++++++++ docs/guide/sequencer.md | 2 +- docs/index.md | 4 +- 8 files changed, 201 insertions(+), 237 deletions(-) create mode 100644 docs/guide/converting.md create mode 100644 docs/guide/reconstruction.md diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 895008325..8cae78a65 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -90,6 +90,7 @@ These rules govern the Python in this repository. They complement 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 catalogues 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 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index fe91074a1..4f5cf0edc 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -1,49 +1,51 @@ # Configuration -_SampleToNES_ reconstructs according to a **generation configuration** — the +_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 reach for most often are on the **Main** tab; the rest -live in the configuration file, for when you want to go deeper. +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. -Which channels a recording may use is not one of them. The NES has four sound -channels — **Pulse 1**, **Pulse 2**, **Triangle**, and **Noise** — and you pick -which of them each recording takes in the converter's list, every time you set up -a conversion. See [choosing which channels a recording -uses](interface.md#choosing-which-channels-a-recording-uses). +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 Three cards on the **Main** tab hold the everyday settings: - **General settings** — **Normalize audio** and **Quantize audio**, and the - **Sample rate** and **NES frequency** a library is built for. -- **Source settings** — the **Drive** a run is pushed with, and the channels and - bends of the recording you picked out of the converter's list. -- **Advanced settings** — the **Method** and **Feature scaling**, which set how - the audio's frequency content is measured and weighted (see [Reconstruction + **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. **View ▸ Show advanced settings** - reveals this card. + 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..15965e1e2 --- /dev/null +++ b/docs/guide/converting.md @@ -0,0 +1,76 @@ +# 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**. + +**x** removes a row from the list. Removing a folder removes every recording in it. + +## 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 every recording at once. + +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 and click the marker next to its name, or double-click the recording name. + +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. **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 b72b4f5d2..04a9530bf 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -10,9 +10,9 @@ instruments, and building a whole song. Both assume it is already 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 takes - under **Source settings**, and adjust **General settings**. At least - one channel must be enabled. +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 @@ -20,12 +20,13 @@ instruments, and building a whole song. Both assume it is already 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 **Reconstruction** -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 @@ -35,12 +36,12 @@ tabs in full. 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; assign a - sample to a channel with the cell's right-click **Set voice**. +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 08cb4d68c..b79170ded 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -1,219 +1,39 @@ # The interface -_SampleToNES_ has four tabs, and `F1` to `F4` switch between them: +_SampleToNES_ has four tabs. Use `F1` to `F4` to switch between them: -- **Main** (`F1`) — turn audio files into [reconstructions](../concepts/reconstruction.md). -- **Reconstruction** (`F2`) — listen to a reconstruction, edit its instruments, and export it. -- **Sequencer** (`F3`) — arrange reconstructions into a song. It has [its own page](sequencer.md). -- **Instructions** (`F4`) — build and browse the [instruction - library](../concepts/instruction-library.md) a conversion draws from. +- [**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. -Work moves through them in that order. You convert on **Main**, and when the run -finishes **Load** opens the result on **Reconstruction**. From there **Add to -Sequencer** hands it to a song. The **Instructions** tab is optional and allows you to explore single _instructions_ — unit blocks the NES sound processor produces. +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. -## Choosing what to convert - -The **Converter** card on the **Main** tab holds a list of what a run converts. -Fill it from the **Filesystem** browser: - -- Double-click an audio file, or Ctrl-click it, to add it. Right-click ▸ **Add as - stem** does the same. -- Ctrl-click a folder, or right-click ▸ **Add folder**, to add every recording - below it, however deep the tree goes. - -Reading a large folder takes a moment, so **Reading the folder** appears while -the scan runs, counting what it has found, with a **Stop** if you picked the -wrong one. A folder holding no audio says so instead of joining the list. - -A single click plays a recording while **Playback ▸ Autoplay** (`Ctrl+P`) is on, -so you can listen through a folder before taking anything from it. With Autoplay -off, use right-click ▸ **Play**. - -**x** takes a row out of the list, and taking out a folder takes everything it -holds. - -## Choosing which channels a recording uses - -The NES has four sound channels — **Pulse 1**, **Pulse 2**, **Triangle**, and -**Noise** — and every recording in the list carries a checkbox under each of -them. Tick the ones that recording may use. Untick them all and the row grays -out: it stays in the list and sits out of the conversion, so you can bring it -back. Pressing `1` to `4` switches one channel across every recording at once. - -A folder is a single row standing for the recordings below it, so its checkboxes -read three ways: ticked where every recording in it uses that channel, filled in -the channel's own color where they differ, and empty where none does. One click -settles the whole folder. To answer for one recording on its own, open the folder -— click the marker beside its name, or double-click the name. - -**Source settings** holds two more things. **Drive** sets how hard the channels -are pushed and applies to the whole run. Below it, the card names the row you -clicked in the list and gives it a box per channel, with a **bend** box beside it -on **Pulse 1**, **Pulse 2**, and **Triangle** that tunes each note to the -recording's exact pitch. Noise takes no bend. - -**Channels per source** caps how many channels one recording may hold in a single -frame, from 1 to 4. Set it to 1 and each recording gets a single voice. - -## One reconstruction each, or one mix from all - -**Output**, at the head of the **Converter** card, decides what the run writes. - -- **One per recording** gives every recording in the list a reconstruction of its - own. The ones that came in with a folder are written into a tree mirroring that - folder. -- **One from all** mixes them into a single reconstruction. Folders are flattened - into the recordings they hold. - -A mix holds up to eight recordings. Switching to **One from all** with more than -that listed asks which to mix, and so does adding a folder that overflows the -room left. The question lists the same rows the card does, counts what you have -picked, and **Add** settles it once the pick fits. Adding one more recording to a -mix that is already full does nothing, so let one go first. - -From the second recording of a mix, rows sit in **level** bands. A level is a -turn to choose: everything on level 1 picks its channels before anything on level -2, so a lead can take what it needs before a pad does. Drag a row onto another to -share its level, or into a gap to give it a level of its own; right-clicking a -row lists the same moves. **Order** decides how the levels take turns — **Round -robin** gives every level a turn each round, **Strict** fills one level before the -next picks. - -## Running a conversion - -Click the button under **Output** to start. It names what it is about to do, and -only one conversion runs at a time; while one does, the button reads **Cancel**. - -**Destination:** says where the run writes — the document a single conversion -makes, or the folder a longer run fills. Click the path to open it in your file -manager. If converting would replace a reconstruction that already exists, the -app asks first. - -When the run finishes, **Load** opens the result on the **Reconstruction** tab; -after a run of several, the button reads **Open** instead. The first conversion -with a given set of settings builds the [instruction -library](../concepts/instruction-library.md) it needs, which takes a while; later -runs on the same settings reuse it. - -## Listening to a reconstruction - -Open a saved reconstruction from the **Browser** on the **Reconstruction** tab. -It groups them two ways in one tree: **By configuration**, by the settings they -were made with, and **By sample**, gathering every version of one source audio -together. If what you have open has unsaved edits, you are asked whether to save -it first. - -To keep the ones you return to within reach, right-click a reconstruction — or a -whole folder — and choose **Mark as favorite**, then tick **Favorites only** to -narrow the tree to them. - -The **Source** card switches playback between **Reconstruction** and **Original -audio**, so you can hear the two against each other. The **Waveform** card draws -the channels with a checkbox for each; `1` to `4` flip the same boxes. - -## Hearing what each recording contributed - -The **Stems** card lists the recordings a reconstruction was built from, under -the level each was picked on. Every row carries a box for each channel that -recording took, and a box at the front that moves all of them together. - -Untick one and those frames fall silent everywhere — in the waveform, in -playback, in the original audio, and in a WAV export — which is how you hear what -one recording contributed, channel by channel. These boxes are listening state -only and change nothing that is saved. **Collapse levels** reads the whole list -as one table. - -**x** at the end of a row removes that recording from the reconstruction and asks -first: its frames fall silent and its row disappears, and the change is written -only when you save. One recording always stays, so the last row's **x** is -disabled. - -## Editing instruments - -The **Instruments** panel shows what each channel plays, as sequences you edit by -dragging the bars or typing values. Which sequences a channel has depends on the -channel: **Pulse 1** and **Pulse 2** carry volume, arpeggio, pitch, hi-pitch, and -duty cycle; **Triangle** the same without duty cycle; **Noise** carries volume, -arpeggio, and duty cycle. - -Typing `|` before a value marks where that sequence repeats from while a note is -held, so `15 14 | 12 10` attacks and then circles the last two. Clearing a -sequence hands that dimension back to the channel, so an instrument with its -volume cleared plays at whatever volume the channel is set to. Each channel -states how many bytes its instrument takes on the NES, so you can see what an -edit costs. - -A hand-written **instrument** — a voice with no recording behind it, 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 -**Audition** takes the place of the pitch steppers: pick **Pulse**, **Triangle**, -or **Noise** and the note keys play the instrument on that generator. - -## Exporting - -You export a reconstruction from the **Reconstruction** menu. -**Export instruments ▸ FamiTracker instruments...** writes one `.fti` per -channel, **Bitphase presets...** writes the same as `.json`, and **NSF -program...** writes a single `.nsf` that plays the whole reconstruction on a NES. -**Export to WAV...** renders the audio, honoring the channel and stem boxes you -have set. - -**Export instrument...** in the **Instruments** panel writes the one channel you -are looking at, in whichever format the save dialog is set to. See [where your -files live](files.md#exported-files). - -To use a reconstruction in a song, right-click it and choose **Add to -Sequencer**. - -## The instruction library - -The **Instructions** tab builds and browses the [instruction -library](../concepts/instruction-library.md), the catalog of NES tones a -conversion searches. Converting builds the one your settings need, so you rarely -need to come here. - -It is useful for building a library ahead of a long session, and for exploring -what a configuration can produce: pick an instruction and its **Waveform** and -**Spectrum** appear, so you can see and hear a single NES tone on its own. -**Generate library** builds one for your current settings, and reads -**Regenerate instructions** once a library is loaded. +The **Instructions** tab is optional. It lets you explore individual _instructions_ — the unit blocks produced by the NES sound processor. ## The menus Each menu covers one kind of work: -- **File** — projects, the module and program a song exports to, and rendering a - song to audio. -- **Edit** — undo and redo, then the actions of whatever you have selected, so - its lower half changes with what you are working on. -- **Reconstruction** — making, opening and saving reconstructions, and exporting - them. -- **Voice** — the ways a voice comes into the sequencer, and the actions of the - voice you picked. +- **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**. -Two items are easy to miss. **View ▸ Show advanced settings** reveals **Advanced -settings** on the **Main** tab, which holds the generation method, the feature -scaling, the worker count, and the library and output folders. -[Configuration](configuration.md) explains each one. **Playback ▸ Audio -settings...** picks the device, sample rate, and buffer size you listen through, -which are separate from the **Sample rate** and **NES frequency** on the **Main** -tab that decide how audio is reconstructed. +Two items are easy to miss: -Project properties belong to a project and are covered in the [sequencer -guide](sequencer.md). +- **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. + +Project properties belong to a project and are covered in the [sequencer guide](sequencer.md). ## 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. If another action already holds them, the app names it and asks -whether to hand them over. **Reset to defaults** puts everything back, and your -changes take effect when you press **OK**. +**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. + +**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. -`Space` plays and pauses and `Esc` stops, wherever you are. 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. +`Space` plays and pauses, and `Esc` stops, anywhere in the app. 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 d568b11ce..728c8f69b 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -37,7 +37,7 @@ than the project and the project already has voices, _SampleToNES_ warns with 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 **Reconstruction** tab -(right-click ▸ **Edit**). See [editing instruments](interface.md#editing-instruments). +(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 d799199dc..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. From b6d1abb3dcf5c3e65c01ca2cc1d815690a6e7da8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 23:18:09 +0200 Subject: [PATCH 087/130] Toned: the folder scan's Stop as the cancel it is --- src/sampletones_application/ui/panels/dialogs/scanning.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/sampletones_application/ui/panels/dialogs/scanning.py b/src/sampletones_application/ui/panels/dialogs/scanning.py index e190a81ef..e0710f043 100644 --- a/src/sampletones_application/ui/panels/dialogs/scanning.py +++ b/src/sampletones_application/ui/panels/dialogs/scanning.py @@ -5,6 +5,7 @@ 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, @@ -13,6 +14,7 @@ ) 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 @@ -99,6 +101,7 @@ def create_window(self) -> None: label=self._stop_label, callback=self._stop, width=-1, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_DANGER_BUTTON), ) def _stop(self) -> None: From eacbdc12ac49bd7b1f9f1db3a15930d94e927f22 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 23:18:19 +0200 Subject: [PATCH 088/130] Lined: the list's names and columns up across its tables --- .../layout/general/stems.py | 1 - .../ui/elements/layout/region.py | 22 +++++++++- .../ui/elements/layout/well.py | 5 ++- .../ui/elements/stems/bands.py | 15 +++---- .../ui/elements/stems/columns.py | 44 ++++++++++++++++--- .../ui/elements/stems/folder.py | 12 ++--- .../ui/elements/stems/heading.py | 4 +- .../ui/elements/stems/list.py | 3 +- .../ui/elements/stems/row.py | 24 +++++++--- .../ui/panels/main/source/grid.py | 4 +- .../layout/general/stems.yaml | 1 - .../ui/elements/layout/test_region.py | 5 ++- 12 files changed, 104 insertions(+), 36 deletions(-) diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index e05797a9a..6148a8794 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -16,6 +16,5 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): folder_indent: int window_overscan: int scrollbar_width: int - column_gutter: int cell_padding: int name_height: int diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index dee5fca4a..eeda64b39 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -17,6 +17,7 @@ LeadBuilder = Callable[[str], None] NO_ROWS: Final[Window] = (0, 0) +NO_GUTTER: Final[int] = 0 NO_LEAD: Final[float] = 0.0 AUTO_HEIGHT: Final[int] = 0 NO_SCROLL: Final[float] = 0.0 @@ -38,7 +39,9 @@ class WindowedRegion: 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. + 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 @@ -62,6 +65,7 @@ def __init__( ceiling: int, padding: int, margin: int, + gutter: int, indent: Optional[int] = None, ) -> None: self._tag = tag @@ -69,6 +73,7 @@ def __init__( 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) @@ -146,6 +151,7 @@ def create(self, parent: str, *, show: bool = True) -> None: self._tag, padding=self._padding, margin=self._margin, + gutter=self._gutter, indent=self._indent, show=show, ) @@ -312,6 +318,7 @@ def _stand_at_natural_height(self) -> None: self._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 _size_to(self, content: float) -> None: within = content <= self._ceiling @@ -324,6 +331,19 @@ def _size_to(self, content: float) -> None: 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 it draws none, and the gutter stands in its place. So + the columns inside a region stand where they stand however long the list it holds grows. + """ + if not dpg.does_item_exist(self._body_tag): + return + + dpg_configure_item(self._body_tag, width=-(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.""" diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index 262eb0220..d7ce50f85 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -16,6 +16,7 @@ def well( *, padding: int, margin: int, + gutter: int, indent: Optional[int] = None, height: int = 0, show: bool = True, @@ -31,6 +32,8 @@ def well( and ``indent`` at the left, the two being the same width unless a caller nests the body inside something. 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. + ``gutter`` widens that right inset by the room a scrollbar takes, which a caller hands over + while the well stands without one, so the body keeps one width however tall its content grows. ``margin`` opens the gap above the first row and below the last, which the row spacing between the content and the spacers adds to. """ @@ -46,7 +49,7 @@ def well( show=show, ): dpg.add_spacer(height=margin) - dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=-padding) + dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=-(padding + gutter)) dpg.add_spacer(height=margin) ThemeRegistry.get(TAG_GLOBAL_THEME_PANEL_GROUND).bind_to_item(tag) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 947919cd6..32642e982 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -13,7 +13,7 @@ ) 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_RESERVE, StemsColumns +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 @@ -196,8 +196,9 @@ def _create_table( 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 holds a scrollbar's width clear at its right end, so the columns - around a folder stand where the columns inside its own scrolling region stand. + 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, @@ -205,11 +206,5 @@ def columns(self, view_model: StemsListViewModel) -> StemsColumns: master=self._offer.master_box, removable=self._offer.removal, bends=self._offer.bends, - reserve=self._reserve(view_model), + folders=view_model.holds_folders, ) - - def _reserve(self, view_model: StemsListViewModel) -> int: - if not view_model.holds_folders: - return NO_RESERVE - - return self._layout.scrollbar_width + self._layout.column_gutter diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index 446e55cea..ad9c1f963 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -9,6 +9,7 @@ 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 @@ -28,10 +29,11 @@ class StemsColumns: 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. - ``reserve`` holds a strip clear at the right end of the grid, as wide as a scrollbar. A folder - draws its recordings inside a region of their own, which spends that width on its scrollbar; - holding the same width clear out here stands the columns of the grid around a folder where the - columns inside it stand. + ``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 @@ -39,16 +41,29 @@ class StemsColumns: master: bool removable: bool bends: bool - reserve: int + 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 a folder's region spends at the right of the grid, held clear across the list. + + A region insets its body by the well's padding and keeps a scrollbar's width clear beside + it, so a strip of that width at the right end of every table outside a folder stands the + two grids in one. + """ + if not self.folders: + return NO_RESERVE + + return self.layout.well_padding + self.layout.scrollbar_width + @property def reserve_width(self) -> int: - """The width the reserve column is declared at, so the room it holds is a scrollbar's. + """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. @@ -85,6 +100,23 @@ 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) + 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 + the glyph rather than at the button, which reads as one column of names without spending + the marker's whole width on a row that has none. + """ + 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 max(NO_INDENT, (self.layout.twisty_width - int(measured[0])) // 2) + 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. diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 04eaf3f9f..cbc94c559 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -8,7 +8,7 @@ from sampletones_application.tags.general import SUF_TABLE from sampletones_application.ui.elements.layout.geometry import RowGeometry from sampletones_application.ui.elements.layout.region import NO_SCROLL, WindowedRegion -from sampletones_application.ui.elements.stems.columns import NO_RESERVE, StemsColumns +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 @@ -50,7 +50,7 @@ def __init__( master=False, removable=False, bends=False, - reserve=NO_RESERVE, + folders=False, ) def reads(self, columns: StemsColumns) -> None: @@ -131,6 +131,7 @@ def _open(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: ceiling=self._layout.folder_ceiling, padding=self._layout.well_padding, margin=self._layout.well_margin, + gutter=self._layout.scrollbar_width, indent=self._layout.well_padding + self._layout.folder_indent, ) region.create(self._tags.body) @@ -156,10 +157,11 @@ def _create_rows( ) -> None: """One table of the recordings a region reaches, declaring the columns the list lines up on. - The region spends a scrollbar's width of its own, which is the width the grid outside it - holds clear, so a box inside a folder stands in the column its neighbours stand in. + 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 neighbours stand in and none of + them leads with a marker. """ - held_columns = replace(self._columns, reserve=NO_RESERVE) + held_columns = replace(self._columns, folders=False) with dpg.table( tag=self._tags.held(row.key), parent=region.body, diff --git a/src/sampletones_application/ui/elements/stems/heading.py b/src/sampletones_application/ui/elements/stems/heading.py index 7dd6fb524..9de7fcd08 100644 --- a/src/sampletones_application/ui/elements/stems/heading.py +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -16,7 +16,7 @@ ) 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_RESERVE, StemsColumns +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 @@ -52,7 +52,7 @@ def __init__( master=False, removable=False, bends=bends, - reserve=NO_RESERVE, + folders=False, ) @property diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 3b7d38e61..453492008 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -5,7 +5,7 @@ from sampletones_application.layout.general.stems import StemsListLayout 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 WindowedRegion +from sampletones_application.ui.elements.layout.region import NO_GUTTER, WindowedRegion from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.stems.bands import LevelBands from sampletones_application.ui.elements.stems.expansion import OpenFolders @@ -73,6 +73,7 @@ def __init__( ceiling=ceiling, padding=layout.well_padding, margin=layout.well_margin, + gutter=NO_GUTTER, ) self._messages = StemsMessages( diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 09285590a..0a48e4b5c 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -21,7 +21,7 @@ ) 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.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 @@ -84,7 +84,7 @@ def create( if self._offer.master_box: self._create_master(row, view_model) - self._create_name(row, view_model) + self._create_name(row, view_model, columns) for channel_name in view_model.channels_in_play: self._create_channel(row, channel_name, columns) @@ -170,11 +170,17 @@ def _tone_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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) -> None: + 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. The name takes the height its boxes take, so - the band a row reads as covers the whole of what stands beside it. + 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) @@ -182,6 +188,7 @@ def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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, @@ -199,6 +206,13 @@ def _create_name(self, row: StemRowViewModel, view_model: StemsListViewModel) -> 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 _draggable(self, view_model: StemsListViewModel) -> bool: """A row is dragged where the list bands its rows, which is what a drag rearranges.""" return self._offer.dragging and not view_model.collapse_levels diff --git a/src/sampletones_application/ui/panels/main/source/grid.py b/src/sampletones_application/ui/panels/main/source/grid.py index 45cfa9e45..d76f7d6a4 100644 --- a/src/sampletones_application/ui/panels/main/source/grid.py +++ b/src/sampletones_application/ui/panels/main/source/grid.py @@ -11,7 +11,7 @@ TAG_MAIN_SOURCE_GROUP_GRID, TAG_MAIN_SOURCE_TABLE_GRID, ) -from sampletones_application.ui.elements.stems.columns import NO_RESERVE, StemsColumns +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, @@ -62,7 +62,7 @@ def __init__( master=False, removable=False, bends=True, - reserve=NO_RESERVE, + folders=False, ) self.on_slot_toggled: Optional[SlotCallback] = None diff --git a/src/sampletones_config/layout/general/stems.yaml b/src/sampletones_config/layout/general/stems.yaml index b2f75945d..dd43c2bf8 100644 --- a/src/sampletones_config/layout/general/stems.yaml +++ b/src/sampletones_config/layout/general/stems.yaml @@ -12,6 +12,5 @@ folder_ceiling: 264 folder_indent: 14 window_overscan: 4 scrollbar_width: 13 -column_gutter: 6 cell_padding: 4 name_height: 30 diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index d7743d954..ac4c90e25 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -6,7 +6,7 @@ from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY from sampletones_application.ui.elements.layout.geometry import RowGeometry -from sampletones_application.ui.elements.layout.region import LeadBuilder, WindowedRegion +from sampletones_application.ui.elements.layout.region import NO_GUTTER, LeadBuilder, WindowedRegion 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 @@ -43,6 +43,7 @@ def region(dpg_context: None) -> WindowedRegion: ceiling=CEILING, padding=0, margin=0, + gutter=NO_GUTTER, ) with dpg.window(tag=ROOT_TAG): built.create(ROOT_TAG) @@ -132,6 +133,7 @@ def unmeasured(self, dpg_context: None) -> WindowedRegion: ceiling=CEILING, padding=0, margin=0, + gutter=NO_GUTTER, ) with dpg.window(tag=ROOT_TAG): built.create(ROOT_TAG) @@ -167,6 +169,7 @@ def unmeasured(self, dpg_context: None) -> WindowedRegion: ceiling=CEILING, padding=0, margin=0, + gutter=NO_GUTTER, ) with dpg.window(tag=ROOT_TAG): built.create(ROOT_TAG) From e1546539a95e04fbcbcdf40ef04617febf4471fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 7 Sep 2026 23:35:54 +0200 Subject: [PATCH 089/130] Redrew: the folder a recording left, and nothing around it --- .../ui/elements/stems/bands.py | 11 +-- .../ui/elements/stems/list.py | 15 +++- .../ui/elements/stems/row.py | 6 +- .../ui/elements/stems/shape.py | 80 ++++++++++++++++--- .../ui/elements/stems/test_folder.py | 47 +++++++++++ 5 files changed, 139 insertions(+), 20 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 32642e982..be836cf90 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -20,7 +20,7 @@ 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 +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 ( @@ -66,18 +66,19 @@ def __init__( self._level_template = language_manager["global.stems.template.level_caption"] self._shape = ListShape.nothing() - def reshaped(self, view_model: StemsListViewModel) -> bool: - """Whether the view names a different shape than the one standing, which asks for a rebuild. + 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 either way, 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) if shape == self._shape: - return False + return Reshape.nothing() + asked = shape.against(self._shape) self._shape = shape - return True + return asked def forget(self) -> None: """Let go of the shape standing, so the next reading is drawn rather than repainted.""" diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 453492008..87f9bf189 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -174,14 +174,23 @@ def create(self, parent: str, *, show: bool = True) -> None: self._region.create(parent, show=show) def update_view(self, view_model: StemsListViewModel) -> None: - """Take up a new reading of the setup: rebuild the bands where it reshapes them, repaint - the rows either way.""" + """Take up a new reading of the setup: draw what it reshapes, repaint the rows either way. + + 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. + """ 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) - if self._bands.reshaped(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) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 0a48e4b5c..529e3f0de 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -106,6 +106,10 @@ def repaint( 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): @@ -117,7 +121,7 @@ def repaint( 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) + 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) diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index 16770b3a0..08e9cca33 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -1,17 +1,51 @@ -from dataclasses import dataclass -from typing import FrozenSet, Self, Tuple +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 nothing(cls) -> Self: + """What a reading the list already stands at asks for, which a repaint answers on its own.""" + return cls(whole=False, folders=()) + + @classmethod + def everything(cls) -> Self: + """What a reading the standing widgets cannot be brought to asks for.""" + 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.""" + 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 reshapes the list - the way a loose row leaving does and the region it stood in is drawn again. + ``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 @@ -20,10 +54,14 @@ class RowPlacement: 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 rebuild and anything else a repaint. + """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. @@ -35,12 +73,7 @@ class ListShape: @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. - - A folder opening or closing reshapes the list, since the region its recordings stand in - is built and taken down with it, and so does a recording leaving the folder, since the - region draws a row for each one the list still stands for. - """ + """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, @@ -60,3 +93,28 @@ def of(cls, view_model: StemsListViewModel, open_folders: OpenFolders) -> Self: 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/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 02585607f..1713cc8aa 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -347,6 +347,53 @@ def test_the_ones_that_stay_are_still_drawn(self, stems_list: GUIStemsList) -> N for held in sources.held[1:]: assert dpg.does_item_exist(f"{PREFIX}.row.{held.key}.{SUF_TEXT}") + 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 "2" in str(dpg.get_item_label(name_of(sources))) + + 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 "2" in str(dpg.get_item_label(name_of(sources))) + + 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 From c635a3698a6fe2bff30083d178deafc307526773 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 00:12:41 +0200 Subject: [PATCH 090/130] Answered: the keyboard on the row the converter list holds --- src/sampletones_application/application.py | 11 +++ .../categories/elements/settings.py | 4 + .../coordinators/tabs/main.py | 20 ++++- .../logic/main/converter/gathering.py | 4 - .../logic/main/converter/logic.py | 11 ++- .../logic/main/sources/list.py | 31 ------- .../ui/elements/stems/gestures.py | 13 ++- .../ui/elements/stems/list.py | 10 ++- .../ui/panels/main/converter/listing.py | 60 ++++++++++++- .../ui/panels/main/converter/panel.py | 10 +++ .../ui/panels/main/source/panel.py | 9 -- .../ui/panels/reconstruction/stems.py | 4 +- .../utils/gui/shortcuts/ids.py | 4 + .../keybindings/default.yaml | 4 + src/sampletones_config/keybindings/macos.yaml | 4 + src/sampletones_config/lang/en.yaml | 3 + .../logic/main/converter/test_logic.py | 59 ++++++++++++- .../sampletones_application/test_startup.py | 48 ++++++++-- .../ui/elements/stems/test_list.py | 35 +++++++- .../ui/panels/main/test_converter.py | 87 ++++++++++++++++++- .../ui/panels/main/test_source.py | 16 ---- 21 files changed, 352 insertions(+), 95 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 60d19bb82..6186048bb 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -505,6 +505,9 @@ def __init__( dialogs=self.dialogs, status_bar=self.status_bar, 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( @@ -808,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. diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 53fe0d432..c16c921da 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -146,6 +146,9 @@ class KeybindingActionElements(AbstractElement): TRACKER_CANCEL_ENTRY = "tracker_cancel_entry" TRACKER_PLAY_FROM_ROW = "tracker_play_from_row" + SOURCES_REMOVE_SOURCE = "sources_remove_source" + SOURCES_CLEAR_SELECTION = "sources_clear_selection" + VOICES_RENAME_VOICE = "voices_rename_voice" VOICES_REMOVE_VOICE = "voices_remove_voice" VOICES_MOVE_VOICE_UP = "voices_move_voice_up" @@ -159,6 +162,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/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index f8421c1f8..0fb52e322 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -59,7 +59,9 @@ 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, ) @@ -105,6 +107,9 @@ def __init__( dialogs: DialogsRenderer, status_bar: GUIStatusBar, stem_selection_window: GUIStemSelectionWindow, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + tab_active: ActivePredicate, ) -> None: self._language_manager = language_manager self._config_manager = config_manager @@ -135,6 +140,9 @@ def __init__( 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() @@ -195,6 +203,9 @@ def _build_cards( 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 @@ -261,6 +272,9 @@ def _build_cards( 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: @@ -273,7 +287,6 @@ def _wire_settings(self, config_manager: ConfigManager) -> None: self._config_panel.on_library_settings_changed = config_manager.apply_library_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._source_panel.on_channel_keyed = self._converter_logic.toggle_channel 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 @@ -344,6 +357,7 @@ def _wire_converter( 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 def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: @@ -780,8 +794,8 @@ 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._source_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: """Puts the advanced card away or stands it back beside the general one.""" diff --git a/src/sampletones_application/logic/main/converter/gathering.py b/src/sampletones_application/logic/main/converter/gathering.py index 9bdfecc19..a4eccbf2a 100644 --- a/src/sampletones_application/logic/main/converter/gathering.py +++ b/src/sampletones_application/logic/main/converter/gathering.py @@ -137,10 +137,6 @@ def toggled(self, key: SourceKey, slot: SettingsSlot, channel_name: ChannelName) """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 toggled_throughout(self, slot: SettingsSlot, channel_name: ChannelName) -> Self: - """The setup with ``channel_name`` settled the one way on every recording listed.""" - return replace(self, sources=self.sources.toggled_throughout(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) diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 79f08a928..cbf7fa7bd 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -274,14 +274,13 @@ def toggle_slot(self, field: SettingsField, channel_name: ChannelName) -> None: self._settle(self._state.with_gathering(self._state.gathering.settled(selected, slot, channel_name, held))) def toggle_channel(self, channel_name: ChannelName) -> None: - """Switches one channel across the whole list, which is what the channel's key reaches. + """Settles one channel on the row a reader picked out, which the channel's key reaches. - The list answers as one group: where every listed recording already holds the channel it - goes from each, and otherwise it reaches the ones standing without it, so one press always - leaves the list agreeing. + 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. """ - gathering = self._state.gathering.toggled_throughout(CHANNEL_SLOT, channel_name) - self._settle(self._state.with_gathering(gathering)) + 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.""" diff --git a/src/sampletones_application/logic/main/sources/list.py b/src/sampletones_application/logic/main/sources/list.py index 4d64fd5b2..7b747ad44 100644 --- a/src/sampletones_application/logic/main/sources/list.py +++ b/src/sampletones_application/logic/main/sources/list.py @@ -171,20 +171,6 @@ def toggled( held = self.agreement(key, slot, channel_name).settles_to return self.settled(key, slot, channel_name, held) - def toggled_throughout( - self, - slot: SettingsSlot, - channel_name: ChannelName, - ) -> Self: - """The list one gesture reaching every row leaves behind. - - The whole list reads as one group: where every recording already makes the choice it goes - from each, and otherwise it reaches the ones standing without it, so one gesture always - leaves the list agreeing on that channel. - """ - held = Agreement.over(slot.holds(recording.settings, channel_name) for recording in self.recordings).settles_to - return replace(self, rows=tuple(self._throughout(slot, channel_name, held))) - def agreement( self, key: SourceKey, @@ -248,23 +234,6 @@ def _rewritten_rows( ) -> Tuple[SourceRow, ...]: return self._rows_with(key, lambda settings: slot.write(settings, channels)) - def _throughout( - self, - slot: SettingsSlot, - channel_name: ChannelName, - held: bool, - ) -> Tuple[SourceRow, ...]: - """Every row with ``channel_name`` settled the one way, folders carrying it to what they hold.""" - rows: Tuple[SourceRow, ...] = () - for row in self.rows: - changed = tuple( - recording.with_settings(slot.settled(recording.settings, channel_name, held)) - for recording in row.recordings - ) - rows += (Folder(root=row.key.path, recordings=changed),) if row.key.names_folder else changed - - return rows - def _rows_with( self, key: SourceKey, diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index e0eb185aa..bd61d5c00 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -20,6 +20,7 @@ ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] ChannelCallback = Callable[[str, ChannelName], None] +RowSelectionCallback = Callable[[str, bool], None] KeyOffsetCallback = Callable[[str, int], None] KeyPairCallback = Callable[[str, str], None] @@ -49,7 +50,7 @@ def __init__( 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_row_activated: Optional[RowSelectionCallback] = None self.on_dropped_on_row: Optional[KeyPairCallback] = None self.on_dropped_on_level: Optional[KeyOffsetCallback] = None self.on_folder_toggled: Optional[StringCallback] = None @@ -142,10 +143,14 @@ 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: - """Hand a clicked row on, and let the next view say which row now reads as picked out.""" + def on_name_selected(self, _sender: Sender, value: bool, user_data: str) -> None: + """Hand a clicked row on, along with whether it now reads as picked out or as let go. + + A row already picked out reads as let go when it is clicked again, which is the answer + DearPyGui hands the callback, so one gesture both picks a row and releases it. + """ if self.activatable: - self._report(self.on_row_activated, user_data) + self._report(self.on_row_activated, user_data, value) 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.""" diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 87f9bf189..b5ae44ee1 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -15,6 +15,7 @@ ChannelsCallback, KeyOffsetCallback, KeyPairCallback, + RowSelectionCallback, StemsGestures, ) from sampletones_application.ui.elements.stems.heading import StemsHeading @@ -123,7 +124,7 @@ def __init__( 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_row_activated: Optional[RowSelectionCallback] = None self.on_dropped_on_row: Optional[KeyPairCallback] = None self.on_dropped_on_level: Optional[KeyOffsetCallback] = None self.on_row_opened: Optional[StringCallback] = None @@ -133,7 +134,7 @@ def __init__( 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_row_activated = lambda key, picked: self.call(self.on_row_activated, key, picked) 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) @@ -259,6 +260,11 @@ def row(self, key: str) -> Optional[StemRowViewModel]: """The row a gesture named, as the list last rendered it.""" return self._view.row(key) + @property + def picked_key(self) -> Optional[str]: + """The row standing picked out, which is what a key press acts on.""" + return self._view.selected_key + 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) diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 31d835cb2..98b74b02b 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -18,9 +18,17 @@ 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, +) +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 +from sampletones_shared.types.callback import PathCallback, StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin ChannelsCallback = Callable[[Path, FrozenSet[ChannelName]], None] @@ -36,6 +44,10 @@ class ConverterListing(CallbackMixin): 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__( @@ -45,8 +57,14 @@ def __init__( 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, @@ -60,6 +78,7 @@ def __init__( 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_selection_cleared: Optional[VoidCallback] = None self.on_source_removed: Optional[PathCallback] = None self.on_folder_removed: Optional[PathCallback] = None self.on_source_played: Optional[PathCallback] = None @@ -67,6 +86,8 @@ def __init__( self.on_source_dropped_on_level: Optional[PathOffsetCallback] = None self.on_menu_requested: Optional[StringCallback] = 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.""" @@ -104,12 +125,45 @@ def _on_folder_channel_toggled(self, key: str, channel_name: ChannelName) -> Non """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, whichever kind it is.""" + def _on_selected(self, key: str, picked: bool) -> None: + """A clicked row is the one the settings card inspects; clicking it again lets it go.""" + if not picked: + self.call(self.on_selection_cleared) + return + 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: its tab is in front and it holds a row picked out. + + A row picked out outlives a move to another tab, so the tab is read at the moment of the + press. A modal dialog claims keys above this scope in the router, so the list needs no + check of its own for one. + """ + return self._tab_active() and self._stems_list.picked_key is not None and not self._router.is_field_focused + + 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. + """ + key = self._stems_list.picked_key + if key is None: + return False + + match self._shortcuts.action(ShortcutCategory.SOURCES, event): + case ShortcutId.SOURCES_REMOVE_SOURCE: + self._on_removed(key) + case ShortcutId.SOURCES_CLEAR_SELECTION: + self.call(self.on_selection_cleared) + 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) diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py index fb19229b3..56bfcc367 100644 --- a/src/sampletones_application/ui/panels/main/converter/panel.py +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -21,6 +21,8 @@ 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 @@ -47,6 +49,9 @@ def __init__( 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) @@ -55,6 +60,9 @@ def __init__( 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, @@ -80,6 +88,7 @@ def __init__( 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 @@ -153,6 +162,7 @@ def _wire(self) -> None: 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) diff --git a/src/sampletones_application/ui/panels/main/source/panel.py b/src/sampletones_application/ui/panels/main/source/panel.py index b37487b9d..0d38214c0 100644 --- a/src/sampletones_application/ui/panels/main/source/panel.py +++ b/src/sampletones_application/ui/panels/main/source/panel.py @@ -71,7 +71,6 @@ def __init__( self.on_generation_settings_changed: Optional[Callable[[GenerationSettingsUpdate], None]] = None self.on_slot_toggled: Optional[Callable[[SettingsField, ChannelName], None]] = None - self.on_channel_keyed: Optional[Callable[[ChannelName], None]] = None super().__init__( tag=TAG_MAIN_SOURCE_PANEL, @@ -107,14 +106,6 @@ def update_view(self, view_model: SourceSettingsPanelViewModel) -> None: dpg_configure_item(self._grid.tag, show=view_model.inspecting) self._grid.render(view_model) - def toggle_channel(self, channel: ChannelName) -> None: - """Switches one channel across the whole list, which is what its key reaches. - - A box on the card answers for the row a reader picked out; the key answers for the list, - so setting a channel on everything at once is one press rather than a row at a time. - """ - self.call(self.on_channel_keyed, channel) - 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) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 73c93aba5..56187477a 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -181,8 +181,8 @@ def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> No 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.""" + def _on_row_activated(self, key: str, _picked: bool) -> None: + """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/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index afd04fd8d..0418a8edc 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" @@ -185,6 +186,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_CANCEL_ENTRY = ("TrackerCancelEntry", ShortcutCategory.TRACKER) TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER) + SOURCES_REMOVE_SOURCE = ("SourcesRemoveSource", ShortcutCategory.SOURCES) + SOURCES_CLEAR_SELECTION = ("SourcesClearSelection", ShortcutCategory.SOURCES) + VOICES_RENAME_VOICE = ("VoicesRenameVoice", ShortcutCategory.VOICES) VOICES_REMOVE_VOICE = ("VoicesRemoveVoice", ShortcutCategory.VOICES) VOICES_MOVE_VOICE_UP = ("VoicesMoveVoiceUp", ShortcutCategory.VOICES) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 94e754d36..8b93f5b93 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -140,6 +140,10 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} + # sources + SourcesRemoveSource: {combination: "Del"} + SourcesClearSelection: {combination: "Esc"} + # voices VoicesRenameVoice: {combination: "F2"} VoicesRemoveVoice: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 36f6fcdaa..2cca9b427 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -140,6 +140,10 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} + # sources + SourcesRemoveSource: {combination: "Del"} + SourcesClearSelection: {combination: "Esc"} + # voices VoicesRenameVoice: {combination: "F2"} VoicesRemoveVoice: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9bc6b66a6..b550e089c 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -851,6 +851,7 @@ settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" settings.keybindings.title.tracker: "Tracker" +settings.keybindings.title.sources: "Converter list" settings.keybindings.title.voices: "Voices" settings.keybindings.title.reassign_confirmation: "Combination in use" settings.keybindings.title.reset_confirmation: "Restore the shipped keys" @@ -929,6 +930,8 @@ 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.sources_clear_selection: "Let the picked recording go" settings.keybindings.label.order_previous_position: "Previous position" settings.keybindings.label.order_next_position: "Next position" settings.keybindings.label.order_previous_channel: "Previous channel" diff --git a/tests/unit/sampletones_application/logic/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 055808db8..1defc8c36 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, List +from typing import Callable, FrozenSet, List from unittest.mock import MagicMock, patch import pytest @@ -820,6 +820,63 @@ def test_turning_to_a_mix_gives_up_the_folder( 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.""" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 21cf729b7..ec97ad5c1 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -13,7 +13,7 @@ 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 +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 ( @@ -391,12 +391,8 @@ 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_channel_across_the_whole_list( - self, - app: Application, - tmp_path: Path, - ) -> None: - """The key is the gesture that answers for everything listed, a row at a time being the box.""" + @staticmethod + def _gathered(app: Application, tmp_path: Path) -> List[Path]: paths = [] for name in ["a.wav", "b.wav"]: path = tmp_path / name @@ -404,12 +400,46 @@ def test_the_main_tab_switches_the_channel_across_the_whole_list( 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) - for path in paths: - assert (ChannelName.TRIANGLE in _row_of(app, path).channels) is not held + 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) 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 429b6f187..57966aba4 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -662,16 +662,45 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf class TestActivation(BaseTestSuite): def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> None: - activated: List[str] = [] + activated: List[Tuple[str, bool]] = [] stems_list = build(layout_config, dragging=False) - stems_list.on_row_activated = activated.append + stems_list.on_row_activated = lambda key, picked: activated.append((key, picked)) bass = row("bass") stems_list.update_view(view(bass)) name_tag = row_tag(bass, SUF_TEXT) dpg.get_item_callback(name_tag)(name_tag, True, bass.key) - assert activated == [bass.key] + assert activated == [(bass.key, True)] + + def test_a_row_clicked_again_reports_that_it_was_let_go(self, dpg_context: None, layout_config) -> None: + """DearPyGui hands the callback what the row now reads as, so one gesture answers both ways.""" + activated: List[Tuple[str, bool]] = [] + stems_list = build(layout_config, dragging=False) + stems_list.on_row_activated = lambda key, picked: activated.append((key, picked)) + bass = row("bass") + stems_list.update_view(view(bass, selected_key=bass.key)) + + name_tag = row_tag(bass, SUF_TEXT) + dpg.get_item_callback(name_tag)(name_tag, False, bass.key) + + assert activated == [(bass.key, False)] + + def test_the_list_names_the_row_a_key_press_acts_on(self, dpg_context: None, layout_config) -> 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) -> 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) -> None: """A click is answered by whoever owns the list, so the next view decides what is selected.""" diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 6208bc2b4..7ced888fa 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -35,6 +35,8 @@ 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 +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.main.converter import ( @@ -44,6 +46,7 @@ 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.shortcuts import shipped_source ROOT_TAG = "test_root" LANGUAGE_MANAGER = LanguageManager(LANG_EN) @@ -102,6 +105,7 @@ def view( phase: ConversionPhase = ConversionPhase.IDLE, input_path: Optional[Path] = None, output_path: Optional[Path] = None, + selected_key: Optional[str] = None, ) -> ConverterViewModel: return ConverterViewModel( phase=phase, @@ -118,11 +122,16 @@ def view( max_channel_cap=len(ChannelName), hierarchy_mode=DEFAULT_STEMS_HIERARCHY_MODE, max_sources=8, - selected_key=None, + selected_key=selected_key, ) -def build(layout_config: LayoutConfig) -> Tuple[GUIConverterPanel, List[OutputKind]]: +def build( + layout_config: LayoutConfig, + *, + key_router: Optional[KeyRouter] = None, + tab_active: ActivePredicate = lambda: True, +) -> 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, @@ -131,6 +140,9 @@ def build(layout_config: LayoutConfig) -> Tuple[GUIConverterPanel, List[OutputKi 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(), + tab_active=tab_active, ) reported: List[OutputKind] = [] panel.on_output_changed = reported.append @@ -276,6 +288,77 @@ def test_the_hint_leaves_with_the_first_recording(self, dpg_context: None, layou assert not shows(TAG_MAIN_CONVERTER_TEXT_STEMS_HINT) +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_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_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_lets_the_row_picked_out_go(self, dpg_context: None, layout_config: LayoutConfig) -> None: + router = KeyRouter() + panel, _reported = build(layout_config, key_router=router) + cleared: List[bool] = [] + panel.on_selection_cleared = lambda: cleared.append(True) + kick = row("kick") + panel.update_view(view(kick, selected_key=kick.key)) + + assert self._press(router, ShortcutId.SOURCES_CLEAR_SELECTION) is True + assert cleared == [True] + + 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.""" diff --git a/tests/unit/sampletones_application/ui/panels/main/test_source.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py index cdf8bc738..fd0fda012 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_source.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -282,22 +282,6 @@ def test_a_box_names_the_choice_and_the_channel_it_stands_on( assert reported == [(SettingsField.CHANNELS, channel)] - @pytest.mark.parametrize("channel", list(ChannelName.items())) - def test_the_key_a_channel_answers_to_reaches_the_whole_list( - self, - channel: ChannelName, - dpg_context: None, - layout_config: LayoutConfig, - ) -> None: - """A box answers for the picked row; the key answers for everything listed.""" - panel, _reported = build(layout_config, view(channels_slot(), inspected=recording("bass"))) - keyed: List[ChannelName] = [] - panel.on_channel_keyed = keyed.append - - panel.toggle_channel(channel) - - assert keyed == [channel] - class TestBoxTags: def test_every_choice_and_channel_carries_a_tag_of_its_own( From df5a69c9553a731d36bec05ae882dfe22cdabb2b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 00:14:22 +0200 Subject: [PATCH 091/130] Stated: the converter's keys and what the list still owes --- docs/development/bugs-and-todos.md | 14 ++++++++++++++ docs/development/keyboard.md | 2 +- docs/guide/converting.md | 6 +++++- docs/guide/interface.md | 2 +- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bf0cd4dee..4dac4054a 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` and `Esc` reach 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 diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md index 126d9f06b..eafc2af6d 100644 --- a/docs/development/keyboard.md +++ b/docs/development/keyboard.md @@ -34,7 +34,7 @@ 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 | +| `PANEL` (60) | a sub-panel holding a cursor or a selection — a sequencer grid, the order list, the voices, the converter's list of gathered recordings | its tab is in front and that sub-panel holds the cursor or the row picked out | 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 diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 15965e1e2..9c75942e2 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -15,11 +15,15 @@ The **Converter** card lists the recordings a conversion uses. Add them from the 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. The window names the folder, counts the recordings found so far, and has a **Stop** button that gives up the search. 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 pick it out. The **Source settings** card then shows that row. Click it again, or press `Esc`, to let it go. Press `Del` to remove the row you picked out. + ## 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 every recording at once. +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 row you picked out. A folder represents all the recordings inside it. Its checkbox shows their channel assignments: diff --git a/docs/guide/interface.md b/docs/guide/interface.md index b79170ded..9b6194f54 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -36,4 +36,4 @@ Project properties belong to a project and are covered in the [sequencer guide]( **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. -`Space` plays and pauses, and `Esc` stops, anywhere in the app. On macOS, the shortcuts use Command where other platforms use Control. +`Space` plays and pauses, and `Esc` stops. When you have picked out a row in the converter's list, the first `Esc` lets that row go and the next one stops playback. On macOS, the shortcuts use Command where other platforms use Control. From 7766da73896eab280ff6616a1dd6e5fef88d2f11 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 10:40:30 +0200 Subject: [PATCH 092/130] Measured: the load bounds on a clock every platform can read --- tests/benchmarks/test_converter_load.py | 31 ++------------- tests/benchmarks/test_pitch_bend.py | 13 ++----- tests/suite/timing.py | 51 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 38 deletions(-) create mode 100644 tests/suite/timing.py diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 3379e5692..6f7317388 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -1,8 +1,6 @@ -import gc from itertools import count from pathlib import Path -from time import process_time -from typing import Callable, Final, Iterator, List, Tuple +from typing import Callable, Final, Iterator, Tuple import dearpygui.dearpygui as dpg import pytest @@ -44,10 +42,10 @@ 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 -REPEATS: Final[int] = 3 GROWTH_ALLOWANCE: Final[float] = 2.0 REGION_HEIGHT: Final[float] = 264.0 ROW_PITCH: Final[float] = 36.0 @@ -86,34 +84,11 @@ def state_of(root: Path, count: int) -> ConverterState: ) -def seconds(work: Callable[[], object]) -> float: - """The best of several runs, taken with the collector held off so the reading is the work's. - - The collector runs on how much is live rather than on what the work does, so a run building ten - times the objects meets it more often and reads as more than ten times the cost — enough to - swallow the growth these bounds are about. Held off for the reading, what is left is how the - work itself follows the length of the list, and the objects are collected once it comes back. - """ - collecting = gc.isenabled() - gc.disable() - try: - readings: List[float] = [] - for _ in range(REPEATS): - started = process_time() - work() - readings.append(process_time() - started) - finally: - if collecting: - gc.enable() - - return min(readings) - - 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.""" one = seconds(small) many = seconds(large) - ratio = many / one if one > 0 else float("inf") + ratio = many / one report = ( f"{SMALL_FOLDER} recordings {one * 1000:.1f} ms, " f"{LARGE_FOLDER} recordings {many * 1000:.1f} ms, " 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/suite/timing.py b/tests/suite/timing.py new file mode 100644 index 000000000..3f36541fe --- /dev/null +++ b/tests/suite/timing.py @@ -0,0 +1,51 @@ +import gc +from time import process_time +from typing import Callable, Final, List + +REPEATS: Final[int] = 3 +MINIMUM_READING: Final[float] = 0.25 +FIRST_BATCH: Final[int] = 1 +MOST_RUNS: Final[int] = 1 << 20 + + +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. The batch is therefore grown 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 the reading, 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, and the + objects are collected once the reading comes back. + """ + collecting = gc.isenabled() + gc.disable() + try: + runs = _runs_reaching(work) + readings: List[float] = [_batch(work, runs) / runs for _ in range(REPEATS)] + finally: + if collecting: + gc.enable() + + return min(readings) + + +def _runs_reaching(work: Callable[[], object]) -> int: + """How many runs a batch takes to cost more than the clock's own step, doubling until it does.""" + runs = FIRST_BATCH + while runs < MOST_RUNS and _batch(work, runs) < MINIMUM_READING: + runs *= 2 + + return runs + + +def _batch(work: Callable[[], object], runs: int) -> float: + """What a run of the work this many times over costs, as the process counts its own time.""" + started = process_time() + for _ in range(runs): + work() + + return process_time() - started From ddc9d901db684eb616f7c0dd0725324bf5db9bc0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 11:09:14 +0200 Subject: [PATCH 093/130] Rested: the row gestures a stems list has no owner for --- .../ui/elements/stems/gestures.py | 39 ++++-- .../ui/elements/stems/list.py | 14 +- src/sampletones_shared/utils/callbacks.py | 9 +- .../ui/elements/stems/test_list.py | 122 +++++++++++++++++- .../utils/test_callbacks.py | 12 +- 5 files changed, 171 insertions(+), 25 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index bd61d5c00..23f741d04 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -12,7 +12,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar 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 +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 @@ -32,6 +32,10 @@ class StemsGestures: 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. + A gesture with none rests here, and the widget it moved is put back where it stood. """ def __init__( @@ -40,10 +44,16 @@ def __init__( *, messages: StemsMessages, status_bar: GUIStatusBar, + activatable: Callable[[], bool], + playable: Callable[[], bool], + has_menu: Callable[[], bool], ) -> None: self._tags = tags self._messages = messages self._status_bar = status_bar + self._activatable = activatable + self._playable = playable + self._has_menu = has_menu self._view = StemsListViewModel.empty() self.on_channels_settled: Optional[ChannelsCallback] = None @@ -57,16 +67,6 @@ def __init__( self.on_row_opened: Optional[StringCallback] = None self.on_row_picked: Optional[StringCallback] = None - @property - def activatable(self) -> bool: - """The owner answers a click on a row, so the list hands one on rather than absorbing it.""" - return self.on_row_activated is not None - - @property - 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 - 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 @@ -148,9 +148,16 @@ def on_name_selected(self, _sender: Sender, value: bool, user_data: str) -> None A row already picked out reads as let go when it is clicked again, which is the answer DearPyGui hands the callback, so one gesture both picks a row and releases it. + + A list whose owner answers no click has the row put back the way the view holds it: the + click moved the widget and nothing behind it, so the row would otherwise keep a picked + look that no reading of the list ever wrote. """ - if self.activatable: - self._report(self.on_row_activated, user_data, value) + if not self._activatable(): + dpg_set_value(self._tags.row(user_data, SUF_TEXT), user_data == self._view.selected_key) + return + + self._report(self.on_row_activated, user_data, value) 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.""" @@ -165,6 +172,10 @@ def on_level_drop(self, sender: Sender, app_data: str) -> None: 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 names the row its menu stands over, where the owner puts one up.""" + if not self._has_menu(): + return + key = self._named_by(app_data, dpg.mvMouseButton_Right) if key is not None: self._report(self.on_menu_asked, key) @@ -180,7 +191,7 @@ def _on_name_double_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> self._report(self.on_folder_toggled, key) return - if self.playable: + if self._playable(): self._report(self.on_row_opened, key) @staticmethod diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index b5ae44ee1..370896281 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -84,7 +84,14 @@ def __init__( activatable=lambda: self.activatable, playable=lambda: self.playable, ) - self._gestures = StemsGestures(self._tags, messages=self._messages, status_bar=status_bar) + self._gestures = StemsGestures( + self._tags, + messages=self._messages, + status_bar=status_bar, + activatable=lambda: self.activatable, + playable=lambda: self.playable, + has_menu=lambda: self.has_menu, + ) self._rows = StemRowRenderer( self._tags, layout=layout, @@ -161,6 +168,11 @@ 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 + @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 create(self, parent: str, *, show: bool = True) -> None: """Build the list's recessed region and the handlers its rows share. 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/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index 57966aba4..f06998762 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,3 +1,4 @@ +import logging from pathlib import Path from typing import Final, FrozenSet, Iterator, List, Optional, Tuple @@ -18,12 +19,8 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, - SUF_CHANNELS, SUF_CHECKBOX, - SUF_HANDLER_REGISTRY, SUF_HEADING, - SUF_LEVEL, - SUF_ROW, SUF_STRIP, SUF_TEXT, TAG_GLOBAL_THEME_CHANNEL_MUTED, @@ -54,6 +51,9 @@ CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DRAG_PAYLOAD_SLOT: Final[int] = 3 LONG_LIST: Final[int] = 200 +CLICK_HANDLER: Final[int] = 0 +DOUBLE_CLICK_HANDLER: Final[int] = 1 +UNSET_CALLBACK_NOTE: Final[str] = "No callback for GUIStemsList" @pytest.fixture @@ -170,6 +170,24 @@ def hover_handler(suffix: str) -> Callback: return dpg.get_item_callback(dpg.get_item_children(TAGS.handlers(suffix), 1)[-1]) +def name_handler(position: int) -> Callback: + """One of the mouse callbacks a row's name shares, as DearPyGui would call it.""" + return dpg.get_item_callback(dpg.get_item_children(TAGS.handlers(SUF_TEXT), 1)[position]) + + +def click_on(entry: StemRowViewModel, position: int, button: int) -> None: + """Land a mouse gesture on a row's name the way DearPyGui reports one.""" + name_tag = row_tag(entry, SUF_TEXT) + name_handler(position)(name_tag, (button, dpg.get_alias_id(name_tag))) + + +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, *, @@ -714,6 +732,102 @@ def test_the_view_says_which_row_reads_as_picked_out(self, dpg_context: None, la 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, + ) -> 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, + ) -> 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, + caplog: pytest.LogCaptureFixture, + ) -> None: + caplog.set_level(logging.DEBUG) + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, CLICK_HANDLER, dpg.mvMouseButton_Right) + + assert UNSET_CALLBACK_NOTE not in caplog.text + + def test_a_right_click_names_its_row_where_the_owner_puts_one_up( + self, + dpg_context: None, + layout_config, + ) -> 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, CLICK_HANDLER, 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, + caplog: pytest.LogCaptureFixture, + ) -> None: + caplog.set_level(logging.DEBUG) + stems_list = build(layout_config, dragging=False) + bass = row("bass") + stems_list.update_view(view(bass)) + + click_on(bass, DOUBLE_CLICK_HANDLER, dpg.mvMouseButton_Left) + + assert UNSET_CALLBACK_NOTE not in caplog.text + + def test_a_double_click_sounds_its_row_where_the_owner_answers( + self, + dpg_context: None, + layout_config, + ) -> 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_CLICK_HANDLER, dpg.mvMouseButton_Left) + + assert opened == [bass.key] + + class TestTheHeading(BaseTestSuite): """The channels are named once above the rows, whatever shape the list takes below it.""" 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.""" From 6f5a92e9ca2a0e49173747dd12046148aa0e294d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 11:37:34 +0200 Subject: [PATCH 094/130] Sounded: a recording from the question of which to mix --- docs/guide/converting.md | 2 +- .../coordinators/tabs/main.py | 1 + .../ui/panels/dialogs/stem_selection.py | 10 +++- .../sampletones_application/test_startup.py | 15 ++++++ .../ui/panels/dialogs/test_stem_selection.py | 46 ++++++++++++++++++- 5 files changed, 70 insertions(+), 4 deletions(-) diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 9c75942e2..1a1a14dcc 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -48,7 +48,7 @@ Below it, the card shows the name of the recording you selected in the list and 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. **Add** becomes available when your selection fits. A full mix cannot accept more recordings, so uncheck one before checking another. +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. diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 0fb52e322..aba2ead11 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -359,6 +359,7 @@ def _wire_converter( 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.""" diff --git a/src/sampletones_application/ui/panels/dialogs/stem_selection.py b/src/sampletones_application/ui/panels/dialogs/stem_selection.py index 412d95c40..f162bda35 100644 --- a/src/sampletones_application/ui/panels/dialogs/stem_selection.py +++ b/src/sampletones_application/ui/panels/dialogs/stem_selection.py @@ -29,6 +29,7 @@ StemRowViewModel, StemsListViewModel, ) +from sampletones_shared.types.callback import PathCallback Answer = Callable[[List[Path]], None] @@ -49,6 +50,9 @@ class GUIStemSelectionWindow(GUIDialogWindow): 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__( @@ -85,10 +89,12 @@ def __init__( status_bar=status_bar, offer=PICKED_SOURCES, ) - self._list.on_row_picked = self._on_picked - + self.on_source_played: Optional[PathCallback] = None self._answer: Optional[Answer] = 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, width=layout.stem_selection.width, diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index ec97ad5c1..f94d52cd5 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -612,6 +612,21 @@ def test_a_folder_the_mix_still_holds_is_gathered(self, app: Application, tmp_pa 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 the question reaches the player the converter's list reaches.""" + window = app._main_tab._stem_selection_window + directory = self._folder(tmp_path, MAX_STEM_SOURCES + 3) + recording = directory / "take_00.wav" + + with patch.object(app._main_tab._file_playback, "play_at") as sounded: + window.on_source_played(recording) + + assert sounded.call_args.args[0] == recording + class TestMainTabReadingOrder: """The tab reads in one direction: what a run is set up with, what it gathers, what a row takes. 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 index 268c98264..f1e7ac3be 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -10,13 +10,14 @@ 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_ROW +from sampletones_application.tags.general import SUF_BUTTON, SUF_CHECKBOX, SUF_ROW, 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 @@ -26,6 +27,8 @@ LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) GATHERED: Final[int] = MAX_STEM_SOURCES + 4 +TAGS: Final[StemsTags] = StemsTags(prefix=PRE_MAIN_CONVERTER_CANDIDATE) +DOUBLE_CLICK_HANDLER: Final[int] = 1 @pytest.fixture(name="window") @@ -115,6 +118,24 @@ def pick(row: StemRowViewModel) -> None: 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.""" + handler = dpg.get_item_children(TAGS.handlers(SUF_TEXT), 1)[DOUBLE_CLICK_HANDLER] + name_tag = name_of(row) + dpg.get_item_callback(handler)(name_tag, (dpg.mvMouseButton_Left, dpg.get_alias_id(name_tag))) + + +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"]) @@ -301,3 +322,26 @@ def test_the_pick_still_settles(self, window: GUIStemSelectionWindow) -> None: 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 From 9420e4c08e87f8d07b51f3e86c3ba260ef50a25e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 11:54:55 +0200 Subject: [PATCH 095/130] Commit: Stood: a folder's row in the rhythm of the rows around it --- .../ui/elements/stems/bands.py | 37 ++++++------ .../ui/elements/stems/folder.py | 36 ++++-------- .../ui/elements/stems/gestures.py | 7 ++- .../ui/elements/stems/row.py | 11 ++-- .../ui/elements/stems/test_folder.py | 58 +++++++++++++++++++ 5 files changed, 103 insertions(+), 46 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index be836cf90..7b34044ac 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -37,9 +37,9 @@ class LevelBands: 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. - A 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. + 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__( @@ -122,26 +122,29 @@ def build(self, view_model: StemsListViewModel) -> None: self._create_strip(view_model.level_count) def _create_listing(self, view_model: StemsListViewModel) -> None: - """Every row in one run, a folder breaking it so its own recordings stand below it.""" - loose: List[StemRowViewModel] = [] + """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: - if not row.stands_for_a_folder: - loose.append(row) - continue - - segment = self._flush(loose, view_model, segment) - self._folders.create(row, view_model) + 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(loose, view_model, segment) + self._flush(standing, view_model, segment) - def _flush(self, loose: List[StemRowViewModel], view_model: StemsListViewModel, segment: int) -> int: - """Draw the run of rows gathered since the last folder, and open the next run empty.""" - if not loose: + 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(loose)) - loose.clear() + self._create_table(self._tags.segment(segment), view_model, tuple(standing)) + standing.clear() return segment + 1 def _create_strip(self, position: int) -> None: diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index cbc94c559..cabb36fd0 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -5,7 +5,6 @@ import dearpygui.dearpygui as dpg from sampletones_application.layout.general.stems import StemsListLayout -from sampletones_application.tags.general import SUF_TABLE from sampletones_application.ui.elements.layout.geometry import RowGeometry from sampletones_application.ui.elements.layout.region import NO_SCROLL, WindowedRegion from sampletones_application.ui.elements.stems.columns import StemsColumns @@ -21,11 +20,12 @@ 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. Opening - it sinks a region below the 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. + 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__( @@ -57,22 +57,6 @@ 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 create(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: - """Draw the folder's own row, and the region its recordings stand in while it is open.""" - with dpg.table( - tag=self._tags.folder(row.key, SUF_TABLE), - parent=self._tags.body, - header_row=False, - policy=dpg.mvTable_SizingFixedFit, - resizable=False, - borders_innerV=True, - ): - self._columns.declare() - self._rows.create(row, view_model, self._columns) - - if self._open_folders.stands_open(row.key): - self._open(row, view_model) - def forget(self) -> None: """Take up where each open folder stood, and let go of the regions a rebuild took down. @@ -123,8 +107,12 @@ def repaint( for held in self._reached(region, row): self._rows.repaint(held, view_model, releasable=releasable) - 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.""" + 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 is why a folder standing closed draws nothing. + """ region = WindowedRegion( tag=self._tags.region(row.key), geometry=self._geometry, diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 23f741d04..dfa044ce6 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -140,7 +140,12 @@ def on_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> N 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.""" + """The marker beside a folder's name puts its recordings in view, or away again. + + The glyph is what states which way the folder stands, so the marker is put back where the + press found it and the list's own reading decides what it reads as next. + """ + dpg_set_value(self._tags.row(user_data, SUF_TWISTY), False) self._report(self.on_folder_toggled, user_data) def on_name_selected(self, _sender: Sender, value: bool, user_data: str) -> None: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 529e3f0de..8a51df8bd 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -13,7 +13,6 @@ SUF_TWISTY, TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_DANGER_BUTTON, - TAG_GLOBAL_THEME_STEMS_DROP_STRIP, TAG_GLOBAL_THEME_STEMS_PICK, TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL, TAG_GLOBAL_THEME_STEMS_ROW, @@ -222,19 +221,23 @@ def _draggable(self, view_model: StemsListViewModel) -> bool: return self._offer.dragging and not view_model.collapse_levels def _create_disclosure(self, row: StemRowViewModel) -> None: - """The marker a folder opens by, which stands beside the folder's own name.""" + """The marker a folder opens by, which stands beside the folder's own name. + + The marker stands as tall as the name it leads, so a folder's row takes the height every + other row takes and the list keeps one rhythm from top to bottom. + """ if not row.stands_for_a_folder: return - twisty = dpg.add_button( + twisty = dpg.add_selectable( 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_DROP_STRIP).bind_to_item(twisty) self._gestures.bind(twisty, SUF_TWISTY) def _twisty_glyph(self, key: str) -> str: diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 1713cc8aa..d2290c600 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -20,6 +20,7 @@ SUF_BUTTON, SUF_CHANNELS, SUF_CHECKBOX, + SUF_GROUP, SUF_TEXT, SUF_TWISTY, ) @@ -156,6 +157,11 @@ def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: return f"{PREFIX}.row.{row.key}.{SUF_CHANNELS}.{channel_name}.{SUF_CHECKBOX}" +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(f"{PREFIX}.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) @@ -439,3 +445,55 @@ def test_it_draws_the_recordings_that_position_reaches(self, stems_list: GUIStem 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_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 + + 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) From 1b1820b7d138313db80061db72f78ac08b816528 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:16:23 +0200 Subject: [PATCH 096/130] Spelled: the prose words that had drifted British --- docs/concepts/reconstruction.md | 2 +- docs/development/guidelines.md | 2 +- docs/development/keyboard.md | 2 +- docs/glossary.md | 2 +- .../coordinators/reconstruction.py | 2 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/instruction/library_manager.py | 2 +- src/sampletones_application/logic/sequencer/channels.py | 4 ++-- .../logic/sequencer/playback/protocol.py | 6 +++--- .../logic/sequencer/playback/synthesizer/rates.py | 2 +- .../logic/sequencer/playback/synthesizer/state.py | 4 ++-- src/sampletones_application/services/export/error.py | 2 +- src/sampletones_application/services/export/kind.py | 2 +- src/sampletones_application/services/export/service.py | 2 +- src/sampletones_application/services/export/success.py | 2 +- src/sampletones_application/services/render/service.py | 2 +- src/sampletones_application/services/render/sink.py | 8 ++++---- .../services/song_player/service.py | 4 ++-- .../services/synthesis/protocol.py | 2 +- src/sampletones_application/ui/elements/stems/folder.py | 2 +- .../utils/gui/keyboard/combination.py | 2 +- src/sampletones_application/utils/gui/keyboard/keys.py | 2 +- .../view_model/shared/keybindings.py | 2 +- src/sampletones_core/exporters/naming.py | 2 +- .../coordinators/tabs/test_sequencer.py | 2 +- .../logic/sequencer/playback/conftest.py | 4 ++-- .../logic/sequencer/playback/test_tick_clock.py | 2 +- .../logic/shared/test_project_source.py | 2 +- .../services/export/test_service.py | 2 +- .../sampletones_application/services/render/conftest.py | 2 +- .../utils/gui/keyboard/test_combination.py | 2 +- tests/unit/sampletones_shared/utils/system/test_paths.py | 4 ++-- 32 files changed, 42 insertions(+), 42 deletions(-) diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 5b7b3633c..3b4cd159a 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -313,7 +313,7 @@ pitches gapless: note *n* covers `[(tₙ + tₙ₊₁) / 2, (tₙ + tₙ₋₁) 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 diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 8cae78a65..d95d9988e 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -87,7 +87,7 @@ These rules govern the Python in this repository. They complement ## 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 catalogues the application instead of explaining it. +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. diff --git a/docs/development/keyboard.md b/docs/development/keyboard.md index eafc2af6d..04b2202e6 100644 --- a/docs/development/keyboard.md +++ b/docs/development/keyboard.md @@ -105,7 +105,7 @@ override naming an action this build has none of, a key the table has none of, o 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 ([`palette.md`](palette.md)) — and the dispatcher +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. 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/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/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index df46e1f7c..be6079847 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -330,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/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/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/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..8ffe484a6 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -25,7 +25,7 @@ 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 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/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index cabb36fd0..ae8d65ec7 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -146,7 +146,7 @@ def _create_rows( """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 neighbours stand in and none of + the recordings inside a folder stand in the columns their neighbors stand in and none of them leads with a marker. """ held_columns = replace(self._columns, folders=False) 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/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/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_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/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/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/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/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 25be24ed6..57c7b0119 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. 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/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_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. From eeb382f8dbfff010954994db8a89b26890a8dbd6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:20:54 +0200 Subject: [PATCH 097/130] Spelled: cancelling as canceling, keys and enums included --- src/sampletones_application/application.py | 2 +- .../coordinators/keybindings.py | 2 +- .../tabs/sequencer/coordinator.py | 2 +- .../logic/export/logic.py | 12 ++++----- .../logic/main/converter/messages.py | 2 +- .../logic/main/converter/run.py | 10 +++---- .../logic/render/logic.py | 8 +++--- .../services/conversion/service.py | 2 +- .../services/render/service.py | 2 +- .../ui/elements/dialog.py | 2 +- .../ui/panels/dialogs/export.py | 2 +- .../ui/panels/instruction/library.py | 2 +- .../view_model/main/converter.py | 6 ++--- .../view_model/shared/export.py | 4 +-- .../view_model/shared/render.py | 4 +-- src/sampletones_config/lang/en.yaml | 6 ++--- .../parallelization/processor.py | 26 +++++++++---------- src/sampletones_core/parallelization/task.py | 2 +- .../parallelization/test_progress_channel.py | 2 +- .../coordinators/tabs/test_main.py | 2 +- .../coordinators/test_display.py | 6 ++--- .../coordinators/test_keybindings.py | 4 +-- .../logic/export/test_logic.py | 8 +++--- .../logic/main/converter/test_logic.py | 2 +- .../logic/main/converter/test_messages.py | 2 +- .../logic/main/converter/test_run.py | 10 +++---- .../logic/render/test_logic.py | 4 +-- .../services/export/test_service.py | 8 +++--- .../services/render/test_service.py | 10 +++---- .../services/test_conversion.py | 4 +-- .../ui/panels/dialogs/test_export.py | 4 +-- .../ui/panels/dialogs/test_render.py | 2 +- .../view_model/main/test_converter.py | 4 +-- .../view_model/shared/test_export.py | 4 +-- .../view_model/shared/test_render.py | 2 +- 35 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6186048bb..ff23dd8e0 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -579,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( 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/tabs/sequencer/coordinator.py b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py index 41fd51796..1a3ec7413 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py +++ b/src/sampletones_application/coordinators/tabs/sequencer/coordinator.py @@ -822,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/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index 3da132ced..a7fec14b3 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,12 +45,12 @@ 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] = [] @@ -78,8 +78,8 @@ def cancel(self) -> None: if not self._service.is_running(): return - self._phase = ExportPhase.CANCELLING - self._figure = self._cancelling_label + self._phase = ExportPhase.CANCELING + self._figure = self._canceling_label self._travelling = False self._emit_view() self._service.cancel() @@ -108,7 +108,7 @@ def _on_started(self) -> None: 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 diff --git a/src/sampletones_application/logic/main/converter/messages.py b/src/sampletones_application/logic/main/converter/messages.py index ff29f8306..d20b280de 100644 --- a/src/sampletones_application/logic/main/converter/messages.py +++ b/src/sampletones_application/logic/main/converter/messages.py @@ -24,7 +24,7 @@ def __init__(self, language_manager: LanguageManager) -> None: 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.cancelling: str = language_manager["main.converter.message.status_cancelling"] + 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"] diff --git a/src/sampletones_application/logic/main/converter/run.py b/src/sampletones_application/logic/main/converter/run.py index 7b2d53eb2..7d6a3e198 100644 --- a/src/sampletones_application/logic/main/converter/run.py +++ b/src/sampletones_application/logic/main/converter/run.py @@ -113,7 +113,7 @@ def is_active(self) -> bool: @property def is_running(self) -> bool: - """The service holds the run, so cancelling it is the service's business.""" + """The service holds the run, so canceling it is the service's business.""" return self._service.is_running() @property @@ -134,8 +134,8 @@ def begin(self, config: Config, plan: ConversionPlan, reconstruction_name: str) def cancel(self) -> None: """Asks the service to give up the run it holds.""" - self._phase = ConversionPhase.CANCELLING - self._report(self._messages.cancelling, 0.0) + self._phase = ConversionPhase.CANCELING + self._report(self._messages.canceling, 0.0) self._system_progress.error() self._service.cancel() @@ -177,8 +177,8 @@ def _on_service_result(self, result: ConversionResult) -> None: self._settle_as_canceled() def _handle_progress_result(self, progress: ServiceProgress[ConversionItem]) -> None: - if self._phase == ConversionPhase.CANCELLING: - self._report(self._messages.cancelling, progress.fraction) + if self._phase == ConversionPhase.CANCELING: + self._report(self._messages.canceling, progress.fraction) return self._phase = ConversionPhase.RUNNING 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/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/render/service.py b/src/sampletones_application/services/render/service.py index 8ffe484a6..a4f195b6c 100644 --- a/src/sampletones_application/services/render/service.py +++ b/src/sampletones_application/services/render/service.py @@ -31,7 +31,7 @@ class SongRenderService(ServiceBase[RenderResult]): 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/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/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/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/view_model/main/converter.py b/src/sampletones_application/view_model/main/converter.py index e0fdd16bd..db58d27d0 100644 --- a/src/sampletones_application/view_model/main/converter.py +++ b/src/sampletones_application/view_model/main/converter.py @@ -17,7 +17,7 @@ class ConversionPhase(StrEnum): IDLE = "idle" WAITING = "waiting" RUNNING = "running" - CANCELLING = "cancelling" + CANCELING = "canceling" COMPLETED = "completed" CANCELED = "canceled" FAILED = "failed" @@ -38,7 +38,7 @@ class ConverterAction(StrEnum): { ConversionPhase.WAITING, ConversionPhase.RUNNING, - ConversionPhase.CANCELLING, + ConversionPhase.CANCELING, } ) SINGLE_SOURCE: Final[int] = 1 @@ -163,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/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/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_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index b550e089c..615d29923 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -376,7 +376,7 @@ main.converter.message.status_no_channels: "No channels are enabled. Enable at l 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" @@ -835,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." @@ -846,7 +846,7 @@ 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.order: "Order list" 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/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py index 4fae8b94b..0aed398b7 100644 --- a/tests/integration/sampletones_core/parallelization/test_progress_channel.py +++ b/tests/integration/sampletones_core/parallelization/test_progress_channel.py @@ -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/unit/sampletones_application/coordinators/tabs/test_main.py b/tests/unit/sampletones_application/coordinators/tabs/test_main.py index 926a538b4..940e65c2b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_main.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_main.py @@ -165,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: 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..1cfd83031 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, ) @@ -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/main/converter/test_logic.py b/tests/unit/sampletones_application/logic/main/converter/test_logic.py index 1defc8c36..7af0ce9ae 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_logic.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_logic.py @@ -127,7 +127,7 @@ def _started_plan(converter_logic: ConverterLogic, service: MagicMock) -> GroupC class TestCancelDuringLibraryGeneration(BaseTestSuite): - """The converter requests a library when none exists and waits for it. Cancelling during that + """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( diff --git a/tests/unit/sampletones_application/logic/main/converter/test_messages.py b/tests/unit/sampletones_application/logic/main/converter/test_messages.py index 53cb64b9a..fb84a37cc 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_messages.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_messages.py @@ -129,7 +129,7 @@ def test_the_label_says_what_the_run_writes(self, test_case: TestCase) -> None: @pytest.mark.parametrize( "phase", - [ConversionPhase.WAITING, ConversionPhase.RUNNING, ConversionPhase.CANCELLING], + [ConversionPhase.WAITING, ConversionPhase.RUNNING, ConversionPhase.CANCELING], ) def test_a_conversion_holding_resources_reads_the_cancel_label(self, phase: ConversionPhase) -> None: label = messages().action_label( diff --git a/tests/unit/sampletones_application/logic/main/converter/test_run.py b/tests/unit/sampletones_application/logic/main/converter/test_run.py index 2df1cc299..cbd89bf9e 100644 --- a/tests/unit/sampletones_application/logic/main/converter/test_run.py +++ b/tests/unit/sampletones_application/logic/main/converter/test_run.py @@ -82,12 +82,12 @@ def test_the_first_progress_puts_the_run_under_way(self, driver: Driver) -> None assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.RUNNING, True) - def test_cancelling_holds_resources_until_the_service_answers(self, driver: Driver) -> None: + 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.CANCELLING, True) + assert (driver.run.phase, driver.run.is_active) == (ConversionPhase.CANCELING, True) driver.service.cancel.assert_called_once() @pytest.mark.parametrize( @@ -141,14 +141,14 @@ def test_a_run_naming_no_recording_leaves_the_reader_looking_at_their_own(self, assert driver.reports[-1].input_path is None - def test_a_cancelled_run_keeps_reporting_the_cancelling_line(self, driver: Driver) -> 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_cancelling" - assert driver.run.phase == ConversionPhase.CANCELLING + 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() 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/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py index 57c7b0119..d2e1bf8da 100644 --- a/tests/unit/sampletones_application/services/export/test_service.py +++ b/tests/unit/sampletones_application/services/export/test_service.py @@ -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/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/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index 47e40de3a..2f82d0d1f 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py @@ -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/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py index 3f42a348d..7a08bf162 100644 --- a/tests/unit/sampletones_application/view_model/main/test_converter.py +++ b/tests/unit/sampletones_application/view_model/main/test_converter.py @@ -120,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), @@ -139,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: 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..da23c4ce1 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_export.py +++ b/tests/unit/sampletones_application/view_model/shared/test_export.py @@ -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 From 76f902b547d5b1da54d1f7619daf64d403bec153 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:24:41 +0200 Subject: [PATCH 098/130] Spelled: travelling and materialise the American way --- docs/development/browser.md | 2 +- src/sampletones_application/logic/export/logic.py | 12 ++++++------ .../logic/project/controller.py | 2 +- .../logic/sequencer/tracker/tracker.py | 4 ++-- src/sampletones_core/exports/scope.py | 2 +- src/sampletones_core/project/patterns/channel.py | 6 +++--- src/sampletones_core/project/song.py | 4 ++-- tests/suite/sequencer.py | 4 ++-- .../logic/export/test_logic.py | 4 ++-- .../logic/sequencer/tracker/test_writer.py | 2 +- .../services/test_progress.py | 2 +- .../ui/panels/dialogs/test_export.py | 2 +- .../view_model/shared/test_export.py | 4 ++-- tests/unit/sampletones_core/project/test_song.py | 2 +- 14 files changed, 26 insertions(+), 26 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index 41bce5535..ae37fc03c 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -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 diff --git a/src/sampletones_application/logic/export/logic.py b/src/sampletones_application/logic/export/logic.py index a7fec14b3..5f8cfcb0f 100644 --- a/src/sampletones_application/logic/export/logic.py +++ b/src/sampletones_application/logic/export/logic.py @@ -56,7 +56,7 @@ def __init__( 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) @@ -80,7 +80,7 @@ def cancel(self) -> None: self._phase = ExportPhase.CANCELING self._figure = self._canceling_label - self._travelling = False + self._traveling = False self._emit_view() self._service.cancel() @@ -102,7 +102,7 @@ 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) @@ -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/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/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_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/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/tests/suite/sequencer.py b/tests/suite/sequencer.py index 4f2ca890a..48e5a5d1a 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -97,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] @@ -311,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/unit/sampletones_application/logic/export/test_logic.py b/tests/unit/sampletones_application/logic/export/test_logic.py index 1cfd83031..e71ada124 100644 --- a/tests/unit/sampletones_application/logic/export/test_logic.py +++ b/tests/unit/sampletones_application/logic/export/test_logic.py @@ -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, 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/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/ui/panels/dialogs/test_export.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_export.py index 2f82d0d1f..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) 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 da23c4ce1..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: 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) From 4c2c43aca7d00589bda334c530815f0f499fe4c5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:29:24 +0200 Subject: [PATCH 099/130] Read: each -wards form for the sentence it sits in --- docs/concepts/reconstruction.md | 4 ++-- docs/concepts/stems.md | 2 +- docs/development/architecture.md | 4 ++-- docs/development/browser.md | 4 ++-- docs/development/bugs-and-todos.md | 2 +- docs/development/sequencer-blocks.md | 2 +- .../logic/reconstruction/browser/tree/collapse.py | 2 +- .../logic/reconstruction/browser/tree/prune.py | 2 +- src/sampletones_application/logic/sequencer/browser.py | 2 +- src/sampletones_application/ui/panels/main/explorer.py | 2 +- .../ui/panels/sequencer/history.py | 2 +- .../utils/gui/keyboard/focus/search.py | 2 +- .../view_model/sequencer/region.py | 2 +- src/sampletones_core/compatibility/reconstruction/v2_2.py | 2 +- src/sampletones_core/configs/generation.py | 2 +- src/sampletones_core/generators/tonal.py | 2 +- .../reconstructions/reconstructor/reconstructor.py | 2 +- .../reconstructions/reconstructor/refinement/refiner.py | 8 ++++---- .../reconstructions/reconstructor/stems/configs/config.py | 2 +- src/sampletones_core/structures/tree/visibility.py | 2 +- src/sampletones_player/compression/matches/played.py | 2 +- src/sampletones_player/compression/parse/boundaries.py | 2 +- src/sampletones_player/compression/pitch.py | 2 +- src/sampletones_player/compression/tokens/phrase.py | 2 +- src/sampletones_player/driver/assembly/source/channels.s | 2 +- src/sampletones_player/trace/trace.py | 2 +- src/sampletones_synthesis/envelopes/linear_attack.py | 2 +- tests/integration/reconstruction/test_pitch_refinement.py | 2 +- .../parallelization/test_progress_channel.py | 4 ++-- .../ui/elements/tree/test_expansion_memory.py | 2 +- .../ui/panels/main/test_explorer_controls.py | 2 +- .../ui/panels/sequencer/input/test_grid_input.py | 8 ++++---- .../ui/panels/sequencer/input/test_tracker_input.py | 8 ++++---- 33 files changed, 46 insertions(+), 46 deletions(-) diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index 3b4cd159a..35c522f40 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -275,7 +275,7 @@ 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. It spends it where the run -asks: a stem entry names the channels it carries towards its own recording, so one recording's bass +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 @@ -306,7 +306,7 @@ 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 diff --git a/docs/concepts/stems.md b/docs/concepts/stems.md index 89cf6a564..f6d377f93 100644 --- a/docs/concepts/stems.md +++ b/docs/concepts/stems.md @@ -144,7 +144,7 @@ and reports the reconstructions written. `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 towards the divider it really sounds. A further +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 diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 448388210..548823900 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -152,7 +152,7 @@ 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. @@ -249,7 +249,7 @@ 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`. diff --git a/docs/development/browser.md b/docs/development/browser.md index ae37fc03c..a8dd63cd8 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -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 @@ -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 4dac4054a..137a1509d 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -86,7 +86,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. 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/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/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/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index fc012c541..ded1a89d4 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -150,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() 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/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/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_core/compatibility/reconstruction/v2_2.py b/src/sampletones_core/compatibility/reconstruction/v2_2.py index 0a6abfdeb..aefafda00 100644 --- a/src/sampletones_core/compatibility/reconstruction/v2_2.py +++ b/src/sampletones_core/compatibility/reconstruction/v2_2.py @@ -163,7 +163,7 @@ def update(data: SerializedData) -> SerializedData: ``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 towards its own recording. The + 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) diff --git a/src/sampletones_core/configs/generation.py b/src/sampletones_core/configs/generation.py index f44bc0854..de182d71d 100644 --- a/src/sampletones_core/configs/generation.py +++ b/src/sampletones_core/configs/generation.py @@ -72,7 +72,7 @@ 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 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/reconstructions/reconstructor/reconstructor.py b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py index 4d77ab61b..66dd30fb2 100644 --- a/src/sampletones_core/reconstructions/reconstructor/reconstructor.py +++ b/src/sampletones_core/reconstructions/reconstructor/reconstructor.py @@ -178,7 +178,7 @@ def reconstruct( ) def _refiner(self, stems_config: StemsConfig) -> PitchRefiner: - """The pass that carries each chosen note towards the fundamental the recording sounds.""" + """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 diff --git a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py index 5d1375511..7ac65abd7 100644 --- a/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py +++ b/src/sampletones_core/reconstructions/reconstructor/refinement/refiner.py @@ -26,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 @@ -72,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. @@ -94,7 +94,7 @@ def refine( } def _bends_of(self, stem_id: int) -> FrozenSet[ChannelName]: - """The channels one stem carries towards its own recording.""" + """The channels one stem carries toward its own recording.""" entry = self.stems.entries_by_id.get(stem_id) if entry is None: return frozenset() @@ -158,7 +158,7 @@ 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) def _reader_at( self, diff --git a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py index 5c1607ba0..926cf5ddd 100644 --- a/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py +++ b/src/sampletones_core/reconstructions/reconstructor/stems/configs/config.py @@ -53,7 +53,7 @@ def single_entry( @cached_property def bent_channels(self) -> FrozenSet[ChannelName]: - """Every channel some stem carries towards its own recording.""" + """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 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/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/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_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/integration/reconstruction/test_pitch_refinement.py b/tests/integration/reconstruction/test_pitch_refinement.py index 8e58fb972..bccaeab6c 100644 --- a/tests/integration/reconstruction/test_pitch_refinement.py +++ b/tests/integration/reconstruction/test_pitch_refinement.py @@ -133,7 +133,7 @@ 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, config: Config, tmp_path: Path, diff --git a/tests/integration/sampletones_core/parallelization/test_progress_channel.py b/tests/integration/sampletones_core/parallelization/test_progress_channel.py index 0aed398b7..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: 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/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index 9d28f4444..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 @@ -184,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: 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) From fbdefaf992badd3ed8031852330c9ae8a056d561 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:31:41 +0200 Subject: [PATCH 100/130] Stated: which British spellings a third-party name keeps --- docs/development/guidelines.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index d95d9988e..3ca1c3ccc 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -83,7 +83,7 @@ These rules govern the Python in this repository. They complement 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. 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. +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 From 205cc23cf0620f41c1557671042dddfe33fddb39 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 12:37:14 +0200 Subject: [PATCH 101/130] Added: a separator between converter context menu items --- .../ui/panels/main/converter/menus.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index 5fcbfcd66..2e9b47d1e 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -79,8 +79,13 @@ def _show_row(self, row: StemRowViewModel, *, banded: bool) -> None: 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) + dpg.add_menu_item( + label=self._label(element), + enabled=enabled, + callback=callback, + ) add_path_menu_items(self._language_manager, row.path) From adf8e87a12931ce057ed18cb098550dd36593ce5 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 8 Sep 2026 13:09:08 +0200 Subject: [PATCH 102/130] Banded: a group's row apart from the recordings around it --- src/sampletones_application/tags/general.py | 12 ++++++ .../ui/elements/stems/bands.py | 11 +++++- .../ui/elements/stems/row.py | 12 +++++- src/sampletones_config/theme/stems/grid.yaml | 12 ++++++ .../theme/stems/group_row.yaml | 12 ++++++ .../ui/elements/stems/test_folder.py | 37 +++++++++++++++++++ 6 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/sampletones_config/theme/stems/grid.yaml create mode 100644 src/sampletones_config/theme/stems/group_row.yaml diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index b29188b72..47876b52a 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -296,6 +296,18 @@ Widget.THEME, "stems_drop_strip", ) +TAG_GLOBAL_THEME_STEMS_GRID = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "stems_grid", +) +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, diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 7b34044ac..106e8768f 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -10,6 +10,7 @@ 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 @@ -183,7 +184,11 @@ def _create_table( view_model: StemsListViewModel, rows: Sequence[StemRowViewModel], ) -> None: - """One grid of rows, every band declaring the same columns so they line up across bands.""" + """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. + """ columns = self.columns(view_model) with dpg.table( tag=tag, @@ -192,7 +197,9 @@ def _create_table( policy=dpg.mvTable_SizingFixedFit, resizable=False, borders_innerV=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) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 8a51df8bd..b791d82c8 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -13,6 +13,7 @@ SUF_TWISTY, TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_DANGER_BUTTON, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, TAG_GLOBAL_THEME_STEMS_PICK, TAG_GLOBAL_THEME_STEMS_PICK_PARTIAL, TAG_GLOBAL_THEME_STEMS_ROW, @@ -78,8 +79,15 @@ def create( view_model: StemsListViewModel, columns: StemsColumns, ) -> None: - """Build the widgets one row stands as, in the columns its grid was declared with.""" - with dpg.table_row(tag=self._tags.row(row.key, SUF_GROUP)): + """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, so a group reads apart from the + recordings standing loose around it without spending a pixel of the list'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) 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/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index d2290c600..7864239d4 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -23,6 +23,7 @@ SUF_GROUP, SUF_TEXT, SUF_TWISTY, + TAG_GLOBAL_THEME_STEMS_GROUP_ROW, ) from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.status import GUIStatusBar @@ -162,6 +163,11 @@ def table_of(row: StemRowViewModel) -> int: return dpg.get_item_parent(f"{PREFIX}.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 neighbours.""" + return dpg.get_item_alias(dpg.get_item_theme(f"{PREFIX}.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) @@ -497,3 +503,34 @@ def test_a_folder_closed_again_rejoins_the_run(self, stems_list: GUIStemsList) - press(twisty_of(sources)) assert table_of(sources) == table_of(bass) == table_of(lead) + + +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) From 69571bafb25a96c5af05bdf16052d9e0f65eab42 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 15:59:29 +0200 Subject: [PATCH 103/130] Improved: converter visuals --- src/sampletones_application/tags/general.py | 6 +++++ .../ui/elements/layout/region.py | 1 + .../ui/elements/layout/well.py | 11 ++++++--- .../ui/elements/stems/bands.py | 6 ++++- .../ui/elements/stems/columns.py | 22 ++++++++++++++---- .../ui/elements/stems/folder.py | 13 +++++++---- .../ui/elements/stems/gestures.py | 7 +----- .../ui/elements/stems/heading.py | 8 ++++--- .../ui/elements/stems/row.py | 19 +++++++++++---- .../ui/panels/main/converter/listing.py | 7 +++++- .../theme/stems/marker.yaml | 19 +++++++++++++++ src/sampletones_config/theme/stems/row.yaml | 4 ++++ .../theme/stems/row_inert.yaml | 4 ++++ .../ui/elements/stems/test_folder.py | 12 ++++++++++ .../ui/panels/main/test_converter.py | 23 +++++++++++++++++++ 15 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 src/sampletones_config/theme/stems/marker.yaml diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 47876b52a..53c50e515 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -302,6 +302,12 @@ 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, diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index eeda64b39..23753f205 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -18,6 +18,7 @@ NO_ROWS: Final[Window] = (0, 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 diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index d7ce50f85..25d7293fc 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -35,7 +35,9 @@ def well( ``gutter`` widens that right inset by the room a scrollbar takes, which a caller hands over while the well stands without one, so the body keeps one width however tall its content grows. ``margin`` opens the gap above the first row and below the last, which the row spacing between - the content and the spacers adds to. + the content and the spacers adds to. A well asked for none lays neither spacer, so its rows + open where the well does — which is what a well nested inside a list takes, its rows being a + run of the list rather than a body of their own. """ body_tag = compose_tag(tag, SUF_GROUP) with dpg.child_window( @@ -48,9 +50,12 @@ def well( no_scrollbar=True, show=show, ): - 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=-(padding + gutter)) - dpg.add_spacer(height=margin) + 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 index 106e8768f..34be58bc6 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -187,7 +187,9 @@ def _create_table( """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. + 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( @@ -197,6 +199,8 @@ def _create_table( 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) diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index ad9c1f963..0fe86eceb 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -96,9 +96,17 @@ def slots(self, channel_name: ChannelName) -> int: 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) + 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. @@ -127,7 +135,7 @@ def name_indent(self, label: str, font: Font) -> int: if measured is None: return 0 - return self._centered(int(measured[0])) + 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.""" @@ -141,6 +149,10 @@ def open_trailing_cell(self) -> None: if self.removable: dpg.add_spacer() - def _centered(self, span: int) -> int: - """The indent standing something of this width in the middle of a channel's column.""" - return max(0, (self.channel_width - self.layout.cell_padding * 2 - span) // 2) + 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/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index ae8d65ec7..fe9ad7e06 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -6,7 +6,7 @@ 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_SCROLL, WindowedRegion +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 @@ -111,14 +111,16 @@ 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 is why a folder standing closed draws nothing. + space its recordings scroll in — which is why a folder standing closed draws nothing. The + region opens no margin of its own: its recordings carry on from the row above them, so + they start where the region does 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=self._layout.well_margin, + margin=NO_MARGIN, gutter=self._layout.scrollbar_width, indent=self._layout.well_padding + self._layout.folder_indent, ) @@ -147,7 +149,9 @@ def _create_rows( 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. + 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( @@ -157,6 +161,7 @@ def _create_rows( policy=dpg.mvTable_SizingFixedFit, resizable=False, borders_innerV=True, + borders_innerH=True, ): held_columns.declare() for held in 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 index dfa044ce6..23f741d04 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -140,12 +140,7 @@ def on_remove_button(self, _sender: Sender, _app_data: Any, user_data: str) -> N 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. - - The glyph is what states which way the folder stands, so the marker is put back where the - press found it and the list's own reading decides what it reads as next. - """ - dpg_set_value(self._tags.row(user_data, SUF_TWISTY), False) + """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: diff --git a/src/sampletones_application/ui/elements/stems/heading.py b/src/sampletones_application/ui/elements/stems/heading.py index 9de7fcd08..1de160723 100644 --- a/src/sampletones_application/ui/elements/stems/heading.py +++ b/src/sampletones_application/ui/elements/stems/heading.py @@ -65,7 +65,11 @@ def name(self, channel_name: ChannelName) -> str: 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.""" + """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, @@ -80,8 +84,6 @@ def create(self, parent: str, columns: StemsColumns) -> None: if self._bends: self._create_slots() - dpg.add_separator(parent=parent) - 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: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index b791d82c8..0e8a57fe3 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -14,6 +14,7 @@ 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, @@ -89,7 +90,7 @@ def create( ThemeRegistry.get(TAG_GLOBAL_THEME_STEMS_GROUP_ROW).bind_to_item(line) if self._offer.master_box: - self._create_master(row, view_model) + self._create_master(row, view_model, columns) self._create_name(row, view_model, columns) for channel_name in view_model.channels_in_play: @@ -142,10 +143,16 @@ def repaint( if self._offer.removal: dpg_configure_item(self._tags.row(row.key, SUF_BUTTON), enabled=live and releasable) - def _create_master(self, row: StemRowViewModel, view_model: StemsListViewModel) -> None: + 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, @@ -231,13 +238,14 @@ def _draggable(self, view_model: StemsListViewModel) -> bool: def _create_disclosure(self, row: StemRowViewModel) -> None: """The marker a folder opens by, which stands beside the folder's own name. - The marker stands as tall as the name it leads, so a folder's row takes the height every - other row takes and the list keeps one rhythm from top to bottom. + The marker is drawn to the height of the name it leads and spends no padding around its + glyph, which stands a folder's row in the rhythm every other row keeps. Its own frame is + the room it was given, so what the pointer shades is the marker and nothing beside it. """ if not row.stands_for_a_folder: return - twisty = dpg.add_selectable( + twisty = dpg.add_button( label=self._twisty_glyph(row.key), tag=self._tags.row(row.key, SUF_TWISTY), width=self._layout.twisty_width, @@ -246,6 +254,7 @@ def _create_disclosure(self, row: StemRowViewModel) -> None: 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: diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 98b74b02b..5c183538a 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -114,8 +114,13 @@ def create(self) -> None: self._stems_list.on_dropped_on_level = self._on_dropped_on_level def update_view(self, view_model: ConverterViewModel) -> None: - """Draw the gathered recordings, with the hint standing while none are.""" + """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: 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/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/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 7864239d4..abd78b9b9 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -24,6 +24,7 @@ SUF_TEXT, SUF_TWISTY, TAG_GLOBAL_THEME_STEMS_GROUP_ROW, + TAG_GLOBAL_THEME_STEMS_MARKER, ) from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.status import GUIStatusBar @@ -470,6 +471,16 @@ def test_a_closed_folder_stands_in_the_grid_of_the_rows_around_it(self, stems_li assert table_of(sources) == table_of(bass) == table_of(lead) + def test_the_marker_stands_its_glyph_in_the_middle_of_its_room(self, stems_list: GUIStemsList) -> None: + """The marker's theme spends no padding around the glyph and centers it in the room it was + given, which is what holds a folder's row to the height of the rows around it and stands + the glyph where a recording listed loose opens its name.""" + 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, @@ -481,6 +492,7 @@ def test_the_marker_stands_as_tall_as_the_name_it_leads( 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")) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 7ced888fa..fff7ab8cb 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -287,6 +287,29 @@ def test_the_hint_leaves_with_the_first_recording(self, dpg_context: None, layou 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.""" From a149d0c35ed357d9dab038f61ca3adb37528f9fe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 16:41:31 +0200 Subject: [PATCH 104/130] Ruled: the settings card's grid and held removal to the list's own rule --- .../ui/elements/stems/list.py | 9 +++++++++ .../ui/panels/main/converter/listing.py | 5 +++-- .../ui/panels/main/converter/menus.py | 3 ++- .../ui/panels/main/source/grid.py | 6 +++++- .../ui/panels/main/test_converter.py | 16 ++++++++++++++++ .../ui/panels/main/test_source.py | 11 +++++++++++ 6 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 370896281..cca7aaf8c 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -277,6 +277,15 @@ def picked_key(self) -> Optional[str]: """The row standing picked out, which is what a key press acts on.""" return self._view.selected_key + @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 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. + """ + return self._view.live and self._releasable + 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) diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 5c183538a..261fa9ad5 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -153,14 +153,15 @@ 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. + 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: + case ShortcutId.SOURCES_REMOVE_SOURCE if self._stems_list.lets_a_row_go: self._on_removed(key) case ShortcutId.SOURCES_CLEAR_SELECTION: self.call(self.on_selection_cleared) diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index 2e9b47d1e..ffa977aac 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -103,6 +103,7 @@ def _show_folder(self, row: StemRowViewModel) -> None: ) dpg.add_menu_item( label=self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), + enabled=self._stems_list.lets_a_row_go, callback=lambda: self.call(self.on_folder_removed, row.path), ) add_path_menu_items(self._language_manager, row.path) @@ -121,7 +122,7 @@ def _moves( path = row.path removal = ( ConverterStemMoveElements.CONTEXT_REMOVE_STEM, - True, + self._stems_list.lets_a_row_go, lambda: self.call(self.on_source_removed, path), ) if not banded: diff --git a/src/sampletones_application/ui/panels/main/source/grid.py b/src/sampletones_application/ui/panels/main/source/grid.py index d76f7d6a4..5c95f206f 100644 --- a/src/sampletones_application/ui/panels/main/source/grid.py +++ b/src/sampletones_application/ui/panels/main/source/grid.py @@ -73,7 +73,10 @@ def tag(self) -> str: 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.""" + """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) @@ -83,6 +86,7 @@ def create(self, view_model: SourceSettingsPanelViewModel) -> None: policy=dpg.mvTable_SizingFixedFit, resizable=False, borders_innerV=True, + borders_outerH=True, ): self._columns.declare() self._create_row(view_model) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index fff7ab8cb..631e01767 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -350,6 +350,22 @@ def test_a_press_rests_while_another_tab_is_in_front( 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) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_source.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py index fd0fda012..ccc93e9d4 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_source.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -20,6 +20,7 @@ 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, ) @@ -203,6 +204,16 @@ def test_the_grid_comes_with_it(self, dpg_context: None, layout_config: LayoutCo 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_every_channel_is_named(self, dpg_context: None, layout_config: LayoutConfig) -> None: build(layout_config, view()) From 8d0bc24e787ac42dd57fe5e00e09ad0983a2d29f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 16:54:47 +0200 Subject: [PATCH 105/130] Picked: a row on every click and left Esc to playback alone --- docs/guide/converting.md | 2 +- docs/guide/interface.md | 2 +- .../categories/elements/settings.py | 1 - .../coordinators/tabs/main.py | 1 - .../logic/main/converter/logic.py | 4 --- .../ui/elements/stems/gestures.py | 14 ++++----- .../ui/elements/stems/list.py | 5 ++-- .../ui/panels/main/converter/listing.py | 13 ++------ .../ui/panels/main/converter/panel.py | 2 -- .../ui/panels/reconstruction/stems.py | 2 +- .../utils/gui/shortcuts/ids.py | 1 - .../keybindings/default.yaml | 1 - src/sampletones_config/keybindings/macos.yaml | 1 - src/sampletones_config/lang/en.yaml | 1 - .../ui/elements/stems/test_list.py | 30 +++++++++++-------- .../ui/panels/main/test_converter.py | 11 ------- 16 files changed, 32 insertions(+), 59 deletions(-) diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 1a1a14dcc..5c278c833 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -19,7 +19,7 @@ Adding a folder opens a small window while the folder is read. The window names **x** removes a row from the list. Removing a folder removes every recording in it. -Click a row to pick it out. The **Source settings** card then shows that row. Click it again, or press `Esc`, to let it go. Press `Del` to remove the row you picked out. +Click a row to pick it out. The **Source settings** card then shows that row. Press `Del` to remove the row you picked out. ## Choosing which channels a recording uses diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9b6194f54..1458e8dfd 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -36,4 +36,4 @@ Project properties belong to a project and are covered in the [sequencer guide]( **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. -`Space` plays and pauses, and `Esc` stops. When you have picked out a row in the converter's list, the first `Esc` lets that row go and the next one stops playback. On macOS, the shortcuts use Command where other platforms use Control. +`Space` plays and pauses, and `Esc` stops. On macOS, the shortcuts use Command where other platforms use Control. diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index c16c921da..a8be65ee5 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -147,7 +147,6 @@ class KeybindingActionElements(AbstractElement): TRACKER_PLAY_FROM_ROW = "tracker_play_from_row" SOURCES_REMOVE_SOURCE = "sources_remove_source" - SOURCES_CLEAR_SELECTION = "sources_clear_selection" VOICES_RENAME_VOICE = "voices_rename_voice" VOICES_REMOVE_VOICE = "voices_remove_voice" diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index aba2ead11..c63bf0594 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -357,7 +357,6 @@ def _wire_converter( 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 diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index cbf7fa7bd..1a25dc142 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -231,10 +231,6 @@ 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, so the card edits what a recording joins the list with.""" - 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)))) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 23f741d04..2df0a77ae 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -20,7 +20,6 @@ ChannelsCallback = Callable[[str, FrozenSet[ChannelName]], None] ChannelCallback = Callable[[str, ChannelName], None] -RowSelectionCallback = Callable[[str, bool], None] KeyOffsetCallback = Callable[[str, int], None] KeyPairCallback = Callable[[str, str], None] @@ -60,7 +59,7 @@ def __init__( 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[RowSelectionCallback] = 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 @@ -143,11 +142,12 @@ 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: - """Hand a clicked row on, along with whether it now reads as picked out or as let go. + def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> None: + """Hand a clicked row on, and let the next view say which row now reads as picked out. - A row already picked out reads as let go when it is clicked again, which is the answer - DearPyGui hands the callback, so one gesture both picks a row and releases it. + A click means the row it landed on, whichever way the widget swung: DearPyGui reports a + selectable once per click, so a double-click that sounds a recording leaves it picked out + the way a single click does. A list whose owner answers no click has the row put back the way the view holds it: the click moved the widget and nothing behind it, so the row would otherwise keep a picked @@ -157,7 +157,7 @@ def on_name_selected(self, _sender: Sender, value: bool, user_data: str) -> None dpg_set_value(self._tags.row(user_data, SUF_TEXT), user_data == self._view.selected_key) return - self._report(self.on_row_activated, user_data, value) + self._report(self.on_row_activated, 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.""" diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index cca7aaf8c..b616ebd73 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -15,7 +15,6 @@ ChannelsCallback, KeyOffsetCallback, KeyPairCallback, - RowSelectionCallback, StemsGestures, ) from sampletones_application.ui.elements.stems.heading import StemsHeading @@ -131,7 +130,7 @@ def __init__( 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[RowSelectionCallback] = 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 @@ -141,7 +140,7 @@ def __init__( 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, picked: self.call(self.on_row_activated, key, picked) + 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) diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 261fa9ad5..cffa2afbe 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -28,7 +28,7 @@ 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.types.callback import PathCallback, StringCallback from sampletones_shared.utils.callbacks import CallbackMixin ChannelsCallback = Callable[[Path, FrozenSet[ChannelName]], None] @@ -78,7 +78,6 @@ def __init__( 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_selection_cleared: Optional[VoidCallback] = None self.on_source_removed: Optional[PathCallback] = None self.on_folder_removed: Optional[PathCallback] = None self.on_source_played: Optional[PathCallback] = None @@ -130,12 +129,8 @@ def _on_folder_channel_toggled(self, key: str, channel_name: ChannelName) -> Non """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, picked: bool) -> None: - """A clicked row is the one the settings card inspects; clicking it again lets it go.""" - if not picked: - self.call(self.on_selection_cleared) - return - + 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) @@ -163,8 +158,6 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: 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 ShortcutId.SOURCES_CLEAR_SELECTION: - self.call(self.on_selection_cleared) case _: return False diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py index 56bfcc367..d10e6e3b5 100644 --- a/src/sampletones_application/ui/panels/main/converter/panel.py +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -88,7 +88,6 @@ def __init__( 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 @@ -162,7 +161,6 @@ def _wire(self) -> None: 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) diff --git a/src/sampletones_application/ui/panels/reconstruction/stems.py b/src/sampletones_application/ui/panels/reconstruction/stems.py index 56187477a..9c0354658 100644 --- a/src/sampletones_application/ui/panels/reconstruction/stems.py +++ b/src/sampletones_application/ui/panels/reconstruction/stems.py @@ -181,7 +181,7 @@ def _on_channels_changed(self, key: str, channels: FrozenSet[ChannelName]) -> No def _on_remove_requested(self, key: str) -> None: self.call(self.on_stem_remove_requested, int(key)) - def _on_row_activated(self, key: str, _picked: bool) -> None: + def _on_row_activated(self, key: str) -> None: """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: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 0418a8edc..6ee3daba3 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -187,7 +187,6 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER) SOURCES_REMOVE_SOURCE = ("SourcesRemoveSource", ShortcutCategory.SOURCES) - SOURCES_CLEAR_SELECTION = ("SourcesClearSelection", ShortcutCategory.SOURCES) VOICES_RENAME_VOICE = ("VoicesRenameVoice", ShortcutCategory.VOICES) VOICES_REMOVE_VOICE = ("VoicesRemoveVoice", ShortcutCategory.VOICES) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 8b93f5b93..288193239 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -142,7 +142,6 @@ bindings: # sources SourcesRemoveSource: {combination: "Del"} - SourcesClearSelection: {combination: "Esc"} # voices VoicesRenameVoice: {combination: "F2"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 2cca9b427..2d967963b 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -142,7 +142,6 @@ bindings: # sources SourcesRemoveSource: {combination: "Del"} - SourcesClearSelection: {combination: "Esc"} # voices VoicesRenameVoice: {combination: "F2"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 615d29923..f0bb76660 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -931,7 +931,6 @@ settings.keybindings.label.select_tab_reconstructions: "Go to the Reconstruction 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.sources_clear_selection: "Let the picked recording go" settings.keybindings.label.order_previous_position: "Previous position" settings.keybindings.label.order_next_position: "Next position" settings.keybindings.label.order_previous_channel: "Previous channel" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_list.py b/tests/unit/sampletones_application/ui/elements/stems/test_list.py index f06998762..64a9377f8 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -680,29 +680,33 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf class TestActivation(BaseTestSuite): def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> None: - activated: List[Tuple[str, bool]] = [] + activated: List[str] = [] stems_list = build(layout_config, dragging=False) - stems_list.on_row_activated = lambda key, picked: activated.append((key, picked)) + stems_list.on_row_activated = activated.append bass = row("bass") stems_list.update_view(view(bass)) - name_tag = row_tag(bass, SUF_TEXT) - dpg.get_item_callback(name_tag)(name_tag, True, bass.key) + select_name(bass, True) - assert activated == [(bass.key, True)] + assert activated == [bass.key] - def test_a_row_clicked_again_reports_that_it_was_let_go(self, dpg_context: None, layout_config) -> None: - """DearPyGui hands the callback what the row now reads as, so one gesture answers both ways.""" - activated: List[Tuple[str, bool]] = [] + def test_the_second_click_of_a_double_click_names_the_same_row( + self, + dpg_context: None, + layout_config, + ) -> 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 = lambda key, picked: activated.append((key, picked)) + stems_list.on_row_activated = activated.append bass = row("bass") - stems_list.update_view(view(bass, selected_key=bass.key)) + stems_list.update_view(view(bass)) - name_tag = row_tag(bass, SUF_TEXT) - dpg.get_item_callback(name_tag)(name_tag, False, bass.key) + select_name(bass, True) + select_name(bass, False) - assert activated == [(bass.key, 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) -> None: stems_list = build(layout_config, dragging=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 index 631e01767..85b36ef12 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -377,17 +377,6 @@ def test_it_removes_the_recording_picked_out(self, dpg_context: None, layout_con assert self._press(router, ShortcutId.SOURCES_REMOVE_SOURCE) is True assert removed == [kick.path] - def test_it_lets_the_row_picked_out_go(self, dpg_context: None, layout_config: LayoutConfig) -> None: - router = KeyRouter() - panel, _reported = build(layout_config, key_router=router) - cleared: List[bool] = [] - panel.on_selection_cleared = lambda: cleared.append(True) - kick = row("kick") - panel.update_view(view(kick, selected_key=kick.key)) - - assert self._press(router, ShortcutId.SOURCES_CLEAR_SELECTION) is True - assert cleared == [True] - 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() From c2ff98d494a5222e91375534e4f939e4baa88d4c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 17:06:07 +0200 Subject: [PATCH 106/130] Printed: the removal key on its menu item and stated the callback note --- docs/development/architecture.md | 2 +- .../ui/panels/main/converter/menus.py | 40 ++++++++----- .../ui/panels/main/converter/panel.py | 1 + .../ui/panels/main/test_converter.py | 58 ++++++++++++++++++- 4 files changed, 86 insertions(+), 15 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 548823900..79d215183 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -59,7 +59,7 @@ 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. diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index ffa977aac..828a6808c 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -17,6 +17,8 @@ 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 @@ -43,9 +45,11 @@ def __init__( *, 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 @@ -87,6 +91,10 @@ def _show_row(self, row: StemRowViewModel, *, banded: bool) -> None: 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: @@ -101,10 +109,9 @@ def _show_folder(self, row: StemRowViewModel) -> None: ), callback=lambda: self.call(self.on_folder_toggled, row.path), ) - dpg.add_menu_item( - label=self._folder_label(ConverterFolderElements.CONTEXT_REMOVE_FOLDER), - enabled=self._stems_list.lets_a_row_go, - callback=lambda: self.call(self.on_folder_removed, 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) @@ -116,17 +123,12 @@ def _moves( ) -> 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 has no order to rearrange, so it offers the one move - that means something there: taking the recording out. + A run writing a reconstruction apiece has no order to rearrange, so it offers none of them + and the row's own removal is the whole of what it can be told to do. """ path = row.path - removal = ( - ConverterStemMoveElements.CONTEXT_REMOVE_STEM, - self._stems_list.lets_a_row_go, - lambda: self.call(self.on_source_removed, path), - ) if not banded: - return [removal] + return [] return [ ( @@ -154,9 +156,21 @@ def _moves( not row.alone_on_level, lambda: self.call(self.on_source_isolated, path), ), - removal, ] + 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.""" diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py index d10e6e3b5..5e8e29bb6 100644 --- a/src/sampletones_application/ui/panels/main/converter/panel.py +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -67,6 +67,7 @@ def __init__( self._menus = ConverterMenus( stems_list=self._listing.stems_list, language_manager=language_manager, + shortcut_source=shortcut_source, ) self._action = ConverterActionButton( layout=layout, diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 85b36ef12..056554efe 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Iterator, List, Optional, Tuple +from typing import Any, Dict, Iterator, List, Optional, Tuple import dearpygui.dearpygui as dpg import pytest @@ -32,6 +32,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.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 @@ -419,3 +420,58 @@ def test_the_input_line_names_the_recording_a_run_is_reading( assert shows(TAG_MAIN_CONVERTER_GROUP_INPUT) assert panel.input_path_text.path == RECORDING + + +class TestTheRemovalItemInTheMenu: + """Taking a row out is one action, so the item and the key print and reach the same thing.""" + + @staticmethod + def _items( + panel: GUIConverterPanel, + entry: StemRowViewModel, + monkeypatch: pytest.MonkeyPatch, + ) -> List[Dict[str, Any]]: + """The items the row's context menu registers, as a reader would meet them.""" + registered: List[Dict[str, Any]] = [] + monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) + monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) + panel._show_menu(entry.key) + return registered + + def _removal( + self, + panel: GUIConverterPanel, + entry: StemRowViewModel, + monkeypatch: pytest.MonkeyPatch, + ) -> Dict[str, Any]: + label = LANGUAGE_MANAGER["main.converter.label.context_remove_stem"] + items = self._items(panel, entry, monkeypatch) + return next(item for item in items if item["label"] == label) + + def test_it_prints_the_key_that_does_the_same_thing( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + removal = self._removal(panel, kick, monkeypatch) + + 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, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), phase=ConversionPhase.RUNNING)) + + removal = self._removal(panel, kick, monkeypatch) + + assert removal["enabled"] is False From 20f01f385ba772fa28324c5f522539a67d268ed6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 17:20:54 +0200 Subject: [PATCH 107/130] Tested: the gutter, the shape, the indents and the gestures nobody answers --- .../sampletones_application/test_startup.py | 32 +++- .../ui/elements/layout/test_region.py | 56 ++++++ .../ui/elements/stems/test_columns.py | 176 ++++++++++++++++++ .../ui/elements/stems/test_folder.py | 23 +++ .../ui/elements/stems/test_list.py | 19 +- .../ui/elements/stems/test_shape.py | 170 +++++++++++++++++ .../ui/panels/main/test_converter.py | 20 +- 7 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_columns.py create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_shape.py diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index f94d52cd5..ca71f127a 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -26,6 +26,7 @@ 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, @@ -44,6 +45,7 @@ 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 @@ -541,6 +543,17 @@ def _ctrl_click_folder(app: Application, directory: Path) -> None: panel._directory_node_clicked(node, UNBUILT_ROW) +DOUBLE_CLICKED_HANDLER = 1 + + +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.""" + tags = StemsTags(prefix=prefix) + handler = dpg.get_item_children(tags.handlers(SUF_TEXT), 1)[DOUBLE_CLICKED_HANDLER] + name_tag = tags.row(key, SUF_TEXT) + dpg.get_item_callback(handler)(name_tag, (dpg.mvMouseButton_Left, dpg.get_alias_id(name_tag))) + + class TestGatheringAFolderIntoAMix: """A folder bringing in more than a mix holds is a question, and the answer reaches the mix. @@ -617,15 +630,22 @@ def test_a_recording_in_the_question_sounds_where_the_reader_asks_for_it( app: Application, tmp_path: Path, ) -> None: - """A reader decides by ear, so the question reaches the player the converter's list reaches.""" - window = app._main_tab._stem_selection_window + """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) - recording = directory / "take_00.wav" + 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._main_tab._file_playback, "play_at") as sounded: - window.on_source_played(recording) + 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 + assert sounded.call_args.args[0] == recording.path class TestMainTabReadingOrder: diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index ac4c90e25..bd7ddef87 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -20,6 +20,8 @@ CEILING = 100 HEADING_TEXT = "channels" STANDING_OFFSET = 300.0 +PADDING = 8 +GUTTER = 13 @pytest.fixture @@ -366,3 +368,57 @@ def test_a_draw_reaching_it_builds_nothing(self, region: WindowedRegion) -> None 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), + 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 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..d276a89b5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -0,0 +1,176 @@ +from typing import Iterator, 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 + +GLYPH = "▸" +GLYPH_WIDTH = 9.0 +GLYPH_SIZE = [GLYPH_WIDTH, 20.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) -> StemsColumns: + """The grid a list of gathered recordings declares.""" + return StemsColumns( + layout=layout_config.general.stems, + channels=CHANNELS, + master=master, + removable=True, + bends=False, + 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 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, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + grid = columns(layout_config, folders=True) + + indent = grid.box_indent(ChannelName.PULSE1) + + assert indent == (grid.channel_width - stems.channel_box_width) // 2 + + def test_the_box_beside_a_row_is_centered_in_its_own_column( + self, + dpg_context: None, + 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, + dpg_context: None, + 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_holds_the_room_clear( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + stems = layout_config.general.stems + + reserve = columns(layout_config, folders=True).reserve + + assert reserve == stems.well_padding + stems.scrollbar_width + + def test_a_grid_holding_none_spends_nothing( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + assert columns(layout_config, folders=False).reserve == 0 + + def test_the_reserve_column_comes_out_the_width_of_the_room( + self, + dpg_context: None, + layout_config: LayoutConfig, + ) -> None: + """A column takes its own width plus the padding either side and the rule beside it.""" + stems = layout_config.general.stems + grid = columns(layout_config, folders=True) + + assert grid.reserve_width == grid.reserve - 2 * stems.cell_padding - 1 diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index abd78b9b9..a51f29c12 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -23,6 +23,7 @@ SUF_GROUP, SUF_TEXT, SUF_TWISTY, + TAG_GLOBAL_THEME_STEMS_GRID, TAG_GLOBAL_THEME_STEMS_GROUP_ROW, TAG_GLOBAL_THEME_STEMS_MARKER, ) @@ -546,3 +547,25 @@ def test_a_recording_inside_a_folder_carries_none(self, stems_list: GUIStemsList 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_states_its_own_rows_clear(self, stems_list: GUIStemsList) -> None: + 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 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 64a9377f8..549000d1c 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,6 +1,6 @@ -import logging from pathlib import Path from typing import Final, FrozenSet, Iterator, List, Optional, Tuple +from unittest.mock import patch import dearpygui.dearpygui as dpg import pytest @@ -53,7 +53,6 @@ LONG_LIST: Final[int] = 200 CLICK_HANDLER: Final[int] = 0 DOUBLE_CLICK_HANDLER: Final[int] = 1 -UNSET_CALLBACK_NOTE: Final[str] = "No callback for GUIStemsList" @pytest.fixture @@ -775,16 +774,16 @@ def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( self, dpg_context: None, layout_config, - caplog: pytest.LogCaptureFixture, ) -> None: - caplog.set_level(logging.DEBUG) stems_list = build(layout_config, dragging=False) bass = row("bass") stems_list.update_view(view(bass)) - click_on(bass, CLICK_HANDLER, dpg.mvMouseButton_Right) + with patch.object(stems_list, "call") as handed_on: + click_on(bass, CLICK_HANDLER, dpg.mvMouseButton_Right) - assert UNSET_CALLBACK_NOTE not in caplog.text + 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, @@ -805,16 +804,16 @@ def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( self, dpg_context: None, layout_config, - caplog: pytest.LogCaptureFixture, ) -> None: - caplog.set_level(logging.DEBUG) stems_list = build(layout_config, dragging=False) bass = row("bass") stems_list.update_view(view(bass)) - click_on(bass, DOUBLE_CLICK_HANDLER, dpg.mvMouseButton_Left) + with patch.object(stems_list, "call") as handed_on: + click_on(bass, DOUBLE_CLICK_HANDLER, dpg.mvMouseButton_Left) - assert UNSET_CALLBACK_NOTE not in caplog.text + assert stems_list.playable is False + handed_on.assert_not_called() def test_a_double_click_sounds_its_row_where_the_owner_answers( self, 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..aa993ad0a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py @@ -0,0 +1,170 @@ +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="nothing_draws_nothing", reshape=Reshape.nothing(), expected=False), + TestCase(label="the_whole_list_draws", reshape=Reshape.everything(), expected=True), + TestCase(label="one_folder_draws", reshape=Reshape.within(("drums",)), expected=True), + TestCase(label="no_folder_draws_nothing", reshape=Reshape.within(()), expected=False), + ) + + @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.""" + + def test_a_row_holding_something_else_still_stands_where_it_did(self) -> None: + """What a folder holds is answered separately, so it plays no part in where the row is.""" + assert placed("drums", held=("one", "two")).stands_where(placed("drums", held=("one",))) + + def test_a_row_that_moved_band_stands_elsewhere(self) -> None: + assert not placed("drums", level=0).stands_where(placed("drums", level=1)) + + def test_a_folder_that_opened_stands_elsewhere(self) -> None: + assert not placed("drums", opened=True).stands_where(placed("drums")) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 056554efe..23ef30d08 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -36,7 +36,7 @@ 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 +from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyEvent, KeyRouter, focus from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource @@ -336,6 +336,24 @@ def test_a_press_rests_while_no_row_is_picked_out( 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, From 9447637c8d6734ac5a9454af6848d08c67e8a99d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 17:29:10 +0200 Subject: [PATCH 108/130] Tidied: the marker's centering, the body's width and where sources is declared --- .../categories/elements/settings.py | 4 ++-- .../ui/elements/layout/region.py | 12 ++++++++---- .../ui/elements/layout/well.py | 14 ++++++-------- .../ui/elements/stems/columns.py | 4 ++-- .../utils/gui/shortcuts/ids.py | 4 ++-- src/sampletones_config/keybindings/default.yaml | 6 +++--- src/sampletones_config/keybindings/macos.yaml | 6 +++--- src/sampletones_config/lang/en.yaml | 2 +- .../ui/elements/stems/test_folder.py | 2 +- 9 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index a8be65ee5..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" @@ -146,8 +148,6 @@ class KeybindingActionElements(AbstractElement): TRACKER_CANCEL_ENTRY = "tracker_cancel_entry" TRACKER_PLAY_FROM_ROW = "tracker_play_from_row" - SOURCES_REMOVE_SOURCE = "sources_remove_source" - VOICES_RENAME_VOICE = "voices_rename_voice" VOICES_REMOVE_VOICE = "voices_remove_voice" VOICES_MOVE_VOICE_UP = "voices_move_voice_up" diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 23753f205..5c61ccec0 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -152,7 +152,7 @@ def create(self, parent: str, *, show: bool = True) -> None: self._tag, padding=self._padding, margin=self._margin, - gutter=self._gutter, + width=self._body_width(scrolling=False), indent=self._indent, show=show, ) @@ -341,10 +341,14 @@ def _hold_gutter(self, *, scrolling: bool) -> None: body is measured against; one within it draws none, and the gutter stands in its place. So the columns inside a region stand where they stand however long the list it holds grows. """ - if not dpg.does_item_exist(self._body_tag): - return + dpg_configure_item(self._body_tag, width=self._body_width(scrolling=scrolling)) - dpg_configure_item(self._body_tag, width=-(self._padding + (NO_GUTTER if scrolling else self._gutter))) + 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.""" diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index 25d7293fc..a9a156682 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -16,7 +16,7 @@ def well( *, padding: int, margin: int, - gutter: int, + width: int, indent: Optional[int] = None, height: int = 0, show: bool = True, @@ -28,12 +28,10 @@ def well( card. Alongside ``card()`` this is where the recessed depth theme is bound; the region sizes itself to its rows unless ``height`` reserves a footprint. - Returns the inset body group content is added to, which keeps ``padding`` clear at the right - and ``indent`` at the left, the two being the same width unless a caller nests the body inside - something. 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. - ``gutter`` widens that right inset by the room a scrollbar takes, which a caller hands over - while the well stands without one, so the body keeps one width however tall its content grows. + 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 none lays neither spacer, so its rows open where the well does — which is what a well nested inside a list takes, its rows being a @@ -53,7 +51,7 @@ def well( if margin: dpg.add_spacer(height=margin) - dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=-(padding + gutter)) + dpg.add_group(tag=body_tag, indent=padding if indent is None else indent, width=width) if margin: dpg.add_spacer(height=margin) diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index 0fe86eceb..df98e1705 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -123,7 +123,7 @@ def marker_indent(self, glyph: str, font: Font) -> int: if measured is None: return NO_INDENT - return max(NO_INDENT, (self.layout.twisty_width - int(measured[0])) // 2) + 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. @@ -133,7 +133,7 @@ def name_indent(self, label: str, font: Font) -> int: """ measured = dpg.get_text_size(label, font=FontRegistry.get_tag(font)) if measured is None: - return 0 + return NO_INDENT return self._centered(int(measured[0]), within=self.channel_width) diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 6ee3daba3..52aa7003d 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -108,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) @@ -186,8 +188,6 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_CANCEL_ENTRY = ("TrackerCancelEntry", ShortcutCategory.TRACKER) TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER) - SOURCES_REMOVE_SOURCE = ("SourcesRemoveSource", ShortcutCategory.SOURCES) - VOICES_RENAME_VOICE = ("VoicesRenameVoice", ShortcutCategory.VOICES) VOICES_REMOVE_VOICE = ("VoicesRemoveVoice", ShortcutCategory.VOICES) VOICES_MOVE_VOICE_UP = ("VoicesMoveVoiceUp", ShortcutCategory.VOICES) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 288193239..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"]} @@ -140,9 +143,6 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} - # sources - SourcesRemoveSource: {combination: "Del"} - # voices VoicesRenameVoice: {combination: "F2"} VoicesRemoveVoice: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 2d967963b..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"]} @@ -140,9 +143,6 @@ bindings: TrackerCancelEntry: {combination: "Esc"} TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"} - # sources - SourcesRemoveSource: {combination: "Del"} - # voices VoicesRenameVoice: {combination: "F2"} VoicesRemoveVoice: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index f0bb76660..9f2902567 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -849,9 +849,9 @@ settings.export.template.size: "{completed} of {total} bytes" 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.sources: "Converter list" settings.keybindings.title.voices: "Voices" settings.keybindings.title.reassign_confirmation: "Combination in use" settings.keybindings.title.reset_confirmation: "Restore the shipped keys" diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index a51f29c12..3f663b642 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -166,7 +166,7 @@ def table_of(row: StemRowViewModel) -> int: def theme_on(row: StemRowViewModel) -> str: - """The theme one row's line carries, which is what bands a group apart from its neighbours.""" + """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(f"{PREFIX}.row.{row.key}.{SUF_GROUP}")) From 001559781cd5d9a3fb08458424a384abdd8af489 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 18:46:28 +0200 Subject: [PATCH 109/130] Picked: the row a menu stands over --- .../ui/elements/stems/gestures.py | 40 +++++++++++-------- .../ui/elements/stems/test_list.py | 36 +++++++++++++++++ 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 2df0a77ae..5fa5dd019 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -33,8 +33,9 @@ class StemsGestures: 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. - A gesture with none rests here, and the widget it moved is put back where it stood. + 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__( @@ -143,21 +144,12 @@ def on_twisty(self, _sender: Sender, _app_data: Any, user_data: str) -> None: self._report(self.on_folder_toggled, user_data) def on_name_selected(self, _sender: Sender, _value: bool, user_data: str) -> None: - """Hand a clicked row on, and let the next view say which row now reads as picked out. + """Pick the row a click landed on, whichever way the widget swung. - A click means the row it landed on, whichever way the widget swung: DearPyGui reports a - selectable once per click, so a double-click that sounds a recording leaves it picked out - the way a single click does. - - A list whose owner answers no click has the row put back the way the view holds it: the - click moved the widget and nothing behind it, so the row would otherwise keep a picked - look that no reading of the list ever wrote. + DearPyGui reports a selectable once per click, so a double-click that sounds a recording + picks its row the way a single click does. """ - if not self._activatable(): - dpg_set_value(self._tags.row(user_data, SUF_TEXT), user_data == self._view.selected_key) - return - - self._report(self.on_row_activated, user_data) + 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.""" @@ -172,14 +164,30 @@ def on_level_drop(self, sender: Sender, app_data: str) -> None: 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 names the row its menu stands over, where the owner puts one up.""" + """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._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._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) 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 549000d1c..8c7d29bd8 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -689,6 +689,42 @@ def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> assert activated == [bass.key] + def test_a_right_click_picks_the_row_its_menu_stands_over( + self, + dpg_context: None, + layout_config, + ) -> 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, CLICK_HANDLER, 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, + ) -> 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, From 72752cc3fa30dff26c024a3a65ad0ff7b7690370 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:01:50 +0200 Subject: [PATCH 110/130] Extracted: hashing into sampletones_shared and gave every runtime-named tag one identity --- .../logic/history/fingerprint.py | 18 +- .../logic/history/manager.py | 2 +- .../logic/instruction/table.py | 2 +- .../logic/reconstruction/manager.py | 2 +- src/sampletones_application/tags/compose.py | 20 ++ .../ui/elements/stems/tags.py | 27 +- .../ui/elements/tree/tag.py | 27 +- .../library/filename/fields.py | 2 +- src/sampletones_core/library/key.py | 2 +- .../reconstructions/converter/paths/fields.py | 2 +- src/sampletones_shared/utils/hashing.py | 124 +++++++++ src/sampletones_shared/utils/serialization.py | 98 +------ tests/benchmarks/test_converter_load.py | 4 +- .../logic/history/test_fingerprint.py | 2 +- .../ui/elements/stems/test_folder.py | 24 +- .../ui/elements/stems/test_list.py | 59 +++++ .../ui/panels/dialogs/test_stem_selection.py | 4 +- .../sampletones_shared/utils/test_hashing.py | 239 ++++++++++++++++++ .../utils/test_serialization.py | 210 --------------- 19 files changed, 496 insertions(+), 372 deletions(-) create mode 100644 src/sampletones_shared/utils/hashing.py create mode 100644 tests/unit/sampletones_shared/utils/test_hashing.py 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/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/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/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/ui/elements/stems/tags.py b/src/sampletones_application/ui/elements/stems/tags.py index a86b62d3f..fa6dabf0a 100644 --- a/src/sampletones_application/ui/elements/stems/tags.py +++ b/src/sampletones_application/ui/elements/stems/tags.py @@ -1,6 +1,6 @@ from dataclasses import dataclass -from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.compose import compose_tag, identity_part from sampletones_application.tags.general import ( SUF_BENDS, SUF_CHANNELS, @@ -24,6 +24,11 @@ class StemsTags: 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 @@ -54,7 +59,7 @@ def segment(self, position: int) -> str: 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, suffix) + 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.""" @@ -75,7 +80,7 @@ def handlers(self, kind: str) -> str: 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, suffix) + 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.""" @@ -83,20 +88,8 @@ def level(self, level_index: int, suffix: str) -> str: def channel(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), - ) + 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 compose_tag( - self.prefix, - SUF_ROW, - key, - SUF_BENDS, - compose_tag(channel_name, SUF_CHECKBOX), - ) + 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_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/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index 79452e7b1..e238835ea 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -17,7 +17,7 @@ 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] = "_" 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/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 6f7317388..46c34a403 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -33,6 +33,7 @@ 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.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 @@ -308,7 +309,8 @@ def list_drawn_as(prefix: str, layout_config: LayoutConfig) -> GUIStemsList: 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] - return sum(1 for held in folder_row.held if dpg.does_item_exist(f"{prefix}.row.{held.key}.{SUF_TEXT}")) + 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): 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/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 3f663b642..f91d11081 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -18,8 +18,6 @@ ) from sampletones_application.tags.general import ( SUF_BUTTON, - SUF_CHANNELS, - SUF_CHECKBOX, SUF_GROUP, SUF_TEXT, SUF_TWISTY, @@ -31,6 +29,7 @@ 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.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 @@ -44,6 +43,7 @@ ROOT_TAG = "test_root" PREFIX = "test.stems" +TAGS: Final[StemsTags] = StemsTags(prefix=PREFIX) CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DOUBLE_CLICK_HANDLER: Final[str] = "mvAppItemType::mvDoubleClickedHandler" DEEP_FOLDER: Final[int] = 200 @@ -145,29 +145,29 @@ def press(tag: str) -> None: def twisty_of(row: StemRowViewModel) -> str: - return f"{PREFIX}.row.{row.key}.{SUF_TWISTY}" + return TAGS.row(row.key, SUF_TWISTY) def region_of(row: StemRowViewModel) -> str: - return f"{PREFIX}.folder.{row.key}.region" + return TAGS.region(row.key) def name_of(row: StemRowViewModel) -> str: - return f"{PREFIX}.row.{row.key}.{SUF_TEXT}" + return TAGS.row(row.key, SUF_TEXT) def box_of(row: StemRowViewModel, channel_name: ChannelName) -> str: - return f"{PREFIX}.row.{row.key}.{SUF_CHANNELS}.{channel_name}.{SUF_CHECKBOX}" + 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(f"{PREFIX}.row.{row.key}.{SUF_GROUP}") + 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(f"{PREFIX}.row.{row.key}.{SUF_GROUP}")) + return dpg.get_item_alias(dpg.get_item_theme(TAGS.row(row.key, SUF_GROUP))) def folder_without(row: StemRowViewModel, leaving: StemRowViewModel) -> StemRowViewModel: @@ -316,7 +316,7 @@ def test_a_folder_that_left_the_list_comes_back_closed(self, stems_list: GUIStem def double_click(tag: str) -> None: """Double-click a widget the way DearPyGui reports it, through the registry its kind shares.""" - registry = f"{PREFIX}.{SUF_TEXT}.handler.registry" + registry = TAGS.handlers(SUF_TEXT) for handler in dpg.get_item_children(registry, 1): if dpg.get_item_info(handler)["type"] == DOUBLE_CLICK_HANDLER: dpg.get_item_callback(handler)(handler, (dpg.mvMouseButton_Left, dpg.get_alias_id(tag))) @@ -337,7 +337,7 @@ def test_its_remove_button_is_live(self, stems_list: GUIStemsList) -> None: sources = folder("sources", holds=3) self._opened(stems_list, sources) - button = f"{PREFIX}.row.{sources.held[0].key}.{SUF_BUTTON}" + button = TAGS.row(sources.held[0].key, SUF_BUTTON) assert dpg.get_item_configuration(button)["enabled"] is True @@ -349,7 +349,7 @@ def test_one_of_them_leaving_draws_the_folder_again(self, stems_list: GUIStemsLi stems_list.update_view(view(folder_without(sources, leaving))) - assert not dpg.does_item_exist(f"{PREFIX}.row.{leaving.key}.{SUF_TEXT}") + 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) @@ -359,7 +359,7 @@ def test_the_ones_that_stay_are_still_drawn(self, stems_list: GUIStemsList) -> N stems_list.update_view(view(folder_without(sources, leaving))) for held in sources.held[1:]: - assert dpg.does_item_exist(f"{PREFIX}.row.{held.key}.{SUF_TEXT}") + 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.""" 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 8c7d29bd8..b030bd21c 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -335,6 +335,65 @@ def test_the_list_reports_the_row_a_gesture_named(self, dpg_context: None, layou assert stems_list.row("nothing") is None +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, + ) -> 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, + ) -> 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, + ) -> 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(BaseTestSuite): def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None: stems_list = build(layout_config) 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 index f1e7ac3be..e5146cdba 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -10,7 +10,7 @@ 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_ROW, SUF_TEXT +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, @@ -109,7 +109,7 @@ def discard(_picked: List[Path]) -> None: def box_of(row: StemRowViewModel) -> str: - return compose_tag(PRE_MAIN_CONVERTER_CANDIDATE, SUF_ROW, row.key, SUF_CHECKBOX) + return TAGS.row(row.key, SUF_CHECKBOX) def pick(row: StemRowViewModel) -> None: 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..afe4a5504 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_hashing.py @@ -0,0 +1,239 @@ +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_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"}]}}} From 02eaf51d7ae5f92b825335e6e43285b8dd96ae93 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:04:11 +0200 Subject: [PATCH 111/130] Cleared: what a timed gesture built, and kept the batch that qualified --- .../ui/elements/stems/gestures.py | 6 ++-- tests/benchmarks/test_converter_load.py | 29 ++++++++++++++++--- tests/suite/timing.py | 23 ++++++++++----- 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index 5fa5dd019..d1a1d8afb 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -1,4 +1,4 @@ -from typing import Any, Callable, FrozenSet, Optional, Tuple +from typing import Any, Callable, Final, FrozenSet, Optional, Tuple import dearpygui.dearpygui as dpg @@ -18,6 +18,8 @@ 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] @@ -73,7 +75,7 @@ def reads(self, view_model: StemsListViewModel) -> None: def create_handlers(self) -> None: """Register one handler registry per row-widget kind.""" - for kind in (SUF_TEXT, SUF_CHANNELS, SUF_CHECKBOX, SUF_BUTTON, SUF_TWISTY): + for kind in ROW_WIDGET_KINDS: dpg_delete_item(self._tags.handlers(kind)) with dpg.item_handler_registry(tag=self._tags.handlers(SUF_TEXT)): diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 46c34a403..3c32bfbf1 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -31,6 +31,7 @@ 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 @@ -48,6 +49,7 @@ SMALL_FOLDER: Final[int] = 1_000 LARGE_FOLDER: Final[int] = 10_000 GROWTH_ALLOWANCE: Final[float] = 2.0 +UNREADABLE: Final[float] = float("inf") REGION_HEIGHT: Final[float] = 264.0 ROW_PITCH: Final[float] = 36.0 OVERSCAN: Final[int] = 4 @@ -86,10 +88,14 @@ def state_of(root: Path, count: int) -> ConverterState: 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.""" + """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 + 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, " @@ -306,6 +312,14 @@ def list_drawn_as(prefix: str, layout_config: LayoutConfig) -> GUIStemsList: 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] @@ -355,8 +369,15 @@ def test_opening_it_costs_what_a_reader_can_see( @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.""" + """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) - return rows_on_screen(prefix, listing) + reached = rows_on_screen(prefix, listing) + cleared(prefix, stems_list) + return reached diff --git a/tests/suite/timing.py b/tests/suite/timing.py index 3f36541fe..96235e6b6 100644 --- a/tests/suite/timing.py +++ b/tests/suite/timing.py @@ -1,6 +1,6 @@ import gc from time import process_time -from typing import Callable, Final, List +from typing import Callable, Final, List, Tuple REPEATS: Final[int] = 3 MINIMUM_READING: Final[float] = 0.25 @@ -19,13 +19,14 @@ def seconds(work: Callable[[], object]) -> float: The collector is held off for the reading, 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, and the - objects are collected once the reading comes back. + collector is armed again once the reading comes back, on whatever the batches left behind. """ collecting = gc.isenabled() gc.disable() try: - runs = _runs_reaching(work) - readings: List[float] = [_batch(work, runs) / runs for _ in range(REPEATS)] + runs, reached = _runs_reaching(work) + readings: List[float] = [reached / runs] + readings.extend(_batch(work, runs) / runs for _ in range(REPEATS - 1)) finally: if collecting: gc.enable() @@ -33,13 +34,19 @@ def seconds(work: Callable[[], object]) -> float: return min(readings) -def _runs_reaching(work: Callable[[], object]) -> int: - """How many runs a batch takes to cost more than the clock's own step, doubling until it does.""" +def _runs_reaching(work: Callable[[], object]) -> Tuple[int, float]: + """The batch that costs more than the clock's own step, doubling until it does. + + The batch that qualified is a reading like any other, so it comes back beside the run count + it took and stands as the first of the readings. + """ runs = FIRST_BATCH - while runs < MOST_RUNS and _batch(work, runs) < MINIMUM_READING: + reading = _batch(work, runs) + while runs < MOST_RUNS and reading < MINIMUM_READING: runs *= 2 + reading = _batch(work, runs) - return runs + return runs, reading def _batch(work: Callable[[], object], runs: int) -> float: From b36968dcbad1985a1122fa40be809c0ccfdeac32 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:05:15 +0200 Subject: [PATCH 112/130] Stated: the folder's reserve once, and every docstring as what it does --- docs/development/bugs-and-todos.md | 2 +- docs/guide/converting.md | 6 +++--- src/sampletones_application/layout/general/stems.py | 9 +++++++++ .../ui/elements/layout/region.py | 4 ++-- .../ui/elements/layout/well.py | 6 +++--- .../ui/elements/stems/columns.py | 12 +++++------- .../ui/elements/stems/folder.py | 6 +++--- .../ui/elements/stems/list.py | 2 +- src/sampletones_application/ui/elements/stems/row.py | 10 +++++----- .../ui/elements/stems/shape.py | 2 +- .../ui/panels/main/converter/listing.py | 4 ++-- .../ui/panels/main/converter/menus.py | 4 ++-- 12 files changed, 37 insertions(+), 30 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 137a1509d..bdb353114 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -6,7 +6,7 @@ * 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` and `Esc` reach it through the `SOURCES` key scope + 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 diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 5c278c833..12dec296a 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -15,15 +15,15 @@ The **Converter** card lists the recordings a conversion uses. Add them from the 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. The window names the folder, counts the recordings found so far, and has a **Stop** button that gives up the search. A folder with no recordings inside it says so and adds nothing. +Adding a folder opens a small window while the folder is read. **Stop** ends the search and keeps what it has found so far. 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 pick it out. The **Source settings** card then shows that row. Press `Del` to remove the row you picked out. +Click a row to select it. The **Source settings** card then shows that recording. Press `Del` to remove the selected row. ## 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 row you picked out. +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: diff --git a/src/sampletones_application/layout/general/stems.py b/src/sampletones_application/layout/general/stems.py index 6148a8794..f380bd0a8 100644 --- a/src/sampletones_application/layout/general/stems.py +++ b/src/sampletones_application/layout/general/stems.py @@ -18,3 +18,12 @@ class StemsListLayout(BaseModel, extra="forbid", frozen=True): 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/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 5c61ccec0..b74806858 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -338,8 +338,8 @@ 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 it draws none, and the gutter stands in its place. So - the columns inside a region stand where they stand however long the list it holds grows. + 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)) diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index a9a156682..5ed231b20 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -33,9 +33,9 @@ def well( 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 none lays neither spacer, so its rows - open where the well does — which is what a well nested inside a list takes, its rows being a - run of the list rather than a body of their own. + 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( diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index df98e1705..8bd9b96e3 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -50,16 +50,14 @@ def channel_width(self) -> int: @property def reserve(self) -> int: - """The room a folder's region spends at the right of the grid, held clear across the list. + """The room held clear at the right of every table outside a folder, which is the region's. - A region insets its body by the well's padding and keeps a scrollbar's width clear beside - it, so a strip of that width at the right end of every table outside a folder stands the - two grids in one. + 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.well_padding + self.layout.scrollbar_width + return self.layout.folder_reserve @property def reserve_width(self) -> int: @@ -113,8 +111,8 @@ def marker_indent(self, glyph: str, font: Font) -> int: 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 - the glyph rather than at the button, which reads as one column of names without spending - the marker's whole width on a row that has none. + 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 diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index fe9ad7e06..62d252952 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -111,9 +111,9 @@ 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 is why a folder standing closed draws nothing. The - region opens no margin of its own: its recordings carry on from the row above them, so - they start where the region does and the seam stays as narrow as the list's own rules. + 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), diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index b616ebd73..295d2c448 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -159,7 +159,7 @@ def tag(self) -> str: @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 diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 0e8a57fe3..ceefa1abf 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -82,8 +82,8 @@ def create( ) -> 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, so a group reads apart from the - recordings standing loose around it without spending a pixel of the list's height. + 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: @@ -238,9 +238,9 @@ def _draggable(self, view_model: StemsListViewModel) -> bool: 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 and spends no padding around its - glyph, which stands a folder's row in the rhythm every other row keeps. Its own frame is - the room it was given, so what the pointer shades is the marker and nothing beside it. + 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 diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index 08e9cca33..a70eae672 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -26,7 +26,7 @@ def nothing(cls) -> Self: @classmethod def everything(cls) -> Self: - """What a reading the standing widgets cannot be brought to asks for.""" + """What a reading asks for once it moves more than a repaint settles.""" return cls(whole=True, folders=()) @classmethod diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index cffa2afbe..75990cbd8 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -139,8 +139,8 @@ def _keys_active(self) -> bool: """Whether the list owns the next key: its tab is in front and it holds a row picked out. A row picked out outlives a move to another tab, so the tab is read at the moment of the - press. A modal dialog claims keys above this scope in the router, so the list needs no - check of its own for one. + press. A modal dialog claims keys above this scope in the router, which is what holds the + list off while one stands open. """ return self._tab_active() and self._stems_list.picked_key is not None and not self._router.is_field_focused diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index 828a6808c..2a6471663 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -123,8 +123,8 @@ def _moves( ) -> 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 has no order to rearrange, so it offers none of them - and the row's own removal is the whole of what it can be told to do. + A run writing a reconstruction apiece keeps its recordings in one flat list, so its rows + offer removal alone. """ path = row.path if not banded: From 5f14ccfbd91cb700d200fd36566592cff88ae02b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:16:29 +0200 Subject: [PATCH 113/130] Tested: folder removal, the menu a right-click raises, the indents and the reserved strip --- tests/suite/gestures.py | 30 ++ .../sampletones_application/test_startup.py | 9 +- .../ui/elements/stems/test_columns.py | 120 +++++++- .../ui/elements/stems/test_folder.py | 122 ++++++-- .../ui/elements/stems/test_list.py | 51 ++-- .../ui/panels/dialogs/test_stem_selection.py | 6 +- .../ui/panels/main/test_converter.py | 270 +++++++++++++++++- 7 files changed, 546 insertions(+), 62 deletions(-) create mode 100644 tests/suite/gestures.py 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/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index ca71f127a..4a7b9c58a 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -64,6 +64,7 @@ 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 @@ -543,15 +544,9 @@ def _ctrl_click_folder(app: Application, directory: Path) -> None: panel._directory_node_clicked(node, UNBUILT_ROW) -DOUBLE_CLICKED_HANDLER = 1 - - 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.""" - tags = StemsTags(prefix=prefix) - handler = dpg.get_item_children(tags.handlers(SUF_TEXT), 1)[DOUBLE_CLICKED_HANDLER] - name_tag = tags.row(key, SUF_TEXT) - dpg.get_item_callback(handler)(name_tag, (dpg.mvMouseButton_Left, dpg.get_alias_id(name_tag))) + click_row_name(StemsTags(prefix=prefix), key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) class TestGatheringAFolderIntoAMix: diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py index d276a89b5..e4a104de6 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -1,4 +1,4 @@ -from typing import Iterator, Tuple +from typing import Any, Dict, Iterator, List, Tuple from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -9,12 +9,14 @@ 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.ui.elements.stems.columns import COLUMN_BORDER, 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] @@ -39,14 +41,20 @@ def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: dpg.destroy_context() -def columns(layout_config: LayoutConfig, *, folders: bool, master: bool = False) -> StemsColumns: +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=False, + bends=bends, folders=folders, ) @@ -100,19 +108,39 @@ class TestWhereABoxStands(BaseTestSuite): def test_a_channel_box_is_centered_in_its_column( self, - dpg_context: None, 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 - grid = columns(layout_config, folders=True) - indent = grid.box_indent(ChannelName.PULSE1) + indent = columns(layout_config, folders=True, bends=True).box_indent(ChannelName.NOISE) - assert indent == (grid.channel_width - stems.channel_box_width) // 2 + 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, - dpg_context: None, layout_config: LayoutConfig, ) -> None: """The master column is narrower than a channel's, so it takes an indent of its own.""" @@ -124,7 +152,6 @@ def test_the_box_beside_a_row_is_centered_in_its_own_column( def test_a_box_wider_than_its_column_opens_at_the_edge( self, - dpg_context: None, layout_config: LayoutConfig, ) -> None: grid = columns(layout_config, folders=True) @@ -148,9 +175,9 @@ class TestTheRoomAFolderSpends(BaseTestSuite): def test_a_grid_holding_folders_holds_the_room_clear( self, - dpg_context: None, layout_config: LayoutConfig, ) -> None: + """A region insets its body by the well's padding and holds a scrollbar's width beside it.""" stems = layout_config.general.stems reserve = columns(layout_config, folders=True).reserve @@ -159,18 +186,83 @@ def test_a_grid_holding_folders_holds_the_room_clear( def test_a_grid_holding_none_spends_nothing( self, - dpg_context: None, layout_config: LayoutConfig, ) -> None: assert columns(layout_config, folders=False).reserve == 0 def test_the_reserve_column_comes_out_the_width_of_the_room( self, - dpg_context: None, layout_config: LayoutConfig, ) -> None: """A column takes its own width plus the padding either side and the rule beside it.""" stems = layout_config.general.stems grid = columns(layout_config, folders=True) - assert grid.reserve_width == grid.reserve - 2 * stems.cell_padding - 1 + assert grid.reserve_width == grid.reserve - 2 * stems.cell_padding - COLUMN_BORDER + + +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_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index f91d11081..52cc64b7b 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -16,6 +16,7 @@ PALETTES_DIRECTORY, THEME_DIRECTORY, ) +from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON, SUF_GROUP, @@ -25,8 +26,10 @@ 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 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 @@ -40,14 +43,15 @@ ) from sampletones_core.constants.enums import ChannelName from tests.suite.base import BaseTestSuite +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) -DOUBLE_CLICK_HANDLER: Final[str] = "mvAppItemType::mvDoubleClickedHandler" 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 @@ -139,6 +143,16 @@ def view(*rows: StemRowViewModel) -> StemsListViewModel: ) +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 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)) @@ -278,7 +292,7 @@ class TestDoubleClick(BaseTestSuite): 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(name_of(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: @@ -287,7 +301,7 @@ def test_a_double_clicked_recording_is_reported(self, stems_list: GUIStemsList) stems_list.on_row_opened = opened.append stems_list.update_view(view(bass)) - double_click(name_of(bass)) + double_click(bass) assert opened == [bass.key] @@ -297,7 +311,7 @@ def test_a_double_clicked_folder_sounds_nothing(self, stems_list: GUIStemsList) stems_list.on_row_opened = opened.append stems_list.update_view(view(sources)) - double_click(name_of(sources)) + double_click(sources) assert opened == [] @@ -314,15 +328,9 @@ def test_a_folder_that_left_the_list_comes_back_closed(self, stems_list: GUIStem assert not dpg.does_item_exist(region_of(sources)) -def double_click(tag: str) -> None: - """Double-click a widget the way DearPyGui reports it, through the registry its kind shares.""" - registry = TAGS.handlers(SUF_TEXT) - for handler in dpg.get_item_children(registry, 1): - if dpg.get_item_info(handler)["type"] == DOUBLE_CLICK_HANDLER: - dpg.get_item_callback(handler)(handler, (dpg.mvMouseButton_Left, dpg.get_alias_id(tag))) - return - - raise AssertionError("the list registers no double-click handler") +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): @@ -388,7 +396,7 @@ def test_the_folder_reads_out_how_many_it_now_holds(self, stems_list: GUIStemsLi stems_list.update_view(view(folder_without(sources, sources.held[0]))) - assert "2" in str(dpg.get_item_label(name_of(sources))) + 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) @@ -396,7 +404,7 @@ def test_a_closed_folder_reads_out_how_many_it_now_holds(self, stems_list: GUISt stems_list.update_view(view(folder_without(sources, sources.held[0]))) - assert "2" in str(dpg.get_item_label(name_of(sources))) + assert dpg.get_item_label(name_of(sources)) == named("sources", holds=2) 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.""" @@ -472,10 +480,9 @@ def test_a_closed_folder_stands_in_the_grid_of_the_rows_around_it(self, stems_li assert table_of(sources) == table_of(bass) == table_of(lead) - def test_the_marker_stands_its_glyph_in_the_middle_of_its_room(self, stems_list: GUIStemsList) -> None: - """The marker's theme spends no padding around the glyph and centers it in the room it was - given, which is what holds a folder's row to the height of the rows around it and stands - the glyph where a recording listed loose opens its name.""" + 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)) @@ -518,6 +525,80 @@ def test_a_folder_closed_again_rejoins_the_run(self, stems_list: GUIStemsList) - 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, + layout_config: LayoutConfig, + ) -> 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 + + class TestTheBandAGroupReadsBy(BaseTestSuite): """A group takes a band of its own behind its row, which is what sets it apart from a recording. @@ -563,7 +644,8 @@ def test_the_grid_draws_row_backgrounds(self, stems_list: GUIStemsList) -> None: assert dpg.get_item_configuration(table_of(sources))["row_background"] is True - def test_the_grid_states_its_own_rows_clear(self, stems_list: GUIStemsList) -> None: + 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)) 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 b030bd21c..9e3b3d305 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -44,6 +44,7 @@ from sampletones_core.constants.enums import ChannelName from sampletones_shared.types.callback import Callback from tests.suite.base import BaseTestSuite +from tests.suite.gestures import CLICKED, DOUBLE_CLICKED, HOVERED, click_row_name, handler_of ROOT_TAG = "test_root" PREFIX = "test.stems" @@ -51,8 +52,6 @@ CHANNELS: Tuple[ChannelName, ...] = (ChannelName.PULSE1, ChannelName.TRIANGLE) DRAG_PAYLOAD_SLOT: Final[int] = 3 LONG_LIST: Final[int] = 200 -CLICK_HANDLER: Final[int] = 0 -DOUBLE_CLICK_HANDLER: Final[int] = 1 @pytest.fixture @@ -166,18 +165,12 @@ def channel_tag(entry: StemRowViewModel, channel_name: ChannelName) -> str: def hover_handler(suffix: str) -> Callback: """The hover callback a row widget of that kind shares, as DearPyGui would call it.""" - return dpg.get_item_callback(dpg.get_item_children(TAGS.handlers(suffix), 1)[-1]) + return dpg.get_item_callback(handler_of(TAGS.handlers(suffix), HOVERED)) -def name_handler(position: int) -> Callback: - """One of the mouse callbacks a row's name shares, as DearPyGui would call it.""" - return dpg.get_item_callback(dpg.get_item_children(TAGS.handlers(SUF_TEXT), 1)[position]) - - -def click_on(entry: StemRowViewModel, position: int, button: int) -> None: +def click_on(entry: StemRowViewModel, kind: str, button: int) -> None: """Land a mouse gesture on a row's name the way DearPyGui reports one.""" - name_tag = row_tag(entry, SUF_TEXT) - name_handler(position)(name_tag, (button, dpg.get_alias_id(name_tag))) + click_row_name(TAGS, entry.key, kind=kind, button=button) def select_name(entry: StemRowViewModel, value: bool) -> None: @@ -763,7 +756,7 @@ def test_a_right_click_picks_the_row_its_menu_stands_over( lead = row("lead") stems_list.update_view(view(bass, lead, selected_key=bass.key)) - click_on(lead, CLICK_HANDLER, dpg.mvMouseButton_Right) + click_on(lead, CLICKED, dpg.mvMouseButton_Right) assert activated == [lead.key] assert asked == [lead.key] @@ -875,7 +868,7 @@ def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( stems_list.update_view(view(bass)) with patch.object(stems_list, "call") as handed_on: - click_on(bass, CLICK_HANDLER, dpg.mvMouseButton_Right) + click_on(bass, CLICKED, dpg.mvMouseButton_Right) assert stems_list.has_menu is False handed_on.assert_not_called() @@ -891,7 +884,7 @@ def test_a_right_click_names_its_row_where_the_owner_puts_one_up( bass = row("bass") stems_list.update_view(view(bass)) - click_on(bass, CLICK_HANDLER, dpg.mvMouseButton_Right) + click_on(bass, CLICKED, dpg.mvMouseButton_Right) assert asked == [bass.key] @@ -905,7 +898,7 @@ def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( stems_list.update_view(view(bass)) with patch.object(stems_list, "call") as handed_on: - click_on(bass, DOUBLE_CLICK_HANDLER, dpg.mvMouseButton_Left) + click_on(bass, DOUBLE_CLICKED, dpg.mvMouseButton_Left) assert stems_list.playable is False handed_on.assert_not_called() @@ -921,11 +914,37 @@ def test_a_double_click_sounds_its_row_where_the_owner_answers( bass = row("bass") stems_list.update_view(view(bass)) - click_on(bass, DOUBLE_CLICK_HANDLER, dpg.mvMouseButton_Left) + 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) -> 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) -> 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.""" 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 index e5146cdba..e287ffa48 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_stem_selection.py @@ -23,12 +23,12 @@ 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) -DOUBLE_CLICK_HANDLER: Final[int] = 1 @pytest.fixture(name="window") @@ -124,9 +124,7 @@ def name_of(row: StemRowViewModel) -> str: def sound(row: StemRowViewModel) -> None: """Double-click one row's name the way DearPyGui reports the gesture.""" - handler = dpg.get_item_children(TAGS.handlers(SUF_TEXT), 1)[DOUBLE_CLICK_HANDLER] - name_tag = name_of(row) - dpg.get_item_callback(handler)(name_tag, (dpg.mvMouseButton_Left, dpg.get_alias_id(name_tag))) + click_row_name(TAGS, row.key, kind=DOUBLE_CLICKED, button=dpg.mvMouseButton_Left) def click_name(row: StemRowViewModel, value: bool) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 23ef30d08..56589b74f 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -4,6 +4,7 @@ import dearpygui.dearpygui as dpg import pytest +from sampletones_application.categories.elements.main import ConverterStemMoveElements from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.output import OutputKind from sampletones_application.constants.sources import SourceKind @@ -47,6 +48,7 @@ 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.gestures import CLICKED, click_row_name from tests.suite.shortcuts import shipped_source ROOT_TAG = "test_root" @@ -81,7 +83,14 @@ def dpg_context(layout_config: LayoutConfig) -> Iterator[None]: dpg.destroy_context() -def row(name: str) -> StemRowViewModel: +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), @@ -93,6 +102,26 @@ def row(name: str) -> StemRowViewModel: 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, @@ -396,6 +425,44 @@ def test_it_removes_the_recording_picked_out(self, dpg_context: None, layout_con 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() @@ -440,6 +507,207 @@ def test_the_input_line_names_the_recording_a_run_is_reading( 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.""" + + @staticmethod + def _registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, as a reader would meet them.""" + registered: List[Dict[str, Any]] = [] + monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) + monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) + return registered + + def test_a_right_click_raises_the_row_s_menu( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> 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"))) + registered = self._registered(monkeypatch) + + 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_right_click_picks_the_row_it_stands_over( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> 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)) + self._registered(monkeypatch) + + 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, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + registered = self._registered(monkeypatch) + + 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, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + sources = folder("sources", holds=3) + panel.update_view(view(sources)) + panel.stems_list.toggle_folder(sources.key) + registered = self._registered(monkeypatch) + + 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, + monkeypatch: pytest.MonkeyPatch, + ) -> 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)) + registered = self._registered(monkeypatch) + + 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: + """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[f"main.converter.label.{element.value}"] + + @classmethod + def _moves( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + monkeypatch: pytest.MonkeyPatch, + ) -> Dict[str, bool]: + """The moves the row's menu offers and whether each stands live, in the order it lists them.""" + registered: List[Dict[str, Any]] = [] + monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) + monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) + panel._show_menu(entry.key) + offered = {cls._label(element) for element in ConverterStemMoveElements} + return {item["label"]: bool(item.get("enabled", True)) for item in registered if item["label"] in offered} + + def test_a_run_writing_one_reconstruction_apiece_offers_removal_alone( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"))) + + moves = self._moves(panel, kick, monkeypatch) + + 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, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel, _reported = build(layout_config) + kick = row("kick") + panel.update_view(view(kick, row("snare"), output=OutputKind.MIXED)) + + moves = self._moves(panel, kick, monkeypatch) + + 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), + ] + + def test_each_move_stands_live_where_the_row_has_room_for_it( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A row alone on the first of two levels can go down and join the level below it.""" + panel, _reported = build(layout_config) + kick = row("kick", level=0, level_count=2) + snare = row("snare", level=1, level_count=2) + panel.update_view(view(kick, snare, output=OutputKind.MIXED)) + + moves = self._moves(panel, kick, monkeypatch) + + assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP)] is False + assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN)] is False + assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE)] is False + assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW)] is True + assert moves[self._label(ConverterStemMoveElements.CONTEXT_ISOLATE)] is False + + def test_a_row_standing_beside_another_can_go_up_and_be_set_apart( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A row second on a level of two can move earlier, join the level above, and stand alone.""" + panel, _reported = build(layout_config) + kick = row("kick", level=1, position=0, level_size=2, level_count=2) + snare = row("snare", level=1, position=1, level_size=2, level_count=2) + panel.update_view(view(row("hat", level=0, level_count=2), kick, snare, output=OutputKind.MIXED)) + + moves = self._moves(panel, snare, monkeypatch) + + assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP)] is True + assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN)] is False + assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE)] is True + assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW)] is False + assert moves[self._label(ConverterStemMoveElements.CONTEXT_ISOLATE)] is True + + class TestTheRemovalItemInTheMenu: """Taking a row out is one action, so the item and the key print and reach the same thing.""" From f605ac8d22c725cb012bb1f5162c0cb6b00f63f3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:23:05 +0200 Subject: [PATCH 114/130] Named: each case for what it checks, and read the keys from the scheme in place --- tests/suite/shortcuts.py | 14 +++++ .../ui/elements/layout/test_region.py | 35 +++++++++++ .../ui/elements/stems/test_shape.py | 61 ++++++++++++++++--- .../ui/panels/main/test_converter.py | 24 +++++++- 4 files changed, 123 insertions(+), 11 deletions(-) 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/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index bd7ddef87..f7dca4812 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -7,6 +7,7 @@ from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY from sampletones_application.ui.elements.layout.geometry import RowGeometry from sampletones_application.ui.elements.layout.region import NO_GUTTER, 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 @@ -22,6 +23,8 @@ STANDING_OFFSET = 300.0 PADDING = 8 GUTTER = 13 +MARGIN = 6 +NO_MARGIN = 0 @pytest.fixture @@ -422,3 +425,35 @@ def test_a_region_falling_back_under_its_ceiling_holds_it_again(self, gutted: Wi 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/test_shape.py b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py index aa993ad0a..cdb51f34e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_shape.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py @@ -144,7 +144,6 @@ class TestCase(BaseRegularTestCase): TestCase(label="nothing_draws_nothing", reshape=Reshape.nothing(), expected=False), TestCase(label="the_whole_list_draws", reshape=Reshape.everything(), expected=True), TestCase(label="one_folder_draws", reshape=Reshape.within(("drums",)), expected=True), - TestCase(label="no_folder_draws_nothing", reshape=Reshape.within(()), expected=False), ) @pytest.mark.parametrize( @@ -155,16 +154,60 @@ class TestCase(BaseRegularTestCase): def test_whether_widgets_were_built(self, test_case: TestCase) -> None: assert test_case.reshape.redraws is test_case.expected + def test_a_reshape_naming_no_folder_is_the_one_that_asks_for_nothing(self) -> None: + """Both stand for a reading the standing widgets already show, so they are one reshape.""" + assert Reshape.within(()) == Reshape.nothing() + class TestWhereARowStands(BaseTestSuite): - """A placement answers whether two readings put the same row in the same place.""" + """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. + """ - def test_a_row_holding_something_else_still_stands_where_it_did(self) -> None: - """What a folder holds is answered separately, so it plays no part in where the row is.""" - assert placed("drums", held=("one", "two")).stands_where(placed("drums", held=("one",))) + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + standing: RowPlacement + incoming: RowPlacement - def test_a_row_that_moved_band_stands_elsewhere(self) -> None: - assert not placed("drums", level=0).stands_where(placed("drums", level=1)) + 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, + ), + ) - def test_a_folder_that_opened_stands_elsewhere(self) -> None: - assert not placed("drums", opened=True).stands_where(placed("drums")) + @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/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 56589b74f..9baa7ab21 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -39,6 +39,7 @@ 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 ( @@ -49,13 +50,14 @@ from sampletones_core.constants.algorithm import DEFAULT_STEMS_HIERARCHY_MODE from sampletones_core.constants.enums import ChannelName from tests.suite.gestures import CLICKED, click_row_name -from tests.suite.shortcuts import shipped_source +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" @pytest.fixture @@ -161,6 +163,7 @@ def build( *, 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( @@ -171,7 +174,7 @@ def build( language_manager=LANGUAGE_MANAGER, status_bar=GUIStatusBar(), key_router=key_router if key_router is not None else KeyRouter(), - shortcut_source=shipped_source(), + shortcut_source=shipped_source() if shortcut_source is None else shortcut_source, tab_active=tab_active, ) reported: List[OutputKind] = [] @@ -748,6 +751,23 @@ def test_it_prints_the_key_that_does_the_same_thing( 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, + monkeypatch: pytest.MonkeyPatch, + ) -> 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, monkeypatch) + + 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, From 1cbef632315c5002c6c35c3704ac2502e566efab Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 19:23:47 +0200 Subject: [PATCH 115/130] Settled: a region once more after it takes a new height --- .../ui/elements/layout/region.py | 16 ++++--- .../ui/elements/layout/test_region.py | 44 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index b74806858..7875475d3 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -86,6 +86,7 @@ def __init__( 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 @@ -120,11 +121,13 @@ 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. 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. + 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) + return self.standing and (self.windowing or self._reading_to_hold or self._resized) @property def natural(self) -> bool: @@ -211,6 +214,7 @@ def settle(self) -> bool: if not self.standing: return False + self._resized = False self._take_lead() if not self._windowed: self._take_reading() @@ -323,7 +327,9 @@ def _stand_at_natural_height(self) -> None: def _size_to(self, content: float) -> None: within = content <= self._ceiling - self._height = content if within else float(self._ceiling) + height = content if within else float(self._ceiling) + self._resized = height != self._height + self._height = height self._natural = within self._reading_to_hold = False dpg_configure_item( diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index f7dca4812..b5182cabb 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -215,6 +215,50 @@ def test_a_region_with_nothing_to_read_asks_for_nothing(self, unmeasured: Window 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), + 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.""" From 589cf461b0ca06342590164397ada02a86f5df7d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 22:12:42 +0200 Subject: [PATCH 116/130] Tested: the reports a menu item makes and the frames a region settles over --- tests/suite/frames.py | 35 ++++++ .../ui/elements/layout/test_region.py | 101 +++++++++++++++++- .../ui/elements/stems/conftest.py | 12 +++ .../ui/elements/stems/test_folder.py | 76 ++++++++++++- .../ui/elements/stems/test_list.py | 93 +++++++++++++++- .../ui/panels/main/test_converter.py | 75 ++++++++++++- .../sampletones_shared/utils/test_hashing.py | 4 + 7 files changed, 386 insertions(+), 10 deletions(-) create mode 100644 tests/suite/frames.py create mode 100644 tests/unit/sampletones_application/ui/elements/stems/conftest.py diff --git a/tests/suite/frames.py b/tests/suite/frames.py new file mode 100644 index 000000000..4e41c835a --- /dev/null +++ b/tests/suite/frames.py @@ -0,0 +1,35 @@ +from typing import Final, List + +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[VoidCallback] = [] + + def hold(self, callback: VoidCallback, frame_count: int = ONE_FRAME) -> None: + """Take work a widget hands over, standing in for ``FrameCallbackManager``.""" + self._held.append(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): + held, self._held = self._held, [] + for callback in held: + callback() diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index b5182cabb..80f9aaea6 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -5,8 +5,15 @@ 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, LeadBuilder, WindowedRegion +from sampletones_application.ui.elements.layout.region import ( + NO_GUTTER, + 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 @@ -25,6 +32,11 @@ GUTTER = 13 MARGIN = 6 NO_MARGIN = 0 +LEAD_TAG = compose_tag(REGION_TAG, SUF_LEAD) +HEADING_HEIGHT = 50.0 +NO_HEADING = 0.0 +FITTING_ROWS = 4 +MEASURING_ROWS = 8 @pytest.fixture @@ -74,9 +86,17 @@ def build(start: int, count: int) -> None: return asked -def block_of(height: float) -> Any: - """Stands in for the rows a frame placed, which is what a reading of a row is taken from.""" - return patch.object(dpg, "get_item_rect_size", return_value=[0, height]) +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]: @@ -301,6 +321,44 @@ def test_a_region_drawn_again_holds_one_heading(self, region: WindowedRegion) -> 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.unmeasured(overscan=OVERSCAN) + 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 @@ -324,6 +382,30 @@ def test_it_carries_its_heading_too(self, region: WindowedRegion) -> None: 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, rows=0) + + 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, rows=0) + 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 @@ -385,6 +467,17 @@ def test_the_reader_is_put_back_once_the_rows_are_placed(self, region: WindowedR 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) 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_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 52cc64b7b..428355d2f 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Final, FrozenSet, Iterator, List, Tuple +from typing import Any, Callable, Final, FrozenSet, Iterator, List, Tuple from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -20,6 +20,7 @@ from sampletones_application.tags.general import ( SUF_BUTTON, SUF_GROUP, + SUF_LEAD, SUF_TEXT, SUF_TWISTY, TAG_GLOBAL_THEME_STEMS_GRID, @@ -43,6 +44,7 @@ ) 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" @@ -53,6 +55,11 @@ 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 +FIRST_HELD: Final[int] = 0 @pytest.fixture @@ -153,6 +160,20 @@ def measured(size: List[float]) -> object: 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)) @@ -651,3 +672,56 @@ def test_the_grid_carries_the_theme_that_states_its_own_rows_clear(self, stems_l 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 9e3b3d305..eea4505f5 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, FrozenSet, Iterator, List, Optional, Tuple +from typing import Any, Final, FrozenSet, Iterator, List, Optional, Tuple from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -21,6 +21,7 @@ SUF_BUTTON, SUF_CHECKBOX, SUF_HEADING, + SUF_LEAD, SUF_STRIP, SUF_TEXT, TAG_GLOBAL_THEME_CHANNEL_MUTED, @@ -44,6 +45,7 @@ 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" @@ -52,6 +54,12 @@ 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 @@ -155,6 +163,20 @@ def view( ) +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 TAGS.row(entry.key, suffix) @@ -1001,3 +1023,72 @@ def test_a_banded_list_builds_every_row(self, dpg_context: None, layout_config) 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_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/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 9baa7ab21..a17b84ec8 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -621,19 +621,30 @@ def _label(element: ConverterStemMoveElements) -> str: return LANGUAGE_MANAGER[f"main.converter.label.{element.value}"] @classmethod - def _moves( + def _offered( cls, panel: GUIConverterPanel, entry: StemRowViewModel, monkeypatch: pytest.MonkeyPatch, - ) -> Dict[str, bool]: - """The moves the row's menu offers and whether each stands live, in the order it lists them.""" + ) -> Dict[str, Dict[str, Any]]: + """The move items the row's menu registers, each under the label it prints.""" registered: List[Dict[str, Any]] = [] monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) panel._show_menu(entry.key) offered = {cls._label(element) for element in ConverterStemMoveElements} - return {item["label"]: bool(item.get("enabled", True)) for item in registered if item["label"] in offered} + return {item["label"]: item for item in registered if item["label"] in offered} + + @classmethod + def _moves( + cls, + panel: GUIConverterPanel, + entry: StemRowViewModel, + monkeypatch: pytest.MonkeyPatch, + ) -> 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, monkeypatch) + 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, @@ -710,6 +721,42 @@ def test_a_row_standing_beside_another_can_go_up_and_be_set_apart( assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW)] is False assert moves[self._label(ConverterStemMoveElements.CONTEXT_ISOLATE)] is True + def test_each_move_reports_the_direction_it_prints( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> 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, monkeypatch) + 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.""" @@ -737,6 +784,26 @@ def _removal( items = self._items(panel, entry, monkeypatch) return next(item for item in items if item["label"] == label) + def test_it_takes_the_recording_it_stands_over_and_nothing_else( + self, + dpg_context: None, + layout_config: LayoutConfig, + monkeypatch: pytest.MonkeyPatch, + ) -> 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, monkeypatch)["callback"]() + + assert removed == [kick.path] + assert folders == [] + def test_it_prints_the_key_that_does_the_same_thing( self, dpg_context: None, diff --git a/tests/unit/sampletones_shared/utils/test_hashing.py b/tests/unit/sampletones_shared/utils/test_hashing.py index afe4a5504..f9c9152c3 100644 --- a/tests/unit/sampletones_shared/utils/test_hashing.py +++ b/tests/unit/sampletones_shared/utils/test_hashing.py @@ -231,6 +231,10 @@ def test_the_order_the_parts_stand_in_settles_the_digest(self) -> None: 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 From 5d63e8933a72cc5a1738bbc5355a3b934bc236c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 22:21:05 +0200 Subject: [PATCH 117/130] Took: every height through the one place that notes the move --- .../ui/elements/layout/region.py | 15 +++++++--- .../ui/elements/layout/test_region.py | 29 +++++++++++++++++-- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index 7875475d3..ef5c047ba 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -320,16 +320,23 @@ def _room_for(self, total: int) -> float: 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._height = self._body_height() + 2 * self._margin + 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 _size_to(self, content: float) -> None: - within = content <= self._ceiling - height = content if within else float(self._ceiling) + 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( diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index 80f9aaea6..20340f39b 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -227,11 +227,36 @@ def test_it_comes_to_rest_once_the_height_follows_the_reading(self, unmeasured: assert not unmeasured.settling - def test_a_region_with_nothing_to_read_asks_for_nothing(self, unmeasured: WindowedRegion) -> None: - """A region drawn where no frame has placed its rows measures nothing, and waits.""" + 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 From 4e58a8a0142f4137bc1e3f0441e9de38a023b656 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 22:28:07 +0200 Subject: [PATCH 118/130] Answered: a gesture for the widgets still standing --- .../ui/elements/stems/gestures.py | 21 +++++++++-- .../ui/elements/stems/test_list.py | 37 +++++++++++++++++++ 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index d1a1d8afb..fd53d4f8d 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -121,10 +121,21 @@ def on_channel_box( return channels = frozenset( - offered for offered in self._view.boxes_of(row) if dpg.get_value(self._tags.channel(key, offered)) + 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) @@ -206,9 +217,13 @@ def _on_name_double_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> @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 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: + if mouse_button != button or not dpg.does_item_exist(clicked_item): return None key = dpg.get_item_user_data(clicked_item) 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 eea4505f5..1976bad15 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -585,6 +585,43 @@ 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) -> 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, + ) -> 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, From 0072d62f79989262a8ea8c805a9ed97d72850d87 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 22:39:36 +0200 Subject: [PATCH 119/130] Timed: a batch through the standard library's own clock --- tests/suite/timing.py | 54 ++++++++----------------------------------- 1 file changed, 10 insertions(+), 44 deletions(-) diff --git a/tests/suite/timing.py b/tests/suite/timing.py index 96235e6b6..e7854ea88 100644 --- a/tests/suite/timing.py +++ b/tests/suite/timing.py @@ -1,11 +1,8 @@ -import gc from time import process_time -from typing import Callable, Final, List, Tuple +from timeit import Timer +from typing import Callable, Final, List REPEATS: Final[int] = 3 -MINIMUM_READING: Final[float] = 0.25 -FIRST_BATCH: Final[int] = 1 -MOST_RUNS: Final[int] = 1 << 20 def seconds(work: Callable[[], object]) -> float: @@ -13,46 +10,15 @@ def seconds(work: Callable[[], object]) -> float: 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. The batch is therefore grown 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. + 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 the reading, since it runs on how much is live rather than on + 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, and the - collector is armed again once the reading comes back, on whatever the batches left behind. + more than ten times the cost. What is left is how the work itself follows its input. """ - collecting = gc.isenabled() - gc.disable() - try: - runs, reached = _runs_reaching(work) - readings: List[float] = [reached / runs] - readings.extend(_batch(work, runs) / runs for _ in range(REPEATS - 1)) - finally: - if collecting: - gc.enable() - + 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) - - -def _runs_reaching(work: Callable[[], object]) -> Tuple[int, float]: - """The batch that costs more than the clock's own step, doubling until it does. - - The batch that qualified is a reading like any other, so it comes back beside the run count - it took and stands as the first of the readings. - """ - runs = FIRST_BATCH - reading = _batch(work, runs) - while runs < MOST_RUNS and reading < MINIMUM_READING: - runs *= 2 - reading = _batch(work, runs) - - return runs, reading - - -def _batch(work: Callable[[], object], runs: int) -> float: - """What a run of the work this many times over costs, as the process counts its own time.""" - started = process_time() - for _ in range(runs): - work() - - return process_time() - started From f7e47241f08ae9ff28219880f61eda57578501ed Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 9 Sep 2026 22:58:42 +0200 Subject: [PATCH 120/130] Stated: the identity rule where tags are governed, and joined the strip to the room it holds --- docs/development/bugs-and-todos.md | 15 ++ docs/development/vocabularies.md | 12 ++ .../ui/elements/layout/well.py | 8 +- .../ui/panels/main/converter/menus.py | 4 +- tests/benchmarks/test_converter_load.py | 33 ---- .../tags/test_compose.py | 31 ++- .../ui/elements/layout/test_geometry.py | 10 + .../ui/elements/stems/test_columns.py | 23 +-- .../ui/elements/stems/test_folder.py | 31 ++- .../ui/elements/stems/test_list.py | 175 ++++++++++------- .../ui/panels/main/test_converter.py | 178 +++++++++--------- 11 files changed, 299 insertions(+), 221 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index bdb353114..8125ab0f6 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -207,6 +207,21 @@ again. releases moves inside the pending step, and a build writing the pending version writes the shape that step produces. +* The converter's card jumps for about two frames the first time a folder is opened in a list that + held one from the outset. `GUIStemsList._rebuild` returns early on exactly `_windows()` — + `collapse_levels and not holds_folders` — which is the same condition under which `_plain_rows` + answers with the row count, so `draw_whole` is always handed none and the shared `RowGeometry` + goes unmeasured until a folder's own region measures it. That first slice is taken at the + `MINIMUM_ROW_PITCH` floor, which reaches far more rows than the region shows, and the settle after + it re-slices at the reading. Either the whole-drawn path reports the rows of its plain segments, + or the geometry opens from `layout.name_height` rather than the floor. + +* 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. + * 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/vocabularies.md b/docs/development/vocabularies.md index 9814868d7..8b8f4690c 100644 --- a/docs/development/vocabularies.md +++ b/docs/development/vocabularies.md @@ -73,6 +73,18 @@ spaced, and a part already holding a composed tag contributes its own segments, 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 diff --git a/src/sampletones_application/ui/elements/layout/well.py b/src/sampletones_application/ui/elements/layout/well.py index 5ed231b20..30e9d97c4 100644 --- a/src/sampletones_application/ui/elements/layout/well.py +++ b/src/sampletones_application/ui/elements/layout/well.py @@ -18,15 +18,14 @@ def well( margin: int, width: int, indent: Optional[int] = None, - height: int = 0, 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 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 @@ -42,8 +41,7 @@ def well( tag=tag, parent=parent, width=-1, - height=height, - auto_resize_y=height == 0, + auto_resize_y=True, border=False, no_scrollbar=True, show=show, diff --git a/src/sampletones_application/ui/panels/main/converter/menus.py b/src/sampletones_application/ui/panels/main/converter/menus.py index 2a6471663..79b8e18ff 100644 --- a/src/sampletones_application/ui/panels/main/converter/menus.py +++ b/src/sampletones_application/ui/panels/main/converter/menus.py @@ -123,8 +123,8 @@ def _moves( ) -> 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 its rows - offer removal alone. + 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: diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index 3c32bfbf1..c3310a0b7 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -50,9 +50,6 @@ LARGE_FOLDER: Final[int] = 10_000 GROWTH_ALLOWANCE: Final[float] = 2.0 UNREADABLE: Final[float] = float("inf") -REGION_HEIGHT: Final[float] = 264.0 -ROW_PITCH: Final[float] = 36.0 -OVERSCAN: Final[int] = 4 SETTINGS: Final[StemSettings] = StemSettings(channels=[ChannelName.PULSE1], bends=[]) SMALL_ROOT: Final[Path] = Path("/gathered/small") LARGE_ROOT: Final[Path] = Path("/gathered/large") @@ -223,36 +220,6 @@ def test_settling_one_recording_inside_a_folder_costs_the_same(self) -> None: assert many < linear(one), report -class TestWhatTheListDraws(BaseTestSuite): - """What a region builds is what a reader can see, however long the list behind it is. - - This is the claim the folder rests on: opening ten thousand recordings costs what opening ten - costs, because the rows outside the window stand as reserved room rather than as widgets. - """ - - def test_the_window_holds_the_same_rows_however_long_the_list(self) -> None: - geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) - _, few = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=SMALL_FOLDER) - _, many = geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=LARGE_FOLDER) - - assert few == many - - def test_the_room_it_reserves_stands_for_the_whole_list(self) -> None: - geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) - - assert geometry.reserve(LARGE_FOLDER) == int(LARGE_FOLDER * ROW_PITCH) - - def test_the_end_of_a_long_list_is_reachable(self) -> None: - geometry = RowGeometry(overscan=OVERSCAN, pitch=ROW_PITCH) - start, count = geometry.slice_of( - offset=LARGE_FOLDER * ROW_PITCH, - height=REGION_HEIGHT, - total=LARGE_FOLDER, - ) - - assert start + count == LARGE_FOLDER - - @pytest.fixture def layout_config() -> LayoutConfig: source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) 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/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py index bd5e28d62..86af02877 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -110,6 +110,16 @@ def test_the_slice_follows_the_rooms_the_offset_counts_out(self, test_case: Test ) 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 diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py index e4a104de6..2f9c6dcdd 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -9,7 +9,7 @@ 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 COLUMN_BORDER, StemsColumns +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 @@ -173,33 +173,12 @@ 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_holds_the_room_clear( - self, - layout_config: LayoutConfig, - ) -> None: - """A region insets its body by the well's padding and holds a scrollbar's width beside it.""" - stems = layout_config.general.stems - - reserve = columns(layout_config, folders=True).reserve - - assert reserve == stems.well_padding + stems.scrollbar_width - def test_a_grid_holding_none_spends_nothing( self, layout_config: LayoutConfig, ) -> None: assert columns(layout_config, folders=False).reserve == 0 - def test_the_reserve_column_comes_out_the_width_of_the_room( - self, - layout_config: LayoutConfig, - ) -> None: - """A column takes its own width plus the padding either side and the rule beside it.""" - stems = layout_config.general.stems - grid = columns(layout_config, folders=True) - - assert grid.reserve_width == grid.reserve - 2 * stems.cell_padding - COLUMN_BORDER - class TestTheColumnsAGridDeclares(BaseTestSuite): """A grid declares its columns in one order at one set of widths, which is what stands every diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 428355d2f..e3200b639 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -30,7 +30,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.status import GUIStatusBar -from sampletones_application.ui.elements.stems.columns import StemsColumns +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 @@ -566,11 +566,7 @@ def _grid(layout_config: LayoutConfig) -> StemsColumns: 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, - layout_config: LayoutConfig, - ) -> None: + 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) @@ -619,6 +615,29 @@ def test_the_reserve_is_the_room_the_open_folder_s_region_takes( inset = -int(dpg.get_item_configuration(body)["width"]) assert inset == layout_config.general.stems.folder_reserve + 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. 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 1976bad15..3049ebace 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -31,6 +31,7 @@ ) 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 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 @@ -231,7 +232,7 @@ def folder_row( 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) -> None: + 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) @@ -240,7 +241,7 @@ def test_a_folder_names_itself_and_how_many_it_holds(self, dpg_context: None, la 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) -> None: + 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") @@ -251,7 +252,7 @@ def test_a_channel_every_recording_holds_reads_ticked(self, dpg_context: None, l def test_a_channel_they_differ_on_reads_clear_in_the_softer_tone( self, dpg_context: None, - layout_config, + 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) @@ -267,7 +268,7 @@ def test_a_channel_they_differ_on_reads_clear_in_the_softer_tone( 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) -> None: + 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()) @@ -275,7 +276,7 @@ def test_a_channel_none_of_them_holds_reads_clear(self, dpg_context: None, layou 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) -> None: + 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]] = [] @@ -289,7 +290,9 @@ def test_a_folders_box_reports_the_channel_it_settles(self, dpg_context: None, l class TestRows(BaseTestSuite): - def test_a_row_names_its_recording_and_offers_every_channel_in_play(self, dpg_context: None, layout_config) -> None: + 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") @@ -299,7 +302,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})) @@ -311,7 +314,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()) @@ -322,7 +325,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") @@ -330,7 +333,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)) @@ -340,7 +343,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") @@ -360,7 +363,7 @@ class TestRowsNamedAlike(BaseTestSuite): def test_two_paths_differing_in_case_carry_their_own_names( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config) lower, upper = row("kick"), row("Kick") @@ -374,7 +377,7 @@ def test_two_paths_differing_in_case_carry_their_own_names( def test_a_channel_ticked_on_one_leaves_the_other_alone( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config) spaced, scored = row("my song"), row("my_song") @@ -393,7 +396,7 @@ def test_a_channel_ticked_on_one_leaves_the_other_alone( def test_the_reading_reported_names_the_row_it_was_ticked_on( self, dpg_context: None, - layout_config, + 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]]] = [] @@ -410,7 +413,7 @@ def test_the_reading_reported_names_the_row_it_was_ticked_on( class TestLevels(BaseTestSuite): - def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config) -> None: + 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), @@ -442,7 +445,7 @@ 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, dragging=True) bass = row("bass") @@ -454,7 +457,7 @@ 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, dragging=False) bass = row("bass") @@ -464,7 +467,7 @@ def test_a_list_without_dragging_carries_no_payload_and_no_strip( assert not dpg.get_item_children(row_tag(bass, SUF_TEXT), DRAG_PAYLOAD_SLOT) 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: + 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") @@ -472,7 +475,7 @@ def test_a_removable_list_gives_each_row_a_button(self, dpg_context: None, layou 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: + 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") @@ -485,7 +488,7 @@ 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, keeps_last_row=True) bass = row("bass") @@ -494,7 +497,9 @@ def test_a_list_holding_on_to_its_last_row_offers_no_way_to_remove_it( 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: + 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") @@ -503,7 +508,7 @@ 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: + 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") @@ -513,7 +518,9 @@ 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: + 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") @@ -523,7 +530,9 @@ def test_a_list_that_keeps_no_row_lets_the_last_one_go(self, dpg_context: None, class TestGestures(BaseTestSuite): - def test_unticking_a_channel_reports_the_row_and_what_it_keeps(self, dpg_context: None, layout_config) -> None: + 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)) @@ -536,7 +545,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 @@ -550,7 +559,9 @@ def test_the_remove_button_reports_its_row(self, dpg_context: None, layout_confi class TestBusyState(BaseTestSuite): - def test_a_list_that_is_not_live_disables_every_control_it_drew(self, dpg_context: None, layout_config) -> None: + 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") @@ -561,7 +572,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)) @@ -575,7 +586,7 @@ def test_a_live_list_answers_again(self, dpg_context: None, layout_config) -> No 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)) @@ -585,7 +596,7 @@ 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) -> None: + 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") @@ -604,7 +615,7 @@ def test_a_click_naming_a_row_that_went_is_let_be(self, dpg_context: None, layou def test_a_channel_settled_while_a_box_is_gone_keeps_what_the_row_held( self, dpg_context: None, - layout_config, + 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) @@ -625,7 +636,7 @@ def test_a_channel_settled_while_a_box_is_gone_keeps_what_the_row_held( 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) @@ -639,7 +650,9 @@ def test_unticking_the_last_channel_keeps_the_widget_the_pointer_is_over( class TestOfferedChannels(BaseTestSuite): - def test_a_row_draws_a_box_only_on_the_channels_it_offers(self, dpg_context: None, layout_config) -> None: + 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})) @@ -648,7 +661,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) @@ -656,7 +669,7 @@ 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_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})))) @@ -667,7 +680,29 @@ def test_a_row_gaining_a_box_is_drawn_again(self, dpg_context: None, layout_conf class TestMasterCheckbox(BaseTestSuite): - def test_a_master_box_reads_whether_the_row_holds_a_channel(self, dpg_context: None, layout_config) -> None: + 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()) @@ -680,7 +715,7 @@ 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_box=True) @@ -693,7 +728,9 @@ 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_box=True) stems_list.on_channels_changed = lambda key, channels: reported.append((key, channels)) @@ -708,7 +745,7 @@ 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_box=True) silent = row("pad", channels=frozenset(), offered_channels=frozenset()) @@ -717,7 +754,7 @@ def test_a_row_offering_no_channel_has_nothing_for_its_master_box_to_do( 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") @@ -727,7 +764,7 @@ def test_a_list_without_a_master_box_draws_none(self, dpg_context: None, layout_ class TestMutedChannels(BaseTestSuite): - def test_a_muted_channel_takes_the_muted_tone(self, dpg_context: None, layout_config) -> None: + 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") @@ -737,7 +774,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") @@ -747,7 +786,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}))) @@ -759,7 +800,7 @@ def test_a_channel_switched_back_on_takes_its_own_color_again(self, dpg_context: class TestCollapsedLevels(BaseTestSuite): - def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout_config) -> None: + 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), @@ -773,7 +814,7 @@ def test_collapsing_draws_every_row_in_one_table(self, dpg_context: None, layout 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: + 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), @@ -789,7 +830,7 @@ def test_expanding_brings_the_captions_back(self, dpg_context: None, layout_conf class TestActivation(BaseTestSuite): - def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> None: + def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config: LayoutConfig) -> None: activated: List[str] = [] stems_list = build(layout_config, dragging=False) stems_list.on_row_activated = activated.append @@ -803,7 +844,7 @@ def test_a_clicked_row_reports_itself(self, dpg_context: None, layout_config) -> def test_a_right_click_picks_the_row_its_menu_stands_over( self, dpg_context: None, - layout_config, + 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] = [] @@ -823,7 +864,7 @@ def test_a_right_click_picks_the_row_its_menu_stands_over( def test_a_row_no_reading_picks_out_reads_plain( self, dpg_context: None, - layout_config, + 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.""" @@ -839,7 +880,7 @@ def test_a_row_no_reading_picks_out_reads_plain( def test_the_second_click_of_a_double_click_names_the_same_row( self, dpg_context: None, - layout_config, + 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.""" @@ -854,7 +895,7 @@ def test_the_second_click_of_a_double_click_names_the_same_row( assert activated == [bass.key, bass.key] - def test_the_list_names_the_row_a_key_press_acts_on(self, dpg_context: None, layout_config) -> None: + 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") @@ -863,14 +904,16 @@ def test_the_list_names_the_row_a_key_press_acts_on(self, dpg_context: None, lay assert stems_list.picked_key == lead.key - def test_a_list_holding_nothing_picked_out_names_no_row(self, dpg_context: None, layout_config) -> None: + 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) -> 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") @@ -893,7 +936,7 @@ class TestGesturesTheOwnerLeavesUnanswered(BaseTestSuite): def test_a_click_leaves_the_row_reading_as_the_view_holds_it( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config, dragging=False) bass = row("bass") @@ -906,7 +949,7 @@ def test_a_click_leaves_the_row_reading_as_the_view_holds_it( def test_the_row_the_view_holds_picked_out_keeps_reading_that_way( self, dpg_context: None, - layout_config, + 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) @@ -920,7 +963,7 @@ def test_the_row_the_view_holds_picked_out_keeps_reading_that_way( def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config, dragging=False) bass = row("bass") @@ -935,7 +978,7 @@ def test_a_right_click_is_let_be_where_the_owner_puts_no_menu_up( def test_a_right_click_names_its_row_where_the_owner_puts_one_up( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: asked: List[str] = [] stems_list = build(layout_config, dragging=False) @@ -950,7 +993,7 @@ def test_a_right_click_names_its_row_where_the_owner_puts_one_up( def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: stems_list = build(layout_config, dragging=False) bass = row("bass") @@ -965,7 +1008,7 @@ def test_a_double_click_is_let_be_where_the_owner_sounds_nothing( def test_a_double_click_sounds_its_row_where_the_owner_answers( self, dpg_context: None, - layout_config, + layout_config: LayoutConfig, ) -> None: opened: List[str] = [] stems_list = build(layout_config, dragging=False) @@ -981,7 +1024,7 @@ def test_a_double_click_sounds_its_row_where_the_owner_answers( 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) -> None: + 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 @@ -992,7 +1035,7 @@ def test_a_left_click_puts_no_menu_up(self, dpg_context: None, layout_config) -> assert asked == [] - def test_a_right_double_click_sounds_nothing(self, dpg_context: None, layout_config) -> None: + 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 @@ -1007,14 +1050,14 @@ def test_a_right_double_click_sounds_nothing(self, dpg_context: None, layout_con 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) -> None: + 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) -> None: + 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"))) @@ -1026,7 +1069,9 @@ 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) -> None: + 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)) @@ -1035,7 +1080,7 @@ def test_a_long_run_of_recordings_builds_the_rows_it_shows(self, dpg_context: No built = sum(1 for entry in rows if dpg.does_item_exist(row_tag(entry, SUF_TEXT))) assert 0 < built < LONG_LIST - def test_a_short_run_of_recordings_builds_them_all(self, dpg_context: None, layout_config) -> None: + 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")) @@ -1043,7 +1088,7 @@ def test_a_short_run_of_recordings_builds_them_all(self, dpg_context: None, layo 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) -> None: + 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))) @@ -1052,7 +1097,7 @@ def test_a_list_holding_a_folder_builds_every_row(self, dpg_context: None, layou 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) -> None: + 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)) diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index a17b84ec8..9c0fe5f02 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, Iterator, List, Optional, Tuple @@ -49,6 +50,8 @@ 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 @@ -186,6 +189,20 @@ def build( return panel, reported +@pytest.fixture +def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: + """The items a menu registers, as a reader would meet them.""" + 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: 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"]) @@ -513,25 +530,16 @@ def test_the_input_line_names_the_recording_a_run_is_reading( class TestTheMenuARightClickPutsUp: """A right-click on a row raises the menu the card draws for it, over the row it landed on.""" - @staticmethod - def _registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: - """The items a menu registers, as a reader would meet them.""" - registered: List[Dict[str, Any]] = [] - monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) - monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) - return registered - def test_a_right_click_raises_the_row_s_menu( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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"))) - registered = self._registered(monkeypatch) click_row_name(panel.stems_list.tags, kick.key, kind=CLICKED, button=dpg.mvMouseButton_Right) @@ -541,7 +549,7 @@ def test_a_right_click_picks_the_row_it_stands_over( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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) @@ -549,7 +557,6 @@ def test_a_right_click_picks_the_row_it_stands_over( 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)) - self._registered(monkeypatch) click_row_name(panel.stems_list.tags, snare.key, kind=CLICKED, button=dpg.mvMouseButton_Right) @@ -559,12 +566,11 @@ def test_a_folder_offers_what_reaches_everything_below_it( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + registered: List[Dict[str, Any]], ) -> None: panel, _reported = build(layout_config) sources = folder("sources", holds=3) panel.update_view(view(sources)) - registered = self._registered(monkeypatch) click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) @@ -577,13 +583,12 @@ def test_an_open_folder_offers_to_close_again( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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) - registered = self._registered(monkeypatch) click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) @@ -593,14 +598,13 @@ def test_a_folder_removed_from_its_menu_takes_everything_it_holds( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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)) - registered = self._registered(monkeypatch) click_row_name(panel.stems_list.tags, sources.key, kind=CLICKED, button=dpg.mvMouseButton_Right) removal = next( @@ -613,7 +617,7 @@ def test_a_folder_removed_from_its_menu_takes_everything_it_holds( assert folders == [sources.path] -class TestTheMovesAMixOffers: +class TestTheMovesAMixOffers(BaseTestSuite): """A run mixing its recordings orders them, so a row's menu offers the moves that reorder it.""" @staticmethod @@ -625,13 +629,10 @@ def _offered( cls, panel: GUIConverterPanel, entry: StemRowViewModel, - monkeypatch: pytest.MonkeyPatch, + registered: List[Dict[str, Any]], ) -> Dict[str, Dict[str, Any]]: """The move items the row's menu registers, each under the label it prints.""" - registered: List[Dict[str, Any]] = [] - monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) - monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) - panel._show_menu(entry.key) + 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} @@ -640,23 +641,23 @@ def _moves( cls, panel: GUIConverterPanel, entry: StemRowViewModel, - monkeypatch: pytest.MonkeyPatch, + 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, monkeypatch) + 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, - monkeypatch: pytest.MonkeyPatch, + 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, monkeypatch) + moves = self._moves(panel, kick, registered) assert list(moves) == [self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM)] @@ -664,13 +665,13 @@ def test_a_mix_offers_every_move_a_row_can_make( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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, monkeypatch) + moves = self._moves(panel, kick, registered) assert list(moves) == [ self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP), @@ -681,51 +682,66 @@ def test_a_mix_offers_every_move_a_row_can_make( self._label(ConverterStemMoveElements.CONTEXT_REMOVE_STEM), ] - def test_each_move_stands_live_where_the_row_has_room_for_it( - self, - dpg_context: None, - layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, - ) -> None: - """A row alone on the first of two levels can go down and join the level below it.""" - panel, _reported = build(layout_config) - kick = row("kick", level=0, level_count=2) - snare = row("snare", level=1, level_count=2) - panel.update_view(view(kick, snare, output=OutputKind.MIXED)) - - moves = self._moves(panel, kick, monkeypatch) - - assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP)] is False - assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN)] is False - assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE)] is False - assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW)] is True - assert moves[self._label(ConverterStemMoveElements.CONTEXT_ISOLATE)] is False + @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, + }, + ), + ) - def test_a_row_standing_beside_another_can_go_up_and_be_set_apart( + @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, - monkeypatch: pytest.MonkeyPatch, + registered: List[Dict[str, Any]], ) -> None: - """A row second on a level of two can move earlier, join the level above, and stand alone.""" panel, _reported = build(layout_config) - kick = row("kick", level=1, position=0, level_size=2, level_count=2) - snare = row("snare", level=1, position=1, level_size=2, level_count=2) - panel.update_view(view(row("hat", level=0, level_count=2), kick, snare, output=OutputKind.MIXED)) + panel.update_view(view(*test_case.rows, output=OutputKind.MIXED)) - moves = self._moves(panel, snare, monkeypatch) + moves = self._moves(panel, test_case.rows[test_case.subject], registered) - assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_UP)] is True - assert moves[self._label(ConverterStemMoveElements.CONTEXT_MOVE_DOWN)] is False - assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_ABOVE)] is True - assert moves[self._label(ConverterStemMoveElements.CONTEXT_JOIN_BELOW)] is False - assert moves[self._label(ConverterStemMoveElements.CONTEXT_ISOLATE)] is True + 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, - monkeypatch: pytest.MonkeyPatch, + registered: List[Dict[str, Any]], ) -> None: """An offset is added to the row's place, so moving earlier reports -1 and later reports 1. @@ -743,7 +759,7 @@ def test_each_move_reports_the_direction_it_prints( 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, monkeypatch) + offered = self._offered(panel, snare, registered) for element in ( ConverterStemMoveElements.CONTEXT_MOVE_UP, ConverterStemMoveElements.CONTEXT_MOVE_DOWN, @@ -762,33 +778,21 @@ class TestTheRemovalItemInTheMenu: """Taking a row out is one action, so the item and the key print and reach the same thing.""" @staticmethod - def _items( - panel: GUIConverterPanel, - entry: StemRowViewModel, - monkeypatch: pytest.MonkeyPatch, - ) -> List[Dict[str, Any]]: - """The items the row's context menu registers, as a reader would meet them.""" - registered: List[Dict[str, Any]] = [] - monkeypatch.setattr(menus_module.dpg, "add_menu_item", lambda **kwargs: registered.append(kwargs) or 0) - monkeypatch.setattr(menus_module.dpg, "add_separator", lambda **_kwargs: 0) - panel._show_menu(entry.key) - return registered - def _removal( - self, panel: GUIConverterPanel, entry: StemRowViewModel, - monkeypatch: pytest.MonkeyPatch, + 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"] - items = self._items(panel, entry, monkeypatch) - return next(item for item in items if item["label"] == label) + 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, - monkeypatch: pytest.MonkeyPatch, + 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) @@ -799,7 +803,7 @@ def test_it_takes_the_recording_it_stands_over_and_nothing_else( kick = row("kick") panel.update_view(view(kick, row("snare"))) - self._removal(panel, kick, monkeypatch)["callback"]() + self._removal(panel, kick, registered)["callback"]() assert removed == [kick.path] assert folders == [] @@ -808,13 +812,13 @@ def test_it_prints_the_key_that_does_the_same_thing( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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, monkeypatch) + removal = self._removal(panel, kick, registered) assert removal["shortcut"] == shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) @@ -822,7 +826,7 @@ def test_it_prints_whatever_the_scheme_in_place_gives_the_action( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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) @@ -830,7 +834,7 @@ def test_it_prints_whatever_the_scheme_in_place_gives_the_action( kick = row("kick") panel.update_view(view(kick, row("snare"))) - removal = self._removal(panel, kick, monkeypatch) + removal = self._removal(panel, kick, registered) assert removal["shortcut"] == REBOUND_REMOVAL assert removal["shortcut"] != shipped_source().display(ShortcutId.SOURCES_REMOVE_SOURCE) @@ -839,12 +843,12 @@ def test_it_stands_inert_while_a_run_holds_the_list( self, dpg_context: None, layout_config: LayoutConfig, - monkeypatch: pytest.MonkeyPatch, + 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, monkeypatch) + removal = self._removal(panel, kick, registered) assert removal["enabled"] is False From d5aae92a3f3d897ee353765d3f665d037445fb2a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 00:10:14 +0200 Subject: [PATCH 121/130] Corrected: the page that promised a scan the walk gives up --- docs/guide/converting.md | 2 +- .../ui/elements/stems/list.py | 10 ++++-- .../ui/elements/stems/row.py | 4 +++ .../ui/elements/stems/test_folder.py | 31 +++++++++++++++++-- 4 files changed, 41 insertions(+), 6 deletions(-) diff --git a/docs/guide/converting.md b/docs/guide/converting.md index 12dec296a..c0de1bcad 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -15,7 +15,7 @@ The **Converter** card lists the recordings a conversion uses. Add them from the 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 keeps what it has found so far. A folder with no recordings inside it says so and adds nothing. +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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 295d2c448..ae0d003ff 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -273,8 +273,14 @@ def row(self, key: str) -> Optional[StemRowViewModel]: @property def picked_key(self) -> Optional[str]: - """The row standing picked out, which is what a key press acts on.""" - return self._view.selected_key + """The row standing picked out, which is what a key press acts on. + + A selection outlives the widgets it was made on: closing a folder takes the recordings + inside it off the list while the reading still names one of them. The pick answers for + the rows drawn, so a key reaches the row the reader is looking at. + """ + key = self._view.selected_key + return key if key is not None and self._rows.stands(key) else None @property def lets_a_row_go(self) -> bool: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index ceefa1abf..152336ede 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -74,6 +74,10 @@ def __init__( self._lbl_remove = language_manager["global.stems.label.remove"] self._folder_template = language_manager["global.stems.template.folder_row"] + def stands(self, key: str) -> bool: + """Whether the row is drawn, which is what a gesture or a key press can reach it by.""" + return bool(dpg.does_item_exist(self._tags.row(key, SUF_TEXT))) + def create( self, row: StemRowViewModel, diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index e3200b639..2d4c8d27e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Final, FrozenSet, Iterator, List, Tuple +from typing import Any, Callable, Final, FrozenSet, Iterator, List, Optional, Tuple from unittest.mock import patch import dearpygui.dearpygui as dpg @@ -137,7 +137,7 @@ def folder(name: str, *, holds: int) -> StemRowViewModel: ) -def view(*rows: StemRowViewModel) -> StemsListViewModel: +def view(*rows: StemRowViewModel, selected_key: Optional[str] = None) -> StemsListViewModel: return StemsListViewModel( rows=rows, channels_in_play=CHANNELS, @@ -146,7 +146,7 @@ def view(*rows: StemRowViewModel) -> StemsListViewModel: picking_room=None, live=True, collapse_levels=True, - selected_key=None, + selected_key=selected_key, ) @@ -307,6 +307,31 @@ def test_its_box_reports_the_recording_it_belongs_to(self, stems_list: GUIStemsL assert settled == [(held.key, frozenset({ChannelName.TRIANGLE}))] +class TestThePickInsideAFolder(BaseTestSuite): + """A key press acts on the row picked out, which a folder standing open is where one is drawn.""" + + 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_leaves_no_row_for_a_key_to_act_on(self, stems_list: GUIStemsList) -> None: + """The reading still names the recording, and the list has taken its widgets away with the + folder, so a key press has nothing on screen to act on.""" + sources = folder("sources", holds=2) + held = sources.held[FIRST_HELD] + + stems_list.update_view(view(sources, selected_key=held.key)) + press(twisty_of(sources)) + press(twisty_of(sources)) + + assert stems_list.picked_key is None + + class TestDoubleClick(BaseTestSuite): """A double-click opens what it landed on: a folder shows what it holds, a recording sounds.""" From 899933abc6ed51ac48e87860cea183d03d472b65 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 00:30:14 +0200 Subject: [PATCH 122/130] Answered: one question per rule across the stems list --- .../ui/elements/stems/bands.py | 7 ++----- .../ui/elements/stems/columns.py | 11 ++++++++++- .../ui/elements/stems/gestures.py | 15 ++++++--------- .../ui/elements/stems/host.py | 19 +++++++++++++++++++ .../ui/elements/stems/list.py | 7 ++----- .../ui/elements/stems/messages.py | 13 ++++++------- .../ui/elements/stems/row.py | 2 +- .../ui/elements/stems/shape.py | 11 +++++------ .../ui/elements/stems/test_list.py | 16 ++++++++++++++++ .../ui/elements/stems/test_shape.py | 6 +----- 10 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 src/sampletones_application/ui/elements/stems/host.py diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 34be58bc6..24142dafb 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -70,13 +70,10 @@ def __init__( 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 either way, so a list that has answered a reshape once answers the - same view with a repaint from then on. + 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) - if shape == self._shape: - return Reshape.nothing() - asked = shape.against(self._shape) self._shape = shape return asked diff --git a/src/sampletones_application/ui/elements/stems/columns.py b/src/sampletones_application/ui/elements/stems/columns.py index 8bd9b96e3..b892a8ca1 100644 --- a/src/sampletones_application/ui/elements/stems/columns.py +++ b/src/sampletones_application/ui/elements/stems/columns.py @@ -68,6 +68,15 @@ def reserve_width(self) -> int: """ 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: @@ -80,7 +89,7 @@ def declare(self) -> None: if self.removable: dpg.add_table_column(width_fixed=True, init_width_or_weight=self.layout.remove_button_width) - if self.reserve_width > 0: + if self.reserved: dpg.add_table_column(width_fixed=True, init_width_or_weight=self.reserve_width) def slots(self, channel_name: ChannelName) -> int: diff --git a/src/sampletones_application/ui/elements/stems/gestures.py b/src/sampletones_application/ui/elements/stems/gestures.py index fd53d4f8d..4b1178c1f 100644 --- a/src/sampletones_application/ui/elements/stems/gestures.py +++ b/src/sampletones_application/ui/elements/stems/gestures.py @@ -10,6 +10,7 @@ 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 @@ -46,16 +47,12 @@ def __init__( *, messages: StemsMessages, status_bar: GUIStatusBar, - activatable: Callable[[], bool], - playable: Callable[[], bool], - has_menu: Callable[[], bool], + host: StemsListHost, ) -> None: self._tags = tags self._messages = messages self._status_bar = status_bar - self._activatable = activatable - self._playable = playable - self._has_menu = has_menu + self._host = host self._view = StemsListViewModel.empty() self.on_channels_settled: Optional[ChannelsCallback] = None @@ -182,7 +179,7 @@ def _on_name_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> None: 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._has_menu(): + if not self._host.has_menu: return key = self._named_by(app_data, dpg.mvMouseButton_Right) @@ -198,7 +195,7 @@ def _pick(self, key: str) -> None: 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._activatable(): + 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: @@ -212,7 +209,7 @@ def _on_name_double_clicked(self, _sender: Sender, app_data: Tuple[int, int]) -> self._report(self.on_folder_toggled, key) return - if self._playable(): + if self._host.playable: self._report(self.on_row_opened, key) @staticmethod 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 ae0d003ff..268eeae24 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -80,16 +80,13 @@ def __init__( language_manager, offer=offer, open_folders=self._open_folders, - activatable=lambda: self.activatable, - playable=lambda: self.playable, + host=self, ) self._gestures = StemsGestures( self._tags, messages=self._messages, status_bar=status_bar, - activatable=lambda: self.activatable, - playable=lambda: self.playable, - has_menu=lambda: self.has_menu, + host=self, ) self._rows = StemRowRenderer( self._tags, diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py index d67b13c6c..6b4202abd 100644 --- a/src/sampletones_application/ui/elements/stems/messages.py +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -1,8 +1,9 @@ -from typing import Any, Callable, Optional, Tuple +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, @@ -25,14 +26,12 @@ def __init__( *, offer: StemsListOffer, open_folders: OpenFolders, - activatable: Callable[[], bool], - playable: Callable[[], bool], + host: StemsListHost, ) -> None: self._language_manager = language_manager self._offer = offer self._open_folders = open_folders - self._activatable = activatable - self._playable = playable + 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"] @@ -85,10 +84,10 @@ def _row_gestures(self, row: StemRowViewModel) -> Tuple[str, ...]: lines: Tuple[str, ...] = () if self._offer.dragging: lines += (self._language_manager["global.stems.message.status_row_drag"].format(name=row.name),) - elif self._activatable(): + elif self._host.activatable: lines += (self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name),) - if self._playable(): + if self._host.playable: lines += (self._language_manager["global.stems.message.status_row_play"].format(name=row.name),) return lines or (row.name,) diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 152336ede..ef45e60d9 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -103,7 +103,7 @@ def create( if self._offer.removal: self._create_remove(row) - if columns.reserve_width > 0: + if columns.reserved: dpg.add_spacer() def repaint( diff --git a/src/sampletones_application/ui/elements/stems/shape.py b/src/sampletones_application/ui/elements/stems/shape.py index a70eae672..1f820128b 100644 --- a/src/sampletones_application/ui/elements/stems/shape.py +++ b/src/sampletones_application/ui/elements/stems/shape.py @@ -19,11 +19,6 @@ class Reshape: whole: bool folders: Tuple[str, ...] - @classmethod - def nothing(cls) -> Self: - """What a reading the list already stands at asks for, which a repaint answers on its own.""" - return cls(whole=False, folders=()) - @classmethod def everything(cls) -> Self: """What a reading asks for once it moves more than a repaint settles.""" @@ -31,7 +26,11 @@ def everything(cls) -> Self: @classmethod def within(cls, folders: Tuple[str, ...]) -> Self: - """What a reading that moved the recordings of these folders alone asks for.""" + """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 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 3049ebace..8f3ad530e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -352,6 +352,22 @@ 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. diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_shape.py b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py index cdb51f34e..0fde51fad 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_shape.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_shape.py @@ -141,7 +141,7 @@ class TestCase(BaseRegularTestCase): reshape: Reshape test_cases = ( - TestCase(label="nothing_draws_nothing", reshape=Reshape.nothing(), expected=False), + 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), ) @@ -154,10 +154,6 @@ class TestCase(BaseRegularTestCase): def test_whether_widgets_were_built(self, test_case: TestCase) -> None: assert test_case.reshape.redraws is test_case.expected - def test_a_reshape_naming_no_folder_is_the_one_that_asks_for_nothing(self) -> None: - """Both stand for a reading the standing widgets already show, so they are one reshape.""" - assert Reshape.within(()) == Reshape.nothing() - class TestWhereARowStands(BaseTestSuite): """A placement answers whether two readings put the same row in the same place. From cf76da32cfe6c8172e7164c36730c615b293b8ac Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 00:44:28 +0200 Subject: [PATCH 123/130] Tested: what the range changed in passing --- .../sampletones_application/test_startup.py | 39 ++++++++++++++ .../ui/elements/stems/test_columns.py | 52 +++++++++++++++++++ .../ui/elements/stems/test_list.py | 20 +++++++ .../ui/panels/dialogs/test_scanning.py | 14 ++++- .../ui/panels/main/test_converter.py | 46 +++++++++++++++- 5 files changed, 168 insertions(+), 3 deletions(-) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 4a7b9c58a..b7d26e5b6 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -467,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. diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py index 2f9c6dcdd..6a2ff4a66 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_columns.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_columns.py @@ -20,6 +20,9 @@ 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) @@ -103,6 +106,48 @@ def test_a_glyph_no_frame_has_measured_yet_opens_at_the_edge( 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.""" @@ -173,6 +218,13 @@ 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, 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 8f3ad530e..24ef7b984 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1081,6 +1081,26 @@ def test_a_banded_list_names_them_too(self, dpg_context: None, layout_config: La assert dpg.does_item_exist(compose_tag(PREFIX, SUF_HEADING, ChannelName.PULSE1, SUF_TEXT)) +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_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.""" diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py index 06bbe2b2b..9136e95f2 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_scanning.py @@ -8,7 +8,7 @@ from sampletones_application.layout.config import LayoutConfig from sampletones_application.paths import LANG_EN from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.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, @@ -57,6 +57,18 @@ def test_it_counts_what_the_walk_has_met(self, window: GUIScanWindow) -> None: 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. diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 9c0fe5f02..28e17cb9c 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -61,6 +61,7 @@ STATUS_TEXT = "No tasks in progress." RECORDING = Path("/audio/kick.wav") REBOUND_REMOVAL = "Ctrl+Shift+K" +SEPARATOR = "separator" @pytest.fixture @@ -191,10 +192,14 @@ def build( @pytest.fixture def registered(monkeypatch: pytest.MonkeyPatch) -> List[Dict[str, Any]]: - """The items a menu registers, as a reader would meet them.""" + """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: 0) + monkeypatch.setattr( + menus_module.dpg, + "add_separator", + lambda **_kwargs: items.append({"label": SEPARATOR}) or 0, + ) return items @@ -545,6 +550,43 @@ def test_a_right_click_raises_the_row_s_menu( 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, From c22c2500d58caac645859626f545af801d4fe27f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 01:28:03 +0200 Subject: [PATCH 124/130] Pinned: the settling frame and the fields a reshape reads --- tests/suite/frames.py | 18 ++++--- .../ui/elements/stems/test_folder.py | 50 +++++++++++++++++++ .../ui/elements/stems/test_list.py | 34 ++++++++++++- 3 files changed, 95 insertions(+), 7 deletions(-) diff --git a/tests/suite/frames.py b/tests/suite/frames.py index 4e41c835a..2000545d2 100644 --- a/tests/suite/frames.py +++ b/tests/suite/frames.py @@ -1,4 +1,4 @@ -from typing import Final, List +from typing import Final, List, Tuple from sampletones_shared.types.callback import VoidCallback @@ -16,11 +16,15 @@ class Frames: """ def __init__(self) -> None: - self._held: List[VoidCallback] = [] + 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``.""" - self._held.append(callback) + """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: @@ -30,6 +34,8 @@ def pending(self) -> int: 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): - held, self._held = self._held, [] - for callback in held: + 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/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 2d4c8d27e..fc9029034 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -59,7 +59,9 @@ 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 @@ -452,6 +454,54 @@ def test_a_closed_folder_reads_out_how_many_it_now_holds(self, stems_list: GUISt 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) 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 24ef7b984..8dfa398fc 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -151,11 +151,12 @@ def view( 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, @@ -442,6 +443,22 @@ def test_each_level_carries_its_own_band(self, dpg_context: None, layout_config: 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 ) -> None: @@ -685,6 +702,21 @@ 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_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})))) From c76b2c4bbca8a980223f3391d9b3976c4c895ea2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 01:43:17 +0200 Subject: [PATCH 125/130] Recorded: the scopes a key reaches and the deviations left standing --- docs/development/bugs-and-todos.md | 28 +++++++++++++++++++ docs/development/keyboard.md | 12 ++++---- .../ui/elements/layout/test_region.py | 2 +- .../ui/panels/main/test_converter.py | 3 +- 4 files changed, 37 insertions(+), 8 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8125ab0f6..8379cb86f 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -74,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: @@ -222,6 +229,27 @@ again. 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 for the rows drawn, and a collapsed card leaves its widgets standing at `show=False`, 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 this is the + smaller half of the case a closed folder raised; it wants an answer of its own, since what is + shown is read from a drawn frame rather than from the widget's existence. + * 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/keyboard.md b/docs/development/keyboard.md index 04b2202e6..5572905cc 100644 --- a/docs/development/keyboard.md +++ b/docs/development/keyboard.md @@ -34,7 +34,7 @@ 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 holding a cursor or a selection — a sequencer grid, the order list, the voices, the converter's list of gathered recordings | its tab is in front and that sub-panel holds the cursor or the row picked out | handles the keys its own category names and yields the combinations it does not own so a higher-reaching shortcut still wins | +| `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 @@ -44,11 +44,11 @@ scope while a grid cursor is set. ### A panel scope answers on its own tab -A cursor and a selection outlive a move to another tab, so a panel is given the predicate that -reports whether its tab is the one in front and reads it at the moment of the press, the way focus -is read. The composition root resolves the tab and the scope composes the answer into its `active`, -which keeps the fact in one place and leaves the router's contract — the scope decides whether it -wants the key — as it stands. +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 diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index 20340f39b..a8a2a4102 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -10,6 +10,7 @@ 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, @@ -31,7 +32,6 @@ PADDING = 8 GUTTER = 13 MARGIN = 6 -NO_MARGIN = 0 LEAD_TAG = compose_tag(REGION_TAG, SUF_LEAD) HEADING_HEIGHT = 50.0 NO_HEADING = 0.0 diff --git a/tests/unit/sampletones_application/ui/panels/main/test_converter.py b/tests/unit/sampletones_application/ui/panels/main/test_converter.py index 28e17cb9c..eeeaf007d 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_converter.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_converter.py @@ -6,6 +6,7 @@ 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 @@ -664,7 +665,7 @@ class TestTheMovesAMixOffers(BaseTestSuite): @staticmethod def _label(element: ConverterStemMoveElements) -> str: - return LANGUAGE_MANAGER[f"main.converter.label.{element.value}"] + return LANGUAGE_MANAGER[Page.MAIN, Panel.CONVERTER, TextType.LABEL, element] @classmethod def _offered( From a9481a60b3f08bf069b438ee7a46338b66804602 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 08:56:18 +0200 Subject: [PATCH 126/130] Held: the picked row to the reading rather than to the widgets --- .../coordinators/tabs/main.py | 1 + .../logic/main/converter/logic.py | 4 ++ .../ui/elements/stems/list.py | 28 +++++++--- .../ui/elements/stems/row.py | 4 -- .../ui/panels/main/converter/listing.py | 4 +- .../ui/panels/main/converter/panel.py | 2 + .../ui/elements/stems/test_folder.py | 54 ++++++++++++++++--- .../ui/elements/stems/test_list.py | 22 ++++++++ 8 files changed, 100 insertions(+), 19 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index c63bf0594..aba2ead11 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -357,6 +357,7 @@ def _wire_converter( 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 diff --git a/src/sampletones_application/logic/main/converter/logic.py b/src/sampletones_application/logic/main/converter/logic.py index 1a25dc142..c0ffd6380 100644 --- a/src/sampletones_application/logic/main/converter/logic.py +++ b/src/sampletones_application/logic/main/converter/logic.py @@ -231,6 +231,10 @@ 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)))) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 268eeae24..6bf3cf8c5 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -27,7 +27,7 @@ StemRowViewModel, StemsListViewModel, ) -from sampletones_shared.types.callback import StringCallback +from sampletones_shared.types.callback import StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin NO_ROWS: Final[int] = 0 @@ -132,6 +132,7 @@ def __init__( 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) @@ -272,12 +273,11 @@ def row(self, key: str) -> Optional[StemRowViewModel]: def picked_key(self) -> Optional[str]: """The row standing picked out, which is what a key press acts on. - A selection outlives the widgets it was made on: closing a folder takes the recordings - inside it off the list while the reading still names one of them. The pick answers for - the rows drawn, so a key reaches the row the reader is looking at. + 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. """ - key = self._view.selected_key - return key if key is not None and self._rows.stands(key) else None + return self._view.selected_key @property def lets_a_row_go(self) -> bool: @@ -293,9 +293,23 @@ def stands_open(self, key: str) -> bool: return self._open_folders.stands_open(key) 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.""" + """Put a folder's recordings in view or away again, and draw the list as it now stands. + + 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. + """ + 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 _following(self) -> bool: diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index ef45e60d9..33ddf9e84 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -74,10 +74,6 @@ def __init__( self._lbl_remove = language_manager["global.stems.label.remove"] self._folder_template = language_manager["global.stems.template.folder_row"] - def stands(self, key: str) -> bool: - """Whether the row is drawn, which is what a gesture or a key press can reach it by.""" - return bool(dpg.does_item_exist(self._tags.row(key, SUF_TEXT))) - def create( self, row: StemRowViewModel, diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index 75990cbd8..dfe0f173b 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -28,7 +28,7 @@ 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 +from sampletones_shared.types.callback import PathCallback, StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin ChannelsCallback = Callable[[Path, FrozenSet[ChannelName]], None] @@ -84,6 +84,7 @@ def __init__( 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) @@ -111,6 +112,7 @@ def create(self) -> None: 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. diff --git a/src/sampletones_application/ui/panels/main/converter/panel.py b/src/sampletones_application/ui/panels/main/converter/panel.py index 5e8e29bb6..4277dd3fa 100644 --- a/src/sampletones_application/ui/panels/main/converter/panel.py +++ b/src/sampletones_application/ui/panels/main/converter/panel.py @@ -89,6 +89,7 @@ def __init__( 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 @@ -162,6 +163,7 @@ def _wire(self) -> None: 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) diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index fc9029034..5616d2e4e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -310,7 +310,12 @@ def test_its_box_reports_the_recording_it_belongs_to(self, stems_list: GUIStemsL class TestThePickInsideAFolder(BaseTestSuite): - """A key press acts on the row picked out, which a folder standing open is where one is drawn.""" + """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) @@ -321,17 +326,52 @@ def test_a_recording_the_folder_shows_is_the_row_a_key_acts_on(self, stems_list: assert stems_list.picked_key == held.key - def test_closing_the_folder_leaves_no_row_for_a_key_to_act_on(self, stems_list: GUIStemsList) -> None: - """The reading still names the recording, and the list has taken its widgets away with the - folder, so a key press has nothing on screen to act on.""" + 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) - held = sources.held[FIRST_HELD] + 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)) - stems_list.update_view(view(sources, selected_key=held.key)) 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 stems_list.picked_key is None + assert cleared == [] + assert stems_list.picked_key == held.key class TestDoubleClick(BaseTestSuite): 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 8dfa398fc..f9509e00e 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -1113,6 +1113,28 @@ def test_a_banded_list_names_them_too(self, dpg_context: None, layout_config: La 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.""" From fc9611acb95b3d38a31660eb33a062a5b3eef051 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 09:00:38 +0200 Subject: [PATCH 127/130] Said: the drag a row takes, and the click it takes instead --- .../ui/elements/stems/messages.py | 6 +- .../ui/elements/stems/offer.py | 11 ++ .../ui/elements/stems/row.py | 6 +- .../ui/elements/stems/test_messages.py | 185 ++++++++++++++++++ 4 files changed, 200 insertions(+), 8 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/stems/test_messages.py diff --git a/src/sampletones_application/ui/elements/stems/messages.py b/src/sampletones_application/ui/elements/stems/messages.py index 6b4202abd..2af4a1b66 100644 --- a/src/sampletones_application/ui/elements/stems/messages.py +++ b/src/sampletones_application/ui/elements/stems/messages.py @@ -56,7 +56,7 @@ def row_explanation(self, row: StemRowViewModel) -> str: elif not row.takes_part: lines.append(self._msg_inert) - if self._offer.dragging: + if self._offer.drags(self._view): lines.append(self._msg_drag) return "\n".join(lines) @@ -82,9 +82,9 @@ def _row_gestures(self, row: StemRowViewModel) -> Tuple[str, ...]: row draws; a list that neither drags nor reveals reads as the name alone. """ lines: Tuple[str, ...] = () - if self._offer.dragging: + if self._offer.drags(self._view): lines += (self._language_manager["global.stems.message.status_row_drag"].format(name=row.name),) - elif self._host.activatable: + if self._host.activatable: lines += (self._language_manager["global.stems.message.status_row_reveal"].format(name=row.name),) if self._host.playable: diff --git a/src/sampletones_application/ui/elements/stems/offer.py b/src/sampletones_application/ui/elements/stems/offer.py index 9c979015d..7130bd064 100644 --- a/src/sampletones_application/ui/elements/stems/offer.py +++ b/src/sampletones_application/ui/elements/stems/offer.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Final +from sampletones_application.view_model.shared.stems import StemsListViewModel + @dataclass(frozen=True) class StemsListOffer: @@ -26,6 +28,15 @@ class StemsListOffer: 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, diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 33ddf9e84..48173fcd9 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -212,7 +212,7 @@ def _create_name( payload_type=self._tags.payload, drop_callback=self._gestures.on_row_drop, ) - if self._draggable(view_model): + 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) @@ -231,10 +231,6 @@ def _name_indent(self, row: StemRowViewModel, columns: StemsColumns) -> int: return columns.marker_indent(self._glyphs.collapsed, Font.ICON) - def _draggable(self, view_model: StemsListViewModel) -> bool: - """A row is dragged where the list bands its rows, which is what a drag rearranges.""" - return self._offer.dragging and not view_model.collapse_levels - def _create_disclosure(self, row: StemRowViewModel) -> None: """The marker a folder opens by, which stands beside the folder's own name. 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..53aa79ad3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py @@ -0,0 +1,185 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Final, FrozenSet, Tuple + +import pytest + +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" + + +@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 view(*rows: StemRowViewModel, collapse_levels: bool) -> StemsListViewModel: + return StemsListViewModel( + rows=rows, + channels_in_play=CHANNELS, + muted_channels=frozenset(), + 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(), +) -> 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)) + 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 From 467074e2e92aa496e0977ae76a67a4d19de171b0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 09:13:31 +0200 Subject: [PATCH 128/130] Opened: the row geometry at the height its layout gives a row --- docs/development/bugs-and-todos.md | 9 ---- .../ui/elements/layout/geometry.py | 21 ++++----- .../ui/elements/layout/region.py | 13 +++--- .../ui/elements/stems/list.py | 24 +++-------- tests/benchmarks/test_converter_load.py | 4 +- .../ui/elements/layout/test_geometry.py | 43 +++++++++++-------- .../ui/elements/layout/test_region.py | 25 ++++++----- .../ui/elements/stems/test_list.py | 19 ++++++++ 8 files changed, 81 insertions(+), 77 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 8379cb86f..799ba3fc0 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -214,15 +214,6 @@ again. releases moves inside the pending step, and a build writing the pending version writes the shape that step produces. -* The converter's card jumps for about two frames the first time a folder is opened in a list that - held one from the outset. `GUIStemsList._rebuild` returns early on exactly `_windows()` — - `collapse_levels and not holds_folders` — which is the same condition under which `_plain_rows` - answers with the row count, so `draw_whole` is always handed none and the shared `RowGeometry` - goes unmeasured until a folder's own region measures it. That first slice is taken at the - `MINIMUM_ROW_PITCH` floor, which reaches far more rows than the region shows, and the settle after - it re-slices at the reading. Either the whole-drawn path reports the rows of its plain segments, - or the geometry opens from `layout.name_height` rather than the floor. - * 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 diff --git a/src/sampletones_application/ui/elements/layout/geometry.py b/src/sampletones_application/ui/elements/layout/geometry.py index e38b2a853..fafb7e5dc 100644 --- a/src/sampletones_application/ui/elements/layout/geometry.py +++ b/src/sampletones_application/ui/elements/layout/geometry.py @@ -19,9 +19,9 @@ class RowGeometry: 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 the least room a row can take, so a first - draw is generous rather than unbounded: it builds more rows than it needs, measures them, and - holds to that reading from the next frame on. + 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. @@ -29,11 +29,12 @@ class RowGeometry: overscan: int pitch: float + opening: float @classmethod - def unmeasured(cls, *, overscan: int) -> Self: - """The reading a list starts from, before it has drawn a row to measure.""" - return cls(overscan=overscan, pitch=UNMEASURED) + 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: @@ -42,12 +43,12 @@ def measured(self) -> bool: @property def room(self) -> float: - """The room one row is worked from: what was read, or the least a row can take. + """The room one row is worked from: what was read, or what the layout opens it at. - ``MINIMUM_ROW_PITCH`` is a floor rather than a guess at the theme in force, so a window - taken before anything has been measured is wider than it needs to be and never narrower. + ``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 MINIMUM_ROW_PITCH + 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.""" diff --git a/src/sampletones_application/ui/elements/layout/region.py b/src/sampletones_application/ui/elements/layout/region.py index ef5c047ba..190abc465 100644 --- a/src/sampletones_application/ui/elements/layout/region.py +++ b/src/sampletones_application/ui/elements/layout/region.py @@ -17,6 +17,7 @@ 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 @@ -185,15 +186,13 @@ def draw(self, total: int, build: SliceBuilder, *, lead: Optional[LeadBuilder]) self._windowed = True self._drawn = (start, count) - def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: int) -> None: + 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. - - ``rows`` is how many rows of one height the content is a plain run of, which is what a - reading of a row is taken from; content standing anything else among its rows is a run of - none. + 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 @@ -201,7 +200,7 @@ def draw_whole(self, build: VoidCallback, *, lead: Optional[LeadBuilder], rows: dpg_delete_children(self._body_tag) self._build_lead(lead) build() - self._total = rows + self._total = NO_TOTAL self._windowed = False self._drawn = NO_ROWS diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 6bf3cf8c5..4b7325e7f 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Final, Optional, Tuple +from typing import Optional, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.stems import StemsListLayout @@ -30,8 +30,6 @@ from sampletones_shared.types.callback import StringCallback, VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin -NO_ROWS: Final[int] = 0 - class GUIStemsList(CallbackMixin): """The stems of one setup, as a table of rows banded by the levels they pick on. @@ -65,7 +63,10 @@ def __init__( self._offer = offer self._view = StemsListViewModel.empty() self._open_folders = OpenFolders() - self._geometry = RowGeometry.unmeasured(overscan=layout.window_overscan) + self._geometry = RowGeometry.opening_at( + overscan=layout.window_overscan, + opening=float(layout.name_height), + ) self._settling = False self._region = WindowedRegion( tag=self._tags.well, @@ -215,7 +216,6 @@ def _rebuild(self, view_model: StemsListViewModel) -> None: self._region.draw_whole( partial(self._bands.build, view_model), lead=partial(self._bands.build_heading, view_model), - rows=self._plain_rows(view_model), ) def _draw_window(self, view_model: StemsListViewModel) -> None: @@ -237,20 +237,6 @@ def _windows(view_model: StemsListViewModel) -> bool: """ return view_model.collapse_levels and not view_model.holds_folders - def _plain_rows(self, view_model: StemsListViewModel) -> int: - """How many rows of one height the well holds, which a reading of a row is counted from. - - A well standing recordings alone answers with its whole count. One standing a caption, a - strip or a folder answers with none: a folder is a table of its own with a region under - it, so a block measured across those carries a table's chrome per folder and reads a row - as taller than it is. The reading a folder's rows are reserved by is then the one that - folder's own region takes, from the run of rows it draws. - """ - if not view_model.collapse_levels or view_model.holds_folders: - return NO_ROWS - - return view_model.row_count - def _repaint(self, view_model: StemsListViewModel) -> None: """Draw what the rows in view currently hold onto the widgets they stand as.""" for row in self._reached(view_model): diff --git a/tests/benchmarks/test_converter_load.py b/tests/benchmarks/test_converter_load.py index c3310a0b7..ad60c12ab 100644 --- a/tests/benchmarks/test_converter_load.py +++ b/tests/benchmarks/test_converter_load.py @@ -310,7 +310,9 @@ def test_it_builds_what_a_reader_can_see( large_listing: StemsListViewModel, ) -> None: layout = layout_config.general.stems - window = RowGeometry.unmeasured(overscan=layout.window_overscan).size(float(layout.folder_ceiling)) + 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)) diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py index 86af02877..a3dab1e4b 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_geometry.py @@ -14,6 +14,7 @@ OVERSCAN = 2 PITCH = 20.0 +OPENING = 16.0 REGION_HEIGHT = 100.0 TOTAL_ROWS = 100 READING_STEPS = 40 @@ -21,30 +22,36 @@ 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) + return RowGeometry(overscan=overscan, pitch=pitch, opening=OPENING) class TestAnUnmeasuredGeometry(BaseTestSuite): - """A geometry that has yet to read a row works from the least room a row can take, so a first - draw is generous rather than unbounded.""" + """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.unmeasured(overscan=OVERSCAN).measured + assert not RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).measured - def test_it_works_from_the_floor(self) -> None: - assert RowGeometry.unmeasured(overscan=OVERSCAN).room == MINIMUM_ROW_PITCH + def test_it_works_from_the_opening_it_was_given(self) -> None: + assert RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING).room == OPENING - def test_its_window_is_wider_than_a_measured_one(self) -> None: - """A floor no row goes under makes the first window larger than it needs to be, never - smaller, so the rows the region shows are among the ones it built.""" - unmeasured = RowGeometry.unmeasured(overscan=OVERSCAN) - assert unmeasured.size(REGION_HEIGHT) > measured().size(REGION_HEIGHT) + 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.unmeasured(overscan=OVERSCAN).windows(height=REGION_HEIGHT, total=10_000) + 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.unmeasured(overscan=OVERSCAN) + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) assert geometry.slice_of(offset=0.0, height=REGION_HEIGHT, total=3) == (0, 3) @@ -197,10 +204,10 @@ class TestReserve(BaseTestSuite): 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_the_floor(self) -> None: + def test_an_unmeasured_geometry_reserves_by_its_opening(self) -> None: rows = 100 - geometry = RowGeometry.unmeasured(overscan=OVERSCAN) - assert geometry.reserve(rows) == int(rows * MINIMUM_ROW_PITCH) + 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() @@ -215,13 +222,13 @@ class TestTake(BaseTestSuite): 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.unmeasured(overscan=OVERSCAN) + 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.unmeasured(overscan=OVERSCAN) + 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: diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_region.py b/tests/unit/sampletones_application/ui/elements/layout/test_region.py index a8a2a4102..4f8db0e6a 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_region.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_region.py @@ -25,6 +25,7 @@ ROOT_TAG = "test_root" REGION_TAG = "test.region" PITCH = 20.0 +OPENING = 8.0 OVERSCAN = 2 CEILING = 100 HEADING_TEXT = "channels" @@ -56,7 +57,7 @@ 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), + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), ceiling=CEILING, padding=0, margin=0, @@ -154,7 +155,7 @@ class TestAnUnmeasuredRegion(BaseTestSuite): def unmeasured(self, dpg_context: None) -> WindowedRegion: built = WindowedRegion( tag=REGION_TAG, - geometry=RowGeometry.unmeasured(overscan=OVERSCAN), + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), ceiling=CEILING, padding=0, margin=0, @@ -190,7 +191,7 @@ class TestAReadingTheHeightHasYetToFollow(BaseTestSuite): def unmeasured(self, dpg_context: None) -> WindowedRegion: built = WindowedRegion( tag=REGION_TAG, - geometry=RowGeometry.unmeasured(overscan=OVERSCAN), + geometry=RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING), ceiling=CEILING, padding=0, margin=0, @@ -271,7 +272,7 @@ class TestAHeightTheFrameHasYetToShow(BaseTestSuite): def sizing(self, dpg_context: None) -> WindowedRegion: built = WindowedRegion( tag=REGION_TAG, - geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH), + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), ceiling=CEILING, padding=0, margin=0, @@ -366,7 +367,7 @@ def test_the_same_rows_without_one_stand_inside_it(self, region: WindowedRegion) 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.unmeasured(overscan=OVERSCAN) + geometry = RowGeometry.opening_at(overscan=OVERSCAN, opening=OPENING) built = WindowedRegion( tag=REGION_TAG, geometry=geometry, @@ -390,26 +391,24 @@ class TestAWholeDraw(BaseTestSuite): 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, rows=30 - ) + 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, rows=0) + 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, rows=0) + 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, rows=0) + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) with block_of(CEILING + PITCH): region.settle() @@ -420,7 +419,7 @@ def test_content_past_the_ceiling_is_held_at_it(self, region: WindowedRegion) -> 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, rows=0) + region.draw_whole(lambda: dpg.add_text("banded", parent=region.body), lead=None) with block_of(CEILING + PITCH): region.settle() @@ -548,7 +547,7 @@ class TestTheGutterAScrollbarWillTake(BaseTestSuite): def gutted_fixture(self, dpg_context: None) -> WindowedRegion: built = WindowedRegion( tag=REGION_TAG, - geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH), + geometry=RowGeometry(overscan=OVERSCAN, pitch=PITCH, opening=OPENING), ceiling=CEILING, padding=PADDING, margin=0, 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 f9509e00e..3176ce7ee 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -30,6 +30,7 @@ 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 @@ -1170,6 +1171,24 @@ def test_a_long_run_of_recordings_builds_the_rows_it_shows( 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")) From f91c988179725cf3841f578983a82fc3193bbbab Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 09:21:20 +0200 Subject: [PATCH 129/130] Tested: what a stems list says, and toned the heading with its boxes --- .../ui/elements/stems/bands.py | 11 + .../ui/elements/stems/list.py | 1 + .../ui/elements/layout/test_collapse.py | 25 +++ .../ui/elements/stems/test_folder.py | 13 ++ .../ui/elements/stems/test_list.py | 98 +++++++++ .../ui/elements/stems/test_messages.py | 194 +++++++++++++++++- .../ui/panels/main/test_source.py | 14 +- 7 files changed, 352 insertions(+), 4 deletions(-) diff --git a/src/sampletones_application/ui/elements/stems/bands.py b/src/sampletones_application/ui/elements/stems/bands.py index 24142dafb..536894ec8 100644 --- a/src/sampletones_application/ui/elements/stems/bands.py +++ b/src/sampletones_application/ui/elements/stems/bands.py @@ -97,6 +97,17 @@ def build_heading(self, view_model: StemsListViewModel, parent: str) -> None: 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]) diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 4b7325e7f..752b570cc 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -239,6 +239,7 @@ def _windows(view_model: StemsListViewModel) -> bool: 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, releasable=self._releasable) self._folders.repaint(row, view_model, releasable=self._releasable) 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/stems/test_folder.py b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py index 5616d2e4e..f9bdc1199 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_folder.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_folder.py @@ -730,6 +730,19 @@ def test_the_reserve_is_the_room_the_open_folder_s_region_takes( 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, 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 3176ce7ee..7ba50d352 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_list.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_list.py @@ -23,6 +23,7 @@ SUF_HEADING, SUF_LEAD, SUF_STRIP, + SUF_TABLE, SUF_TEXT, TAG_GLOBAL_THEME_CHANNEL_MUTED, TAG_GLOBAL_THEME_CHANNEL_PULSE1_PARTIAL, @@ -36,6 +37,7 @@ 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 @@ -552,6 +554,22 @@ 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_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: @@ -848,6 +866,43 @@ def test_a_channel_switched_back_on_takes_its_own_color_again( assert dpg.get_item_theme(channel_tag(bass, ChannelName.TRIANGLE)) != muted +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) @@ -1148,6 +1203,16 @@ def test_the_grid_rules_the_rows_off_from_the_heading(self, dpg_context: None, l 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) @@ -1249,6 +1314,39 @@ def test_a_well_holding_rows_back_asks_for_another_frame( 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, diff --git a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py index 53aa79ad3..899bf53c0 100644 --- a/tests/unit/sampletones_application/ui/elements/stems/test_messages.py +++ b/tests/unit/sampletones_application/ui/elements/stems/test_messages.py @@ -4,6 +4,7 @@ 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 @@ -21,6 +22,8 @@ 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) @@ -68,11 +71,34 @@ def recording( ) -def view(*rows: StemRowViewModel, collapse_levels: bool) -> StemsListViewModel: +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=frozenset(), + muted_channels=muted_channels, picked_keys=frozenset(), picking_room=None, live=True, @@ -87,6 +113,7 @@ def messages( 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( @@ -95,7 +122,7 @@ def messages( open_folders=open_folders, host=host, ) - answering.reads(view(*rows, collapse_levels=collapse_levels)) + answering.reads(view(*rows, collapse_levels=collapse_levels, muted_channels=muted_channels)) return answering @@ -183,3 +210,164 @@ def test_a_list_that_sounds_a_row_says_so(self) -> None: 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/panels/main/test_source.py b/tests/unit/sampletones_application/ui/panels/main/test_source.py index ccc93e9d4..103d28bc6 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_source.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_source.py @@ -15,7 +15,7 @@ THEME_DIRECTORY, ) from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import SUF_HEADING, SUF_TEXT +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, @@ -214,6 +214,18 @@ def test_the_grid_rules_the_names_off_from_the_boxes( 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()) From 4288cc9364c3f82099abe12e435b1f15383af1a3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 10 Sep 2026 09:48:06 +0200 Subject: [PATCH 130/130] Named: the panel key scope once, and the rule a row leaves by --- docs/development/bugs-and-todos.md | 18 +++++++++++++----- docs/guide/converting.md | 4 ++-- src/sampletones_application/application.py | 6 +++--- .../ui/elements/stems/folder.py | 10 ++-------- .../ui/elements/stems/list.py | 13 ++++--------- .../ui/elements/stems/row.py | 18 ++++++++++-------- .../ui/panels/main/converter/listing.py | 14 +++++++------- .../reconstruction/instruments/instruments.py | 10 +++++++--- .../ui/panels/sequencer/order/panel.py | 16 +++++++--------- .../ui/panels/sequencer/tracker/panel.py | 16 +++++++--------- .../ui/panels/sequencer/voices/panel.py | 15 +++++++++------ .../utils/gui/keyboard/__init__.py | 2 ++ .../utils/gui/keyboard/scope.py | 15 +++++++++++++++ 13 files changed, 88 insertions(+), 69 deletions(-) create mode 100644 src/sampletones_application/utils/gui/keyboard/scope.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 799ba3fc0..04ae1542b 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -235,11 +235,19 @@ again. read from `live`. * The removal key reaches a row a collapsed converter card is hiding. `GUIStemsList.picked_key` - answers for the rows drawn, and a collapsed card leaves its widgets standing at `show=False`, 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 this is the - smaller half of the case a closed folder raised; it wants an answer of its own, since what is - shown is read from a drawn frame rather than from the widget's existence. + 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 diff --git a/docs/guide/converting.md b/docs/guide/converting.md index c0de1bcad..ce11aacb6 100644 --- a/docs/guide/converting.md +++ b/docs/guide/converting.md @@ -19,7 +19,7 @@ Adding a folder opens a small window while the folder is read. **Stop** ends the **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. Press `Del` to remove the selected row. +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 @@ -31,7 +31,7 @@ A folder represents all the recordings inside it. Its checkbox shows their chann - 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 and click the marker next to its name, or double-click the recording name. +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. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ff23dd8e0..c2a924bd6 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1566,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: diff --git a/src/sampletones_application/ui/elements/stems/folder.py b/src/sampletones_application/ui/elements/stems/folder.py index 62d252952..0ed773e0c 100644 --- a/src/sampletones_application/ui/elements/stems/folder.py +++ b/src/sampletones_application/ui/elements/stems/folder.py @@ -88,13 +88,7 @@ def redraw(self, key: str, view_model: StemsListViewModel) -> None: self._fill(region, row, view_model) - def repaint( - self, - row: StemRowViewModel, - view_model: StemsListViewModel, - *, - releasable: bool, - ) -> None: + 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 @@ -105,7 +99,7 @@ def repaint( return for held in self._reached(region, row): - self._rows.repaint(held, view_model, releasable=releasable) + 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. diff --git a/src/sampletones_application/ui/elements/stems/list.py b/src/sampletones_application/ui/elements/stems/list.py index 752b570cc..5619c8dfe 100644 --- a/src/sampletones_application/ui/elements/stems/list.py +++ b/src/sampletones_application/ui/elements/stems/list.py @@ -241,8 +241,8 @@ 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, releasable=self._releasable) - self._folders.repaint(row, view_model, releasable=self._releasable) + self._rows.repaint(row, view_model) + self._folders.repaint(row, view_model) def _reached(self, view_model: StemsListViewModel) -> Tuple[StemRowViewModel, ...]: """The rows the well has widgets for, which are the ones a repaint reaches.""" @@ -273,7 +273,7 @@ def lets_a_row_go(self) -> bool: 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. """ - return self._view.live and self._releasable + return self._view.live and self._rows.releasable(self._view) def stands_open(self, key: str) -> bool: """Whether the folder's recordings are in view, which is what a menu names its move by.""" @@ -328,12 +328,7 @@ def _settle(self) -> None: self._folders.redraw(key, self._view) row = self._view.row(key) if row is not None: - self._folders.repaint(row, self._view, releasable=self._releasable) + self._folders.repaint(row, self._view) if self._following: self._settle_soon() - - @property - def _releasable(self) -> bool: - """Whether a row may leave, which a list holding on to its last one answers by its count.""" - return self._view.row_count > 1 or not self._offer.keeps_last_row diff --git a/src/sampletones_application/ui/elements/stems/row.py b/src/sampletones_application/ui/elements/stems/row.py index 48173fcd9..baf19a821 100644 --- a/src/sampletones_application/ui/elements/stems/row.py +++ b/src/sampletones_application/ui/elements/stems/row.py @@ -102,13 +102,7 @@ def create( if columns.reserved: dpg.add_spacer() - def repaint( - self, - row: StemRowViewModel, - view_model: StemsListViewModel, - *, - releasable: bool, - ) -> None: + 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 @@ -141,7 +135,15 @@ def repaint( self._tone_master(row, view_model) if self._offer.removal: - dpg_configure_item(self._tags.row(row.key, SUF_BUTTON), enabled=live and releasable) + 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, diff --git a/src/sampletones_application/ui/panels/main/converter/listing.py b/src/sampletones_application/ui/panels/main/converter/listing.py index dfe0f173b..6af3a4bcc 100644 --- a/src/sampletones_application/ui/panels/main/converter/listing.py +++ b/src/sampletones_application/ui/panels/main/converter/listing.py @@ -23,6 +23,7 @@ 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 @@ -138,13 +139,12 @@ def _on_selected(self, key: str) -> None: self.call(self.on_row_selected, Path(key), row.kind) def _keys_active(self) -> bool: - """Whether the list owns the next key: its tab is in front and it holds a row picked out. - - A row picked out outlives a move to another tab, so the tab is read at the moment of the - press. A modal dialog claims keys above this scope in the router, which is what holds the - list off while one stands open. - """ - return self._tab_active() and self._stems_list.picked_key is not None and not self._router.is_field_focused + """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. 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/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/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/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