diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8772a4..8a9cc876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,9 @@ jobs: - name: Run every pre-commit hook run: uv run pre-commit run --all-files --show-diff-on-failure --color always + - name: Check the committed icons match the mark + run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3cdb1383..f94d5553 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -126,7 +126,7 @@ jobs: venv_python=.venv-build/bin/python fi "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" + "$venv_python" -m pip install ".[build]" --group assets - name: Build the bundle (Linux) if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index 88d53808..585ea73d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,25 +4,20 @@ __pycache__/ .ipynb_checkpoints/ .mypy_cache/ .pytest_cache/ +.ruff_cache/ .venv/ .venv-build/ .vscode/ -bin/ -build/ dist/ wheels/ -!scripts/**/build/ +/bin/ +/build/ sampletones !src/sampletones !tests/sampletones -**/*.idea -**/*.vscode/** -**/*.ipynb_checkpoints/** -**/*__pycache__/** - *.pyc *.pyo *.coverage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 570b4496..0f67e871 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,16 @@ repos: require_serial: true exclude: ^(tests/) + - id: icons + name: icons + entry: uv run python scripts/assets/icons.py + language: system + files: ^src/sampletones_assets/(icons|mark)/ + pass_filenames: false + verbose: true + stages: + - pre-push + - id: pytest name: pytest entry: make test diff --git a/CHANGELOG.md b/CHANGELOG.md index 7114660c..70f9edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Improved Sequencer module playback. * Added song export to WAV/MP3. * Added tracker selection operations. +* Added a _SampleToNES_ logo. ## v0.3.0 [2026-07-31] diff --git a/LICENSE b/LICENSE index aa541094..5918803c 100644 --- a/LICENSE +++ b/LICENSE @@ -22,7 +22,9 @@ SOFTWARE. --- -The MIT license above covers the SampleToNES source code only. +The MIT license above covers the SampleToNES source code and the application +icons under `src/sampletones_assets/icons/`, which are drawn from the mark +declared in `src/sampletones_assets/mark/`. Font files bundled under `src/sampletones_assets/fonts/` are the work of third parties and remain under their own licenses (SIL Open Font License 1.1 and the diff --git a/Makefile b/Makefile index 9997def7..7ab6b93d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples check-import-boundary check-tag-names check-unused-tags \ + ftm-samples icons check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @@ -78,6 +79,7 @@ help: setup: $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) + $(MAKE) icons $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: @@ -109,6 +111,9 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker +icons: + uv run --group assets python scripts/assets/icons.py + check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/README.md b/README.md index 9688052a..9d505bfb 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,15 @@ [](https://pypi.org/project/sampletones/) [](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +
+
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**.
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 65d37c0f..99cd24b3 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -109,3 +109,18 @@ The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA li
are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes
from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the
CUDA components from their publishers straight to your machine.
+
+## Build-time tooling
+
+The application icons are drawn by `sampletones_assets.mark` and rasterized with
+[Pillow](https://pypi.org/project/Pillow/), which is under the
+[MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs
+to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that
+generates the icons: it stays out of the wheel's dependency set, and PyInstaller is told to
+leave it out of the bundles. Both distributions carry the finished icon files, so Pillow's
+attribution clause — a condition on redistributing Pillow itself — rests with the build
+environment.
+
+The icons (`sampletones.svg`, `sampletones.png` and the multi-resolution `sampletones.ico`)
+are original _SampleToNES_ artwork and fall under the MIT License together with the rest of
+the source.
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index 68ecab45..fceecb7c 100644
--- a/docs/development/architecture.md
+++ b/docs/development/architecture.md
@@ -2,7 +2,7 @@
This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs.
-Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, and the YAML configuration package has `docs/development/config-organization.md`.
+Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, and the YAML configuration package has `docs/development/config-organization.md`.
---
@@ -259,6 +259,8 @@ They read the source as an AST through the shared layer in `sampletones_shared/m
`logic/history/` implements the session-scoped undo engine (`HistoryManager`); its invariants and mechanics are documented in `docs/development/undo.md`.
+`logic/reconstruction/browser/` builds the tree of reconstructions both browser tabs render (`BrowserManager`); its pipeline, node vocabulary and shaping rules are documented in `docs/development/browser.md`.
+
**Contracts:**
- Logic classes produce view models and may therefore import `view_model/`; they import neither `ui/` nor `coordinators/`.
- Logic classes never call DPG.
diff --git a/docs/development/browser.md b/docs/development/browser.md
new file mode 100644
index 00000000..e764ab7b
--- /dev/null
+++ b/docs/development/browser.md
@@ -0,0 +1,275 @@
+# The Reconstruction Browser
+
+This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs
+share: how a reconstructions directory becomes rows, what a row stands for, and what it answers.
+Consult it when changing what the browser lists, how a row reads, or what a click on one does. It
+complements `docs/development/architecture.md` (layering and ownership) and
+`docs/development/guidelines.md` (coding rules).
+
+---
+
+## Principles
+
+1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once
+ into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree
+ about what exists by construction, and a folder name is parsed into its configuration fields once
+ per refresh.
+2. **The model carries the shape; the panel carries the widgets.** Which rows exist, what they are
+ called, which of them fold together and in what order they sit are decided on the tree. Both tabs
+ render one model, so they show one shape, and each rule is exercised without a window.
+3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path,
+ playback and opening a reconstruction all key on `filepath`. That is what frees a name to be
+ rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings
+ joined into one row, a colliding label marked with its configuration hash.
+4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a
+ transformation, a source folder, one source audio: each becomes a row that carries no path of its
+ own. What such a row offers follows from the subtree beneath it.
+5. **One thing may stand in several places.** A reconstruction is listed by the configuration that
+ produced it and again by the audio it was made from, so an action on the thing rather than on the
+ row asks for every row standing for it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands
+ them to both tabs.
+6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the
+ background worker — tag, label, font, theme, handler, open state — and the main thread creates the
+ widgets from those specs, spread across frames.
+7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows
+ is decided by the panel showing it: a search typed in one tab leaves the other reading as it was,
+ and each browser opens in the mode a session left it in.
+8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the
+ tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave
+ the tree standing as it was, and so does the next run of the application. What a filter unfolds on
+ top of that shape is remembered as the filter's own, held for as long as the filter is, and handed
+ back when it goes — apart from a row the reader's own has come to stand on, which stays open so the
+ view they built stays on the screen.
+
+---
+
+## The pipeline
+
+`BrowserManager` (`logic/reconstruction/browser/manager.py`) owns the tree and runs a refresh in four
+steps: **scan** the directory, **build** each branch from that one scan, **shape** what came out, and
+**publish** it through `Tree.set_root`. `BrowserLogic` sits above it as the surface the coordinators
+drive, and `get_all_reconstruction_files` reads the scan.
+
+| Stage | Module | What it does |
+|---|---|---|
+| Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it |
+| Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree |
+| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation groups and names it by its generators; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings |
+| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration |
+| Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first |
+| Containers | `tree/containers.py` | `find_or_create_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each node type is looked up among the siblings of its own kind, so a folder and an audio sharing a name stay two rows |
+
+The policy the two branches share: a configuration directory sitting at the top level of the
+reconstructions directory is the one lifted under groups and transposed into the sample view. A
+configuration directory nested inside a plain folder keeps its friendly name where it sits, and a
+reconstruction outside every configuration directory appears in the configuration branch, that being
+the branch which follows the disk.
+
+## The node vocabulary
+
+`sampletones_core/structures/tree/` holds the nodes, all anytree-backed:
+
+* `TreeNode(name, node_type)` — a row and its kind. `NodeType.ROOT` for the container both branches
+ hang from, `GROUP` and `SAMPLE` for the headings the browser writes, `DIRECTORY` and `FILE` for what
+ the disk holds.
+* `FileSystemNode(filepath)` — a row standing for a path. Favorites, playability, themes and the path
+ items all test for this class.
+* `ConfigNode(config)` — a filesystem row belonging to a reconstruction configuration, carrying the
+ parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps
+ working, and the fields travel with the row, which is what lets a label, a tooltip and a font state
+ the configuration from the node already in hand.
+
+`create_directory_node` chooses between the last two from the fields the scan read. Which row carries
+the configuration follows the branch: in the configuration branch it is the directory that names it,
+and in the sample branch it is the variant leaf, since there the configuration is what distinguishes
+one row from the next.
+
+## The shaping rules
+
+* **Prune** (`prune_empty_containers`) — a heading the browser wrote that gathers nothing leaves,
+ deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to
+ show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk.
+* **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into
+ that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place.
+ The surviving row keeps its node type, path, configuration and children, so its click behaviour,
+ theme, context menu and favorite star carry over. A fold that would repeat a name already beside it
+ stays open instead, and the branch roots stay in place. With a single configuration present the
+ configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a
+ second configuration arrives.
+* **Order** (`order_children`) — containers ahead of leaves, then `natural_sort_key` over the label, so
+ a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every
+ label is final; the branches directly under the container root keep the order the builder states them
+ in.
+* **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where
+ siblings would read alike, every member of that label takes its short configuration hash. One rule
+ serves the generator directories under a transformation group, the nested configuration directories,
+ and the variants under a sample.
+
+## The panels
+
+The browsers form one line of inheritance, each level owning what it shares:
+
+* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the
+ filter they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory`
+ (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row,
+ the detail tooltip, the status-bar messages, and the context-menu items every browser can offer.
+* `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the
+ controls bringing the tree up to date and folding it away, the tree window, the folder-and-file
+ handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as
+ a `FileBrowserTags` class attribute and states what its card and refresh control read.
+* `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the
+ rows the two branches hold, the colour a group and a sample read in, and the context menus. The
+ Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what
+ opening a reconstruction means in that tab.
+
+The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on
+`GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them.
+
+**A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to
+date, collects the rows into specs, and hands them to `TreeEmitter`, which clears the old rows and
+stages the new ones in budget-sized batches so interactive callbacks run between slices. The
+completion callback shows the empty state where one is called for, runs the panel's hook, and releases
+the lock. Because a browser is asked to rebuild from either tab and from several places in the
+application, exactly one rebuild is in flight at a time.
+
+**A row's tag** (`compose_node_tag`, `ui/elements/tree/tag.py`) joins the names above it, which reads
+the row back to whoever inspects the widget tree, and appends a digest over the exact path of
+`(node_type, name)` pairs. Rows the names alone spell alike — a folder and the audio beside it, two
+labels differing only in spacing or case — therefore keep tags of their own. A tag is composed rather
+than stored, so any holder of a node can address its row: this is how expanding a subtree, repainting a
+star and applying a filter reach the widgets.
+
+**What a row answers** follows its kind. A reconstruction plays on a click, opens on a double click,
+and offers its path items, the tab's own actions and the favorite mark. A directory offers its path
+items and the favorite mark. A group or a sample stands for no path, so its menu reads the subtree: how
+many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows
+it by, and — on a sample — the audio its reconstructions were made from, answered through any one of
+them.
+
+**Favorites are paths.** `TreeLogic.is_node_favorite` tests the row's path against the session's set,
+and `has_favorite_ancestor` tests the path's parents, so a reconstruction reads as part of a favorite
+folder wherever a view puts it — including the sample branch, whose headings carry no path. Since one
+path reaches the panel as several rows, `application.py` resolves the toggled path into every row
+standing for it and hands them to both tabs, and each row repaints with the ancestry its own path
+carries.
+
+## Filtering
+
+`TreeFilter` (`ui/elements/tree/filter.py`) holds what a browser is currently asked to show, and the
+panel showing it owns the filter. It is stated whole and replaced whole — `with_query`,
+`with_favorites_only` — so one place resolves what the browser shows, and `NO_FILTER` is the filter a
+browser showing its whole tree holds.
+
+The two criteria answer different questions, so each lands in a different place:
+
+| Criterion | What it decides | Where it lands | What a change costs |
+|---|---|---|---|
+| `favorites_only` | which rows the browser **draws** | `_append_spec` records the rows the mode shows, so `TreeEmitter` creates widgets for those alone | `redraw_tree` collects the rows again from the model in hand, on the tree worker |
+| `query` | which of the drawn rows are **shown** | `update_tree_visibility` flips `show` over the rows already on screen, once the typing settles | a resolution of the query, debounced |
+
+One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibility.py`) takes the
+rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row
+one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in
+memory follows the size of what was found, and a row beneath a match is answered from its own path
+upwards.
+
+**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows
+and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion
+names are its **anchors**: for a search, the rows whose label matched; for the favorites mode, a row a
+star sits on, and — where no row stands for the starred path — the shallowest rows that path reaches.
+In the sample branch the headings carry no path, which is what makes the variants the rows a starred
+folder arrives at.
+
+**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a
+match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points
+the reader at a star, so what opens is the rows above it (`_way_down_to`, over the anchors' ancestors)
+while the star's own row stands where the reader left it — a starred folder is revealed. A starred
+reconstruction inside a starred folder anchors on its own, which is what opens the folder above it.
+
+**Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds
+is a preference stated per kind of favorite, held in `ApplicationConfig.browser` and offered as
+**View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred
+folder, and everything it brings in where no row stands for it, reads the directories answer. Both are
+off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The
+panel reads the pair through `TreeLogicProtocol`, once per resolution.
+
+**The way down opens on the pass the reader asked for, and stands for as long as the mode does.**
+Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts
+is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends
+it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a
+refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the
+stars followed stay the ones the switch asked about. Every turn of the mode is a pass's to answer: the
+pass that reads the mode off lets the memory go and those rows fold back. A change of preference asks
+for nothing; it is answered the next time the reader asks for the mode, which keeps a menu click from
+moving the tree the reader is working in.
+
+**The way down becomes the reader's once their own rows stand on it.** A reader looking at their
+favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on
+the screen. `_release_mode_rows` therefore reads the model on the pass that finds the mode off — on the
+tree worker, beside the other walks a pass makes — and hands the memory the ways down to the reader's
+rows; `RowExpansionMemory.release` keeps the rows of the mode's among them, which writes the way down
+into the shape a session keeps. What is left held the mode's opening alone, and folds with it.
+
+**A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on
+the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so
+declining a row declines its subtree, and one decision covers it while the traversal walks on.
+
+**Two memories, each holding what one hand opened.** `RowExpansionMemory` owns both and the rules that
+join them, holding each row by the tag it is addressed under so a later pass creates it open again. The
+reader's rows hold what the reader did — a click, read a frame later once the row has answered it, and
+the expansion items and the collapse control, which record what they set — and they are what a session
+writes down. The mode's rows hold the way down it opened, and go when the mode does, so a narrowed
+browser hands the tree back the way the reader had it, keeping the rows theirs now stand on. Folding a
+row is the reader's word on it whichever hand opened it, so `remember` releases the mode's claim along
+with the reader's and the row stays folded. A pass writes from the tree worker while a click writes from
+the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the
+model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves
+them with it. Which browsers record a shape at all is `_REMEMBERS_EXPANSION`: it decides whether a click
+is followed through to the memory, and a browser that keeps none leaves it empty.
+
+A search unfolds by the same rule from the other end: its matches and the rows above them open for as
+long as the query stands, resolved afresh on each pass, and clearing the query folds them back.
+
+The shape outlives the run as well. A browser is handed the mode and the rows it opens with as it is
+built (`initial_favorites_only`, `initial_expanded_rows`). A change of mode is written where it happens,
+through `on_favorites_filter_changed`, and the shape is asked for the once, at exit:
+`_persist_application_state` takes each tab's rows into `ApplicationState.expanded_rows` under the
+panel's tag, so a pass holds what it opened in memory, on the tree worker, and the session file reads it
+from there.
+
+**The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level
+at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its
+children have been read, and whether its row stands open. They part company — a folder read and then
+folded away is loaded and closed — and the open one is the shape a session writes to
+`ApplicationState.expanded_directories`. A refresh reads down to each remembered folder through
+`_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories
+are dropped as the manager is built.
+
+**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each
+row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — and the
+anchors the preference follows are read out of that one answer. What it materialises is the starred rows
+and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding
+hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones
+and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A
+favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring
+one takes it out along with what it held.
+
+A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back
+empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the
+filter's answer reads where the rows would be.
+
+**The control** is a checkbox under the search box carrying the favorite glyph, which reads in the
+favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states
+which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It
+follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every
+checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so
+the shade states whether the control is live.
+
+Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its
+own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag,
+which is how a collapsed card is remembered too.
+
+**Folding the whole tree away** is the other control every card carries. It reaches the rows through the
+model rather than the widget tree, so one pass covers a branch however deep it runs, and it records what
+it set — leaving the memory empty, which is the shape a later pass then draws. The explorer folds first
+and drops the folders it had read afterwards, so opening one lists it as it stands on disk.
diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md
index 4658b213..198ab3bc 100644
--- a/docs/development/bugs-and-todos.md
+++ b/docs/development/bugs-and-todos.md
@@ -5,8 +5,10 @@
* Interface scale
* Tree navigation using keys
* Waveform LOD for zooming
+* Alt for scrolling graphs
* Drag and drop
* Multiple Reconstruction views
+* In-project sample selection in Reconstruction view
* Playing a fragment by clicking on a waveform
* Note pitch shown as a transpose offset rather than a note name
@@ -17,7 +19,8 @@
### Workflow
* Waveform construction preview for single-file conversion
-* Selection and trimming for a reconstruction (reconstruction editing)
+* Selection operations on a reconstruction
+* Reconstruction trimming
### Features
@@ -27,13 +30,14 @@
### Technical
* API documentation
-* Code documentation (docstrings)
+* Code documentation
* Backward compatibility: library/reconstruction upgrade scheme
* Respecting FamiTracker limitations
-* Carrying the project comment into a Bitphase document, once the format holds it
* Per-tab undo routing
-* Delete duplicated HistoryAction enumeration
+* In-application console
+* Improve performance of browser favorite scan of the entire tree per click
## Bugs
* No refreshing after library generation
+* Misaligned dialog boxes sizes at initialization
diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md
index 7d553cb0..e0de8283 100644
--- a/docs/development/dependencies.md
+++ b/docs/development/dependencies.md
@@ -51,6 +51,27 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser
`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them.
+## Application icon
+
+The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in
+`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization
+settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The
+package writes the whole suite — the vector `sampletones.svg` and the rasters the application
+ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py`
+points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the
+`assets` dependency group.
+
+The whole suite is committed, so a plain checkout carries the icons the application opens its window
+with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again
+from the mark, and the `icons` pre-push hook writes them for a push that touches either directory,
+holding the committed files to what the mark describes. CI runs that same hook.
+
+Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that:
+`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is
+installed, and PyInstaller follows that import into the bundle. The application reads its icons as
+files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries
+that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it.
+
## Linux (standalone executable)
Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list.
diff --git a/docs/guide/interface.md b/docs/guide/interface.md
index f77ba657..6ff99732 100644
--- a/docs/guide/interface.md
+++ b/docs/guide/interface.md
@@ -16,7 +16,8 @@ begin.
Pick an audio file — or a whole folder — in the **Filesystem** browser on the
left, set up how the reconstruction is done in the centre, and click **Convert
-sample** (or **Convert directory** for a folder). The
+sample** (or **Convert directory** for a folder). The browser opens the folders you
+were last working in, and **Collapse all** folds them away again. The
[instruction library](../concepts/instruction-library.md) for your settings is
built automatically the first time it is needed, so you can convert straight away.
While it runs, the panel names the file going in and where the result is going, and
@@ -37,12 +38,29 @@ reveals. [Configuration](configuration.md) explains each one.
The **Reconstructions** tab is where you audition a reconstruction against the
original, fine-tune it, and export it.
-Open a saved reconstruction from the list on the left; if the current one has
-unsaved edits, you are asked whether to save it first. You can play it back and
+Open a saved reconstruction from the **Browser** on the left, which offers the
+same files two ways: **By configuration** groups them by the settings they were
+made with, and **By sample** gathers every version of one source audio together.
+If the current reconstruction has unsaved edits, you are asked whether to save it
+first. You can play it back and
switch **Play audio source:** between **Reconstruction** and **Original audio** to
compare the two, and **Locate original audio** re-links the source file if it has
moved.
+To keep the reconstructions you return to within reach, right-click one — or a
+whole folder — and choose **Mark as favorite**, which highlights it in both views.
+Tick **Favorites only** under the search box to narrow the browser to your
+favorites and everything inside them. The browser keeps the folders you had open
+while it narrows, so switching the tick on and off leaves the tree as you left it.
+If you would rather it opened its way down to each favorite for you, turn that on
+under **View ▸ Auto-expand favorites**, which answers for reconstructions and for
+folders separately. It opens the way down each time you tick **Favorites only**,
+and unticking folds those rows back.
+
+**Collapse all**, beside the refresh button, folds the whole tree away in one
+click. Whatever you leave open is remembered, so the tree comes back the way you
+left it the next time you start the application.
+
To get your results out, use the **Reconstruction** menu. **Export instruments ▸
FamiTracker instruments...** writes one `.fti` per channel, **Bitphase
presets...** writes the same as `.json`, and **Export to WAV...** renders the
diff --git a/docs/index.md b/docs/index.md
index 3abf6df1..1b02957d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -58,6 +58,7 @@ The [**development**](development/) section is for contributors.
- [Undo engine](development/undo.md) — the design of the undo/redo subsystem.
- [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids.
- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file.
+- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it.
- [Configuration](development/config-organization.md) — how the YAML configuration package is laid out.
- [Coding guidelines](development/guidelines.md) — conventions for the codebase.
- [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on.
diff --git a/pyproject.toml b/pyproject.toml
index 99ca6af6..ac47fab5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,7 +75,9 @@ gpu-cuda11 = [
]
[dependency-groups]
+assets = ["pillow>=11,<13"]
dev = [
+ { include-group = "assets" },
"black==26.5.1",
"isort==8.0.1",
"mypy==2.1.0",
@@ -129,6 +131,7 @@ addopts = "--import-mode=importlib"
[tool.coverage.run]
source = [
"sampletones_application",
+ "sampletones_assets",
"sampletones_core",
"sampletones_shared",
"sampletones_synthesis",
@@ -143,6 +146,7 @@ python_version = "3.12"
files = [
"src/sampletones",
"src/sampletones_application",
+ "src/sampletones_assets",
"src/sampletones_core",
"src/sampletones_shared",
"src/sampletones_synthesis",
diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py
new file mode 100755
index 00000000..c6484eb9
--- /dev/null
+++ b/scripts/assets/icons.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+"""
+Writes the application icon suite from the packaged mark definition.
+
+The mark, its template and the code drawing them live in `sampletones_assets/mark`; this
+script points them at the directory the icons are shipped from.
+
+Usage:
+ python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Final, Sequence
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.suite import write_icon_suite
+
+REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons"
+
+
+def main(argv: Sequence[str]) -> int:
+ """Writes the icon suite and reports each file it produced."""
+
+ parser = argparse.ArgumentParser(
+ description="Write the application icon suite from the mark definition.",
+ )
+ parser.add_argument(
+ "--directory",
+ type=Path,
+ default=ICONS_DIRECTORY,
+ help="directory receiving the icon files",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ for path in write_icon_suite(arguments.directory, Mark.load()):
+ print(f"Wrote {path}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/calibration.py b/scripts/calibration.py
index 870735ea..c129d180 100644
--- a/scripts/calibration.py
+++ b/scripts/calibration.py
@@ -15,8 +15,8 @@
GeneratorName,
SpectrumMethod,
)
-from sampletones_core.paths import USER_PATH_DOCUMENTS
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import USER_PATH_DOCUMENTS
DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration"
DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}"
diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py
index 89a5f9e6..d6a55601 100755
--- a/scripts/checks/language_keys.py
+++ b/scripts/checks/language_keys.py
@@ -39,7 +39,7 @@
from sampletones_shared.meta.source.modules import discover_modules, module_name
from sampletones_shared.meta.source.packages import package_directory
from sampletones_shared.meta.source.values import EnumMembers, EnumTable
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
EnumPredicate = Callable[[object], bool]
diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py
index 32f6f8c5..002c9bf8 100755
--- a/scripts/checks/palette_colors.py
+++ b/scripts/checks/palette_colors.py
@@ -28,7 +28,7 @@
from sampletones_shared.meta.source.modules import SourceModule, discover_modules
from sampletones_shared.meta.source.nodes import terminal_name
from sampletones_shared.meta.source.packages import package_directory
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application")
diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py
index 14931ae4..c2a87b5c 100755
--- a/scripts/checks/unused_tags.py
+++ b/scripts/checks/unused_tags.py
@@ -22,7 +22,7 @@
from sampletones_shared.meta.source.modules import SourceModule, discover_modules
from sampletones_shared.meta.source.packages import package_directory
from sampletones_shared.meta.source.references import count_identifier_loads
-from sampletones_shared.paths import REPOSITORY_ROOT, SOURCE_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags")
REFERENCE_ROOTS: Final[Tuple[Path, ...]] = (
diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py
index 843e3efd..08fd64db 100644
--- a/scripts/ci/checks/bundle.py
+++ b/scripts/ci/checks/bundle.py
@@ -16,6 +16,9 @@
"THIRD-PARTY-LICENSES.txt",
)
+INTERNAL_DIRECTORY: Final[str] = "_internal"
+BUILD_TOOLS: Final[Sequence[str]] = ("PIL",)
+
def launcher_path(bundle: Path, *, system: str) -> Path:
"""The executable a built bundle offers on the platform it was built for."""
@@ -28,8 +31,19 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()]
+def carried_build_tools(bundle: Path) -> List[str]:
+ """The build-time packages found in a bundle, which the notices place on the build machine.
+
+ A bundle carries the application and its runtime dependencies. Tooling that only draws the
+ assets belongs to the machine that builds it, so finding it here means the notices describe
+ a different set of components than the bundle ships.
+ """
+ directories = (bundle, bundle / INTERNAL_DIRECTORY)
+ return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)]
+
+
def main(argv: Sequence[str]) -> int:
- """Confirm a built bundle ships its notices and that its launcher starts."""
+ """Confirm a built bundle ships its notices, holds to them, and that its launcher starts."""
parser = argparse.ArgumentParser(
description="Verify a built bundle before it is archived.",
)
@@ -46,6 +60,11 @@ def main(argv: Sequence[str]) -> int:
print(f"::error::Bundle {bundle} is missing {', '.join(absent)}")
return 1
+ carried = carried_build_tools(bundle)
+ if carried:
+ print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
+ return 1
+
launcher = launcher_path(bundle, system=platform.system())
if not launcher.is_file():
print(f"::error::Bundle {bundle} offers no launcher at {launcher}")
diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh
index 75629a66..3fd6327e 100755
--- a/scripts/linux/build/build.sh
+++ b/scripts/linux/build/build.sh
@@ -28,6 +28,7 @@ else
fi
bash "$SCRIPT_DIR/preflight.sh" "$@"
+bash "$SCRIPT_DIR/icons.sh"
if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then
echo "Removing the previous artifact: ./bin/sampletones"
@@ -44,6 +45,7 @@ echo "Building executable..."
--add-data "src/sampletones_assets/fonts:assets/fonts" \
--add-data "src/sampletones_config:config" \
--copy-metadata sampletones \
+ --exclude-module PIL \
"${RELEASE_HOOK_ARGS[@]}" \
"src/sampletones/__main__.py"
diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh
new file mode 100644
index 00000000..df7bf35b
--- /dev/null
+++ b/scripts/linux/build/icons.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -e
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+. "$SCRIPT_DIR/../lib/root.sh"
+
+PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
+VENV_PY="$PROJECT_DIR/.venv-build/bin/python"
+
+echo "Generating the icon suite..."
+"$VENV_PY" scripts/assets/icons.py
diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh
index 7b829235..d5accc98 100755
--- a/scripts/linux/build/sampletones.sh
+++ b/scripts/linux/build/sampletones.sh
@@ -23,6 +23,6 @@ echo "Installing dependencies..."
EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}")
echo "Installing with extras: $EXTRAS_STR"
-"$VENV_PY" -m pip install ".[$EXTRAS_STR]"
+"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets
echo "sampletones Python package installed successfully."
diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat
index 9b8137d7..915dc471 100644
--- a/scripts/windows/build/build.bat
+++ b/scripts/windows/build/build.bat
@@ -30,6 +30,7 @@ if "%RELEASE%"=="1" (
)
call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1
+call "%SCRIPT_DIR%icons.bat" || exit /b 1
if exist "bin\sampletones.exe" (
echo Removing the previous artifact: bin\sampletones.exe
@@ -51,6 +52,7 @@ echo Building executable...
--add-data "src\sampletones_assets\fonts;assets\fonts" ^
--add-data "src\sampletones_config;config" ^
--copy-metadata sampletones ^
+ --exclude-module PIL ^
%RELEASE_HOOK% ^
"src\sampletones\__main__.py" || exit /b
diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat
new file mode 100644
index 00000000..d4ee7f14
--- /dev/null
+++ b/scripts/windows/build/icons.bat
@@ -0,0 +1,14 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1
+
+set "PROJECT_DIR=%SCRIPT_DIR%..\..\.."
+set "VENV_DIR=%PROJECT_DIR%\.venv-build"
+set "VENV_PY=%VENV_DIR%\Scripts\python.exe"
+
+echo Generating the icon suite...
+"%VENV_PY%" scripts\assets\icons.py || exit /b 1
+
+exit /b 0
diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat
index 35fed651..902ea372 100644
--- a/scripts/windows/build/sampletones.bat
+++ b/scripts/windows/build/sampletones.bat
@@ -22,7 +22,7 @@ echo Installing dependencies...
"%VENV_PY%" -m pip install --upgrade pip
echo Installing with extras: !EXTRAS!
-"%VENV_PY%" -m pip install ".[!EXTRAS!]" || exit /b 1
+"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1
echo sampletones Python package installed successfully.
exit /b 0
diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py
index 9592c1e9..b7625bb2 100644
--- a/src/sampletones/__main__.py
+++ b/src/sampletones/__main__.py
@@ -5,7 +5,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Optional
-from sampletones_core.paths import EXT_FILES_AUDIO
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
if TYPE_CHECKING:
from sampletones_core.configs import Config
@@ -119,7 +119,7 @@ def main() -> None:
config_path = Path(args.config) if args.config else None
output_path = Path(args.output) if args.output else None
- from sampletones_core.paths import (
+ from sampletones_shared.paths.extensions import (
EXT_FILE_LIBRARY,
EXT_FILE_PROJECT,
EXT_FILE_RECONSTRUCTION,
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index ede3f176..ecb423e3 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -46,7 +46,7 @@
ReconstructionTitlePart,
document_title,
)
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.logic.render import SongRenderLogic
from sampletones_application.parameters import (
@@ -81,6 +81,7 @@
from sampletones_application.tags.general import (
TAG_GLOBAL_DIALOG_ABOUT,
TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION,
+ TAG_GLOBAL_TEXTURE_LOGO,
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_MENU_FPS,
TAG_GLOBAL_THEME_PLAYER_BUTTON,
@@ -141,9 +142,9 @@
from sampletones_core.constants.audio import BufferSize, SampleRate
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILES_AUDIO
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.registry import build_tracker_backends
@@ -155,6 +156,7 @@
)
from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
from sampletones_shared.types.application import Sender
SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}"
@@ -389,11 +391,9 @@ def __init__(
export_service=self.export_service,
tracker_backends=self.tracker_backends,
on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation,
- on_reconstruct_file=self._reconstruct_file_dialog,
- on_reconstruct_directory=self._reconstruct_directory_dialog,
on_change_audio_state=self._update_menu,
+ on_favorite_changed=self._repaint_reconstruction_favorites,
on_reconstruction_instrument_updated=self._regenerate_instrument,
- is_operation_active=self._is_operation_active,
original_audio_locator=self._original_audio_locator,
layout=ReconstructionTabParameters.from_config(self.layout),
language_manager=self.language_manager,
@@ -457,6 +457,7 @@ def __init__(
dialogs=self.dialogs,
status_bar=self.status_bar,
on_edit_sample_requested=self._edit_project_sample,
+ on_favorite_changed=self._repaint_reconstruction_favorites,
on_sample_reconstruction_replaced=self._rebind_replaced_sample,
on_tab_switch=self._set_current_tab,
on_nes_frequency_changed=self._retune_samples_for_rate,
@@ -619,6 +620,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
display_settings=self._display_coordinator.open,
keyboard_settings=self._keybindings_coordinator.open,
toggle_advanced_settings=self._toggle_advanced_settings,
+ toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions,
+ toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories,
toggle_fullscreen=self._shell.toggle_fullscreen,
about=self._open_about_dialog,
next_tab=self._next_tab,
@@ -715,6 +718,8 @@ def _build_initial_menu_state(self) -> MenuBarViewModel:
channels=self._sequencer_tab.channels,
fullscreen=self.session_manager.fullscreen,
advanced_settings=self.session_manager.advanced_settings,
+ auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions,
+ auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories,
)
def _is_sequencer_tab_current(self) -> bool:
@@ -754,6 +759,8 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel:
channels=self._sequencer_tab.channels,
fullscreen=self.session_manager.fullscreen,
advanced_settings=self.session_manager.advanced_settings,
+ auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions,
+ auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories,
)
def _on_history_changed(self) -> None:
@@ -806,6 +813,18 @@ def _toggle_advanced_settings(
self._main_tab.toggle_advanced_settings()
self._update_menu()
+ def _toggle_auto_expand_favorite_reconstructions(self) -> None:
+ self.session_manager.set_auto_expand_favorite_reconstructions(
+ not self.session_manager.auto_expand_favorite_reconstructions
+ )
+ self._update_menu()
+
+ def _toggle_auto_expand_favorite_directories(self) -> None:
+ self.session_manager.set_auto_expand_favorite_directories(
+ not self.session_manager.auto_expand_favorite_directories
+ )
+ self._update_menu()
+
def _reconstruct_file_dialog(self) -> None:
if self._is_operation_active():
logger.warning("A conversion or library generation is already in progress; cannot start a new one")
@@ -928,6 +947,16 @@ def _refresh_reconstruction_trees(self) -> None:
self._reconstructions_tab.refresh_browser()
self._sequencer_tab.refresh_browser()
+ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the toggled path in both browsers, whichever tab the star was clicked in.
+
+ The two browsers render one tree and read one set of favorites, so the rows standing for the
+ toggled path are read once here and handed to each of them.
+ """
+ nodes = self.browser_manager.nodes_at(node.filepath)
+ self._reconstructions_tab.repaint_browser_favorites(nodes)
+ self._sequencer_tab.repaint_browser_favorites(nodes)
+
def _navigate_to_reconstructions(self) -> None:
self._set_current_tab(Tab.RECONSTRUCTIONS)
@@ -1144,7 +1173,8 @@ def _open_audio_settings(self) -> None:
)
def _open_about_dialog(self) -> None:
- """Presents the application name, version, description, and authorship in a modal notice."""
+ """Presents the application's mark beside its name, version, description, and authorship."""
+ about = self.layout.general.dialogs.about
description = self.language_manager["global.dialog.message.about_description"]
author_line = self.language_manager["global.dialog.template.about_author"].format(
author=SAMPLETONES_AUTHOR,
@@ -1152,21 +1182,26 @@ def _open_about_dialog(self) -> None:
)
def content(parent: str) -> None:
- name_text = dpg.add_text(SAMPLETONES_NAME_VERSION, parent=parent)
- dpg.add_separator(parent=parent)
- FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE)
- dpg.add_text(
- description,
- parent=parent,
- wrap=self.dialogs.default_wrap,
- )
- author_text = dpg.add_text(author_line, parent=parent)
- FontRegistry.bind_to_item(author_text, Font.ITALIC)
+ with dpg.group(horizontal=True, parent=parent):
+ dpg.add_image(
+ TAG_GLOBAL_TEXTURE_LOGO,
+ width=about.logo,
+ height=about.logo,
+ )
+ with dpg.group():
+ name_text = dpg.add_text(SAMPLETONES_NAME_VERSION)
+ FontRegistry.bind_to_item(name_text, Font.BOLD_TITLE)
+ dpg.add_separator()
+ dpg.add_text(description, wrap=about.text_wrap)
+ author_text = dpg.add_text(author_line)
+ FontRegistry.bind_to_item(author_text, Font.ITALIC)
self.dialogs.show_modal(
get_dialog_tag(TAG_GLOBAL_DIALOG_ABOUT),
self.language_manager["global.dialog.title.about"],
content,
+ width=about.width,
+ height=about.height,
)
def _refresh_audio_devices(self) -> None:
@@ -1326,10 +1361,22 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]:
def _persist_application_state(self) -> None:
self.session_manager.set_current_audio_device(self.audio_device_manager)
self._viewport_manager.save_window_state()
+ self._save_browser_shapes()
current_tab = self._shell.get_current_tab()
self.session_manager.set_current_tab(current_tab)
self.session_manager.save_config()
+ def _save_browser_shapes(self) -> None:
+ """Asks every tab holding a tree to write down which of its rows stand open.
+
+ The shape belongs to the browser showing it, and it is read the once here rather than followed
+ row by row, a pass over the rows running on the tree worker.
+ """
+ self._main_tab.save_browser_shape()
+ self._reconstructions_tab.save_browser_shape()
+ self._sequencer_tab.save_browser_shape()
+ self._instructions_tab.save_browser_shape()
+
def _build_edit_actions(self) -> bool:
"""States the actions of the grid holding the cursor into the Edit menu being built."""
return self._edit_router.build_menu_actions()
diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py
index 7d1fbec7..c032172e 100644
--- a/src/sampletones_application/categories/elements/global_.py
+++ b/src/sampletones_application/categories/elements/global_.py
@@ -29,6 +29,7 @@ class TreeElements(AbstractElement):
SEARCH = "search"
FILTER = "filter"
CLEAR_SEARCH = "clear_search"
+ FAVORITES_ONLY = "favorites_only"
class ContextElements(AbstractElement):
@@ -112,6 +113,9 @@ class MenuElements(AbstractElement):
GROUP_VIEW = "group_view"
ITEM_VIEW_SHOW_ADVANCED_SETTINGS = "item_view_show_advanced_settings"
ITEM_VIEW_FULLSCREEN = "item_view_fullscreen"
+ GROUP_VIEW_AUTO_EXPAND_FAVORITES = "group_view_auto_expand_favorites"
+ ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "item_view_auto_expand_favorite_reconstructions"
+ ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = "item_view_auto_expand_favorite_directories"
ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings"
ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings"
GROUP_HELP = "group_help"
@@ -129,6 +133,7 @@ class StatusElements(AbstractElement):
NODE_LIBRARY = "node_library"
TREE_SEARCH = "tree_search"
CLEAR_SEARCH = "clear_search"
+ FAVORITES_ONLY = "favorites_only"
INPUT = "input"
COMBO = "combo"
NODE_DIRECTORY = "node_directory"
@@ -160,6 +165,7 @@ class GraphElements(AbstractElement):
class GlobalMessageElements(AbstractElement):
TREE_NO_RESULTS = "tree_no_results"
+ TREE_NO_FAVORITES = "tree_no_favorites"
INVALID_METADATA_ERROR = "invalid_metadata_error"
RECONSTRUCTION_NO_DATA = "reconstruction_no_data"
RECONSTRUCTION_SAVED_SUCCESSFULLY = "reconstruction_saved_successfully"
diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py
index c6823a69..496e1b82 100644
--- a/src/sampletones_application/categories/elements/main.py
+++ b/src/sampletones_application/categories/elements/main.py
@@ -4,7 +4,6 @@
class ExplorerElements(AbstractElement):
SECTION = "section"
REFRESH_BUTTON = "refresh_button"
- COLLAPSE_ALL_BUTTON = "collapse_all_button"
CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction"
CONTEXT_LOAD_LIBRARY = "context_load_library"
CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file"
@@ -12,7 +11,6 @@ class ExplorerElements(AbstractElement):
CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory"
CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory"
STATUS_REFRESH = "status_refresh"
- STATUS_COLLAPSE_ALL = "status_collapse_all"
STATUS_NODE_AUDIO_NO_AUTOPLAY = "status_node_audio_no_autoplay"
STATUS_NODE_AUDIO = "status_node_audio"
STATUS_NODE_LIBRARY = "status_node_library"
diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py
index 6fa1ac89..ec4b1af2 100644
--- a/src/sampletones_application/categories/elements/settings.py
+++ b/src/sampletones_application/categories/elements/settings.py
@@ -78,6 +78,8 @@ class KeybindingActionElements(AbstractElement):
DISPLAY_SETTINGS = "display_settings"
KEYBOARD_SETTINGS = "keyboard_settings"
TOGGLE_ADVANCED_SETTINGS = "toggle_advanced_settings"
+ TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions"
+ TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories"
TOGGLE_FULLSCREEN = "toggle_fullscreen"
ABOUT_DIALOG = "about_dialog"
NEXT_TAB = "next_tab"
diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py
index 0a5fbfa4..8a2689e6 100644
--- a/src/sampletones_application/categories/hierarchy.py
+++ b/src/sampletones_application/categories/hierarchy.py
@@ -30,6 +30,7 @@ class Widget(StrEnum):
TABLE = "table"
TABS = "tabs"
TEXT = "text"
+ TEXTURE = "texture"
THEME = "theme"
TOOLTIP = "tooltip"
TREE = "tree"
diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py
index 206b541d..20f91256 100644
--- a/src/sampletones_application/config/managers/application.py
+++ b/src/sampletones_application/config/managers/application.py
@@ -132,6 +132,20 @@ def toggle_autoplay(self) -> bool:
self.config.playback.autoplay = not self.config.playback.autoplay
return self.config.playback.autoplay
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self.config.browser.auto_expand_favorite_reconstructions
+
+ def set_auto_expand_favorite_reconstructions(self, value: bool) -> None:
+ self.config.browser.auto_expand_favorite_reconstructions = value
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self.config.browser.auto_expand_favorite_directories
+
+ def set_auto_expand_favorite_directories(self, value: bool) -> None:
+ self.config.browser.auto_expand_favorite_directories = value
+
@property
def follow_mode(self) -> FollowMode:
return self.config.playback.follow_mode
diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py
index bd2fadd7..cd703831 100644
--- a/src/sampletones_application/config/managers/config.py
+++ b/src/sampletones_application/config/managers/config.py
@@ -20,9 +20,9 @@
from sampletones_core.data.metadata import Metadata
from sampletones_core.fft import Window
from sampletones_core.library import InstructionLibraryKey
-from sampletones_core.paths import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.constants.project import RECONSTRUCTIONS_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.serialization import load_json
from sampletones_shared.utils.validation import validate_with_recovery
diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py
index 7725786b..c5ee6fa1 100644
--- a/src/sampletones_application/config/managers/session.py
+++ b/src/sampletones_application/config/managers/session.py
@@ -53,9 +53,34 @@ def is_card_collapsed(self, card_tag: str) -> bool:
def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None:
self._state_manager.set_card_collapsed(card_tag, collapsed)
+ def is_favorites_filter_active(self, panel_tag: str) -> bool:
+ return self._state_manager.is_favorites_filter_active(panel_tag)
+
+ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None:
+ self._state_manager.set_favorites_filter_active(panel_tag, active)
+
+ def expanded_rows(self, panel_tag: str) -> Set[str]:
+ return self._state_manager.expanded_rows(panel_tag)
+
+ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None:
+ self._state_manager.set_expanded_rows(panel_tag, rows)
+
+ @property
+ def expanded_directories(self) -> Set[Path]:
+ return self._state_manager.expanded_directories
+
+ def set_expanded_directories(self, directories: Set[Path]) -> None:
+ self._state_manager.set_expanded_directories(directories)
+
def toggle_autoplay(self) -> bool:
return self._config_manager.toggle_autoplay()
+ def set_auto_expand_favorite_reconstructions(self, value: bool) -> None:
+ self._config_manager.set_auto_expand_favorite_reconstructions(value)
+
+ def set_auto_expand_favorite_directories(self, value: bool) -> None:
+ self._config_manager.set_auto_expand_favorite_directories(value)
+
def set_follow_mode(self, value: FollowMode) -> None:
self._config_manager.set_follow_mode(value)
@@ -226,6 +251,14 @@ def advanced_settings(self) -> bool:
def autoplay(self) -> bool:
return self._config_manager.autoplay
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._config_manager.auto_expand_favorite_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._config_manager.auto_expand_favorite_directories
+
@property
def follow_mode(self) -> FollowMode:
return self._config_manager.follow_mode
diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py
index 9d12c5f2..97936b04 100644
--- a/src/sampletones_application/config/managers/state.py
+++ b/src/sampletones_application/config/managers/state.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Optional
+from typing import Optional, Set
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.session.state.state import ApplicationState
@@ -94,6 +94,27 @@ def is_card_collapsed(self, card_tag: str) -> bool:
def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None:
self.state.collapsed_cards[card_tag] = collapsed
+ def is_favorites_filter_active(self, panel_tag: str) -> bool:
+ return self.state.favorites_filters.get(panel_tag, False)
+
+ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None:
+ self.state.favorites_filters[panel_tag] = active
+
+ def expanded_rows(self, panel_tag: str) -> Set[str]:
+ return set(self.state.expanded_rows.get(panel_tag, ()))
+
+ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None:
+ """Writes the rows a browser stands open, in a settled order so the file reads the same twice."""
+ self.state.expanded_rows[panel_tag] = sorted(rows)
+
+ @property
+ def expanded_directories(self) -> Set[Path]:
+ return set(self.state.expanded_directories)
+
+ def set_expanded_directories(self, directories: Set[Path]) -> None:
+ """Writes the folders the explorer stands open, in a settled order for a file read twice."""
+ self.state.expanded_directories = sorted(directories)
+
def load_current_tab(self) -> Tab:
return self.state.current.tab
diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py
index b6fcd9c7..239bfeaf 100644
--- a/src/sampletones_application/config/profile.py
+++ b/src/sampletones_application/config/profile.py
@@ -4,7 +4,7 @@
from pathlib import Path
from sampletones_application.paths import APPLICATION_STATE_PATH
-from sampletones_core.paths import APPLICATION_CONFIG_PATH
+from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
@dataclass(frozen=True)
diff --git a/src/sampletones_application/config/session/application/browser.py b/src/sampletones_application/config/session/application/browser.py
new file mode 100644
index 00000000..73c18da2
--- /dev/null
+++ b/src/sampletones_application/config/session/application/browser.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, Field
+
+
+class BrowserConfig(BaseModel):
+ auto_expand_favorite_reconstructions: bool = Field(
+ default=False,
+ description="If showing the favorites alone opens the rows above a favorite reconstruction.",
+ )
+ auto_expand_favorite_directories: bool = Field(
+ default=False,
+ description="If showing the favorites alone opens the rows above a favorite directory.",
+ )
diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py
index 35ca10ca..0c3fc0c9 100644
--- a/src/sampletones_application/config/session/application/config.py
+++ b/src/sampletones_application/config/session/application/config.py
@@ -1,6 +1,7 @@
from pydantic import BaseModel, ConfigDict, Field
from sampletones_application.config.session.application.audio import AudioConfig
+from sampletones_application.config.session.application.browser import BrowserConfig
from sampletones_application.config.session.application.display import DisplayConfig
from sampletones_application.config.session.application.favorites import Favorites
from sampletones_application.config.session.application.history import HistoryConfig
@@ -20,6 +21,10 @@ class ApplicationConfig(BaseModel):
default_factory=AudioConfig,
description="The audio configuration settings.",
)
+ browser: BrowserConfig = Field(
+ default_factory=BrowserConfig,
+ description="How the browsers of reconstructions read what they narrow to.",
+ )
display: DisplayConfig = Field(
default_factory=DisplayConfig,
description="The palette and frame pacing preferences.",
diff --git a/src/sampletones_application/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py
index 3d8d300c..885909d6 100644
--- a/src/sampletones_application/config/session/state/paths.py
+++ b/src/sampletones_application/config/session/state/paths.py
@@ -2,7 +2,7 @@
from pydantic import BaseModel, Field, field_serializer
-from sampletones_core.paths import (
+from sampletones_shared.paths.user import (
CONFIG_PATH,
LIBRARY_DIRECTORY,
PROJECTS_DIRECTORY,
diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py
index 00853aec..2524a797 100644
--- a/src/sampletones_application/config/session/state/state.py
+++ b/src/sampletones_application/config/session/state/state.py
@@ -1,4 +1,5 @@
-from typing import Dict
+from pathlib import Path
+from typing import Dict, List
from pydantic import BaseModel, Field
@@ -20,6 +21,18 @@ class ApplicationState(BaseModel):
default_factory=dict,
description="Collapsed state of each card, keyed by the card's tag.",
)
+ favorites_filters: Dict[str, bool] = Field(
+ default_factory=dict,
+ description="Whether each browser shows its favorites alone, keyed by the panel's tag.",
+ )
+ expanded_rows: Dict[str, List[str]] = Field(
+ default_factory=dict,
+ description="The rows each browser stands open, keyed by the panel's tag.",
+ )
+ expanded_directories: List[Path] = Field(
+ default_factory=list,
+ description="The folders the Main tab's explorer stands open.",
+ )
current: Current = Field(
default_factory=Current,
description="The current state of application elements, e.g. selected tab.",
diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py
index 92567fc8..282f1df8 100644
--- a/src/sampletones_application/coordinators/config.py
+++ b/src/sampletones_application/coordinators/config.py
@@ -26,9 +26,9 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_JSON
from sampletones_shared.application import SAMPLETONES_VERSION
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_JSON
from sampletones_shared.utils.validation import flatten_location
_LOAD_FAILURE_MESSAGES: Dict[ConfigLoadFailureReason, GlobalMessageElements] = {
diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py
index 295d790b..63f755e3 100644
--- a/src/sampletones_application/coordinators/project.py
+++ b/src/sampletones_application/coordinators/project.py
@@ -31,7 +31,6 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_PROJECT
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.scope import ExportScope
@@ -45,6 +44,7 @@
SerializationError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_PROJECT
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_directory, get_filename
diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py
index f63b21e2..d2e5222a 100644
--- a/src/sampletones_application/coordinators/reconstruction.py
+++ b/src/sampletones_application/coordinators/reconstruction.py
@@ -30,10 +30,10 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.types.feature import FeatureValue
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py
index 69742232..fb6d0b4f 100644
--- a/src/sampletones_application/coordinators/tabs/instructions.py
+++ b/src/sampletones_application/coordinators/tabs/instructions.py
@@ -69,6 +69,7 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import VoidCallback
@@ -135,6 +136,7 @@ def __init__(
self._library_tree_logic,
scheduling=layout.scheduling,
initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_LIBRARY_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_INSTRUCTIONS_LIBRARY_PANEL),
language_manager=language_manager,
status_bar=status_bar,
colors=layout.tree_colors,
@@ -142,7 +144,7 @@ def __init__(
)
self._library_panel.set_collapse_handler(self._on_library_collapse_changed)
self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled
- self._library_tree_logic.on_favorite_changed = self._library_panel.update_favorite_indicator
+ self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites
self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility
self._library_logic.configure_lock(
@@ -352,6 +354,10 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
"""Persists a centre-column card's collapsed state so it restores on the next launch."""
self._session_manager.set_card_collapsed(card_tag, collapsed)
+ def _repaint_library_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row."""
+ self._library_panel.update_favorite_indicators((node,))
+
def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
"""Persists the library panel's collapse, then docks or restores the width of the column it fills."""
self._session_manager.set_card_collapsed(card_tag, collapsed)
@@ -480,6 +486,13 @@ def load_library_safely(self, filepath: Path) -> None:
except (SampleToNESError, OSError) as exception:
logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}")
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the catalogue stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._library_panel.tag,
+ self._library_panel.expanded_rows,
+ )
+
def is_library_generating(self) -> bool:
return self._library_logic.is_library_generating()
diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py
index a84eac4f..3cc17ada 100644
--- a/src/sampletones_application/coordinators/tabs/main.py
+++ b/src/sampletones_application/coordinators/tabs/main.py
@@ -62,6 +62,7 @@
)
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import PathCallback, VoidCallback
@@ -129,6 +130,7 @@ def __init__(
self._explorer_logic: ExplorerLogic = ExplorerLogic(
config_manager,
language_manager=language_manager,
+ open_directories=session_manager.expanded_directories,
)
self._explorer_tree_logic: TreeLogic = TreeLogic(
session_manager,
@@ -145,7 +147,7 @@ def __init__(
initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL),
)
self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled
- self._explorer_tree_logic.on_favorite_changed = self._explorer_panel.update_favorite_indicator
+ self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites
self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility
self._explorer_tree_logic.on_autoplay_error = self._on_explorer_autoplay_error
@@ -252,6 +254,10 @@ def __init__(
self._converter_panel.on_convert_requested = self._converter_logic.start_conversion
self._converter_panel.on_cancel_requested = self._request_cancel_confirmation
+ def _repaint_explorer_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row."""
+ self._explorer_panel.update_favorite_indicators((node,))
+
def _on_explorer_autoplay_error(self, exception: Exception) -> None:
FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception))
@@ -488,6 +494,10 @@ def refresh_converter_view(self) -> None:
def set_input_path(self, path: Path, convert: bool) -> None:
self._converter_logic.set_input_path(path, convert=convert)
+ def save_browser_shape(self) -> None:
+ """Writes down the folders the explorer stands open, so a later run reads down to them."""
+ self._session_manager.set_expanded_directories(self._explorer_logic.open_directories)
+
def refresh_browser(self) -> None:
self._explorer_panel.refresh()
diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py
index 5053f44c..53a77dbc 100644
--- a/src/sampletones_application/coordinators/tabs/reconstruction.py
+++ b/src/sampletones_application/coordinators/tabs/reconstruction.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Callable, Dict, Optional, Tuple
+from typing import Callable, Dict, Optional, Sequence, Tuple
import dearpygui.dearpygui as dpg
@@ -15,8 +15,8 @@
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.guard import GuardedPlayer
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
-from sampletones_application.logic.reconstruction.browser import BrowserLogic
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.reconstruction.instruments import (
OnReconstructionInstrumentUpdatedCallback,
ReconstructionInstrumentsLogic,
@@ -59,7 +59,9 @@
from sampletones_application.ui.panels.reconstruction.audio import (
GUIReconstructionAudioPanel,
)
-from sampletones_application.ui.panels.reconstruction.browser import GUIBrowserPanel
+from sampletones_application.ui.panels.reconstruction.browser import (
+ GUIReconstructionsBrowserPanel,
+)
from sampletones_application.ui.panels.reconstruction.instruments.instruments import (
GUIReconstructionInstrumentsPanel,
)
@@ -79,7 +81,7 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.paths import EXT_FILE_WAVE
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.scope import ExportScope
@@ -92,6 +94,7 @@
LoadReconstructionError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.types.callback import PathCallback, VoidCallback
_LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT)
@@ -110,11 +113,9 @@ def __init__(
export_service: ExportService,
tracker_backends: Dict[TrackerFormat, TrackerBackend],
on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None],
- on_reconstruct_file: VoidCallback,
- on_reconstruct_directory: VoidCallback,
on_change_audio_state: VoidCallback,
+ on_favorite_changed: Callable[[FileSystemNode], None],
on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback,
- is_operation_active: Callable[[], bool],
original_audio_locator: OriginalAudioLocator,
*,
layout: ReconstructionTabParameters,
@@ -156,21 +157,23 @@ def __init__(
audio_device_manager,
scheduling=layout.scheduling,
)
- self._browser_panel: GUIBrowserPanel = GUIBrowserPanel(
+ self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel(
self._browser_logic.tree,
self._browser_tree_logic,
scheduling=layout.scheduling,
language_manager=language_manager,
status_bar=status_bar,
colors=layout.tree_colors,
- is_operation_active=is_operation_active,
initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
+ initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
)
self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled
- self._browser_tree_logic.on_favorite_changed = self._browser_panel.update_favorite_indicator
+ self._browser_tree_logic.on_favorite_changed = on_favorite_changed
self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility
self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error
self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
+ self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
self._reconstruction_player_logic = PlayerLogic(
audio_device_manager,
on_change_audio_state,
@@ -218,8 +221,6 @@ def __init__(
)
self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree
- self._browser_panel.on_reconstruct_file = on_reconstruct_file
- self._browser_panel.on_reconstruct_directory = on_reconstruct_directory
self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation
self._browser_panel.on_reconstruction_remove_requested = self._request_remove_reconstruction
self._browser_panel.on_directory_remove_requested = self._request_remove_directory
@@ -496,6 +497,14 @@ def _on_browser_collapse_changed(
self._session_manager.set_card_collapsed(card_tag, collapsed)
self._sync_browser_width()
+ def _on_browser_favorites_filter_changed(
+ self,
+ panel_tag: str,
+ favorites_only: bool,
+ ) -> None:
+ """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
+ self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
+
def _on_instruments_collapse_changed(
self,
card_tag: str,
@@ -549,6 +558,16 @@ def unlock(self) -> None:
def refresh_browser(self) -> None:
self._browser_panel.refresh()
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the browser stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._browser_panel.tag,
+ self._browser_panel.expanded_rows,
+ )
+
+ def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ self._browser_panel.update_favorite_indicators(nodes)
+
def display_reconstruction(self) -> None:
self._reconstruction_panel_logic.display_reconstruction()
self._reconstruction_instruments_logic.update_display()
diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py
index 5b7f7f4e..5d16f7c1 100644
--- a/src/sampletones_application/coordinators/tabs/sequencer.py
+++ b/src/sampletones_application/coordinators/tabs/sequencer.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Callable, Optional, ParamSpec, Tuple, Union
+from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union
import dearpygui.dearpygui as dpg
@@ -19,7 +19,7 @@
from sampletones_application.logic.history.manager import HistoryManager
from sampletones_application.logic.history.transaction import CoalesceKey
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic
from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic
from sampletones_application.logic.sequencer.clipboard import (
@@ -127,6 +127,7 @@
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.project.song_position import SongPosition
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import StringCallback, VoidCallback
@@ -157,6 +158,7 @@ def __init__(
dialogs: DialogsRenderer,
status_bar: GUIStatusBar,
on_edit_sample_requested: StringCallback,
+ on_favorite_changed: Callable[[FileSystemNode], None],
on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None],
on_tab_switch: Callable[[Tab], None],
on_nes_frequency_changed: Callable[[int], None],
@@ -167,6 +169,7 @@ def __init__(
self._history = history
self._original_audio_locator = original_audio_locator
self._on_edit_sample_requested = on_edit_sample_requested
+ self._on_favorite_changed = on_favorite_changed
self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced
self._on_tab_switch = on_tab_switch
self._on_nes_frequency_changed = on_nes_frequency_changed
@@ -204,6 +207,8 @@ def __init__(
status_bar=status_bar,
colors=layout.tree_colors,
initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL),
)
self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller)
self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller)
@@ -626,6 +631,7 @@ def _wire_samples_callbacks(self) -> None:
def _wire_browser_callbacks(self) -> None:
self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
+ self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction
self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open
self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction
@@ -633,7 +639,7 @@ def _wire_browser_callbacks(self) -> None:
self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate
self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree
self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled
- self._sequencer_tree_logic.on_favorite_changed = self._sequencer_browser_panel.update_favorite_indicator
+ self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed
self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility
self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error
@@ -659,6 +665,10 @@ def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
self._session_manager.set_card_collapsed(card_tag, collapsed)
self._sync_browser_width()
+ def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None:
+ """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
+ self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
+
def sync_responsive_layout(self) -> None:
"""Refits this tab's side column to the current viewport, the entry the resize handler calls."""
self._sync_browser_width()
@@ -947,6 +957,16 @@ def repaint(self) -> None:
def refresh_browser(self) -> None:
self._sequencer_browser_panel.refresh()
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the browser stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._sequencer_browser_panel.tag,
+ self._sequencer_browser_panel.expanded_rows,
+ )
+
+ def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ self._sequencer_browser_panel.update_favorite_indicators(nodes)
+
def _on_song_changed(self) -> None:
self._sequencer_tracker_logic.push_settings()
self._sequencer_tracker_logic.push_tracker()
diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py
index 14c90435..7c5f4410 100644
--- a/src/sampletones_application/layout/fonts.py
+++ b/src/sampletones_application/layout/fonts.py
@@ -13,27 +13,31 @@ class Step(Enum):
SMALL = "small"
MEDIUM = "medium"
LARGE = "large"
+ TITLE = "title"
class FontScale(BaseModel, extra="forbid", frozen=True):
small: int
medium: int
large: int
+ title: int
def step(self, step: Step) -> int:
return {
Step.SMALL: self.small,
Step.MEDIUM: self.medium,
Step.LARGE: self.large,
+ Step.TITLE: self.title,
}[step]
class FontsLayout(BaseModel, extra="forbid", frozen=True):
"""Per-typeface pixel-size scales for every rendered font.
- Each typeface carries its own ``small``/``medium``/``large`` scale, so Sans and
- Mono are tuned to the same apparent size independently. ``scale`` is the DearPyGui
- global font multiplier applied on top.
+ Each typeface carries its own ``small``/``medium``/``large``/``title`` scale, so Sans
+ and Mono are tuned to the same apparent size independently, and a rung is drawn at
+ where a font asks for it. ``scale`` is the DearPyGui global font multiplier applied
+ on top.
"""
scale: int
diff --git a/src/sampletones_application/layout/general/dialogs/about.py b/src/sampletones_application/layout/general/dialogs/about.py
new file mode 100644
index 00000000..c7cf36db
--- /dev/null
+++ b/src/sampletones_application/layout/general/dialogs/about.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+
+class AboutDialogLayout(BaseModel, extra="forbid", frozen=True):
+ """The About dialog's size, the size its mark is drawn at, and the room left around the mark."""
+
+ width: int
+ height: int
+ logo: int
+ padding: int
+
+ @property
+ def text_wrap(self) -> int:
+ """Width the text standing beside the mark wraps at."""
+ return self.width - self.logo - self.padding
diff --git a/src/sampletones_application/layout/general/dialogs/dialogs.py b/src/sampletones_application/layout/general/dialogs/dialogs.py
index 700b5a76..3d1d8ebf 100644
--- a/src/sampletones_application/layout/general/dialogs/dialogs.py
+++ b/src/sampletones_application/layout/general/dialogs/dialogs.py
@@ -1,5 +1,6 @@
from pydantic import BaseModel
+from sampletones_application.layout.general.dialogs.about import AboutDialogLayout
from sampletones_application.layout.general.dialogs.height import DialogSizeNoWidth
from sampletones_application.layout.primitives import Dimensions
@@ -11,3 +12,4 @@ class DialogsLayout(BaseModel, extra="forbid", frozen=True):
confirmation: DialogSizeNoWidth
text_input: DialogSizeNoWidth
traceback: Dimensions
+ about: AboutDialogLayout
diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py
index 05ca9155..def2e397 100644
--- a/src/sampletones_application/logic/instruction/library_manager.py
+++ b/src/sampletones_application/logic/instruction/library_manager.py
@@ -18,7 +18,6 @@
from sampletones_core.library.creator import InstructionsLibraryCreator
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.parallelization import TaskProgress, TaskStatus
-from sampletones_core.paths import EXT_FILE_LIBRARY
from sampletones_core.structures.tree import (
GeneratorNode,
LibraryNode,
@@ -27,6 +26,7 @@
TreeNode,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py
index 5e665f2c..40a1ec06 100644
--- a/src/sampletones_application/logic/main/explorer.py
+++ b/src/sampletones_application/logic/main/explorer.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from typing import AbstractSet, Set
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
@@ -12,10 +13,12 @@ def __init__(
config_manager: ConfigManager,
*,
language_manager: LanguageManager,
+ open_directories: AbstractSet[Path],
) -> None:
self._manager = ExplorerManager(
config_manager,
language_manager=language_manager,
+ open_directories=open_directories,
)
@property
@@ -25,8 +28,18 @@ def tree(self) -> Tree:
def refresh_tree(self) -> None:
self._manager.refresh_tree()
- def is_directory_expanded(self, filepath: Path) -> bool:
- return self._manager.is_directory_expanded(filepath)
+ def has_loaded_children(self, filepath: Path) -> bool:
+ return self._manager.has_loaded_children(filepath)
+
+ def is_directory_open(self, filepath: Path) -> bool:
+ return self._manager.is_directory_open(filepath)
+
+ def set_directory_open(self, filepath: Path, is_open: bool) -> None:
+ self._manager.set_directory_open(filepath, is_open)
+
+ @property
+ def open_directories(self) -> Set[Path]:
+ return self._manager.open_directories
def expand_directory(self, node: FileSystemNode) -> None:
self._manager.expand_directory(node)
diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py
index 40b84ed2..0458af98 100644
--- a/src/sampletones_application/logic/main/explorer_manager.py
+++ b/src/sampletones_application/logic/main/explorer_manager.py
@@ -1,38 +1,56 @@
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import AbstractSet, List, Optional, Set
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_core.paths import (
- EXT_FILE_LIBRARY,
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
FileSystemNode,
NodeType,
Tree,
TreeNode,
+ create_directory_node,
+)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_LIBRARY,
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
)
from sampletones_shared.utils.system.system import System
class ExplorerManager:
+ """Reads the filesystem into the Main tab's tree, a folder at a time as the reader opens it.
+
+ Two facts are held about a folder: whether its children have been read, and whether its row stands
+ open. They part company — a folder the reader read and then folded away is loaded and closed — and
+ the shape a session is left in is the open one, which is what a later run is handed back.
+ """
+
def __init__(
self,
config_manager: ConfigManager,
depth: int = 0,
*,
language_manager: LanguageManager,
+ open_directories: AbstractSet[Path],
) -> None:
self._language_manager = language_manager
self.tree = Tree()
self.config_manager = config_manager
- self._expanded_directories: Dict[Path, bool] = {}
+ self._loaded_directories: Set[Path] = set()
+ self._open_directories: Set[Path] = {path for path in open_directories if path.is_dir()}
self.depth = depth
def refresh_tree(self) -> None:
+ """Reads the filesystem afresh, down to every folder the tree has to show a row for.
+
+ A refresh builds the tree from nothing, so ``_loaded_directories`` starts empty and each folder
+ it needs is read into it once: the rows a read places under a folder stand as the walk carries
+ on deeper.
+ """
+ self._loaded_directories.clear()
container_root = TreeNode(
name=self._language_manager["global.browser.label.root"],
node_type=NodeType.ROOT,
@@ -45,25 +63,37 @@ def refresh_tree(self) -> None:
parent=container_root,
)
- selected_path = self._get_ancestor_of_selected(filesystem_path)
- if selected_path is not None:
- self._expand_path_to_selected(filesystem_node, selected_path)
+ for path in self._paths_to_reveal(filesystem_path):
+ self._expand_path_to(filesystem_node, path)
self.tree.set_root(container_root)
+ def _paths_to_reveal(self, filesystem_path: Path) -> List[Path]:
+ """The folders a refresh reads down to, among the ones this filesystem holds.
+
+ A folder standing open is read again so it comes back open, and the directories the
+ application works in are revealed so the reader finds them without walking there.
+ """
+ candidates = (*sorted(self._open_directories), *self.selected_directories)
+ return [path for path in candidates if self._holds(filesystem_path, path)]
+
+ def _holds(self, filesystem_path: Path, path: Path) -> bool:
+ return path == filesystem_path or filesystem_path in path.parents
+
def _create_directory_node(
self,
directory_path: Path,
parent: Optional[TreeNode] = None,
) -> FileSystemNode:
- node = FileSystemNode(
+ node = create_directory_node(
+ directory_path,
name=directory_path.name or str(directory_path),
- filepath=directory_path,
- node_type=NodeType.DIRECTORY,
+ config=ConfigDirectoryFields.from_directory_name(directory_path.name),
parent=parent,
)
self._load_directory_children(node)
+ self._open_directories.add(node.filepath)
return node
def _load_directory_children(
@@ -72,13 +102,10 @@ def _load_directory_children(
level: int = 0,
) -> None:
directory_path = directory_node.filepath
- if not directory_path.is_dir():
+ if not directory_path.is_dir() or directory_path in self._loaded_directories:
return
- self._expanded_directories[directory_path] = level == 0
- for existing_child in list(directory_node.children):
- existing_child.parent = None
-
+ self._loaded_directories.add(directory_path)
try:
entries = sorted(
directory_path.iterdir(),
@@ -93,10 +120,10 @@ def _load_directory_children(
if entry_path.name.startswith("."):
continue
- child_node = FileSystemNode(
+ child_node = create_directory_node(
+ entry_path,
name=entry_path.name,
- filepath=entry_path,
- node_type=NodeType.DIRECTORY,
+ config=ConfigDirectoryFields.from_directory_name(entry_path.name),
parent=directory_node,
)
if level < self.depth:
@@ -143,7 +170,9 @@ def has_relevant_content(self, directory_path: Path) -> bool:
return False
def collapse_all(self) -> None:
- self._expanded_directories.clear()
+ """Folds every folder away and drops what was read, so opening one lists it as it stands."""
+ self._loaded_directories.clear()
+ self._open_directories.clear()
root = self.tree.get_root()
if not root:
@@ -155,15 +184,32 @@ def collapse_all(self) -> None:
child.parent = None
def expand_directory(self, directory_node: FileSystemNode) -> None:
+ """Reads a folder's children the first time it is opened, which is what fills its row."""
if directory_node.node_type != NodeType.DIRECTORY:
return
- directory_path = directory_node.filepath
- if not self.is_directory_expanded(directory_path):
- self._load_directory_children(directory_node)
+ self._load_directory_children(directory_node)
- def is_directory_expanded(self, directory_path: Path) -> bool:
- return self._expanded_directories.get(directory_path, False)
+ def has_loaded_children(self, directory_path: Path) -> bool:
+ """Whether the folder's children have been read, which is what a row below it needs."""
+ return directory_path in self._loaded_directories
+
+ def is_directory_open(self, directory_path: Path) -> bool:
+ """Whether the folder's row stands open, which a refresh brings it back as."""
+ return directory_path in self._open_directories
+
+ def set_directory_open(self, directory_path: Path, is_open: bool) -> None:
+ """Takes what a click left the folder standing as, which is the shape a session writes down."""
+ if is_open:
+ self._open_directories.add(directory_path)
+ return
+
+ self._open_directories.discard(directory_path)
+
+ @property
+ def open_directories(self) -> Set[Path]:
+ """The folders standing open, which is the shape a later run is handed back."""
+ return set(self._open_directories)
def _get_filesystems(self) -> List[Path]:
system = System.current()
@@ -182,23 +228,18 @@ def _get_windows_drives(self) -> List[Path]:
return drives
- def _get_ancestor_of_selected(self, path: Path) -> Optional[Path]:
- for selected_path in self.selected_directories:
- try:
- selected_path.relative_to(path)
- return selected_path
- except ValueError:
- continue
-
- return None
-
- def _expand_path_to_selected(
+ def _expand_path_to(
self,
filesystem_node: FileSystemNode,
- selected_path: Path,
+ path: Path,
) -> None:
+ """Reads the folders down to a path, so a row stands for it and for every folder above it.
+
+ Each folder walked through is opened, that being what shows the row below it. The folder at the
+ end is read as well where it stands open, so it comes back holding what it held.
+ """
try:
- relative_parts = selected_path.relative_to(filesystem_node.filepath).parts
+ relative_parts = path.relative_to(filesystem_node.filepath).parts
except ValueError:
return
@@ -208,13 +249,27 @@ def _expand_path_to_selected(
for part in relative_parts:
current_path = current_path / part
self._load_directory_children(current_node)
+ self._open_directories.add(current_node.filepath)
+
+ child = self._child_at(current_node, current_path)
+ if child is None:
+ return
+
+ current_node = child
+
+ if self.is_directory_open(current_node.filepath):
+ self._load_directory_children(current_node)
- for child in current_node.children:
- if isinstance(child, FileSystemNode) and child.filepath == current_path:
- current_node = child
- break
- else:
- break
+ def _child_at(
+ self,
+ directory_node: FileSystemNode,
+ path: Path,
+ ) -> Optional[FileSystemNode]:
+ for child in directory_node.children:
+ if isinstance(child, FileSystemNode) and child.filepath == path:
+ return child
+
+ return None
@property
def selected_directories(self) -> List[Path]:
diff --git a/src/sampletones_application/logic/reconstruction/browser/__init__.py b/src/sampletones_application/logic/reconstruction/browser/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser.py b/src/sampletones_application/logic/reconstruction/browser/logic.py
similarity index 93%
rename from src/sampletones_application/logic/reconstruction/browser.py
rename to src/sampletones_application/logic/reconstruction/browser/logic.py
index 9e1550f5..4b512b02 100644
--- a/src/sampletones_application/logic/reconstruction/browser.py
+++ b/src/sampletones_application/logic/reconstruction/browser/logic.py
@@ -1,7 +1,7 @@
from pathlib import Path
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_core.structures.tree import Tree
from sampletones_shared.utils.system.filesystem import remove_path
diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py
new file mode 100644
index 00000000..4fd3deef
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/manager.py
@@ -0,0 +1,93 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.config import ConfigManager
+from sampletones_application.logic.reconstruction.browser.tree.collapse import (
+ collapse_single_child_containers,
+)
+from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import (
+ build_configuration_branch,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.order import order_children
+from sampletones_application.logic.reconstruction.browser.tree.prune import (
+ prune_empty_containers,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.branch import (
+ build_sample_branch,
+)
+from sampletones_application.logic.reconstruction.browser.tree.scan import (
+ scan_reconstructions,
+)
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+
+
+class BrowserManager:
+ """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory.
+
+ A refresh scans the directory, builds the configuration branch and the sample branch from that
+ one reading, shapes what came out — empty headings pruned, lone headings folded into the row they
+ lead to, siblings ordered — and publishes the result as the tree both browser tabs render.
+ """
+
+ def __init__(
+ self,
+ config_manager: ConfigManager,
+ *,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._language_manager = language_manager
+ self.config_manager = config_manager
+ self.reconstructions_directory = config_manager.get_reconstructions_directory()
+
+ self.tree = Tree()
+ self._scan = ReconstructionScan(entries=())
+
+ def set_reconstructions_directory(self, directory: Path) -> None:
+ self.reconstructions_directory = directory
+ self.refresh_tree()
+
+ def refresh_tree(self) -> None:
+ if not self.reconstructions_directory.is_dir():
+ self._scan = ReconstructionScan(entries=())
+ self.tree.set_root(None)
+ return
+
+ self._scan = scan_reconstructions(self.reconstructions_directory)
+ self.tree.set_root(self._build_root(self._scan))
+
+ def _build_root(self, scan: ReconstructionScan) -> TreeNode:
+ container_root = TreeNode(
+ name=self._language_manager["global.browser.label.root"],
+ node_type=NodeType.ROOT,
+ )
+ build_configuration_branch(
+ scan,
+ name=self._language_manager["global.browser.label.by_configuration"],
+ parent=container_root,
+ )
+ build_sample_branch(
+ scan,
+ name=self._language_manager["global.browser.label.by_sample"],
+ parent=container_root,
+ )
+
+ prune_empty_containers(container_root)
+ collapse_single_child_containers(container_root)
+ order_children(container_root)
+ return container_root
+
+ def get_all_reconstruction_files(self) -> List[Path]:
+ return sorted({entry.path for entry in self._scan.reconstructions})
+
+ def nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]:
+ """Answers every row the browser offers for a path, across both views.
+
+ A reconstruction is listed by its configuration and again by the sample it came from, so a
+ caller acting on the file rather than on one row — repainting a favorite star, for instance —
+ asks here once and hands the rows to each browser tab.
+ """
+ return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
new file mode 100644
index 00000000..1bb4026c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
@@ -0,0 +1,57 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ ARTIFICIAL_CONTAINERS,
+)
+from sampletones_core.configs.display import DISPLAY_SEPARATOR
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+
+def collapse_single_child_containers(node: TreeNode) -> None:
+ """Folds every heading the browser invents that stands above a single row into that row.
+
+ A heading leading to one row asks the reader to open a level that tells them nothing new, so the
+ row takes the heading's name ahead of its own and rises into its place. Working from the deepest
+ rows upwards folds a whole chain at once, one separator per level: with a single configuration
+ present the configuration branch reads ``44.1 kHz·30 Hz·FFT·γ0·PTN`` as one row, and it grows back
+ into groups as soon as a second configuration arrives.
+
+ The row that survives keeps its node type, its path, its configuration and its children, so its
+ click behaviour, theme, context menu and favorite star carry over from before the fold. The two
+ branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk
+ holds stays a folder of its own, since the configuration branch mirrors the disk.
+ """
+ for child in list(node.children):
+ collapse_single_child_containers(child)
+
+ if _can_fold(node):
+ _fold_into_child(node)
+
+
+def _can_fold(node: TreeNode) -> bool:
+ parent = node.parent
+ if parent is None or parent.node_type == NodeType.ROOT:
+ return False
+
+ if node.node_type not in ARTIFICIAL_CONTAINERS or len(node.children) != 1:
+ return False
+
+ return not _siblings_hold(node, _joined_name(node, node.children[0]))
+
+
+def _siblings_hold(node: TreeNode, name: str) -> bool:
+ """Whether a row beside this heading already reads as the name the fold would produce.
+
+ The folded row joins the siblings of the heading it replaces, and a browser row is addressed by
+ the names leading to it, so a heading whose fold would repeat a name beside it stays as it is.
+ """
+ return any(sibling.name == name for sibling in node.parent.children if sibling is not node)
+
+
+def _fold_into_child(node: TreeNode) -> None:
+ child = node.children[0]
+ child.name = _joined_name(node, child)
+ child.parent = node.parent
+ node.parent = None
+
+
+def _joined_name(node: TreeNode, child: TreeNode) -> str:
+ return DISPLAY_SEPARATOR.join([str(node.name), str(child.name)])
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py
new file mode 100644
index 00000000..702c6a28
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py
@@ -0,0 +1,61 @@
+from sampletones_application.logic.reconstruction.browser.tree.configurations.grouping import (
+ organize_top_level_config_directories,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.structures.tree import (
+ FileSystemNode,
+ NodeType,
+ TreeNode,
+ create_directory_node,
+)
+
+
+def build_configuration_branch(
+ scan: ReconstructionScan,
+ *,
+ name: str,
+ parent: TreeNode,
+) -> TreeNode:
+ """Builds the branch listing reconstructions by the configuration that produced them.
+
+ The scanned folders appear as they sit on disk, and a top-level configuration directory is then
+ lifted under frequency ▶ method groups and named by its generators, so configurations sharing a
+ spectrum read side by side. A configuration directory nested inside a plain folder keeps its
+ friendly name in place, and a reconstruction outside every configuration directory is listed
+ here, this being the branch that follows the disk.
+ """
+ branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+ for entry in scan.entries:
+ _append_entry(entry, parent=branch)
+
+ organize_top_level_config_directories(branch)
+ return branch
+
+
+def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None:
+ match entry:
+ case ReconstructionEntry():
+ FileSystemNode(
+ entry.name,
+ node_type=NodeType.FILE,
+ filepath=entry.path,
+ parent=parent,
+ )
+ case DirectoryEntry():
+ directory_node = create_directory_node(
+ entry.path,
+ name=entry.name,
+ config=entry.config,
+ parent=parent,
+ )
+ for child_entry in entry.entries:
+ _append_entry(child_entry, parent=directory_node)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
new file mode 100644
index 00000000..c98c3265
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
@@ -0,0 +1,52 @@
+from sampletones_application.logic.reconstruction.browser.tree.configurations.naming import (
+ assign_display_names,
+ disambiguate_generator_siblings,
+)
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ find_or_create_group,
+)
+from sampletones_core.configs.display import (
+ format_frequencies,
+ format_transformation,
+)
+from sampletones_core.structures.tree import (
+ ConfigNode,
+ FileSystemNode,
+ NodeType,
+ TreeNode,
+)
+
+
+def organize_top_level_config_directories(branch: TreeNode) -> None:
+ """Groups top-level config directories under frequencies/transformation nodes, leaving other folders flat.
+
+ A config directory moves under ``frequencies`` ▶ ``transformation`` artificial group nodes and is
+ renamed to its generator abbreviation, while any other top-level folder keeps the existing
+ flat friendly naming for the config directories nested inside it.
+ """
+ for child in list(branch.children):
+ match child:
+ case ConfigNode() if child.node_type == NodeType.DIRECTORY:
+ _attach_config_directory_under_groups(child, branch)
+ case FileSystemNode() if child.node_type == NodeType.DIRECTORY:
+ assign_display_names(child)
+
+ disambiguate_generator_siblings(branch)
+
+
+def _attach_config_directory_under_groups(
+ directory_node: ConfigNode,
+ branch: TreeNode,
+) -> None:
+ fields = directory_node.config
+ frequencies_node = find_or_create_group(
+ format_frequencies(fields.sr, fields.nf),
+ parent=branch,
+ )
+ transformation_node = find_or_create_group(
+ format_transformation(fields.sm, fields.tg),
+ parent=frequencies_node,
+ )
+
+ directory_node.name = fields.gn
+ directory_node.parent = transformation_node
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
new file mode 100644
index 00000000..aac9cec3
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
@@ -0,0 +1,42 @@
+from typing import List, Sequence, Tuple
+
+from sampletones_core.configs.display import unique_display_names
+from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
+
+
+def disambiguate_generator_siblings(node: TreeNode) -> None:
+ """Appends a short config hash to generator directories sharing a name under one method group."""
+ if node.node_type == NodeType.GROUP:
+ _rename_config_directories(
+ [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)]
+ )
+
+ for child in node.children:
+ disambiguate_generator_siblings(child)
+
+
+def assign_display_names(node: TreeNode) -> None:
+ """Renames config-directory nodes to friendly labels, disambiguating colliding siblings.
+
+ Only directories whose names parse as reconstruction config directories are rewritten;
+ plain folders keep their on-disk name. The check is scoped per parent because duplicate
+ display names among siblings would otherwise collapse to duplicate widget tags downstream.
+ """
+ _rename_config_directories(
+ [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)]
+ )
+ for child in node.children:
+ assign_display_names(child)
+
+
+def _config_directory_children(node: TreeNode) -> List[ConfigNode]:
+ return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY]
+
+
+def _rename_config_directories(
+ proposed_names: Sequence[Tuple[ConfigNode, str]],
+) -> None:
+ """Names each configuration directory, marking those a sibling would otherwise shadow."""
+ labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names])
+ for (directory_node, _), label in zip(proposed_names, labels):
+ directory_node.name = label
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py
new file mode 100644
index 00000000..18687ede
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py
@@ -0,0 +1,42 @@
+from typing import Final, FrozenSet
+
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset(
+ {
+ NodeType.GROUP,
+ NodeType.SAMPLE,
+ }
+)
+
+
+def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode:
+ """Answers the group of this name under ``parent``, adding one where the parent holds none.
+
+ A group stands for something the disk states rather than holds — a frequency pair, a spectrum
+ method, a source folder — so a builder meeting that name again extends the group it already made.
+ """
+ return _find_or_create(name, node_type=NodeType.GROUP, parent=parent)
+
+
+def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode:
+ """Answers the sample of this name under ``parent``, adding one where the parent holds none.
+
+ A sample stands for one source audio and gathers the reconstructions made from it. It carries a
+ node type of its own, so a folder and an audio of the same name stay two rows: each is looked up
+ among the siblings of its own kind.
+ """
+ return _find_or_create(name, node_type=NodeType.SAMPLE, parent=parent)
+
+
+def _find_or_create(
+ name: str,
+ *,
+ node_type: NodeType,
+ parent: TreeNode,
+) -> TreeNode:
+ for child in parent.children:
+ if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name:
+ return child
+
+ return TreeNode(name, node_type=node_type, parent=parent)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py
new file mode 100644
index 00000000..ccc1ec18
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py
@@ -0,0 +1,30 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional, Tuple, TypeAlias, Union
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_core.reconstructions.converter.paths.fields import (
+ ConfigDirectoryFields,
+)
+
+ScanEntry: TypeAlias = Union["DirectoryEntry", ReconstructionEntry]
+
+
+@dataclass(frozen=True)
+class DirectoryEntry:
+ """A folder a scan met, holding the configuration its name states and the entries inside it.
+
+ A folder whose name encodes a reconstruction configuration carries those fields, read once here,
+ so every branch builder states the configuration from the record it already has. A folder holds
+ folders as readily as reconstructions, which is why the entry kinds are named together here.
+ """
+
+ path: Path
+ config: Optional[ConfigDirectoryFields]
+ entries: Tuple[ScanEntry, ...]
+
+ @property
+ def name(self) -> str:
+ return self.path.name
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py
new file mode 100644
index 00000000..ba4b4ce1
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class ReconstructionEntry:
+ """A reconstruction file a scan met, named by the audio it reconstructs."""
+
+ path: Path
+
+ @property
+ def name(self) -> str:
+ return self.path.stem
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py
new file mode 100644
index 00000000..c5d007e8
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py
@@ -0,0 +1,40 @@
+from dataclasses import dataclass
+from typing import List, Sequence, Tuple
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+
+
+@dataclass(frozen=True)
+class ReconstructionScan:
+ """One reading of a reconstructions directory, shared by every browser branch.
+
+ Both branches describe the same disk because both describe this record: the configuration view
+ follows the entries as they sit, and the sample view regroups them by the audio they came from.
+ """
+
+ entries: Tuple[ScanEntry, ...]
+
+ @property
+ def reconstructions(self) -> Tuple[ReconstructionEntry, ...]:
+ return self.collect_reconstructions(self.entries)
+
+ @staticmethod
+ def collect_reconstructions(
+ entries: Sequence[ScanEntry],
+ ) -> Tuple[ReconstructionEntry, ...]:
+ """Flattens scanned entries into the reconstructions they hold, in the order the scan met them."""
+ collected: List[ReconstructionEntry] = []
+ for entry in entries:
+ match entry:
+ case ReconstructionEntry():
+ collected.append(entry)
+ case DirectoryEntry():
+ collected.extend(ReconstructionScan.collect_reconstructions(entry.entries))
+
+ return tuple(collected)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/order.py b/src/sampletones_application/logic/reconstruction/browser/tree/order.py
new file mode 100644
index 00000000..901f8517
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/order.py
@@ -0,0 +1,25 @@
+from typing import Tuple
+
+from sampletones_core.structures.tree import NodeType, TreeNode
+from sampletones_shared.utils.text import NaturalSortKey, natural_sort_key
+
+
+def order_children(node: TreeNode) -> None:
+ """Sorts every set of siblings into reading order: what opens first, then names read naturally.
+
+ The pass runs once every label is final, so a row sits where its displayed name puts it — `8 kHz`
+ ahead of `44.1 kHz`, whatever the folder names on disk spell. The branches directly under the
+ container root keep the order the browser states them in.
+ """
+ for child in node.children:
+ order_children(child)
+
+ if node.node_type != NodeType.ROOT:
+ node.children = tuple(sorted(node.children, key=_sibling_key))
+
+
+def _sibling_key(node: TreeNode) -> Tuple[bool, NaturalSortKey]:
+ return (
+ node.node_type == NodeType.FILE,
+ natural_sort_key(str(node.name)),
+ )
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py
new file mode 100644
index 00000000..1bc61eef
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py
@@ -0,0 +1,20 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ ARTIFICIAL_CONTAINERS,
+)
+from sampletones_core.structures.tree import TreeNode
+
+
+def prune_empty_containers(node: TreeNode) -> None:
+ """Drops the containers the browser invents that gather nothing, deepest first.
+
+ A group or a sample is a heading the browser writes itself, so one left holding nothing says
+ nothing and leaves. Working from the deepest rows upwards lets a whole chain of such headings go
+ at once, the branch root among them, which keeps a reconstructions directory holding nothing to
+ show silent. A folder the disk holds stays where it is, since the configuration branch reads the
+ disk as it is.
+ """
+ for child in list(node.children):
+ prune_empty_containers(child)
+
+ if node.node_type in ARTIFICIAL_CONTAINERS and not node.children and node.parent is not None:
+ node.parent = None
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
new file mode 100644
index 00000000..7ae3c774
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
@@ -0,0 +1,36 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ find_or_create_group,
+ find_or_create_sample,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.variants import (
+ append_variants,
+ collect_variants,
+)
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+
+def build_sample_branch(
+ scan: ReconstructionScan,
+ *,
+ name: str,
+ parent: TreeNode,
+) -> TreeNode:
+ """Builds the branch listing each source audio with the configurations that reconstructed it.
+
+ Every top-level configuration directory contributes its reconstructions under the source folders
+ they mirror, so one audio gathers its variants and each variant is labelled by its configuration.
+ """
+ branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+ variants_by_source = collect_variants(scan)
+ for source in sorted(variants_by_source):
+ source_node = branch
+ for part in source.directory_parts:
+ source_node = find_or_create_group(part, parent=source_node)
+
+ audio_node = find_or_create_sample(source.name, parent=source_node)
+ append_variants(audio_node, variants_by_source[source])
+
+ return branch
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py
new file mode 100644
index 00000000..f6ce7811
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+
+@dataclass(frozen=True, order=True)
+class SampleSource:
+ """The audio a set of reconstructions was made from, as its folder and name within a configuration.
+
+ Two configuration directories reconstructing one audio file mirror the same source subtree, so
+ the relative folder and the audio name together gather the variants of that audio.
+ """
+
+ directory_parts: Tuple[str, ...]
+ name: str
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py
new file mode 100644
index 00000000..15960a45
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from pathlib import Path
+
+from sampletones_core.reconstructions.converter.paths.fields import (
+ ConfigDirectoryFields,
+)
+
+
+@dataclass(frozen=True)
+class SampleVariant:
+ """One reconstruction of a source audio, with the configuration that produced it."""
+
+ config: ConfigDirectoryFields
+ path: Path
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py
new file mode 100644
index 00000000..79a4002d
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py
@@ -0,0 +1,50 @@
+from typing import Dict, List, Sequence
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.source import (
+ SampleSource,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.variant import (
+ SampleVariant,
+)
+from sampletones_core.configs.display import unique_display_names
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
+
+
+def collect_variants(scan: ReconstructionScan) -> Dict[SampleSource, List[SampleVariant]]:
+ variants_by_source: Dict[SampleSource, List[SampleVariant]] = {}
+ for entry in scan.entries:
+ match entry:
+ case DirectoryEntry(config=ConfigDirectoryFields() as config):
+ for reconstruction in scan.collect_reconstructions(entry.entries):
+ relative_path = reconstruction.path.relative_to(entry.path)
+ source = SampleSource(
+ directory_parts=relative_path.parent.parts,
+ name=relative_path.stem,
+ )
+ variants_by_source.setdefault(source, []).append(
+ SampleVariant(config=config, path=reconstruction.path)
+ )
+
+ return variants_by_source
+
+
+def append_variants(
+ audio_node: TreeNode,
+ variants: Sequence[SampleVariant],
+) -> None:
+ labels = unique_display_names([(variant.config.display_name, variant.config.ch) for variant in variants])
+ for variant, label in zip(variants, labels):
+ ConfigNode(
+ label,
+ node_type=NodeType.FILE,
+ filepath=variant.path,
+ config=variant.config,
+ parent=audio_node,
+ )
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
new file mode 100644
index 00000000..56a4bb4c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+
+
+def scan_reconstructions(directory: Path) -> ReconstructionScan:
+ """Reads a reconstructions directory once, recording its folders and the reconstructions inside.
+
+ Every folder is recorded together with the configuration its name states, and every
+ reconstruction file beneath it. This single reading feeds both browser branches, so the two
+ views agree on what is on disk.
+ """
+ return ReconstructionScan(entries=_scan_entries(directory))
+
+
+def _scan_entries(directory: Path) -> Tuple[ScanEntry, ...]:
+ entries: List[ScanEntry] = []
+ for path in sorted(directory.iterdir()):
+ entry = _scan_path(path)
+ if entry is not None:
+ entries.append(entry)
+
+ return tuple(entries)
+
+
+def _scan_path(path: Path) -> Optional[ScanEntry]:
+ if path.is_dir():
+ return DirectoryEntry(
+ path=path,
+ config=ConfigDirectoryFields.from_directory_name(path.name),
+ entries=_scan_entries(path),
+ )
+
+ if path.suffix == EXT_FILE_RECONSTRUCTION:
+ return ReconstructionEntry(path=path)
+
+ return None
diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py
deleted file mode 100644
index b5881275..00000000
--- a/src/sampletones_application/logic/reconstruction/browser_manager.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from pathlib import Path
-from typing import Dict, List, Optional, Tuple
-
-from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.config.managers.config import ConfigManager
-from sampletones_core.configs.display import DISPLAY_SEPARATOR, short_hash
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
-from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
-)
-
-
-class BrowserManager:
- def __init__(
- self,
- config_manager: ConfigManager,
- *,
- language_manager: LanguageManager,
- ) -> None:
- self._language_manager = language_manager
- self.config_manager = config_manager
- self.reconstructions_directory = config_manager.get_reconstructions_directory()
-
- self.tree = Tree()
-
- def set_reconstructions_directory(self, directory: Path) -> None:
- self.reconstructions_directory = directory
- self.refresh_tree()
-
- def refresh_tree(self) -> None:
- if not self.reconstructions_directory.exists() or not self.reconstructions_directory.is_dir():
- self.tree.set_root(None)
- return
-
- container_root = TreeNode(
- name=self._language_manager["global.browser.label.root"],
- node_type=NodeType.ROOT,
- )
- for path in sorted(self.reconstructions_directory.iterdir()):
- self._build_tree(path, parent=container_root)
-
- self._assign_directory_display_names(container_root)
- self.tree.set_root(container_root)
-
- def _build_tree(
- self,
- path: Path,
- parent: Optional[TreeNode] = None,
- ) -> Optional[FileSystemNode]:
- if not path.exists():
- return None
-
- if path.is_file():
- if path.suffix == EXT_FILE_RECONSTRUCTION:
- return FileSystemNode(
- path.stem,
- filepath=path,
- node_type=NodeType.FILE,
- parent=parent,
- )
- return None
-
- children_nodes = []
- for child_path in sorted(path.iterdir()):
- child_node = self._build_tree(child_path, parent=parent)
- if child_node is not None:
- children_nodes.append(child_node)
-
- directory_node = FileSystemNode(
- path.name,
- filepath=path,
- node_type=NodeType.DIRECTORY,
- parent=parent,
- )
- for child_node in children_nodes:
- child_node.parent = directory_node
-
- return directory_node
-
- def _assign_directory_display_names(self, node: TreeNode) -> None:
- """Renames config-directory nodes to friendly labels, disambiguating colliding siblings.
-
- Only directories whose names parse as reconstruction config directories are rewritten;
- plain folders keep their on-disk name. The check is scoped per parent because duplicate
- display names among siblings would otherwise collapse to duplicate widget tags downstream.
- """
- self._rename_config_directory_children(node)
- for child in node.children:
- self._assign_directory_display_names(child)
-
- def _rename_config_directory_children(self, node: TreeNode) -> None:
- groups: Dict[str, List[Tuple[FileSystemNode, ConfigDirectoryFields]]] = {}
- for child in node.children:
- if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY:
- continue
-
- fields = ConfigDirectoryFields.from_directory_name(child.filepath.name)
- if fields is None:
- continue
-
- groups.setdefault(fields.display_name, []).append((child, fields))
-
- for display_name, members in groups.items():
- if len(members) == 1:
- directory_node, _ = members[0]
- directory_node.name = display_name
- continue
-
- for directory_node, fields in members:
- directory_node.name = f"{display_name}{DISPLAY_SEPARATOR}#{short_hash(fields.ch)}"
-
- def get_all_reconstruction_files(self) -> List[Path]:
- file_nodes = [
- node
- for node in self.tree.collect_leaves()
- if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE
- ]
- return [node.filepath for node in file_nodes]
diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py
index 6e6ff157..43fc3565 100644
--- a/src/sampletones_application/logic/sequencer/browser.py
+++ b/src/sampletones_application/logic/sequencer/browser.py
@@ -2,7 +2,7 @@
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import Tree
diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py
index 00473536..c42332c9 100644
--- a/src/sampletones_application/logic/shared/tree.py
+++ b/src/sampletones_application/logic/shared/tree.py
@@ -5,12 +5,12 @@
from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.utils.callbacks.queue import CallbackQueue
-from sampletones_core import paths
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths import extensions
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool:
return False
suffix = node.filepath.suffix.lower()
- return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO
+ return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO
def _execute_autoplay(self) -> None:
if self._pending_autoplay_node is not None:
@@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
return
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
try:
reconstruction = Reconstruction.load(node.filepath)
self._audio_device_manager.play(
@@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
f"Failed to play reconstruction file: {node.filepath}",
)
self.call(self.on_autoplay_error, exception)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._audio_device_manager.play_file(
node.filepath,
update=False,
@@ -147,17 +147,14 @@ def is_node_favorite(self, node: TreeNode) -> bool:
return node.filepath in self._session_manager.favorites
def has_favorite_ancestor(self, node: FileSystemNode) -> bool:
- current_node = node.parent
- while current_node is not None:
- if not isinstance(current_node, FileSystemNode):
- break
+ """Whether a favorite directory holds this path, at any depth above it.
- if self.is_node_favorite(current_node):
- return True
-
- current_node = current_node.parent
-
- return False
+ The answer reads the path rather than the rows above it, so it holds wherever a view puts
+ the node: a reconstruction listed under the sample it came from sits below groups the
+ browser invented, and the directory that makes it a favorite child is still on its path.
+ """
+ favorites = self._session_manager.favorites
+ return any(directory in favorites for directory in node.filepath.parents)
def toggle_favorite(self, node: FileSystemNode) -> None:
self._session_manager.toggle_favorite(node.filepath)
@@ -179,3 +176,11 @@ def _execute_search_update(self) -> None:
@property
def autoplay_enabled(self) -> bool:
return self._session_manager.autoplay
+
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._session_manager.auto_expand_favorite_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._session_manager.auto_expand_favorite_directories
diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py
index 4ecbdcfc..0ae44e42 100644
--- a/src/sampletones_application/paths.py
+++ b/src/sampletones_application/paths.py
@@ -1,8 +1,8 @@
from pathlib import Path
from typing import Final
-from sampletones_core.paths import USER_PATH_CONFIG
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+from sampletones_shared.paths.user import USER_PATH_CONFIG
APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application"
BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior"
diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py
index 46bc618c..6477969c 100644
--- a/src/sampletones_application/shell.py
+++ b/src/sampletones_application/shell.py
@@ -32,6 +32,7 @@
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.texture import TextureRegistry
from sampletones_application.ui.menu import MenuBar
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.theme import Theme
@@ -103,6 +104,8 @@ class ShortcutBindings:
display_settings: Callback
keyboard_settings: Callback
toggle_advanced_settings: Callback
+ toggle_auto_expand_favorite_reconstructions: Callback
+ toggle_auto_expand_favorite_directories: Callback
toggle_fullscreen: Callback
about: Callback
next_tab: Callback
@@ -167,6 +170,7 @@ def setup(
) -> None:
dpg.create_context()
self._set_fonts()
+ self._set_textures()
self._register_shortcuts(bindings)
self._set_default_theme()
self._viewport_manager.create_viewport()
@@ -196,6 +200,9 @@ def _setup_dearpygui(self) -> None:
def _set_fonts(self) -> None:
FontRegistry.register_fonts(self._layout.fonts.scale)
+ def _set_textures(self) -> None:
+ TextureRegistry.register_textures()
+
def _set_default_theme(self) -> None:
self._theme.bind()
@@ -246,6 +253,10 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback
ShortcutId.DISPLAY_SETTINGS: bindings.display_settings,
ShortcutId.KEYBOARD_SETTINGS: bindings.keyboard_settings,
ShortcutId.TOGGLE_ADVANCED_SETTINGS: bindings.toggle_advanced_settings,
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: (
+ bindings.toggle_auto_expand_favorite_reconstructions
+ ),
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories,
ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen,
ShortcutId.ABOUT_DIALOG: bindings.about,
ShortcutId.NEXT_TAB: bindings.next_tab,
diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py
index da52f490..05eac7e1 100644
--- a/src/sampletones_application/tags/general.py
+++ b/src/sampletones_application/tags/general.py
@@ -32,6 +32,12 @@
Widget.FONT,
"bold_large",
)
+TAG_GLOBAL_FONT_BOLD_TITLE = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.FONT,
+ "bold_title",
+)
TAG_GLOBAL_FONT_ITALIC = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -302,12 +308,6 @@
Widget.THEME,
"file_wave",
)
-TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY = TagName(
- Page.GLOBAL,
- Panel.IMPLICIT,
- Widget.THEME,
- "file_not_expanded_directory",
-)
TAG_GLOBAL_THEME_INPUT_INVALID = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -560,6 +560,18 @@
Widget.MENU,
"item_view_fullscreen",
)
+TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_view_auto_expand_favorite_reconstructions",
+)
+TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_view_auto_expand_favorite_directories",
+)
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -669,6 +681,13 @@
"sequencer",
)
+TAG_GLOBAL_TEXTURE_LOGO = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.TEXTURE,
+ "logo",
+)
+
SUF_BUTTON = "button"
SUF_BUTTONS = "buttons"
SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy")
@@ -676,6 +695,7 @@
SUF_BUTTON_SAVE = compose_tag(SUF_BUTTON, "save")
SUF_BUTTON_CANCEL = compose_tag(SUF_BUTTON, "cancel")
SUF_BUTTON_SEARCH = compose_tag(SUF_BUTTON, "search")
+SUF_BUTTON_COLLAPSE_ALL = compose_tag(SUF_BUTTON, "collapse_all")
SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback")
SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement")
SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment")
@@ -690,9 +710,11 @@
SUF_LABEL = "label"
SUF_PATH = "path"
SUF_TEXT = "text"
+SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites")
SUF_INPUT = "input"
SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search")
SUF_CHECKBOX = "checkbox"
+SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites")
SUF_TABLE = "table"
SUF_TOOLTIP = "tooltip"
SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail")
diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py
index 164febdf..f50d7bea 100644
--- a/src/sampletones_application/tags/main.py
+++ b/src/sampletones_application/tags/main.py
@@ -61,12 +61,6 @@
Widget.BUTTON,
"refresh",
)
-TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL = TagName(
- Page.MAIN,
- Panel.EXPLORER,
- Widget.BUTTON,
- "collapse_all",
-)
TAG_MAIN_CONFIG_PANEL = TagName(
Page.MAIN,
Panel.CONFIG,
diff --git a/src/sampletones_application/ui/elements/fonts/font.py b/src/sampletones_application/ui/elements/fonts/font.py
index dcb5bc46..319f5578 100644
--- a/src/sampletones_application/ui/elements/fonts/font.py
+++ b/src/sampletones_application/ui/elements/fonts/font.py
@@ -11,6 +11,7 @@ class Font(Enum):
BOLD = "Bold"
BOLD_SMALL = "BoldSmall"
BOLD_LARGE = "BoldLarge"
+ BOLD_TITLE = "BoldTitle"
MONO = "Mono"
MONO_SMALL = "MonoSmall"
MONO_BOLD = "MonoBold"
diff --git a/src/sampletones_application/ui/elements/fonts/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py
index cea0c99f..837e58b4 100644
--- a/src/sampletones_application/ui/elements/fonts/registry.py
+++ b/src/sampletones_application/ui/elements/fonts/registry.py
@@ -7,6 +7,7 @@
TAG_GLOBAL_FONT_BOLD,
TAG_GLOBAL_FONT_BOLD_LARGE,
TAG_GLOBAL_FONT_BOLD_SMALL,
+ TAG_GLOBAL_FONT_BOLD_TITLE,
TAG_GLOBAL_FONT_ICON,
TAG_GLOBAL_FONT_ITALIC,
TAG_GLOBAL_FONT_ITALIC_LARGE,
@@ -38,6 +39,7 @@ class FontRegistry:
Font.BOLD: (TAG_GLOBAL_FONT_BOLD, FontResource.BOLD, Typeface.SANS, Step.MEDIUM),
Font.BOLD_SMALL: (TAG_GLOBAL_FONT_BOLD_SMALL, FontResource.BOLD, Typeface.SANS, Step.SMALL),
Font.BOLD_LARGE: (TAG_GLOBAL_FONT_BOLD_LARGE, FontResource.BOLD, Typeface.SANS, Step.LARGE),
+ Font.BOLD_TITLE: (TAG_GLOBAL_FONT_BOLD_TITLE, FontResource.BOLD, Typeface.SANS, Step.TITLE),
Font.MONO: (TAG_GLOBAL_FONT_MONO, FontResource.MONO, Typeface.MONO, Step.MEDIUM),
Font.MONO_SMALL: (TAG_GLOBAL_FONT_MONO_SMALL, FontResource.MONO, Typeface.MONO, Step.SMALL),
Font.MONO_BOLD: (TAG_GLOBAL_FONT_MONO_BOLD, FontResource.MONO_BOLD, Typeface.MONO, Step.MEDIUM),
diff --git a/src/sampletones_application/ui/elements/texture.py b/src/sampletones_application/ui/elements/texture.py
new file mode 100644
index 00000000..fe933c39
--- /dev/null
+++ b/src/sampletones_application/ui/elements/texture.py
@@ -0,0 +1,26 @@
+from typing import ClassVar, Dict
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO
+from sampletones_application.ui.resources.items import IconResource
+from sampletones_application.ui.resources.resources import get_icon_path
+
+
+class TextureRegistry:
+ """Reads the images the interface draws into DearPyGui textures, the once at startup.
+
+ A texture is created before any window asks for it and stands for the whole run, so whatever draws
+ the application's mark names it by the tag it was created under.
+ """
+
+ _IMAGES: ClassVar[Dict[str, IconResource]] = {
+ TAG_GLOBAL_TEXTURE_LOGO: IconResource.UNIX,
+ }
+
+ @classmethod
+ def register_textures(cls) -> None:
+ with dpg.texture_registry():
+ for tag, resource in cls._IMAGES.items():
+ width, height, _channels, data = dpg.load_image(get_icon_path(resource))
+ dpg.add_static_texture(width, height, data, tag=tag)
diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py
new file mode 100644
index 00000000..2428bd6e
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/browser.py
@@ -0,0 +1,285 @@
+from abc import ABC, abstractmethod
+from typing import AbstractSet, Dict
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON_COLLAPSE_ALL,
+ TAG_GLOBAL_THEME_SECONDARY_BUTTON,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.layout.collapse import CollapseAxis
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
+from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.parallelization.thread import concurrent
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree
+from sampletones_shared.types.callback import Callback, MessageCallback, VoidCallback
+
+
+class GUIFileBrowserPanel(GUITreePanel, ABC):
+ """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card.
+
+ The card holds the controls bringing the tree up to date and folding it away, above the search box
+ and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread
+ on demand, and enables or disables the whole card as the tree locks and unlocks. A subclass
+ declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control
+ read, answers what refreshing the model means, and shapes each row.
+
+ A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control
+ showing those favorites alone to the card.
+ """
+
+ _REBUILD_ON_CREATE: bool = True
+ _OFFERS_FAVORITES_FILTER: bool = False
+
+ def __init__(
+ self,
+ tree: Tree,
+ tree_logic: TreeLogicProtocol,
+ *,
+ scheduling: SchedulingBehavior,
+ search_label: str,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ colors: TreeColors,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
+ ) -> None:
+ self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"]
+ self._msg_collapse_all = language_manager["global.status.message.collapse_all"]
+
+ super().__init__(
+ tree=tree,
+ tag=self._tags.panel,
+ tree_tag=self._tags.tree,
+ tree_logic=tree_logic,
+ scheduling=scheduling,
+ search_label=search_label,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ colors=colors,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
+ )
+
+ self._enable_horizontal_collapse(
+ initial_collapsed=initial_collapsed,
+ side=CollapseAxis.HORIZONTAL_LEFT,
+ )
+
+ @property
+ @abstractmethod
+ def _tags(self) -> FileBrowserTags:
+ """The tags naming this browser's widgets, which a panel states as a class attribute."""
+
+ @property
+ @abstractmethod
+ def section_label(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def section_glyph(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def refresh_button_label(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def refresh_status_message(self) -> str: ...
+
+ def create_panel(self, parent: str) -> None:
+ """Builds the card, and fills the tree where the panel is the one reading its model.
+
+ A browser reading the filesystem shows its rows as it appears, while a catalogue filled by
+ the owner that gathers it waits for that reading to arrive.
+ """
+ self._setup_handlers()
+ with (
+ dpg.child_window(
+ tag=self.tag,
+ width=self.width,
+ height=self.height,
+ parent=parent,
+ border=False,
+ ),
+ self._collapsible_section(
+ self.section_label,
+ glyph=self.section_glyph,
+ ),
+ ):
+ self._create_controls()
+ dpg.add_separator()
+ self._create_tree_window()
+
+ self._create_detail_tooltip(self._tags.window_tree)
+ if self._REBUILD_ON_CREATE:
+ self.rebuild_tree()
+
+ def _create_controls(self) -> None:
+ """Offers the two controls every browser of files carries: bring it up to date, fold it away."""
+ with dpg.group(tag=self._tags.group_controls):
+ self._create_refresh_button()
+ self._create_collapse_all_button()
+
+ self._bind_refresh_message()
+
+ def _create_refresh_button(self) -> None:
+ GUIButton(
+ tag=self._tags.button_refresh,
+ label=self.refresh_button_label,
+ width=-1,
+ callback=self._on_refresh_clicked,
+ theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
+ )
+
+ def _create_collapse_all_button(self) -> None:
+ """Offers the control folding the whole tree away, reading as the utility the refresh one does."""
+ collapse_all_tag = compose_tag(self.tag, SUF_BUTTON_COLLAPSE_ALL)
+ GUIButton(
+ tag=collapse_all_tag,
+ label=self._lbl_collapse_all,
+ width=-1,
+ callback=self._on_collapse_all_clicked,
+ theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
+ )
+ self._status_bar.bind_to_item(
+ collapse_all_tag,
+ self._msg_collapse_all,
+ )
+
+ def _bind_refresh_message(self) -> None:
+ self._status_bar.bind_to_item(
+ self._tags.button_refresh,
+ self.refresh_status_message,
+ )
+
+ def _on_refresh_clicked(self) -> None:
+ """Answers the refresh control, by default with a rebuild of the tree as the model stands."""
+ self.rebuild_tree()
+
+ def _on_collapse_all_clicked(self) -> None:
+ """Folds every row of the tree away, leaving the reader the level the tree opens at.
+
+ The rows are reached through the model, so one pass covers a branch however deep it runs,
+ and the browser is told what each row now stands as.
+ """
+ root = self.tree.get_root()
+ if root is None:
+ return
+
+ for child in root.children:
+ self._set_subtree_expanded(child, expanded=False)
+
+ def _create_tree_window(self) -> None:
+ self.create_search(self._body_container)
+ if self._OFFERS_FAVORITES_FILTER:
+ self.create_favorites_filter(self._body_container)
+
+ with (
+ dpg.child_window(
+ tag=self._tags.window_tree,
+ horizontal_scrollbar=True,
+ ),
+ dpg.group(tag=self._tags.group_tree),
+ ):
+ self._create_tree_root()
+
+ def _create_tree_root(self) -> None:
+ """Opens the container every row attaches to, as a group the rows read directly under."""
+ with dpg.group(tag=self.tree_tag):
+ pass
+
+ def _create_tree_root_heading(self, label: str) -> None:
+ """Opens the root container as a labelled row the whole tree folds under."""
+ with dpg.tree_node(
+ label=label,
+ tag=self.tree_tag,
+ default_open=True,
+ ):
+ pass
+
+ def _create_file_system_handlers(
+ self,
+ *,
+ on_directory_clicked: Callback,
+ on_file_clicked: Callback,
+ on_file_double_clicked: Callback,
+ file_status_message: MessageCallback,
+ ) -> Dict[NodeType, NodeHandler]:
+ """The two rows a browser of files offers: a folder that expands, and a file it opens.
+
+ A folder row reads the same wherever it appears — the status bar says it expands — so the pair
+ is shaped here, and each browser states what a click on one of its own rows means.
+ """
+ return {
+ NodeType.DIRECTORY: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.DIRECTORY),
+ node_type=NodeType.DIRECTORY,
+ item_click_callback=on_directory_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ NodeType.FILE: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.FILE),
+ node_type=NodeType.FILE,
+ item_click_callback=on_file_clicked,
+ item_double_click_callback=on_file_double_clicked,
+ status_bar_callback=file_status_message,
+ ),
+ }
+
+ def _mark_favorite_ancestry(
+ self,
+ node: FileSystemNode,
+ state: TreeNodeState,
+ ) -> None:
+ """Carries a favorite down the branch, so every row under one reads as part of it."""
+ state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node)
+
+ def refresh(self) -> None:
+ self.rebuild_tree()
+
+ @concurrent(wait=False, method_bound=True)
+ def rebuild_tree(self) -> None:
+ self._launch_tree_rebuild(self._refresh_model)
+
+ @concurrent(wait=False, method_bound=True)
+ def redraw_tree(self) -> None:
+ self._launch_tree_rebuild(self._keep_model)
+
+ def _launch_tree_rebuild(self, refresh: VoidCallback) -> None:
+ """Fills the whole tree from the model ``refresh`` leaves behind, off the main thread."""
+ self._launch_rebuild(
+ refresh,
+ lambda: self._collect_specs(self.tree_tag),
+ root_tag=self.tree_tag,
+ on_finished=self._on_rebuild_finished,
+ )
+
+ @abstractmethod
+ def _refresh_model(self) -> None:
+ """Brings the model the tree renders up to date, on the background rebuild worker."""
+
+ def _keep_model(self) -> None:
+ """Leaves the model as the last refresh brought it, which is what a redraw reads."""
+
+ def _on_rebuild_finished(self) -> None:
+ """Runs on the main thread with the rows on screen, where a browser reads something out."""
+
+ def set_tree_enabled(self, enabled: bool) -> None:
+ dpg_configure_item(self._tags.group_tree, enabled=enabled)
+ dpg_configure_item(self._tags.group_controls, enabled=enabled)
+ self.set_favorites_filter_enabled(enabled)
diff --git a/src/sampletones_application/ui/elements/tree/expansion.py b/src/sampletones_application/ui/elements/tree/expansion.py
new file mode 100644
index 00000000..3143afc0
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/expansion.py
@@ -0,0 +1,75 @@
+from threading import RLock
+from typing import AbstractSet, Set
+
+
+class RowExpansionMemory:
+ """The rows a browser stands open, held apart by the hand that opened them.
+
+ The reader's rows are the ones they opened themselves, and they are the shape a session writes down.
+ The mode's rows are the way down the favorites mode opened on the pass the reader asked for, which
+ stands for as long as the mode does. Each row is held by the tag it is addressed under, a pass
+ replacing every node the model states.
+
+ A pass writes from the tree worker while a click writes from the main thread, so one lock covers
+ every answer the memory gives.
+ """
+
+ def __init__(self, reader_rows: AbstractSet[str]) -> None:
+ self._lock = RLock()
+ self._reader_rows: Set[str] = set(reader_rows)
+ self._mode_rows: Set[str] = set()
+
+ def __bool__(self) -> bool:
+ with self._lock:
+ return bool(self._reader_rows or self._mode_rows)
+
+ @property
+ def rows(self) -> Set[str]:
+ """The rows the reader stands open, which is the shape a session writes down."""
+ with self._lock:
+ return set(self._reader_rows)
+
+ @property
+ def follows_the_mode(self) -> bool:
+ """Whether the mode's way down stands open, which is what a release has rows to answer for."""
+ with self._lock:
+ return bool(self._mode_rows)
+
+ def stands_open(self, node_tag: str) -> bool:
+ with self._lock:
+ return node_tag in self._reader_rows or node_tag in self._mode_rows
+
+ def remember(self, node_tag: str, *, expanded: bool) -> None:
+ """Holds what the reader left a row standing as, which is theirs from then on.
+
+ A row they fold is theirs to fold whichever hand opened it, so folding it lets go of the mode's
+ claim on it as well and the row stays folded.
+ """
+ with self._lock:
+ if expanded:
+ self._reader_rows.add(node_tag)
+ return
+
+ self._reader_rows.discard(node_tag)
+ self._mode_rows.discard(node_tag)
+
+ def follow(self, way_down: AbstractSet[str]) -> None:
+ """Notes the way down a pass opened, which stands open for as long as the mode does."""
+ with self._lock:
+ self._mode_rows |= way_down
+
+ def release(self, ways_down: AbstractSet[str]) -> None:
+ """Folds the rows the mode opened, keeping the ones a row of the reader's stands on.
+
+ A row of the mode's holding one of theirs below it holds theirs on the screen, and it therefore
+ becomes the reader's to keep. What is left held the mode's opening alone, and folds with it.
+ """
+ with self._lock:
+ self._reader_rows |= self._mode_rows & ways_down
+ self._mode_rows = set()
+
+ def hold_to(self, rows: AbstractSet[str]) -> None:
+ """Holds both memories to the rows given, a row held beyond them having left the model."""
+ with self._lock:
+ self._reader_rows &= rows
+ self._mode_rows &= rows
diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py
new file mode 100644
index 00000000..310ea91a
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/filter.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from typing import Final
+
+
+@dataclass(frozen=True)
+class TreeFilter:
+ """What a browser is currently asked to show, held by the panel showing it.
+
+ Several browsers render one tree, so what each of them narrows to belongs to the panel: a query
+ typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole,
+ so the panel resolves what it shows in one place.
+
+ The two criteria answer different questions: the query decides which of the rows on screen are
+ shown, while showing favorites alone decides which rows are drawn at all.
+ """
+
+ query: str
+ favorites_only: bool
+
+ @property
+ def is_active(self) -> bool:
+ """Whether the filter narrows what the browser shows."""
+ return bool(self.query) or self.favorites_only
+
+ def with_query(self, query: str) -> TreeFilter:
+ """The filter reading a new query, keeping everything else it states."""
+ return replace(self, query=query)
+
+ def with_favorites_only(self, favorites_only: bool) -> TreeFilter:
+ """The filter showing the favorites alone or the whole tree, keeping the query it states."""
+ return replace(self, favorites_only=favorites_only)
+
+
+NO_FILTER: Final[TreeFilter] = TreeFilter(
+ query="",
+ favorites_only=False,
+)
diff --git a/src/sampletones_application/ui/elements/tree/protocol.py b/src/sampletones_application/ui/elements/tree/protocol.py
index eea8262d..83aa4dd1 100644
--- a/src/sampletones_application/ui/elements/tree/protocol.py
+++ b/src/sampletones_application/ui/elements/tree/protocol.py
@@ -15,6 +15,12 @@ class TreeLogicProtocol(Protocol):
@property
def autoplay_enabled(self) -> bool: ...
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool: ...
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool: ...
+
@property
def locked(self) -> bool: ...
diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py
new file mode 100644
index 00000000..c4e375e8
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/tag.py
@@ -0,0 +1,29 @@
+from typing import Final
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_core.structures.tree import TreeNode
+from sampletones_shared.utils.serialization import calculate_hash
+
+NODE_TAG_DIGEST_LENGTH: Final[int] = 8
+
+_IDENTITY_SEPARATOR: Final[str] = "\x00"
+
+
+def compose_node_tag(node: TreeNode, *, panel_tag: str) -> str:
+ """Composes the widget tag of one tree row: readable by the names above it, unique by its path.
+
+ The names read the row back to whoever inspects the widget tree, and the digest states the exact
+ path — each ancestor's node type together with its name — so every row the names alone spell
+ alike keeps a tag of its own: a folder and the audio beside it, or two labels differing only in
+ spacing or case. The separator the digest joins on is one the disk gives no name, which is what
+ makes one identity reach one digest.
+ """
+ names = "_".join(str(ancestor.name) for ancestor in node.path)
+ return compose_tag(panel_tag, f"node_{names}", _node_digest(node))
+
+
+def _node_digest(node: TreeNode) -> str:
+ identity = _IDENTITY_SEPARATOR.join(
+ part for ancestor in node.path for part in (ancestor.node_type.value, str(ancestor.name))
+ )
+ return calculate_hash(identity, length=NODE_TAG_DIGEST_LENGTH)
diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py
new file mode 100644
index 00000000..d9c65ea7
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/tags.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class FileBrowserTags:
+ """The DearPyGui tags naming one file browser's widgets, stated together where the browser is declared.
+
+ Every browser builds the same arrangement — a panel card holding a controls group with a refresh
+ button, and a window holding the group the tree attaches to — so the tags naming those widgets
+ are one value the panel declares beside its class. Stating them together makes each browser name
+ a complete set at one place, checked where it is written.
+ """
+
+ panel: str
+ tree: str
+ window_tree: str
+ group_tree: str
+ group_controls: str
+ button_refresh: str
diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py
index 18b37a05..f63f5db6 100644
--- a/src/sampletones_application/ui/elements/tree/tree.py
+++ b/src/sampletones_application/ui/elements/tree/tree.py
@@ -1,7 +1,20 @@
from abc import ABC, abstractmethod
from functools import partial
from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import (
+ AbstractSet,
+ Any,
+ Callable,
+ Dict,
+ Final,
+ FrozenSet,
+ List,
+ Optional,
+ Sequence,
+ Set,
+ Tuple,
+ Union,
+)
import dearpygui.dearpygui as dpg
@@ -12,16 +25,17 @@
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_BUTTON_SEARCH,
+ SUF_CHECKBOX_FAVORITES,
SUF_HANDLER_DETAIL_TOOLTIP,
SUF_HANDLER_NODE,
SUF_INPUT_SEARCH,
+ SUF_TEXT_FAVORITES,
SUF_TOOLTIP_DETAIL,
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_FAVORITE,
TAG_GLOBAL_THEME_FAVORITE_CHILD,
TAG_GLOBAL_THEME_FILE_LIBRARY,
TAG_GLOBAL_THEME_FILE_NO_CONTENT,
- TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY,
TAG_GLOBAL_THEME_FILE_RECONSTRUCTION,
TAG_GLOBAL_THEME_FILE_WAVE,
TAG_GLOBAL_THEME_TREE_WINDOW,
@@ -43,16 +57,20 @@
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
from sampletones_application.ui.elements.tree.emitter import TreeEmitter
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter
from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.spec import NodeSpec
from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_application.ui.elements.tree.tag import compose_node_tag
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.gui.dpg import (
dpg_configure_item,
dpg_get_value,
dpg_is_item_hovered,
+ dpg_set_value,
)
from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import (
@@ -64,8 +82,8 @@
BackgroundWorkCancelled,
SingleThreadExecutor,
)
-from sampletones_core import paths
from sampletones_core.configs.display import (
+ format_generators,
format_nes_frequency,
format_sample_rate,
format_spectrum_method,
@@ -74,12 +92,16 @@
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
+ ConfigNode,
FileSystemNode,
LibraryNode,
NodeType,
Tree,
TreeNode,
+ TreeVisibility,
+ resolve_visibility,
)
+from sampletones_shared.paths import extensions
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import (
Callback,
@@ -89,11 +111,14 @@
)
from sampletones_shared.utils.system.paths import open_path_in_explorer
+NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset()
+
class GUITreePanel(GUIPanel, ABC):
_NAME_FONT: Font = Font.REGULAR_SMALL
_CONFIG_FONT: Font = Font.MONO_SMALL
_MONOSPACE_CONFIG_NODES: bool = False
+ _REMEMBERS_EXPANSION: bool = False
def __init__(
self,
@@ -101,14 +126,14 @@ def __init__(
tag: str,
tree_tag: str,
tree_logic: TreeLogicProtocol,
- width: int = -1,
- height: int = -1,
*,
scheduling: SchedulingBehavior,
search_label: str,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
self._logic = tree_logic
@@ -118,11 +143,19 @@ def __init__(
self.tree_tag = tree_tag
self._pending_specs: List[NodeSpec] = []
+ self._expansion = RowExpansionMemory(initial_expanded_rows)
self._emitter = TreeEmitter(scheduling=scheduling)
+ self._filter: TreeFilter = NO_FILTER.with_favorites_only(initial_favorites_only)
+ self._search_visibility: Optional[TreeVisibility] = None
+ self._favorites_visibility: Optional[TreeVisibility] = None
+ self._auto_expand_pending: bool = False
+
self._selected_node_tag: Optional[Union[str, int]] = None
self._search_input_tag: Optional[str] = None
self._search_button_tag: Optional[str] = None
+ self._favorites_checkbox_tag: Optional[str] = None
+ self._favorites_glyph_tag: Optional[str] = None
self._detail_tooltip_tag = compose_tag(tag, SUF_TOOLTIP_DETAIL)
self._detail_tooltip_handler_tag = compose_tag(tag, SUF_HANDLER_DETAIL_TOOLTIP)
@@ -142,6 +175,7 @@ def __init__(
self._lbl_detail_generators = language_manager["global.context.label.detail_generators"]
self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"]
+ self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None
self.on_add_to_sequencer: Optional[PathCallback] = None
self.can_add_to_sequencer: Optional[Callable[[], bool]] = None
self.on_replace_in_sequencer: Optional[PathCallback] = None
@@ -150,8 +184,8 @@ def __init__(
super().__init__(
tag=tag,
- width=width,
- height=height,
+ width=-1,
+ height=-1,
)
def _launch_rebuild(
@@ -168,9 +202,9 @@ def _launch_rebuild(
1. A rebuild already in flight holds the lock, so return and let it finish.
2. Acquire the lock; responsibility for releasing it passes to the emit pipeline.
- 3. ``refresh`` updates the model, then ``collect`` resolves it into a flat
- :class:`NodeSpec` list -- every per-node decision, including the filesystem
- content check, happens here on the worker.
+ 3. ``refresh`` updates the model and the filter is resolved against it, then
+ ``collect`` resolves it into a flat :class:`NodeSpec` list -- every per-node
+ decision, including the filesystem content check, happens here on the worker.
4. Post the specs to :class:`TreeEmitter` through the queue. This crosses back to
the main thread, where the emitter clears the old tree and stages the new nodes
across frames.
@@ -186,12 +220,18 @@ def _launch_rebuild(
handed_off = False
try:
refresh()
+ self._resolve_filter()
specs = collect()
CallbackQueue.add(
self._emitter.emit,
tuple(specs),
root_tag,
- partial(self._finish_emit, root_tag, on_finished),
+ partial(
+ self._finish_emit,
+ root_tag,
+ on_finished,
+ len(specs),
+ ),
priority=self._scheduling.emit.priority,
)
handed_off = True
@@ -206,8 +246,23 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]:
if root is not None:
self._build_tree_node(root, state=TreeNodeState(parent=root_tag))
+ self._forget_rows_the_model_dropped()
return self._pending_specs
+ def _forget_rows_the_model_dropped(self) -> None:
+ """Holds the memory of open rows to the rows the model states, read afresh on every pass.
+
+ A row the memory holds beyond the rows the model states belongs to a folder the disk has lost,
+ so its place goes with it. The model is what the answer is read from, so a browser opening in the
+ favorites mode — or opening on a session written before the reconstructions directory moved —
+ drops what is gone.
+ """
+ root = self.tree.get_root()
+ if root is None or not self._expansion:
+ return
+
+ self._expansion.hold_to({self._generate_node_tag(node) for node in root.descendants if node.children})
+
def create_search(self, parent: str) -> None:
self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH)
self._search_button_tag = compose_tag(self.tag, SUF_BUTTON_SEARCH)
@@ -235,6 +290,104 @@ def create_search(self, parent: str) -> None:
self._language_manager["global.status.message.clear_search"],
)
+ def create_favorites_filter(self, parent: str) -> None:
+ """Builds the control showing the favorites alone, as a row of its own under the search box.
+
+ The checkbox carries the label, so the words are part of what the reader clicks, and the star
+ beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair
+ every checkbox reads — the text colour while the control is live, the muted one while a
+ rebuild holds it — so the shade states whether the control can be acted on.
+ """
+ self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES)
+ self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES)
+
+ with dpg.group(horizontal=True, parent=parent):
+ dpg.add_checkbox(
+ tag=self._favorites_checkbox_tag,
+ label=self._language_manager["global.browser.label.favorites_only"],
+ default_value=self._filter.favorites_only,
+ callback=self._on_favorites_only_changed,
+ )
+ dpg.add_text(
+ self._glyphs.common.favorite,
+ tag=self._favorites_glyph_tag,
+ )
+
+ FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON)
+ self._apply_favorites_glyph_color()
+ self._status_bar.bind_to_item(
+ self._favorites_checkbox_tag,
+ self._language_manager["global.status.message.favorites_only"],
+ )
+
+ def _on_favorites_only_changed(
+ self,
+ _sender: Sender,
+ favorites_only: bool,
+ ) -> None:
+ """Takes the mode the control now reads, and draws the rows that mode names.
+
+ The rebuild resolves the filter against the model as it collects the rows, so the mode is
+ stated here and answered there, and turning it on walks the model once.
+ """
+ self._state_favorites_only(favorites_only)
+ self._apply_favorites_glyph_color()
+ self.call(
+ self.on_favorites_filter_changed,
+ self.tag,
+ favorites_only,
+ )
+ self.redraw_tree()
+
+ def _apply_favorites_glyph_color(self) -> None:
+ """Colours the star by the mode the control reads, wherever the browser offers one."""
+ if self._favorites_glyph_tag is None:
+ return
+
+ dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color())
+
+ def _favorites_glyph_color(self) -> BaseColor:
+ """The colour the star takes: the favorite colour while the mode is on, muted while it is off."""
+ if self._filter.favorites_only:
+ return self._colors.favorite
+
+ return self._colors.muted
+
+ def set_favorites_filter_enabled(self, enabled: bool) -> None:
+ """Follows the tree's lock through to the control, which asks for a rebuild of that tree."""
+ if self._favorites_checkbox_tag is None:
+ return
+
+ dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled)
+
+ def _state_favorites_only(self, favorites_only: bool) -> None:
+ """Takes the mode the reader switched to, asking the pass it starts to follow the stars.
+
+ Switching the mode on is the reader asking to be shown their favorites, so the pass it starts
+ opens the way down to them and notes which rows it opened. That way stands open for as long as
+ the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at
+ their favorites. Switching the mode off is answered by the pass that follows it as well, which
+ hands those rows back.
+ """
+ self._filter = self._filter.with_favorites_only(favorites_only)
+ self._auto_expand_pending = favorites_only
+
+ def _release_mode_rows(self) -> None:
+ """Hands back the rows the favorites mode opened, which the memory holds the rule for.
+
+ The rows the reader stands open are read off the model, and the way down to each of them is what
+ the mode keeps standing however deep theirs stands. The model is read on the pass that finds the
+ mode off, on the tree worker.
+ """
+ root = self.tree.get_root()
+ if root is None or not self._expansion.follows_the_mode:
+ return
+
+ reader_rows = self._expansion.rows
+ self._expansion.release(
+ self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]),
+ )
+
def _get_node_handler_tag(self, node_type: NodeType) -> str:
return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE)
@@ -248,22 +401,28 @@ def _append_spec(
open_on_double_click: bool = False,
should_expand: bool = False,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> None:
"""Resolve a node into a :class:`NodeSpec` and record it for emission.
Runs on the background traversal worker, so the theme and handler tags — including the
directory content check that touches the filesystem — are chosen here, off the main
thread. A shutdown request raises to unwind the traversal promptly.
+
+ Which rows are recorded is the favorites mode's to state, and it shows a row together with
+ every row above it: a row it holds back therefore stands above rows it holds back too, so one
+ decision covers the whole subtree and the traversal walks on.
"""
if SingleThreadExecutor.is_shutting_down():
raise BackgroundWorkCancelled
+ if not self._is_node_drawn(node):
+ return
+
theme_tag = self._resolve_node_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_node_expanded=is_node_expanded,
)
+ stands_open = should_expand or self._expansion.stands_open(node_tag)
self._pending_specs.append(
NodeSpec(
node=node,
@@ -274,31 +433,65 @@ def _append_spec(
leaf=leaf,
open_on_arrow=open_on_arrow,
open_on_double_click=open_on_double_click,
- should_expand=should_expand,
+ should_expand=stands_open,
theme_tag=theme_tag,
handler_tag=self._node_handlers[node.node_type].tag,
)
)
- def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> None:
+ @property
+ def expanded_rows(self) -> Set[str]:
+ return self._expansion.rows
+
+ def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None:
+ """Folds or unfolds the row together with every row below it holding something.
+
+ Each row is reached by the tag it was built under and set directly, and the browser is told
+ what it now stands as, so a rebuild brings the whole subtree back the way this left it.
+ """
+ for container in (node, *node.descendants):
+ if container.children:
+ node_tag = self._generate_node_tag(container)
+ dpg_set_value(node_tag, expanded)
+ self._expansion.remember(node_tag, expanded=expanded)
+
+ def _finish_emit(
+ self,
+ root_tag: str,
+ on_finished: Optional[VoidCallback],
+ drawn_rows: int,
+ ) -> None:
"""Complete a rebuild on the main thread: show the empty state, run the hook, unlock.
- The emitter runs this once its last batch has attached. When a filtered tree
- resolved to an empty model, the no-results message fills the cleared tree so the
- filter outcome is visible. Applying the filter here lets late-emitted nodes honour
+ The emitter runs this once its last batch has attached. A filtered rebuild that drew no
+ row fills the cleared tree with the message naming that outcome, so the filter's answer is
+ legible where the rows would be. Applying the filter here lets late-emitted nodes honour
an active search, and releasing the lock hands control back to interactive rebuilds.
"""
- if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None:
- dpg.add_text(self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag)
+ if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows:
+ dpg.add_text(
+ self._empty_filter_message(),
+ parent=root_tag,
+ )
if on_finished is not None:
on_finished()
- if self.tree.is_filtered():
+ if self._filter.query:
self.update_tree_visibility()
self.unlock()
+ def _empty_filter_message(self) -> str:
+ """Names the filter a rebuild came back empty from: the favorites mode, or the search."""
+ return self._language_manager[
+ (
+ "global.dialog.message.tree_no_favorites"
+ if self._filter.favorites_only
+ else "global.dialog.message.tree_no_results"
+ )
+ ]
+
def _create_hover_callback(
self,
status_bar_callback: Optional[MessageCallback],
@@ -376,8 +569,10 @@ def single_click_callback(
app_data: Tuple[int, int],
) -> None:
user_data = dpg.get_item_user_data(app_data[1])
+ self._remember_clicked_row(user_data)
if item_click_callback is not None:
item_click_callback(sender, app_data, user_data=user_data)
+
if status_bar_callback is not None:
self._status_bar.set(status_bar_callback, user_data=user_data)
@@ -401,6 +596,34 @@ def double_click_callback(
return double_click_callback
+ def _remember_clicked_row(self, user_data: Any) -> None:
+ """Follows a click through to what it left the row standing as, a frame after it landed.
+
+ A click on a row the reader can open is how that row folds and unfolds, and the row states
+ its own answer once the frame carrying the click has drawn. Reading it the frame after
+ therefore reports what the reader did, whichever button they pressed, and a row holding
+ nothing has nothing to remember.
+ """
+ if not self._REMEMBERS_EXPANSION or not isinstance(user_data, tuple):
+ return
+
+ node, node_tag = user_data
+ if not node.children:
+ return
+
+ CallbackQueue.add(
+ self._read_row_expansion,
+ node_tag,
+ delay=1,
+ )
+
+ def _read_row_expansion(self, node_tag: str) -> None:
+ """Takes the state a row stands in into the memory, on the main thread that owns the row."""
+ if not dpg.does_item_exist(node_tag):
+ return
+
+ self._expansion.remember(node_tag, expanded=bool(dpg_get_value(node_tag)))
+
def _setup_handlers(self) -> None:
for handler in self._node_handlers.values():
with dpg.item_handler_registry(tag=handler.tag):
@@ -441,14 +664,13 @@ def _build_tree_node(
def _has_relevant_content(self, node: TreeNode) -> bool: ...
def _should_expand_node(self, node: TreeNode) -> bool:
- if not self.tree.is_filtered():
- return False
-
- for descendant in node.descendants:
- if self.tree.is_node_visible(descendant):
- return True
+ """Whether the search points at the row, which a match and every row above one is.
- return False
+ A search names the rows whose label matched and shows what each of them gathers, so a folder it
+ named opens, for as long as the query stands. What the favorites mode opens is its memory to
+ answer, read as each row is created.
+ """
+ return self._search_visibility is not None and self._search_visibility.should_expand(node)
def _create_status_bar_message_function(
self,
@@ -472,29 +694,41 @@ def _create_status_bar_message_function_for_library_node(
) -> MessageCallback:
return self._create_status_bar_message_function(self._language_manager["global.status.message.node_library"])
- def _create_status_bar_message_function_for_directory_node(
+ def _create_status_bar_message_function_for_expandable_node(
self,
) -> MessageCallback:
+ """Builds the hover message of a row the reader opens, naming what that row holds.
+
+ A folder, a group and a sample are all opened the same way and hold different things, so the
+ message follows the node it is asked about: the sample names the reconstructions it gathers.
+ """
+
def message_function(
*_args: Any,
- user_data: Tuple[FileSystemNode, str],
+ user_data: Tuple[TreeNode, str],
**_kwargs: Any,
) -> str:
- _, node_tag = user_data
+ node, node_tag = user_data
expand_or_collapse = (
self._language_manager["global.dialog.template.collapse"]
if dpg_get_value(node_tag)
else self._language_manager["global.dialog.template.expand"]
)
- return self._language_manager["global.status.message.node_directory"].format(
- expand_or_collapse=expand_or_collapse
- )
+ return self._expandable_node_message(node).format(expand_or_collapse=expand_or_collapse)
return self._create_status_bar_message_function(message_function)
+ def _expandable_node_message(self, node: TreeNode) -> str:
+ match node.node_type:
+ case NodeType.SAMPLE:
+ return self._language_manager["global.status.message.node_sample"]
+ case NodeType.GROUP:
+ return self._language_manager["global.status.message.node_group"]
+
+ return self._language_manager["global.status.message.node_directory"]
+
def _generate_node_tag(self, node: TreeNode) -> str:
- path_parts = [ancestor.name for ancestor in node.path]
- return compose_tag(self.tag, f"node_{'_'.join(path_parts)}")
+ return compose_node_tag(node, panel_tag=self.tag)
def _context_menu_header_name(self, node: TreeNode) -> str:
"""Returns the raw on-disk identifier, complementing the friendly label shown in the tree."""
@@ -545,12 +779,15 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]:
match node:
case LibraryNode():
return self._library_detail_items(node.library_key)
- case FileSystemNode() if node.node_type == NodeType.DIRECTORY:
- return self._reconstruction_detail_items(node.filepath.name)
+ case ConfigNode():
+ return self._reconstruction_detail_items(node.config)
return []
- def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, str]]:
+ def _library_detail_items(
+ self,
+ key: InstructionLibraryKey,
+ ) -> List[Tuple[str, str]]:
nes_frequency = round(key.sample_rate / key.frame_length)
return [
(self._lbl_detail_sample_rate, format_sample_rate(key.sample_rate)),
@@ -561,23 +798,24 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s
(self._lbl_detail_configuration, short_hash(key.config_hash)),
]
- def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, str]]:
- fields = ConfigDirectoryFields.from_directory_name(directory_name)
- if fields is None:
- return []
-
- generators = ", ".join(generator.capitalized for generator in fields.generators)
+ def _reconstruction_detail_items(
+ self,
+ fields: ConfigDirectoryFields,
+ ) -> List[Tuple[str, str]]:
return [
(self._lbl_detail_sample_rate, format_sample_rate(fields.sr)),
(self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)),
(self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)),
(self._lbl_detail_transformation_gamma, str(fields.tg)),
- (self._lbl_detail_generators, generators),
+ (self._lbl_detail_generators, format_generators(fields.generators)),
(self._lbl_detail_configuration, short_hash(fields.ch)),
]
def _add_context_menu_details(self, node: TreeNode) -> None:
- add_detail_items(self._node_detail_items(node), color=self._colors.muted)
+ add_detail_items(
+ self._node_detail_items(node),
+ color=self._colors.muted,
+ )
def _add_context_menu_play_item(self, node: FileSystemNode) -> None:
if not self._logic.is_playable_file(node):
@@ -685,20 +923,144 @@ def _on_replace_in_sequencer(
self.call(self.on_replace_in_sequencer, user_data.filepath)
def _on_search_changed(self, _sender: Sender, query: str) -> None:
- if query:
- self.apply_filter(query, self._default_search_predicate)
- else:
- self.clear_filter()
-
- self._logic.schedule_search_update(query)
+ self._set_query(query)
def _on_clear_search_clicked(self) -> None:
if self._search_input_tag is not None:
dpg.set_value(self._search_input_tag, "")
- self.clear_filter()
+ self._set_query("")
+
+ def _set_query(self, query: str) -> None:
+ """Take the query the browser is now asked to show, and resolve the rows it names.
+
+ The rows already drawn are the favorites mode's to state, so a keystroke resolves the search
+ alone and the tree on screen answers the one after it.
+ """
+ self._filter = self._filter.with_query(query)
+ self._search_visibility = self._resolve_search_visibility()
+ self._logic.schedule_search_update(query)
+
+ def _resolve_filter(self) -> None:
+ """Resolve the filter against the model as it stands, which a rebuild does once per pass.
+
+ The model is what the whole answer is read from, so the resolution runs on the rebuild worker
+ and a filter stated before a refresh answers for the rows that refresh brings. Both of the
+ favorites mode's turns are answered here, each by the pass that follows it: the pass the reader
+ asked for notes the way down it opens, and the pass that reads the mode off hands those rows
+ back.
+ """
+ self._search_visibility = self._resolve_search_visibility()
+ (
+ self._favorites_visibility,
+ way_down,
+ ) = self._resolve_favorites()
+ if self._filter.favorites_only:
+ self._expansion.follow(way_down)
+ else:
+ self._release_mode_rows()
+
+ self._auto_expand_pending = False
+
+ def _resolve_search_visibility(self) -> Optional[TreeVisibility]:
+ """The rows the search query names, and nothing to narrow by while no query is typed."""
+ query = self._filter.query
+ if not query:
+ return None
- self._logic.schedule_search_update("")
+ return resolve_visibility(
+ self.tree.find_nodes(
+ TreeNode,
+ lambda node: self._default_search_predicate(node, query),
+ )
+ )
+
+ def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]:
+ """The rows the favorites mode keeps, and the rows it opens the way down through.
+
+ The two answer different questions — which rows the browser draws, and which of them stand
+ open — so each is read out of one walk of the model: the rows a star reaches state what is
+ drawn, and the rows above the anchors among them state the way down. A corpus of any size
+ therefore resolves into a walk and a pair of sets.
+ """
+ if not self._filter.favorites_only:
+ return None, set()
+
+ reached = self.tree.find_nodes(TreeNode, self._is_node_starred)
+ return (
+ resolve_visibility(reached),
+ self._way_down_to(self._auto_expanded_anchors(reached)),
+ )
+
+ def _way_down_to(self, rows: Sequence[TreeNode]) -> Set[str]:
+ """The tags of the rows standing above these rows, which is a way down to them.
+
+ A row given here is one the browser is pointed at, so a way down opens the rows above it while
+ its own row stands as the reader left it. The container both branches hang from is a row on no
+ screen, and stays out of the answer.
+ """
+ root = self.tree.get_root()
+ return {self._generate_node_tag(ancestor) for row in rows for ancestor in row.ancestors if ancestor is not root}
+
+ def _auto_expanded_anchors(
+ self,
+ reached: Sequence[TreeNode],
+ ) -> List[TreeNode]:
+ """The anchors whose star the reader asked the browser to open the way down to.
+
+ The pass the reader started by switching the mode on is the one that follows a star, so a pass
+ of its own accord points at nothing. Which stars are followed is a preference stated per kind
+ and read once per pass: a starred reconstruction answers for itself, and a starred folder
+ answers for itself together with the rows it brings in where no row stands for the folder.
+ """
+ if not self._auto_expand_pending:
+ return []
+
+ reconstructions = self._logic.auto_expand_favorite_reconstructions
+ directories = self._logic.auto_expand_favorite_directories
+ return [
+ node
+ for node in reached
+ if self._is_node_anchored(node)
+ and (reconstructions if self._is_starred_reconstruction(node) else directories)
+ ]
+
+ def _is_starred_reconstruction(self, node: TreeNode) -> bool:
+ """Whether the star the mode reaches this row through sits on a reconstruction.
+
+ A row the reader starred answers by its own kind. A row a starred folder brings in answers by
+ that folder, a folder being the only thing that holds another row.
+ """
+ return node.node_type == NodeType.FILE and self._logic.is_node_favorite(node)
+
+ def _is_node_starred(self, node: TreeNode) -> bool:
+ """Whether the favorites mode names the row: it carries a star, or a starred folder holds it.
+
+ Being held by a starred folder is a fact about the path, so a reconstruction listed under the
+ sample it came from answers the same as the row standing for it beside its configuration.
+ """
+ if self._logic.is_node_favorite(node):
+ return True
+
+ return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node)
+
+ def _is_node_anchored(self, node: TreeNode) -> bool:
+ """Whether the mode points the reader at the row, which is what the way down opens to.
+
+ A star sits on a row the reader marked, so that row is pointed at wherever it sits — inside
+ another starred folder among the rest, which is what lets an explicit favorite open the folder
+ above it. A row a starred folder merely holds is where the star first reaches only while no
+ row above it is reached, which is how the sample branch answers: its headings carry no path,
+ so the variants are where the star arrives.
+
+ Asked of the rows the star reaches, so a row it declines stands under a row it named: the
+ reader is pointed at the folder, and the rows inside it stand as they were.
+ """
+ if self._logic.is_node_favorite(node):
+ return True
+
+ parent = node.parent
+ return parent is None or not self._is_node_starred(parent)
def _default_search_predicate(self, node: TreeNode, query: str) -> bool:
return query.lower() in node.name.lower()
@@ -706,7 +1068,16 @@ def _default_search_predicate(self, node: TreeNode, query: str) -> bool:
@abstractmethod
def rebuild_tree(self) -> None: ...
+ @abstractmethod
+ def redraw_tree(self) -> None:
+ """Draws the rows again from the model in hand, which a change of filter asks for."""
+
def update_tree_visibility(self) -> None:
+ """Show the rows the search names and hide the rest, over the rows already on screen.
+
+ Runs on the main thread once the typing settles, so a query narrows what is drawn in place
+ of asking for a rebuild.
+ """
root = self.tree.get_root()
if root is None:
return
@@ -719,34 +1090,35 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None:
if not dpg.does_item_exist(node_tag):
return
- is_visible = self.tree.is_node_visible(node)
- dpg.configure_item(node_tag, show=is_visible)
+ dpg.configure_item(node_tag, show=self._is_node_visible(node))
for child in node.children:
self._update_node_visibility_recursive(child)
- def apply_filter(
- self,
- query: str,
- predicate: Callable[[TreeNode, str], bool],
- ) -> None:
- self.tree.apply_filter(query, predicate)
+ def _is_node_visible(self, node: TreeNode) -> bool:
+ """Whether the search shows the row, which every row on screen reads as while none is typed."""
+ if self._search_visibility is None:
+ return True
+
+ return self._search_visibility.is_visible(node)
- def clear_filter(self) -> None:
- self.tree.clear_filter()
+ def _is_node_drawn(self, node: TreeNode) -> bool:
+ """Whether the favorites mode draws the row, which it does for every row while it is off."""
+ if self._favorites_visibility is None:
+ return True
+
+ return self._favorites_visibility.is_visible(node)
def _apply_node_theme(
self,
node_tag: str,
node: TreeNode,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> None:
FontRegistry.bind_to_item(node_tag, self._resolve_node_name_font(node))
theme_tag = self._resolve_node_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_node_expanded=is_node_expanded,
)
ThemeRegistry.get(theme_tag).bind_to_item(node_tag)
@@ -755,7 +1127,6 @@ def _resolve_node_theme_tag(
node: TreeNode,
*,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> str:
"""Select the theme tag for a node from its type, favorite state, and content.
@@ -773,7 +1144,6 @@ def _resolve_node_theme_tag(
return self._resolve_file_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_not_expanded=is_node_expanded,
)
return self._resolve_other_theme_tag(node)
@@ -800,23 +1170,20 @@ def _resolve_file_theme_tag(
node: FileSystemNode,
*,
has_favorite_ancestor: bool = False,
- is_not_expanded: bool = False,
) -> str:
if self._logic.is_node_favorite(node):
return TAG_GLOBAL_THEME_FAVORITE
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return TAG_GLOBAL_THEME_FILE_LIBRARY
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return TAG_GLOBAL_THEME_FILE_WAVE
case _:
if has_favorite_ancestor:
return TAG_GLOBAL_THEME_FAVORITE_CHILD
- if is_not_expanded:
- return TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY
return TAG_GLOBAL_THEME_DEFAULT
def _resolve_other_theme_tag(self, node: TreeNode) -> str:
@@ -863,9 +1230,30 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None:
self._logic.toggle_favorite(node)
- def update_favorite_indicator(self, node: FileSystemNode) -> None:
- has_favorite_ancestor = self._logic.has_favorite_ancestor(node)
- self._reapply_theme_recursively(node, has_favorite_ancestor)
+ def update_favorite_indicators(
+ self,
+ nodes: Sequence[FileSystemNode],
+ ) -> None:
+ """Follows a favorite change through the rows it reaches, and what each of them holds.
+
+ A path reaches the panel as many rows as the views offer it — a reconstruction is listed both
+ by its configuration and by the sample it came from — and the star belongs to the path, so
+ the caller names every row standing for it and each of them takes the new theme with the
+ ancestry its own path carries.
+
+ While the mode shows the favorites alone the star decides which rows exist, so the change is
+ answered by drawing the tree again from the model in hand: starring a row brings it in, and
+ unstarring one takes it out along with what it held.
+ """
+ if self._filter.favorites_only:
+ self.redraw_tree()
+ return
+
+ for node in nodes:
+ self._reapply_theme_recursively(
+ node,
+ self._logic.has_favorite_ancestor(node),
+ )
@abstractmethod
def set_tree_enabled(self, enabled: bool) -> None: ...
diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py
index 0894f533..0ef75f5d 100644
--- a/src/sampletones_application/ui/menu.py
+++ b/src/sampletones_application/ui/menu.py
@@ -52,6 +52,8 @@
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_RECONSTRUCT_FILE,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE_AS,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN,
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS,
TAG_GLOBAL_PANEL_PLAYER,
@@ -537,6 +539,8 @@ def _create_view_menu(self) -> None:
check=True,
)
dpg.add_separator()
+ self._create_auto_expand_favorites_menu()
+ dpg.add_separator()
self._shortcut_manager.add_menu_item(
ShortcutId.DISPLAY_SETTINGS,
label=self._label(MenuElements.ITEM_VIEW_DISPLAY_SETTINGS),
@@ -546,6 +550,26 @@ def _create_view_menu(self) -> None:
label=self._label(MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS),
)
+ def _create_auto_expand_favorites_menu(self) -> None:
+ """Offers, per kind of favorite, whether showing the favorites alone opens the way down to one.
+
+ A browser showing its favorites alone decides which rows it draws; whether it also unfolds the
+ rows above a star is the reader's, and a reconstruction and a directory are answered apart.
+ """
+ with dpg.menu(label=self._label(MenuElements.GROUP_VIEW_AUTO_EXPAND_FAVORITES)):
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS),
+ check=True,
+ )
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES),
+ check=True,
+ )
+
def _create_help_menu(self) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_HELP)):
self._shortcut_manager.add_menu_item(
@@ -661,6 +685,18 @@ def update(self, state: MenuBarViewModel) -> None:
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS,
state.advanced_settings,
)
+ self._update_auto_expand_favorites(state)
+
+ def _update_auto_expand_favorites(self, state: MenuBarViewModel) -> None:
+ """Shows, per kind of favorite, whether the browsers open the way down to one."""
+ dpg_set_value(
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ state.auto_expand_favorite_reconstructions,
+ )
+ dpg_set_value(
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ state.auto_expand_favorite_directories,
+ )
def _update_follow_mode(self, state: MenuBarViewModel) -> None:
"""Marks the reach the view follows the playhead at, the one mode carrying the check."""
diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py
index af186d27..5c98d092 100644
--- a/src/sampletones_application/ui/panels/instruction/library.py
+++ b/src/sampletones_application/ui/panels/instruction/library.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Protocol, Tuple
+from typing import AbstractSet, Any, Callable, Optional, Protocol, Tuple
import dearpygui.dearpygui as dpg
@@ -7,10 +7,7 @@
from sampletones_application.layout.behavior.scheduling.scheduling import (
SchedulingBehavior,
)
-from sampletones_application.tags.general import (
- TAG_GLOBAL_THEME_PRIMARY_BUTTON,
- TAG_GLOBAL_THEME_SECONDARY_BUTTON,
-)
+from sampletones_application.tags.general import TAG_GLOBAL_THEME_PRIMARY_BUTTON
from sampletones_application.tags.instructions import (
TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION,
TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
@@ -31,17 +28,16 @@
from sampletones_application.ui.elements.context_menu import context_menu
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
from sampletones_application.ui.elements.tree.colors import TreeColors
from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip
-from sampletones_application.utils.parallelization.thread import concurrent
from sampletones_application.view_model.instruction.library import LibraryPanelViewModel
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
@@ -81,9 +77,21 @@ def update_status(self) -> None: ...
def get_path(self, key: InstructionLibraryKey) -> Path: ...
-class GUIInstructionsLibraryPanel(GUITreePanel):
+class GUIInstructionsLibraryPanel(GUIFileBrowserPanel):
+ """The Instructions tab's catalogue of instruction libraries and the generators inside them."""
+
_NAME_FONT: Font = Font.REGULAR_SMALL
_MONOSPACE_CONFIG_NODES: bool = True
+ _REBUILD_ON_CREATE: bool = False
+ _REMEMBERS_EXPANSION: bool = True
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_INSTRUCTIONS_LIBRARY_PANEL,
+ tree=TAG_INSTRUCTIONS_LIBRARY_TREE,
+ window_tree=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE,
+ group_tree=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE,
+ group_controls=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS,
+ button_refresh=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
+ )
def __init__(
self,
@@ -91,7 +99,8 @@ def __init__(
tree_logic: TreeLogicProtocol,
*,
scheduling: SchedulingBehavior,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_expanded_rows: AbstractSet[str],
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
@@ -109,25 +118,35 @@ def __init__(
self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None
self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None
- self._node_handlers: Dict[NodeType, NodeHandler]
-
super().__init__(
- self._library_logic.tree,
- tag=TAG_INSTRUCTIONS_LIBRARY_PANEL,
- tree_tag=TAG_INSTRUCTIONS_LIBRARY_TREE,
+ tree=library_logic.tree,
tree_logic=tree_logic,
scheduling=scheduling,
search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=False,
+ initial_expanded_rows=initial_expanded_rows,
)
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["instructions.library.label.libraries_text"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.instruction_data
+
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["instructions.library.label.refresh_libraries_button"]
+
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["instructions.library.message.status_refresh"]
+
def _setup_handlers(self) -> None:
self._node_handlers = {
NodeType.LIBRARY: NodeHandler(
@@ -146,41 +165,16 @@ def _setup_handlers(self) -> None:
super()._setup_handlers()
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with (
- dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ),
- self._collapsible_section(
- self._language_manager["instructions.library.label.libraries_text"],
- glyph=self._glyphs.headers.instruction_data,
- ),
- ):
- self._create_library_status()
- self._create_library_controls()
- self._create_library_tree()
-
- self._create_detail_tooltip(TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE)
-
- def _create_library_status(self) -> None:
- text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS)
- FontRegistry.bind_to_item(text, Font.MONO_SMALL)
+ def _create_controls(self) -> None:
+ """Reads out what the catalogue holds, and offers what can be done to it.
- def _create_library_controls(self) -> None:
- with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS):
+ The controls come in two sets: the ones a reader picks from while the catalogue sits still,
+ and the progress bar and cancel button a generation replaces them with.
+ """
+ self._create_library_status()
+ with dpg.group(tag=self._tags.group_controls):
with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_IDLE):
- GUIButton(
- tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
- label=self._language_manager["instructions.library.label.refresh_libraries_button"],
- width=-1,
- callback=self._on_refresh_clicked,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
+ self._create_refresh_button()
with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_GENERATE):
GUIButton(
tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
@@ -214,10 +208,7 @@ def _create_library_controls(self) -> None:
width=-1,
callback=self._on_cancel_clicked,
)
- self._status_bar.bind_to_item(
- TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
- self._language_manager["instructions.library.message.status_refresh"],
- )
+ self._bind_refresh_message()
self._status_bar.bind_to_item(
TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
self._language_manager["instructions.library.message.status_generate"],
@@ -227,26 +218,15 @@ def _create_library_controls(self) -> None:
self._language_manager["instructions.library.message.status_cancel_generation"],
)
- def _create_library_tree(self) -> None:
- dpg.add_separator()
- self.create_search(self._body_container)
- with (
- dpg.child_window(
- tag=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE,
- width=-1,
- height=-1,
- horizontal_scrollbar=True,
- ),
- dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE),
- dpg.tree_node(
- label=self._language_manager["instructions.library.label.available_libraries_text"],
- tag=self.tree_tag,
- default_open=True,
- ),
- ):
- pass
+ def _create_library_status(self) -> None:
+ text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS)
+ FontRegistry.bind_to_item(text, Font.MONO_SMALL)
+
+ def _create_tree_root(self) -> None:
+ self._create_tree_root_heading(self._language_manager["instructions.library.label.available_libraries_text"])
def _on_refresh_clicked(self) -> None:
+ """Answers the refresh control by reading the libraries again, which rebuilds the tree."""
self.call(self.on_refresh_requested)
def _on_generate_clicked(self) -> None:
@@ -282,12 +262,13 @@ def update_view(self, view_model: LibraryPanelViewModel) -> None:
)
def set_tree_enabled(self, enabled: bool) -> None:
+ """Locks the tree and the control reading it again, leaving a running generation cancellable."""
dpg_configure_item(
- TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE,
+ self._tags.group_tree,
enabled=enabled,
)
dpg_configure_item(
- TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
+ self._tags.button_refresh,
enabled=enabled,
)
self._apply_action_button_states()
@@ -312,14 +293,11 @@ def _apply_action_button_states(self) -> None:
show=operation_active,
)
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- self._library_logic.rebuild_tree,
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- on_finished=self._library_logic.update_status,
- )
+ def _refresh_model(self) -> None:
+ self._library_logic.rebuild_tree()
+
+ def _on_rebuild_finished(self) -> None:
+ self._library_logic.update_status()
def _has_relevant_content(self, node: TreeNode) -> bool:
return True
diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py
index 0f477526..45da542d 100644
--- a/src/sampletones_application/ui/panels/main/explorer.py
+++ b/src/sampletones_application/ui/panels/main/explorer.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, Dict, List, Optional, Protocol, Tuple
+from typing import Any, List, Optional, Protocol, Tuple
import dearpygui.dearpygui as dpg
@@ -7,9 +7,7 @@
from sampletones_application.layout.behavior.scheduling.scheduling import (
SchedulingBehavior,
)
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON
from sampletones_application.tags.main import (
- TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
TAG_MAIN_EXPLORER_BUTTON_REFRESH,
TAG_MAIN_EXPLORER_GROUP_CONTROLS,
TAG_MAIN_EXPLORER_GROUP_TREE,
@@ -17,20 +15,16 @@
TAG_MAIN_EXPLORER_TREE,
TAG_MAIN_EXPLORER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.spec import NodeSpec
from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS
from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core import paths
from sampletones_core.structures.tree import (
FileSystemNode,
NodeType,
@@ -39,6 +33,7 @@
TreeTraversal,
traverse,
)
+from sampletones_shared.paths import extensions
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import MessageCallback, PathCallback
@@ -61,12 +56,27 @@ def collapse_all(self) -> None: ...
def expand_directory(self, node: FileSystemNode) -> None: ...
- def is_directory_expanded(self, filepath: Path) -> bool: ...
+ def has_loaded_children(self, filepath: Path) -> bool: ...
+
+ def is_directory_open(self, filepath: Path) -> bool: ...
+
+ def set_directory_open(self, filepath: Path, is_open: bool) -> None: ...
def has_relevant_content(self, filepath: Path) -> bool: ...
-class GUIExplorerPanel(GUITreePanel):
+class GUIExplorerPanel(GUIFileBrowserPanel):
+ """The Main tab's browser of the filesystem, whose rows are the folders and files on disk."""
+
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_MAIN_EXPLORER_PANEL,
+ tree=TAG_MAIN_EXPLORER_TREE,
+ window_tree=TAG_MAIN_EXPLORER_WINDOW_TREE,
+ group_tree=TAG_MAIN_EXPLORER_GROUP_TREE,
+ group_controls=TAG_MAIN_EXPLORER_GROUP_CONTROLS,
+ button_refresh=TAG_MAIN_EXPLORER_BUTTON_REFRESH,
+ )
+
def __init__(
self,
explorer_logic: ExplorerLogicProtocol,
@@ -76,14 +86,11 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
) -> None:
self._language_manager = language_manager
self._explorer_logic = explorer_logic
- self._lbl_section = language_manager["main.explorer.label.section"]
- self._node_handlers: Dict[NodeType, NodeHandler]
-
self.on_wave_file_clicked: Optional[PathCallback] = None
self.on_directory_clicked: Optional[PathCallback] = None
self.on_reconstruct_directory: Optional[PathCallback] = None
@@ -94,127 +101,58 @@ def __init__(
self.on_set_as_library_directory: Optional[PathCallback] = None
super().__init__(
- tree=self._explorer_logic.tree,
- tag=TAG_MAIN_EXPLORER_PANEL,
- tree_tag=TAG_MAIN_EXPLORER_TREE,
+ tree=explorer_logic.tree,
tree_logic=tree_logic,
scheduling=scheduling,
search_label=language_manager["global.browser.label.filter"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=False,
+ initial_expanded_rows=NO_EXPANDED_ROWS,
)
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with (
- dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ),
- self._collapsible_section(
- self._lbl_section,
- glyph=self._glyphs.headers.filesystem,
- ),
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_MAIN_EXPLORER_WINDOW_TREE)
- self.rebuild_tree()
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["main.explorer.label.section"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.filesystem
+
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["main.explorer.label.refresh_button"]
+
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["main.explorer.message.status_refresh"]
def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_file_node_clicked,
- item_double_click_callback=self._on_file_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_file_node(),
- ),
- }
+ self._node_handlers = self._create_file_system_handlers(
+ on_directory_clicked=self._on_directory_node_clicked,
+ on_file_clicked=self._on_file_node_clicked,
+ on_file_double_clicked=self._on_file_node_double_clicked,
+ file_status_message=self._create_status_bar_message_function_for_file_node(),
+ )
super()._setup_handlers()
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_MAIN_EXPLORER_BUTTON_REFRESH,
- label=self._language_manager["main.explorer.label.refresh_button"],
- parent=self._body_container,
- width=-1,
- callback=self.refresh,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- GUIButton(
- tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
- label=self._language_manager["main.explorer.label.collapse_all_button"],
- parent=self._body_container,
- width=-1,
- callback=self.collapse_all,
- )
- self._status_bar.bind_to_item(
- TAG_MAIN_EXPLORER_BUTTON_REFRESH,
- self._language_manager["main.explorer.message.status_refresh"],
- )
- self._status_bar.bind_to_item(
- TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
- self._language_manager["main.explorer.message.status_collapse_all"],
- )
+ def _create_tree_root(self) -> None:
+ self._create_tree_root_heading(self.section_label)
- def _create_tree_window(self) -> None:
- self.create_search(self._body_container)
- with (
- dpg.child_window(
- tag=TAG_MAIN_EXPLORER_WINDOW_TREE,
- horizontal_scrollbar=True,
- ),
- dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE),
- dpg.tree_node(
- label=self._lbl_section,
- tag=self.tree_tag,
- default_open=True,
- ),
- ):
- pass
-
- def collapse_all(
- self,
- _sender: Sender,
- _app_data: int,
- _user_data: Any,
- ) -> None:
- self._explorer_logic.collapse_all()
- children = dpg.get_item_children(self.tree_tag, 1)
- assert children is not None, "Explorer tree has no children."
- for node_tag in children:
- dpg.set_value(node_tag, False)
+ def _refresh_model(self) -> None:
+ self._explorer_logic.refresh_tree()
- def refresh(self) -> None:
- self.rebuild_tree()
+ def _on_collapse_all_clicked(self) -> None:
+ """Folds every folder away and drops the children it had loaded, so opening one reads it again.
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- self._explorer_logic.refresh_tree,
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- )
+ The rows fold while the model still states them, and the folders the model held go afterwards,
+ which is what makes a later open list the folder as it stands on disk.
+ """
+ super()._on_collapse_all_clicked()
+ self._explorer_logic.collapse_all()
@concurrent(wait=False, method_bound=True)
def _rebuild_node_subtree(
@@ -234,14 +172,13 @@ def _collect_subtree_specs(
node_tag: str,
) -> List[NodeSpec]:
self._pending_specs = []
- if self._explorer_logic.is_directory_expanded(node.filepath):
+ if self._explorer_logic.has_loaded_children(node.filepath):
for child in node.children:
- has_favorite_ancestor = self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(child)
self._build_tree_node(
child,
TreeNodeState(
parent=node_tag,
- has_favorite_ancestor=has_favorite_ancestor,
+ has_favorite_ancestor=self._logic.has_favorite_ancestor(child),
),
)
@@ -261,20 +198,16 @@ def _build_tree_node(
if not isinstance(node, FileSystemNode):
return
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
+ self._mark_favorite_ancestry(node, state)
if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath)
- is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath)
self._append_spec(
node,
node_tag,
state.parent,
open_on_double_click=True,
- should_expand=should_expand,
+ should_expand=self._should_expand_node(node) or self._explorer_logic.is_directory_open(node.filepath),
has_favorite_ancestor=state.has_favorite_ancestor,
- is_node_expanded=is_directory_expanded,
)
else:
self._append_spec(
@@ -302,19 +235,19 @@ def message_function(
node, _ = user_data
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return reconstruction_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return library_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return audio_message_function(
*args,
user_data=user_data,
@@ -335,9 +268,9 @@ def _on_file_node_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return self._logic.request_autoplay(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self.call(self.on_wave_file_clicked, node.filepath)
return self._logic.request_autoplay(node)
@@ -356,12 +289,12 @@ def _on_file_node_double_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
self._load_reconstruction(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._logic.cancel_autoplay()
return self._reconstruct_file(node)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return self._load_library(node)
return None
@@ -421,10 +354,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool:
return True
- def set_tree_enabled(self, enabled: bool) -> None:
- dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_TREE, enabled=enabled)
- dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_CONTROLS, enabled=enabled)
-
def _reconstruct_file(self, node: FileSystemNode) -> None:
if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
return
@@ -436,35 +365,40 @@ def _toggle_directory_expansion(
node: FileSystemNode,
node_tag: str,
) -> None:
+ """Folds or unfolds a folder, reading its children the first time it is opened.
+
+ The folder is told what it now stands as, which is the shape a refresh and a later run of the
+ application bring it back in.
+ """
if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
return
if not dpg.does_item_exist(node_tag):
return
- is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath)
- state = dpg.get_value(node_tag)
- if not is_directory_expanded:
+ is_open = not dpg.get_value(node_tag)
+ if not self._explorer_logic.has_loaded_children(node.filepath):
self._explorer_logic.expand_directory(node)
self._rebuild_node_subtree(node, node_tag)
- dpg.set_value(node_tag, not state)
+ dpg.set_value(node_tag, is_open)
+ self._explorer_logic.set_directory_open(node.filepath, is_open)
def _add_context_menu_file_actions(self, node: FileSystemNode) -> None:
dpg.add_separator()
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_reconstruction"],
callback=lambda: self._load_reconstruction(node),
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_library"],
callback=lambda: self._load_library(node),
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_reconstruct_file"],
callback=lambda: self._context_reconstruct_file(node),
diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py
index 3873d72f..8f618d03 100644
--- a/src/sampletones_application/ui/panels/reconstruction/browser.py
+++ b/src/sampletones_application/ui/panels/reconstruction/browser.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Tuple
+from typing import AbstractSet, Optional
import dearpygui.dearpygui as dpg
@@ -7,9 +7,6 @@
from sampletones_application.layout.behavior.scheduling.scheduling import (
SchedulingBehavior,
)
-from sampletones_application.tags.general import (
- TAG_GLOBAL_THEME_SECONDARY_BUTTON,
-)
from sampletones_application.tags.reconstructions import (
TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS,
@@ -18,32 +15,29 @@
TAG_RECONSTRUCTIONS_BROWSER_TREE,
TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
-from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
-from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
-from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
- TreeTraversal,
- traverse,
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.panels.shared.browser import (
+ GUIReconstructionBrowserPanel,
)
+from sampletones_core.structures.tree import FileSystemNode, Tree
from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import PathCallback, VoidCallback
+from sampletones_shared.types.callback import PathCallback
+
+class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel):
+ """The Reconstructions tab's browser, whose reconstructions open in the tab beside it."""
-class GUIBrowserPanel(GUITreePanel):
- _MONOSPACE_CONFIG_NODES: bool = True
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL,
+ tree=TAG_RECONSTRUCTIONS_BROWSER_TREE,
+ window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE,
+ group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE,
+ group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS,
+ button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
+ )
def __init__(
self,
@@ -54,247 +48,46 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- is_operation_active: Callable[[], bool],
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
- self.on_refresh_tree: Optional[VoidCallback] = None
- self.on_reconstruct_file: Optional[VoidCallback] = None
- self.on_reconstruct_directory: Optional[VoidCallback] = None
- self.on_load_reconstruction: Optional[PathCallback] = None
- self.on_reconstruction_remove_requested: Optional[PathCallback] = None
- self.on_directory_remove_requested: Optional[PathCallback] = None
-
- self._is_operation_active = is_operation_active
-
- self._lbl_reconstructions = language_manager["reconstructions.browser.label.reconstructions_tree"]
-
- self._node_handlers: Dict[NodeType, NodeHandler]
super().__init__(
tree=tree,
- tag=TAG_RECONSTRUCTIONS_BROWSER_PANEL,
- tree_tag=TAG_RECONSTRUCTIONS_BROWSER_TREE,
tree_logic=tree_logic,
scheduling=scheduling,
- search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
)
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with (
- dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ),
- self._collapsible_section(
- self._lbl_reconstructions,
- glyph=self._glyphs.headers.reconstruction,
- ),
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE)
- self.rebuild_tree()
-
- def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_reconstruction_node_clicked,
- item_double_click_callback=self._on_reconstruction_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(),
- ),
- }
-
- super()._setup_handlers()
-
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- label=self._language_manager["reconstructions.browser.label.refresh_button"],
- width=-1,
- callback=self.rebuild_tree,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- self._status_bar.bind_to_item(
- TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- self._language_manager["reconstructions.browser.message.status_refresh"],
- )
-
- def _create_tree_window(self) -> None:
- self.create_search(self._body_container)
- with (
- dpg.child_window(
- tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE,
- horizontal_scrollbar=True,
- ),
- dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE),
- dpg.tree_node(
- label=self._lbl_reconstructions,
- tag=self.tree_tag,
- default_open=True,
- ),
- ):
- pass
-
- def refresh(self) -> None:
- self.rebuild_tree()
-
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- lambda: self.call(self.on_refresh_tree),
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- )
-
- def _has_relevant_content(self, node: TreeNode) -> bool:
- if node.node_type == NodeType.FILE:
- return True
-
- return bool(node.children)
-
- @traverse(TreeTraversal.BFS)
- def _build_tree_node(
- self,
- node: TreeNode,
- state: TreeNodeState,
- **kwargs: Any,
- ) -> None:
- node_tag = self._generate_node_tag(node)
- if node.node_type == NodeType.ROOT:
- return
-
- if not isinstance(node, FileSystemNode):
- return
-
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
- if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node)
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- should_expand=should_expand,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
- else:
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- leaf=True,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
-
- state.parent = node_tag
-
- def set_tree_enabled(self, enabled: bool) -> None:
- dpg_configure_item(
- TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE,
- enabled=enabled,
- )
- dpg_configure_item(
- TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS,
- enabled=enabled,
- )
-
- def _reconstruct_file(self) -> None:
- self.call(self.on_reconstruct_file)
-
- def _reconstruct_directory(self) -> None:
- self.call(self.on_reconstruct_directory)
-
- def _on_directory_node_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Right:
- return self._show_directory_context_menu(node)
-
- return None
-
- def _on_reconstruction_node_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, node_tag = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.request_autoplay(node)
-
- if mouse_button == dpg.mvMouseButton_Right:
- self._show_reconstruction_context_menu(node, node_tag)
+ self.on_load_reconstruction: Optional[PathCallback] = None
+ self.on_reconstruction_remove_requested: Optional[PathCallback] = None
+ self.on_directory_remove_requested: Optional[PathCallback] = None
- def _on_reconstruction_node_double_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.cancel_autoplay()
- self._load_reconstruction(node)
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["reconstructions.browser.label.refresh_button"]
- def _show_directory_context_menu(self, node: FileSystemNode) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
- return
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["reconstructions.browser.message.status_refresh"]
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_details(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_remove_directory_item(node)
- self._add_context_menu_favorite_item(node)
+ def _open_reconstruction(self, node: FileSystemNode) -> None:
+ self._load_reconstruction(node)
- def _show_reconstruction_context_menu(
- self,
- node: FileSystemNode,
- _node_tag: str,
- ) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
- return
+ def _add_directory_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_remove_directory_item(node)
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_play_item(node)
- self._add_context_menu_load_reconstruction_item(node)
- self._add_context_menu_remove_reconstruction_item(node)
- self._add_context_menu_sequencer_items(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_locate_audio_item(node)
- self._add_context_menu_favorite_item(node)
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_load_reconstruction_item(node)
+ self._add_context_menu_remove_reconstruction_item(node)
+ self._add_context_menu_sequencer_items(node)
def _add_context_menu_load_reconstruction_item(
self,
diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py
index d6fe796a..9bfe14e3 100644
--- a/src/sampletones_application/ui/panels/sequencer/browser.py
+++ b/src/sampletones_application/ui/panels/sequencer/browser.py
@@ -1,12 +1,9 @@
-from typing import Any, Dict, Optional, Tuple
-
-import dearpygui.dearpygui as dpg
+from typing import AbstractSet
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.behavior.scheduling.scheduling import (
SchedulingBehavior,
)
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
TAG_SEQUENCER_BROWSER_GROUP_CONTROLS,
@@ -15,32 +12,27 @@
TAG_SEQUENCER_BROWSER_TREE,
TAG_SEQUENCER_BROWSER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
-from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
-from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
-from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
- TreeTraversal,
- traverse,
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.panels.shared.browser import (
+ GUIReconstructionBrowserPanel,
)
-from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import VoidCallback
+from sampletones_core.structures.tree import FileSystemNode, Tree
+
+class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel):
+ """The Sequencer tab's browser, whose reconstructions become the song's samples."""
-class GUISequencerBrowserPanel(GUITreePanel):
- _MONOSPACE_CONFIG_NODES: bool = True
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_SEQUENCER_BROWSER_PANEL,
+ tree=TAG_SEQUENCER_BROWSER_TREE,
+ window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE,
+ group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE,
+ group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS,
+ button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
+ )
def __init__(
self,
@@ -51,225 +43,35 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
- self.on_refresh_tree: Optional[VoidCallback] = None
-
- self._lbl_reconstructions = language_manager["sequencer.browser.label.reconstructions_tree"]
-
- self._node_handlers: Dict[NodeType, NodeHandler]
super().__init__(
tree=tree,
- tag=TAG_SEQUENCER_BROWSER_PANEL,
- tree_tag=TAG_SEQUENCER_BROWSER_TREE,
tree_logic=tree_logic,
scheduling=scheduling,
- search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
- )
-
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with (
- dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ),
- self._collapsible_section(
- self._lbl_reconstructions,
- glyph=self._glyphs.headers.reconstruction,
- ),
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_SEQUENCER_BROWSER_WINDOW_TREE)
- self.rebuild_tree()
-
- def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_reconstruction_node_clicked,
- item_double_click_callback=self._on_reconstruction_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(),
- ),
- }
-
- super()._setup_handlers()
-
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- label=self._language_manager["sequencer.browser.label.refresh_button"],
- width=-1,
- callback=self.rebuild_tree,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- self._status_bar.bind_to_item(
- TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- self._language_manager["sequencer.browser.message.status_refresh"],
- )
-
- def _create_tree_window(self) -> None:
- self.create_search(self._body_container)
- with (
- dpg.child_window(
- tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE,
- horizontal_scrollbar=True,
- ),
- dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE),
- dpg.tree_node(
- label=self._lbl_reconstructions,
- tag=self.tree_tag,
- default_open=True,
- ),
- ):
- pass
-
- def refresh(self) -> None:
- self.rebuild_tree()
-
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- lambda: self.call(self.on_refresh_tree),
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
)
- def _has_relevant_content(self, node: TreeNode) -> bool:
- if node.node_type == NodeType.FILE:
- return True
-
- return bool(node.children)
-
- @traverse(TreeTraversal.BFS)
- def _build_tree_node(
- self,
- node: TreeNode,
- state: TreeNodeState,
- **kwargs: Any,
- ) -> None:
- node_tag = self._generate_node_tag(node)
- if node.node_type == NodeType.ROOT:
- return
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["sequencer.browser.label.refresh_button"]
- if not isinstance(node, FileSystemNode):
- return
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["sequencer.browser.message.status_refresh"]
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
- if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node)
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- should_expand=should_expand,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
- else:
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- leaf=True,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
-
- state.parent = node_tag
-
- def set_tree_enabled(self, enabled: bool) -> None:
- dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled)
- dpg_configure_item(
- TAG_SEQUENCER_BROWSER_GROUP_CONTROLS,
- enabled=enabled,
- )
-
- def _on_directory_node_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Right:
- return self._show_directory_context_menu(node)
-
- return None
-
- def _on_reconstruction_node_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, node_tag = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.request_autoplay(node)
-
- if mouse_button == dpg.mvMouseButton_Right:
- self._show_reconstruction_context_menu(node, node_tag)
-
- def _on_reconstruction_node_double_clicked(
- self,
- _sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.cancel_autoplay()
- self.call(self.on_add_to_sequencer, node.filepath)
-
- def _show_directory_context_menu(self, node: FileSystemNode) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
- return
-
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_details(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_favorite_item(node)
-
- def _show_reconstruction_context_menu(
- self,
- node: FileSystemNode,
- _node_tag: str,
- ) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
- return
+ def _open_reconstruction(self, node: FileSystemNode) -> None:
+ self.call(self.on_add_to_sequencer, node.filepath)
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_play_item(node)
- self._add_context_menu_sequencer_items(node)
- self._add_context_menu_replace_item(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_locate_audio_item(node)
- self._add_context_menu_favorite_item(node)
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_sequencer_items(node)
+ self._add_context_menu_replace_item(node)
diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py
new file mode 100644
index 00000000..35652a6b
--- /dev/null
+++ b/src/sampletones_application/ui/panels/shared/browser.py
@@ -0,0 +1,333 @@
+from abc import abstractmethod
+from typing import AbstractSet, Any, Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.tags.general import (
+ TAG_GLOBAL_THEME_DEFAULT,
+ TAG_GLOBAL_THEME_FILE_WAVE,
+)
+from sampletones_application.ui.elements.context_menu import add_detail_items, context_menu
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
+from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_core.structures.tree import (
+ FileSystemNode,
+ NodeType,
+ Tree,
+ TreeNode,
+ TreeTraversal,
+ traverse,
+)
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import VoidCallback
+
+
+class GUIReconstructionBrowserPanel(GUIFileBrowserPanel):
+ """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs.
+
+ Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node
+ clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and
+ its refresh control, and adds the items its context menus offer.
+
+ Reconstructions carry favorites, so this browser offers the control showing them alone and opens
+ in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild
+ — a refresh, a change of mode — brings the rows back standing as they were left, and so does the
+ next run of the application.
+ """
+
+ _MONOSPACE_CONFIG_NODES: bool = True
+ _OFFERS_FAVORITES_FILTER: bool = True
+ _REMEMBERS_EXPANSION: bool = True
+
+ def __init__(
+ self,
+ tree: Tree,
+ tree_logic: TreeLogicProtocol,
+ *,
+ scheduling: SchedulingBehavior,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ colors: TreeColors,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
+ ) -> None:
+ self._language_manager = language_manager
+ self.on_refresh_tree: Optional[VoidCallback] = None
+
+ super().__init__(
+ tree=tree,
+ tree_logic=tree_logic,
+ scheduling=scheduling,
+ search_label=language_manager["global.browser.label.search"],
+ language_manager=language_manager,
+ status_bar=status_bar,
+ colors=colors,
+ initial_collapsed=initial_collapsed,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
+ )
+
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["global.browser.label.browser"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.reconstruction
+
+ def _refresh_model(self) -> None:
+ self.call(self.on_refresh_tree)
+
+ def _setup_handlers(self) -> None:
+ self._node_handlers = {
+ NodeType.GROUP: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.GROUP),
+ node_type=NodeType.GROUP,
+ item_click_callback=self._on_container_node_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ NodeType.SAMPLE: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.SAMPLE),
+ node_type=NodeType.SAMPLE,
+ item_click_callback=self._on_container_node_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ **self._create_file_system_handlers(
+ on_directory_clicked=self._on_directory_node_clicked,
+ on_file_clicked=self._on_reconstruction_node_clicked,
+ on_file_double_clicked=self._on_reconstruction_node_double_clicked,
+ file_status_message=self._create_status_bar_message_function_for_reconstruction_node(),
+ ),
+ }
+
+ super()._setup_handlers()
+
+ def _has_relevant_content(self, node: TreeNode) -> bool:
+ if node.node_type == NodeType.FILE:
+ return True
+
+ return bool(node.children)
+
+ @traverse(TreeTraversal.BFS)
+ def _build_tree_node(
+ self,
+ node: TreeNode,
+ state: TreeNodeState,
+ **kwargs: Any,
+ ) -> None:
+ node_tag = self._generate_node_tag(node)
+ match node.node_type:
+ case NodeType.ROOT:
+ return
+ case NodeType.GROUP | NodeType.SAMPLE:
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ should_expand=self._should_expand_node(node),
+ )
+ state.parent = node_tag
+ return
+
+ if not isinstance(node, FileSystemNode):
+ return
+
+ self._mark_favorite_ancestry(node, state)
+ if node.node_type == NodeType.DIRECTORY:
+ should_expand = self._should_expand_node(node)
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ should_expand=should_expand,
+ has_favorite_ancestor=state.has_favorite_ancestor,
+ )
+ else:
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ leaf=True,
+ has_favorite_ancestor=state.has_favorite_ancestor,
+ )
+
+ state.parent = node_tag
+
+ def _resolve_other_theme_tag(self, node: TreeNode) -> str:
+ """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour.
+
+ A sample row names the audio a set of reconstructions was made from, so it reads in the
+ colour audio files carry elsewhere in the application.
+ """
+ match node.node_type:
+ case NodeType.GROUP:
+ return TAG_GLOBAL_THEME_DEFAULT
+ case NodeType.SAMPLE:
+ return TAG_GLOBAL_THEME_FILE_WAVE
+
+ return super()._resolve_other_theme_tag(node)
+
+ def _on_container_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[TreeNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_container_context_menu(node)
+
+ def _on_directory_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_directory_context_menu(node)
+
+ def _on_reconstruction_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, node_tag = user_data
+ if mouse_button == dpg.mvMouseButton_Left:
+ self._logic.request_autoplay(node)
+
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_reconstruction_context_menu(node, node_tag)
+
+ def _on_reconstruction_node_double_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ """Opens the double-clicked reconstruction, dropping the preview the click before it queued.
+
+ A single click queues an autoplay preview, and the second click of a double click means the
+ reader wants the file itself, so the preview is dropped before the subclass opens it.
+ """
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Left:
+ self._logic.cancel_autoplay()
+ self._open_reconstruction(node)
+
+ def _show_container_context_menu(self, node: TreeNode) -> None:
+ """Offers what a row the browser invents can answer: what it gathers, and how it folds.
+
+ A group or a sample stands for a facet of the reconstructions below it, so its menu reads the
+ subtree — how many reconstructions it gathers, the rows folding under it, the label the tree
+ shows it by, and for a sample the audio its reconstructions were made from.
+ """
+ if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE):
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_reconstruction_count(node)
+ self._add_context_menu_expansion_items(node)
+ self._add_context_menu_copy_name_item(node)
+ self._add_context_menu_sample_audio_item(node)
+
+ def _add_context_menu_reconstruction_count(self, node: TreeNode) -> None:
+ """States how many reconstructions the row gathers, which is what the row stands for."""
+ count = sum(1 for descendant in node.descendants if descendant.node_type == NodeType.FILE)
+ add_detail_items(
+ [(self._language_manager["global.context.label.detail_reconstructions"], str(count))],
+ color=self._colors.muted,
+ )
+
+ def _add_context_menu_expansion_items(self, node: TreeNode) -> None:
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.expand_all"],
+ callback=lambda: self._set_subtree_expanded(node, expanded=True),
+ )
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.collapse_all"],
+ callback=lambda: self._set_subtree_expanded(node, expanded=False),
+ )
+
+ def _add_context_menu_copy_name_item(self, node: TreeNode) -> None:
+ """Offers the label the tree reads the row by, which for a folded chain names every level."""
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.copy_name"],
+ callback=lambda: dpg.set_clipboard_text(str(node.name)),
+ )
+
+ def _add_context_menu_sample_audio_item(self, node: TreeNode) -> None:
+ """Offers the audio behind a sample row, through any one reconstruction gathered under it.
+
+ Every reconstruction under one sample was made from the same audio, so the first of them
+ answers for the row.
+ """
+ if node.node_type != NodeType.SAMPLE:
+ return
+
+ reconstruction = self._first_reconstruction_below(node)
+ if reconstruction is None:
+ return
+
+ dpg.add_separator()
+ self._add_context_menu_locate_audio_item(reconstruction)
+
+ def _first_reconstruction_below(self, node: TreeNode) -> Optional[FileSystemNode]:
+ for descendant in node.descendants:
+ if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE:
+ return descendant
+
+ return None
+
+ def _show_directory_context_menu(self, node: FileSystemNode) -> None:
+ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_details(node)
+ self._add_context_menu_path_items(node.filepath)
+ self._add_directory_context_menu_items(node)
+ self._add_context_menu_favorite_item(node)
+
+ def _show_reconstruction_context_menu(
+ self,
+ node: FileSystemNode,
+ _node_tag: str,
+ ) -> None:
+ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_play_item(node)
+ self._add_reconstruction_context_menu_items(node)
+ self._add_context_menu_path_items(node.filepath)
+ self._add_context_menu_locate_audio_item(node)
+ self._add_context_menu_favorite_item(node)
+
+ def _add_directory_context_menu_items(self, node: FileSystemNode) -> None:
+ pass
+
+ @abstractmethod
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: ...
+
+ @abstractmethod
+ def _open_reconstruction(self, node: FileSystemNode) -> None: ...
diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py
index d4a8c2ba..d85b185f 100644
--- a/src/sampletones_application/ui/resources/items.py
+++ b/src/sampletones_application/ui/resources/items.py
@@ -1,6 +1,6 @@
from enum import Enum
-from sampletones_core.paths import (
+from sampletones_shared.paths.resources import (
FONT_ICON,
FONT_MONO_BOLD,
FONT_MONO_REGULAR,
diff --git a/src/sampletones_application/ui/resources/resources.py b/src/sampletones_application/ui/resources/resources.py
index e644d7de..b841967f 100644
--- a/src/sampletones_application/ui/resources/resources.py
+++ b/src/sampletones_application/ui/resources/resources.py
@@ -3,7 +3,7 @@
IconResource,
)
from sampletones_application.ui.resources.loader import ResourceLoader
-from sampletones_core.paths import FONT_DIRECTORY, ICON_DIRECTORY
+from sampletones_shared.paths.resources import FONT_DIRECTORY, ICON_DIRECTORY
icon_loader = ResourceLoader(ICON_DIRECTORY)
font_loader = ResourceLoader(FONT_DIRECTORY)
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index e24eb06e..61ed7573 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -29,7 +29,7 @@
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
from sampletones_application.utils.palette.source import PaletteSource
-from sampletones_core.paths import EXT_FILE_YAML
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
from sampletones_shared.utils.serialization import load_yaml
_BASE_THEME_NAME: Final[str] = "default"
diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py
index 8bd9d58e..9cc89a16 100644
--- a/src/sampletones_application/utils/gui/dialogs.py
+++ b/src/sampletones_application/utils/gui/dialogs.py
@@ -167,11 +167,6 @@ def __init__(
self._lbl_cancel = language_manager["global.dialog.label.cancel"]
self._lbl_traceback_show = language_manager["global.traceback.label.show"]
- @property
- def default_wrap(self) -> int:
- """Text wrap width matching the default dialog width, for caller-built content."""
- return self._default_wrap
-
def show_modal(
self,
tag: str,
diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py
index 6018b4f3..73f503a8 100644
--- a/src/sampletones_application/utils/gui/shortcuts/catalog.py
+++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py
@@ -6,8 +6,8 @@
from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
-from sampletones_core.paths import EXT_FILE_YAML
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
@dataclass(frozen=True)
diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py
index a4defd27..dde05a07 100644
--- a/src/sampletones_application/utils/gui/shortcuts/ids.py
+++ b/src/sampletones_application/utils/gui/shortcuts/ids.py
@@ -85,6 +85,14 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
DISPLAY_SETTINGS = ("DisplaySettings", ShortcutCategory.APPLICATION)
KEYBOARD_SETTINGS = ("KeyboardSettings", ShortcutCategory.APPLICATION)
TOGGLE_ADVANCED_SETTINGS = ("ToggleAdvancedSettings", ShortcutCategory.APPLICATION)
+ TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = (
+ "ToggleAutoExpandFavoriteReconstructions",
+ ShortcutCategory.APPLICATION,
+ )
+ TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = (
+ "ToggleAutoExpandFavoriteDirectories",
+ ShortcutCategory.APPLICATION,
+ )
TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION)
ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION)
NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION)
diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py
index ce04fbb4..7b1d7c99 100644
--- a/src/sampletones_application/utils/palette/catalog.py
+++ b/src/sampletones_application/utils/palette/catalog.py
@@ -5,8 +5,8 @@
from typing import Dict, Final, Tuple
from sampletones_application.utils.palette.palette import Palette
-from sampletones_core.paths import EXT_FILE_YAML
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
DEFAULT_PALETTE_NAME: Final[str] = "studio"
diff --git a/src/sampletones_application/view_model/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py
index b14d4d15..d93d2d93 100644
--- a/src/sampletones_application/view_model/shared/menu.py
+++ b/src/sampletones_application/view_model/shared/menu.py
@@ -27,6 +27,8 @@ class MenuBarViewModel(BaseModel, frozen=True):
loop_song: bool
fullscreen: bool
advanced_settings: bool
+ auto_expand_favorite_reconstructions: bool
+ auto_expand_favorite_directories: bool
@property
def undo_enabled(self) -> bool:
diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico
index 7a82dace..5d37a4a2 100644
Binary files a/src/sampletones_assets/icons/sampletones.ico and b/src/sampletones_assets/icons/sampletones.ico differ
diff --git a/src/sampletones_assets/icons/sampletones.png b/src/sampletones_assets/icons/sampletones.png
index 431bd262..3f4bf28f 100644
Binary files a/src/sampletones_assets/icons/sampletones.png and b/src/sampletones_assets/icons/sampletones.png differ
diff --git a/src/sampletones_assets/icons/sampletones.svg b/src/sampletones_assets/icons/sampletones.svg
new file mode 100644
index 00000000..631ddf7d
--- /dev/null
+++ b/src/sampletones_assets/icons/sampletones.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py
new file mode 100644
index 00000000..5fd855bd
--- /dev/null
+++ b/src/sampletones_assets/mark/geometry.py
@@ -0,0 +1,128 @@
+import itertools
+from dataclasses import dataclass
+from typing import List
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+@dataclass(frozen=True)
+class Rectangle:
+ """An axis-aligned box in grid units, the shape one segment of the stepped half fills."""
+
+ left: float
+ top: float
+ right: float
+ bottom: float
+
+
+def _cubic_coordinate(
+ start: float,
+ control_start: float,
+ control_end: float,
+ end: float,
+ progress: float,
+) -> float:
+ remainder = 1.0 - progress
+ return (
+ remainder**3 * start
+ + 3 * remainder**2 * progress * control_start
+ + 3 * remainder * progress**2 * control_end
+ + progress**3 * end
+ )
+
+
+def _cubic_point(
+ start: Point,
+ curve: CubicCurve,
+ progress: float,
+) -> Point:
+ return Point(
+ x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress),
+ y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress),
+ )
+
+
+def sine_points(sine: MarkSine, samples: int) -> List[Point]:
+ """The smooth half as a polyline, sampled evenly along every segment.
+
+ Each segment contributes ``samples`` points, ending on its own end point, so the next
+ segment starts where the previous one arrived and the polyline runs unbroken from the
+ wave's start to its handover.
+ """
+ points = [sine.start]
+ position = sine.start
+ for curve in sine.curves:
+ for step in range(1, samples + 1):
+ points.append(_cubic_point(position, curve, step / samples))
+
+ position = curve.end
+
+ return points
+
+
+def _direction(delta: float) -> float:
+ if delta > 0:
+ return 1.0
+
+ if delta < 0:
+ return -1.0
+
+ return 0.0
+
+
+def _segment_rectangle(
+ start: Point,
+ end: Point,
+ *,
+ half_width: float,
+ joined_start: bool,
+ joined_end: bool,
+) -> Rectangle:
+ """The stroke rectangle of one axis-aligned segment.
+
+ A joined end reaches half the stroke width past its corner, so consecutive rectangles
+ fill their right-angle miter; an open end keeps a butt cap.
+ """
+ direction_x = _direction(end.x - start.x)
+ direction_y = _direction(end.y - start.y)
+ start_reach = half_width if joined_start else 0.0
+ end_reach = half_width if joined_end else 0.0
+
+ reached_start = (
+ start.x - direction_x * start_reach,
+ start.y - direction_y * start_reach,
+ )
+ reached_end = (
+ end.x + direction_x * end_reach,
+ end.y + direction_y * end_reach,
+ )
+ across_x = half_width * abs(direction_y)
+ across_y = half_width * abs(direction_x)
+
+ return Rectangle(
+ left=min(reached_start[0], reached_end[0]) - across_x,
+ top=min(reached_start[1], reached_end[1]) - across_y,
+ right=max(reached_start[0], reached_end[0]) + across_x,
+ bottom=max(reached_start[1], reached_end[1]) + across_y,
+ )
+
+
+def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]:
+ """The stepped half as filled rectangles, one per segment between its corners.
+
+ The rectangles meet at every corner the wave turns at, so the sequence covers the
+ stroke a vector renderer draws with square joins.
+ """
+ segments = list(itertools.pairwise(square.points))
+ final_segment = len(segments) - 1
+ return [
+ _segment_rectangle(
+ start,
+ end,
+ half_width=width / 2,
+ joined_start=index > 0,
+ joined_end=index < final_segment,
+ )
+ for index, (start, end) in enumerate(segments)
+ ]
diff --git a/src/sampletones_assets/mark/mark.yaml b/src/sampletones_assets/mark/mark.yaml
new file mode 100644
index 00000000..34c72068
--- /dev/null
+++ b/src/sampletones_assets/mark/mark.yaml
@@ -0,0 +1,43 @@
+frame:
+ grid: 64
+ corner_radius: 14
+ rim:
+ inset: 1
+ width: 2
+ opacity: 0.14
+
+colors:
+ background:
+ top: "#3a3650"
+ bottom: "#211d30"
+ sine: "#64c8ff"
+ square: "#ffc864"
+ rim: "#cdb6ff"
+
+waves:
+ width: 4
+ sine:
+ start: {x: 8, y: 32}
+ curves:
+ - control_start: {x: 11, y: 16}
+ control_end: {x: 15, y: 16}
+ end: {x: 18, y: 32}
+ - control_start: {x: 21, y: 48}
+ control_end: {x: 25, y: 48}
+ end: {x: 28, y: 32}
+ square:
+ points:
+ - {x: 28, y: 32}
+ - {x: 28, y: 20}
+ - {x: 38, y: 20}
+ - {x: 38, y: 44}
+ - {x: 48, y: 44}
+ - {x: 48, y: 20}
+ - {x: 56, y: 20}
+ - {x: 56, y: 32}
+
+render:
+ supersample: 16
+ curve_samples: 96
+ raster_size: 256
+ windows_sizes: [256, 128, 64, 48, 32, 24, 16]
diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py
new file mode 100644
index 00000000..4b9decd9
--- /dev/null
+++ b/src/sampletones_assets/mark/paths.py
@@ -0,0 +1,7 @@
+from importlib.resources import files
+from pathlib import Path
+from typing import Final
+
+MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark")))
+MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml"
+TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg"
diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py
new file mode 100644
index 00000000..7c437fe0
--- /dev/null
+++ b/src/sampletones_assets/mark/raster.py
@@ -0,0 +1,116 @@
+from typing import Final, Tuple
+
+from PIL import Image, ImageDraw
+
+from sampletones_assets.mark.geometry import sine_points, square_rectangles
+from sampletones_assets.mark.specification import Mark
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction
+
+TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0)
+OPAQUE: Final[int] = 255
+
+
+class MarkRaster:
+ """Draws the mark into one supersampled image, ready to scale down to each shipped size.
+
+ Drawing happens at the render factor times the design grid and the result is resampled
+ down, which is what keeps the curve edges and the rounded corners smooth at 16 px.
+ """
+
+ def __init__(self, mark: Mark) -> None:
+ self.mark = mark
+
+ @property
+ def scale(self) -> int:
+ """Factor the design grid is drawn at."""
+ return self.mark.render.supersample
+
+ @property
+ def canvas(self) -> int:
+ """Edge length in pixels of the image the mark is drawn into."""
+ return self.mark.frame.grid * self.scale
+
+ @property
+ def corner_radius(self) -> float:
+ """Corner radius of the frame, in the pixels of the drawn image."""
+ return self.mark.frame.corner_radius * self.scale
+
+ @property
+ def frame_box(self) -> Tuple[int, int, int, int]:
+ """The whole image, as the box the frame and its rim are drawn in."""
+ return (0, 0, self.canvas - 1, self.canvas - 1)
+
+ def render(self) -> Image.Image:
+ """The mark drawn at the supersampled resolution, on a transparent ground."""
+ image = self._background()
+ draw = ImageDraw.Draw(image)
+ self._draw_sine(draw)
+ self._draw_square(draw)
+ image.alpha_composite(self._rim())
+ return image
+
+ def _background(self) -> Image.Image:
+ """The frame: a vertical gradient between the two background colours, rounded at its corners."""
+ size = (self.canvas, self.canvas)
+ top = Image.new("RGB", size, self.mark.colors.background.top)
+ bottom = Image.new("RGB", size, self.mark.colors.background.bottom)
+ shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size))
+
+ background = Image.new("RGBA", size, TRANSPARENT)
+ background.paste(shaded, mask=self._frame_mask())
+ return background
+
+ def _frame_mask(self) -> Image.Image:
+ mask = Image.new("L", (self.canvas, self.canvas), 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ fill=OPAQUE,
+ )
+ return mask
+
+ def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None:
+ """Sweeps a disk of the stroke's half width along the curve.
+
+ The union of densely stamped disks equals a round-capped stroke of the curve, which is
+ what holds the outline smooth along its whole sweep.
+ """
+ radius = self.mark.waves.width * self.scale / 2
+ for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples):
+ center_x, center_y = point.x * self.scale, point.y * self.scale
+ draw.ellipse(
+ (
+ center_x - radius,
+ center_y - radius,
+ center_x + radius,
+ center_y + radius,
+ ),
+ fill=self.mark.colors.sine,
+ )
+
+ def _draw_square(self, draw: ImageDraw.ImageDraw) -> None:
+ for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width):
+ draw.rectangle(
+ (
+ round(rectangle.left * self.scale),
+ round(rectangle.top * self.scale),
+ round(rectangle.right * self.scale) - 1,
+ round(rectangle.bottom * self.scale) - 1,
+ ),
+ fill=self.mark.colors.square,
+ )
+
+ def _rim(self) -> Image.Image:
+ """The hairline along the frame's edge, as a layer to composite over the drawn mark."""
+ overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT)
+ ImageDraw.Draw(overlay).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ outline=self._rim_color(),
+ width=round(self.mark.frame.rim.width * self.scale),
+ )
+ return overlay
+
+ def _rim_color(self) -> ColorRGBA:
+ return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity)
diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py
new file mode 100644
index 00000000..fa600fe1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/__init__.py
@@ -0,0 +1,38 @@
+from typing import Self
+
+from pydantic import BaseModel, Field
+
+from sampletones_assets.mark.paths import MARK_PATH
+from sampletones_assets.mark.specification.colors import MarkColors
+from sampletones_assets.mark.specification.frame import MarkFrame
+from sampletones_assets.mark.specification.render import MarkRender
+from sampletones_assets.mark.specification.waves import MarkWaves
+from sampletones_shared.utils.serialization import load_yaml_model
+
+
+class Mark(BaseModel, extra="forbid", frozen=True):
+ """The design definition of the application mark.
+
+ Every shipped icon derives from this one definition — the vector, the raster the
+ application loads, and the multi-resolution Windows icon — so the mark is drawn from a
+ single source and stays the same shape at every size. Coordinates are written on the
+ frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an
+ icon size.
+ """
+
+ frame: MarkFrame = Field(description="The rounded square the mark sits on.")
+ colors: MarkColors = Field(description="The colours the mark is drawn in.")
+ waves: MarkWaves = Field(description="The wave crossing the frame.")
+ render: MarkRender = Field(description="How the mark is rasterized.")
+
+ @classmethod
+ def load(cls) -> Self:
+ """Load the packaged mark definition.
+
+ Returns:
+ The mark validated from `sampletones_assets/mark/mark.yaml`.
+
+ Raises:
+ TypeError: If the definition file holds anything other than a mapping.
+ """
+ return load_yaml_model(MARK_PATH, cls)
diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py
new file mode 100644
index 00000000..1063ea2b
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/colors.py
@@ -0,0 +1,29 @@
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field
+
+from sampletones_shared.utils.color import parse_hex_color
+
+
+def _validate_hex_color(value: str) -> str:
+ parse_hex_color(value)
+ return value
+
+
+HexColor = Annotated[str, AfterValidator(_validate_hex_color)]
+
+
+class MarkBackground(BaseModel, extra="forbid", frozen=True):
+ """The vertical gradient filling the frame."""
+
+ top: HexColor = Field(description="Colour at the top edge of the frame.")
+ bottom: HexColor = Field(description="Colour at the bottom edge of the frame.")
+
+
+class MarkColors(BaseModel, extra="forbid", frozen=True):
+ """The mark's colours, written as the hex strings the vector carries."""
+
+ background: MarkBackground = Field(description="Gradient behind the wave.")
+ sine: HexColor = Field(description="Colour of the smooth half of the wave.")
+ square: HexColor = Field(description="Colour of the stepped half of the wave.")
+ rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.")
diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py
new file mode 100644
index 00000000..4c9111a1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/frame.py
@@ -0,0 +1,43 @@
+from typing import Self
+
+from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator
+
+
+class MarkRim(BaseModel, extra="forbid", frozen=True):
+ """The hairline drawn just inside the frame's edge, lifting it off a dark desktop."""
+
+ inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.")
+ width: PositiveFloat = Field(description="Stroke width of the hairline.")
+ opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.")
+
+
+class MarkFrame(BaseModel, extra="forbid", frozen=True):
+ """The rounded square the mark sits on.
+
+ ``grid`` is the edge length every other coordinate is expressed in, so the whole design
+ follows from this one number and scales to any icon size.
+ """
+
+ grid: PositiveInt = Field(description="Edge length of the design grid.")
+ corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.")
+ rim: MarkRim = Field(description="The hairline inside the frame's edge.")
+
+ @property
+ def rim_radius(self) -> float:
+ """Corner radius the rim follows, keeping it concentric with the frame."""
+ return self.corner_radius - self.rim.inset
+
+ @property
+ def rim_extent(self) -> float:
+ """Edge length of the rim's square, inset on both sides."""
+ return self.grid - 2 * self.rim.inset
+
+ @model_validator(mode="after")
+ def _validate_rounding(self) -> Self:
+ if 2 * self.corner_radius > self.grid:
+ raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}")
+
+ if self.rim.inset >= self.corner_radius:
+ raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}")
+
+ return self
diff --git a/src/sampletones_assets/mark/specification/point.py b/src/sampletones_assets/mark/specification/point.py
new file mode 100644
index 00000000..db8689f0
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/point.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel, Field
+
+
+class Point(BaseModel, extra="forbid", frozen=True):
+ """A position on the mark's design grid, in grid units."""
+
+ x: float = Field(description="Distance from the left edge of the grid.")
+ y: float = Field(description="Distance from the top edge of the grid.")
+
+
+class CubicCurve(BaseModel, extra="forbid", frozen=True):
+ """One cubic Bézier segment, starting where the segment before it ended."""
+
+ control_start: Point = Field(description="Control point steering the segment away from its start.")
+ control_end: Point = Field(description="Control point steering the segment into its end.")
+ end: Point = Field(description="Point the segment reaches.")
diff --git a/src/sampletones_assets/mark/specification/render.py b/src/sampletones_assets/mark/specification/render.py
new file mode 100644
index 00000000..2e77b129
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/render.py
@@ -0,0 +1,28 @@
+from typing import Tuple
+
+from pydantic import BaseModel, Field, PositiveInt, field_validator
+
+
+class MarkRender(BaseModel, extra="forbid", frozen=True):
+ """How the mark is turned into pixels.
+
+ Drawing happens at ``supersample`` times the design grid and the result is resampled
+ down to each shipped size, which is what keeps the curve edges and the rounded corners
+ smooth at 16 px.
+ """
+
+ supersample: PositiveInt = Field(description="Factor the design grid is drawn at before it is scaled down.")
+ curve_samples: PositiveInt = Field(description="Points each cubic segment of the smooth half is stamped along.")
+ raster_size: PositiveInt = Field(description="Edge length of the raster the application loads.")
+ windows_sizes: Tuple[PositiveInt, ...] = Field(
+ min_length=1,
+ description="Edge lengths the multi-resolution Windows icon carries.",
+ )
+
+ @field_validator("windows_sizes")
+ @classmethod
+ def _validate_windows_sizes(cls, windows_sizes: Tuple[int, ...]) -> Tuple[int, ...]:
+ if list(windows_sizes) != sorted(set(windows_sizes), reverse=True):
+ raise ValueError("Windows icon sizes must be listed once each, in descending order")
+
+ return windows_sizes
diff --git a/src/sampletones_assets/mark/specification/waves.py b/src/sampletones_assets/mark/specification/waves.py
new file mode 100644
index 00000000..b97a1c35
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/waves.py
@@ -0,0 +1,59 @@
+import itertools
+from typing import Self, Tuple
+
+from pydantic import BaseModel, Field, PositiveFloat, model_validator
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+
+
+class MarkSine(BaseModel, extra="forbid", frozen=True):
+ """The smooth half of the wave, as cubic segments running on from the start point."""
+
+ start: Point = Field(description="Point the wave enters the frame at.")
+ curves: Tuple[CubicCurve, ...] = Field(min_length=1, description="Segments the wave follows, in drawing order.")
+
+ @property
+ def end(self) -> Point:
+ """Point the last segment reaches, where the stepped half takes over."""
+ return self.curves[-1].end
+
+
+class MarkSquare(BaseModel, extra="forbid", frozen=True):
+ """The stepped half of the wave, as corners joined by axis-aligned segments."""
+
+ points: Tuple[Point, ...] = Field(min_length=2, description="Corners the wave turns at, in drawing order.")
+
+ @model_validator(mode="after")
+ def _validate_segments_run_along_one_axis(self) -> Self:
+ for start, end in itertools.pairwise(self.points):
+ if start.x != end.x and start.y != end.y:
+ raise ValueError(
+ f"A square wave segment runs along one axis, "
+ f"where ({start.x}, {start.y}) to ({end.x}, {end.y}) turns on both"
+ )
+
+ return self
+
+
+class MarkWaves(BaseModel, extra="forbid", frozen=True):
+ """The single wave the mark carries: one sample entering smooth and leaving stepped.
+
+ Both halves are stroked at the same width, which is what reads them as one continuous
+ wave crossing the frame.
+ """
+
+ width: PositiveFloat = Field(description="Stroke width both halves of the wave are drawn at.")
+ sine: MarkSine = Field(description="The smooth half, entering from the left.")
+ square: MarkSquare = Field(description="The stepped half, leaving to the right.")
+
+ @model_validator(mode="after")
+ def _validate_the_halves_meet(self) -> Self:
+ handover = self.square.points[0]
+ if handover != self.sine.end:
+ raise ValueError(
+ f"The stepped half starts where the smooth half ends, "
+ f"where it starts at ({handover.x}, {handover.y}) "
+ f"and the smooth half ends at ({self.sine.end.x}, {self.sine.end.y})"
+ )
+
+ return self
diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py
new file mode 100644
index 00000000..a464b50a
--- /dev/null
+++ b/src/sampletones_assets/mark/suite.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from PIL import Image
+
+from sampletones_assets.mark.raster import MarkRaster
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.vector import render_vector
+from sampletones_shared.paths.resources import (
+ ICON_UNIX_FILENAME,
+ ICON_VECTOR_FILENAME,
+ ICON_WIN_FILENAME,
+)
+
+
+def _resized(master: Image.Image, size: int) -> Image.Image:
+ return master.resize((size, size), Image.Resampling.LANCZOS)
+
+
+def _write_vector(path: Path, mark: Mark) -> Path:
+ path.write_text(render_vector(mark), encoding="utf-8")
+ return path
+
+
+def _write_raster(path: Path, master: Image.Image, size: int) -> Path:
+ _resized(master, size).save(path)
+ return path
+
+
+def _write_windows_icon(
+ path: Path,
+ master: Image.Image,
+ sizes: Tuple[int, ...],
+) -> Path:
+ """Writes the multi-resolution icon, rendering one frame per declared size.
+
+ Every frame is resampled from the supersampled master, so a 16 px frame carries the
+ detail the design grid puts there.
+ """
+ primary, *appended = (_resized(master, size) for size in sizes)
+ primary.save(
+ path,
+ format="ICO",
+ sizes=[(size, size) for size in sizes],
+ append_images=appended,
+ )
+ return path
+
+
+def write_icon_suite(directory: Path, mark: Mark) -> List[Path]:
+ """Writes the vector, the raster and the Windows icon the application ships.
+
+ Args:
+ directory (Path): Directory receiving the icon files, created where it is missing.
+ mark (Mark): Design definition every file is drawn from.
+
+ Returns:
+ List[Path]: The files written, in the order they were produced.
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ master = MarkRaster(mark).render()
+
+ return [
+ _write_vector(directory / ICON_VECTOR_FILENAME, mark),
+ _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size),
+ _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes),
+ ]
diff --git a/src/sampletones_assets/mark/template.svg b/src/sampletones_assets/mark/template.svg
new file mode 100644
index 00000000..3be72f01
--- /dev/null
+++ b/src/sampletones_assets/mark/template.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/vector.py b/src/sampletones_assets/mark/vector.py
new file mode 100644
index 00000000..f9ba412c
--- /dev/null
+++ b/src/sampletones_assets/mark/vector.py
@@ -0,0 +1,71 @@
+import itertools
+from string import Template
+from typing import Dict
+
+from sampletones_assets.mark.paths import TEMPLATE_PATH
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.specification.point import Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+def _number(value: float) -> str:
+ return f"{value:g}"
+
+
+def _coordinates(point: Point) -> str:
+ return f"{_number(point.x)} {_number(point.y)}"
+
+
+def _sine_path(sine: MarkSine) -> str:
+ commands = [f"M{_coordinates(sine.start)}"]
+ for curve in sine.curves:
+ controls = f"{_coordinates(curve.control_start)} {_coordinates(curve.control_end)}"
+ commands.append(f"C{controls} {_coordinates(curve.end)}")
+
+ return " ".join(commands)
+
+
+def _square_path(square: MarkSquare) -> str:
+ """The stepped half as vertical and horizontal commands, one per segment.
+
+ Each segment turns on a single axis, so it is written as the one coordinate it moves
+ along and the renderer holds the other.
+ """
+ commands = [f"M{_coordinates(square.points[0])}"]
+ for previous, point in itertools.pairwise(square.points):
+ commands.append(f"V{_number(point.y)}" if point.x == previous.x else f"H{_number(point.x)}")
+
+ return " ".join(commands)
+
+
+def _placeholders(mark: Mark) -> Dict[str, str]:
+ return {
+ "grid": _number(mark.frame.grid),
+ "corner_radius": _number(mark.frame.corner_radius),
+ "background_top": mark.colors.background.top,
+ "background_bottom": mark.colors.background.bottom,
+ "sine_path": _sine_path(mark.waves.sine),
+ "sine_color": mark.colors.sine,
+ "square_path": _square_path(mark.waves.square),
+ "square_color": mark.colors.square,
+ "wave_width": _number(mark.waves.width),
+ "rim_inset": _number(mark.frame.rim.inset),
+ "rim_extent": _number(mark.frame.rim_extent),
+ "rim_radius": _number(mark.frame.rim_radius),
+ "rim_color": mark.colors.rim,
+ "rim_opacity": _number(mark.frame.rim.opacity),
+ "rim_width": _number(mark.frame.rim.width),
+ }
+
+
+def render_vector(mark: Mark) -> str:
+ """The mark as a standalone vector, filling the packaged template with its own geometry.
+
+ Coordinates stay on the design grid, which keeps the wave edges on whole pixels when the
+ icon is rasterized at 32 px and 16 px.
+
+ Raises:
+ KeyError: If the template names a placeholder the mark leaves unfilled.
+ """
+ template = Template(TEMPLATE_PATH.read_text(encoding="utf-8"))
+ return template.substitute(_placeholders(mark))
diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml
index 86914813..e373d87a 100644
--- a/src/sampletones_config/keybindings/default.yaml
+++ b/src/sampletones_config/keybindings/default.yaml
@@ -55,6 +55,8 @@ bindings:
KeyboardSettings: {combination: "Ctrl+K"}
ToggleAdvancedSettings: {combination: "Ctrl+Alt+T"}
ToggleFullscreen: {combination: "F11"}
+ ToggleAutoExpandFavoriteReconstructions: {combination: ~}
+ ToggleAutoExpandFavoriteDirectories: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Ctrl+PgDn", field_transparent: true}
PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true}
diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml
index a4ede06a..20773137 100644
--- a/src/sampletones_config/keybindings/macos.yaml
+++ b/src/sampletones_config/keybindings/macos.yaml
@@ -55,6 +55,8 @@ bindings:
KeyboardSettings: {combination: "Cmd+K"}
ToggleAdvancedSettings: {combination: "Cmd+Alt+T"}
ToggleFullscreen: {combination: "Cmd+Ctrl+F"}
+ ToggleAutoExpandFavoriteReconstructions: {combination: ~}
+ ToggleAutoExpandFavoriteDirectories: {combination: ~}
AboutDialog: {combination: ~}
NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true}
PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true}
diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml
index 7840744f..8c1dc25e 100644
--- a/src/sampletones_config/lang/en.yaml
+++ b/src/sampletones_config/lang/en.yaml
@@ -59,6 +59,7 @@ global.dialog.filter.mp3: "MP3 audio"
# Global — Dialog messages
global.dialog.message.tree_no_results: "No results found."
+global.dialog.message.tree_no_favorites: "No favorites found."
global.dialog.message.invalid_metadata_error: "Invalid file metadata."
global.dialog.message.reconstruction_no_data: "No reconstruction loaded."
global.dialog.message.reconstruction_saved_successfully: "Reconstruction saved successfully."
@@ -126,9 +127,14 @@ global.traceback.label.hide: "Hide traceback"
# Global — Tree
# =============================================================================
global.browser.label.root: "Root"
+global.browser.label.browser: "Browser"
+global.browser.label.by_configuration: "By configuration"
+global.browser.label.by_sample: "By sample"
global.browser.label.search: "Search"
global.browser.label.filter: "Filter"
global.browser.label.clear_search: "Clear"
+global.browser.label.favorites_only: "Favorites only"
+global.browser.label.collapse_all: "Collapse all"
# =============================================================================
# Global — Context menu
@@ -142,7 +148,10 @@ global.context.label.mark_as_favorite: "Mark as favorite"
global.context.label.unmark_as_favorite: "Unmark as favorite"
global.context.label.copy_filename: "Copy filename to clipboard"
global.context.label.copy_path: "Copy path to clipboard"
+global.context.label.copy_name: "Copy name to clipboard"
global.context.label.open_in_explorer: "Open in explorer"
+global.context.label.expand_all: "Expand all"
+global.context.label.collapse_all: "Collapse all"
global.context.label.add_to_sequencer: "Add to Sequencer"
global.context.template.replace_sample: "Replace {sample}"
global.context.label.locate_original_audio: "Locate original audio"
@@ -157,6 +166,7 @@ global.context.label.detail_spectrum_method: "Generation method"
global.context.label.detail_transformation_gamma: "Transformation gamma"
global.context.label.detail_window_size: "Window size"
global.context.label.detail_configuration: "Configuration"
+global.context.label.detail_reconstructions: "Reconstructions"
global.context.label.instrument_size: "Instrument size"
global.context.label.sample_size: "Sample size"
global.context.template.size_bytes: "{bytes} B"
@@ -212,6 +222,9 @@ global.menu.label.item_playback_audio_settings: "Audio settings..."
global.menu.label.group_view: "View"
global.menu.label.item_view_show_advanced_settings: "Show advanced settings"
global.menu.label.item_view_fullscreen: "Fullscreen"
+global.menu.label.group_view_auto_expand_favorites: "Auto-expand favorites"
+global.menu.label.item_view_auto_expand_favorite_reconstructions: "Reconstructions"
+global.menu.label.item_view_auto_expand_favorite_directories: "Directories"
global.menu.label.item_view_display_settings: "Display settings..."
global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..."
global.menu.label.group_help: "Help"
@@ -231,9 +244,13 @@ global.status.message.node_reconstruction: "Click to play reconstruction. Double
global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu."
global.status.message.tree_search: "Type query to filter nodes."
global.status.message.clear_search: "Clear the search filter."
+global.status.message.favorites_only: "Show the favorites alone, or the whole tree."
+global.status.message.collapse_all: "Fold every row of the tree away."
global.status.message.input: "Ctrl + click to type value."
global.status.message.combo: "Click to select a value from the list."
global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu."
+global.status.message.node_group: "Click to {expand_or_collapse} this group. Right-click to open context menu."
+global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu."
global.status.message.retuning_samples: "Retuning samples..."
# =============================================================================
@@ -266,7 +283,6 @@ global.graph.message.waveform_regenerating: "Regenerating reconstruction..."
# =============================================================================
main.explorer.label.section: "Filesystem"
main.explorer.label.refresh_button: "Refresh"
-main.explorer.label.collapse_all_button: "Collapse all"
main.explorer.label.context_load_reconstruction: "Load reconstruction"
main.explorer.label.context_load_library: "Load instructions library"
main.explorer.label.context_reconstruct_file: "Reconstruct file"
@@ -276,7 +292,6 @@ main.explorer.label.context_set_output_directory: "Set as output directory"
main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu."
main.explorer.message.status_node_audio: "Click to play audio. Double-click to reconstruct audio. Right-click to open context menu."
main.explorer.message.status_refresh: "Rescan the filesystem for audio files."
-main.explorer.message.status_collapse_all: "Collapse every folder in the tree."
main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one."
main.explorer.title.converter_running_dialog: "Conversion in progress"
@@ -359,7 +374,6 @@ main.advanced.message.status_select_output: "Choose the directory for reconstruc
# Reconstructions tab — Browser
# =============================================================================
reconstructions.browser.label.refresh_button: "Refresh reconstructions"
-reconstructions.browser.label.reconstructions_tree: "Reconstructions"
reconstructions.browser.label.context_load_reconstruction: "Load reconstruction"
reconstructions.browser.label.context_remove_reconstruction: "Remove reconstruction"
reconstructions.browser.label.context_remove_directory: "Remove directory"
@@ -433,7 +447,6 @@ reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the
# Sequencer tab — Browser
# =============================================================================
sequencer.browser.label.refresh_button: "Refresh reconstructions"
-sequencer.browser.label.reconstructions_tree: "Reconstructions"
sequencer.browser.message.status_refresh: "Rescan for available reconstructions."
# =============================================================================
@@ -778,6 +791,8 @@ settings.keybindings.label.audio_settings: "Audio settings"
settings.keybindings.label.display_settings: "Display settings"
settings.keybindings.label.keyboard_settings: "Keyboard shortcuts"
settings.keybindings.label.toggle_advanced_settings: "Advanced settings"
+settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions"
+settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories"
settings.keybindings.label.toggle_fullscreen: "Fullscreen"
settings.keybindings.label.about_dialog: "About"
settings.keybindings.label.next_tab: "Next tab"
diff --git a/src/sampletones_config/layout/fonts.yaml b/src/sampletones_config/layout/fonts.yaml
index 38c32e67..afc296a7 100644
--- a/src/sampletones_config/layout/fonts.yaml
+++ b/src/sampletones_config/layout/fonts.yaml
@@ -3,11 +3,14 @@ sans:
small: 22
medium: 23
large: 25
+ title: 34
mono:
small: 19
medium: 22
large: 26
+ title: 30
icon:
small: 20
medium: 27
large: 33
+ title: 45
diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml
index 7c57efcd..48e41eab 100644
--- a/src/sampletones_config/layout/general/dialogs.yaml
+++ b/src/sampletones_config/layout/general/dialogs.yaml
@@ -14,3 +14,8 @@ text_input:
traceback:
width: 0
height: 400
+about:
+ width: 480
+ height: 210
+ logo: 56
+ padding: 40
diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml
index 6d8d9580..1bf4156f 100644
--- a/src/sampletones_config/palettes/dark.yaml
+++ b/src/sampletones_config/palettes/dark.yaml
@@ -110,7 +110,6 @@ colors:
file_wave: "#4fa6ff"
file_library: "#89d185"
file_reconstruction: "#dcdcaa"
- file_muted: "#a8a8ae"
favorite: "#ffd76e"
favorite_child: "#ddd2ac"
diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml
index e72dc7ff..d8a97bca 100644
--- a/src/sampletones_config/palettes/light.yaml
+++ b/src/sampletones_config/palettes/light.yaml
@@ -110,7 +110,6 @@ colors:
file_wave: "#0a5aa8"
file_library: "#146c2a"
file_reconstruction: "#3a3a9c"
- file_muted: "#6e7580"
favorite: "#8a6000"
favorite_child: "#75663c"
diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml
index a92a7027..371f5ee4 100644
--- a/src/sampletones_config/palettes/studio.yaml
+++ b/src/sampletones_config/palettes/studio.yaml
@@ -110,7 +110,6 @@ colors:
file_wave: "#64c8ff"
file_library: "#96ff96"
file_reconstruction: "#b4b4ff"
- file_muted: "#b4b4b4"
favorite: "#ffd76e"
favorite_child: "#e7dbb7"
diff --git a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml b/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml
deleted file mode 100644
index f09d7ae1..00000000
--- a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-name: node_file_not_expanded_directory
-tag: global.theme.file_not_expanded_directory
-
-components:
- - item_type: TreeNode
- entries:
- - type: color
- key: Text
- value: .file_muted
diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py
index aa8ad78f..924a95a3 100644
--- a/src/sampletones_core/audio/writers/capability.py
+++ b/src/sampletones_core/audio/writers/capability.py
@@ -2,7 +2,7 @@
from typing import Final, Mapping, Tuple
from sampletones_core.constants.audio import SAMPLE_RATES
-from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE
from .bitrate import MP3_SAMPLE_RATES
from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat
diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py
index f7367a6b..43ecee39 100644
--- a/src/sampletones_core/calibration/corpus/writer.py
+++ b/src/sampletones_core/calibration/corpus/writer.py
@@ -2,7 +2,7 @@
from typing import Dict, List
from sampletones_core.audio.io import write_wave
-from sampletones_core.paths import EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.utils.system.paths import get_filename
from .item import CorpusItem
diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py
index 27aed45a..751bbb53 100644
--- a/src/sampletones_core/calibration/paths.py
+++ b/src/sampletones_core/calibration/paths.py
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Final
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration"
REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml"
diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py
index 5390791f..2dafc796 100644
--- a/src/sampletones_core/configs/config.py
+++ b/src/sampletones_core/configs/config.py
@@ -8,7 +8,7 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.data import DataModel
from sampletones_core.data.metadata import Metadata
-from sampletones_core.paths import CONFIG_PATH
+from sampletones_shared.paths.user import CONFIG_PATH
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_json, save_json
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py
index 6d3d00ed..04a1750d 100644
--- a/src/sampletones_core/configs/display.py
+++ b/src/sampletones_core/configs/display.py
@@ -1,9 +1,12 @@
-from typing import Dict, Final
+from collections import Counter
+from typing import Dict, Final, Sequence, Tuple
-from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_shared.constants.symbols import HASH
DISPLAY_SEPARATOR: Final[str] = "·"
GAMMA_PREFIX: Final[str] = "γ"
+GENERATOR_SEPARATOR: Final[str] = ", "
DISPLAY_HASH_LENGTH: Final[int] = 7
HERTZ_UNIT: Final[str] = "Hz"
@@ -37,5 +40,57 @@ def format_spectrum_method(method: SpectrumMethod) -> str:
return SPECTRUM_METHOD_LABELS[method]
+def format_transformation_gamma(transformation_gamma: int) -> str:
+ """Marks a transformation gamma with ``γ`` (e.g. ``γ0``)."""
+ return f"{GAMMA_PREFIX}{transformation_gamma}"
+
+
+def format_generators(generators: Sequence[GeneratorName]) -> str:
+ """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``)."""
+ return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators)
+
+
+def format_frequencies(sample_rate: int, nes_frequency: int) -> str:
+ """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``)."""
+ return DISPLAY_SEPARATOR.join(
+ [
+ format_sample_rate(sample_rate),
+ format_nes_frequency(nes_frequency),
+ ],
+ )
+
+
+def format_transformation(
+ spectrum_method: SpectrumMethod,
+ transformation_gamma: int,
+) -> str:
+ """Renders the spectrum a library was built from, method before gamma (e.g. ``FFT·γ0``)."""
+ return DISPLAY_SEPARATOR.join(
+ [
+ format_spectrum_method(spectrum_method),
+ format_transformation_gamma(transformation_gamma),
+ ],
+ )
+
+
def short_hash(config_hash: str) -> str:
return config_hash[:DISPLAY_HASH_LENGTH]
+
+
+def disambiguated_display_name(name: str, config_hash: str) -> str:
+ """Appends the short config hash, marked with ``#``, so colliding names stay distinct."""
+ return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}"
+
+
+def unique_display_names(entries: Sequence[Tuple[str, str]]) -> Tuple[str, ...]:
+ """Answers labels that tell one group of siblings apart, given ``(name, config hash)`` pairs.
+
+ A name held by a single entry stands as it is. A name shared by several entries takes the short
+ config hash on every one of them, so each sibling states the configuration that distinguishes
+ it. The answer is index-aligned with ``entries``.
+ """
+ occurrences = Counter(name for name, _ in entries)
+ return tuple(
+ name if occurrences[name] == 1 else disambiguated_display_name(name, config_hash)
+ for name, config_hash in entries
+ )
diff --git a/src/sampletones_core/configs/general.py b/src/sampletones_core/configs/general.py
index 210696a6..b95c8a5f 100644
--- a/src/sampletones_core/configs/general.py
+++ b/src/sampletones_core/configs/general.py
@@ -10,7 +10,7 @@
)
from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH
from sampletones_core.data import DataModel
-from sampletones_core.paths import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
class GeneralConfig(DataModel):
diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py
index 5e050b77..0733a532 100644
--- a/src/sampletones_core/library/filename/fields.py
+++ b/src/sampletones_core/library/filename/fields.py
@@ -7,7 +7,7 @@
from sampletones_core.constants.enums import SpectrumMethod
from sampletones_core.constants.field_aliases import ALIASES
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import HASH_PATTERN
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py
index 194cd700..5088d498 100644
--- a/src/sampletones_core/library/filename/utils.py
+++ b/src/sampletones_core/library/filename/utils.py
@@ -2,14 +2,12 @@
from sampletones_core.configs.display import (
DISPLAY_SEPARATOR,
- GAMMA_PREFIX,
- format_nes_frequency,
- format_sample_rate,
- format_spectrum_method,
+ format_frequencies,
+ format_transformation,
)
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.library.key import InstructionLibraryKey
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.system.paths import get_filename
@@ -39,12 +37,9 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey:
def get_display_name_from_key(key: InstructionLibraryKey) -> str:
nes_frequency = round(key.sample_rate / key.frame_length)
- gamma = f"{GAMMA_PREFIX}{key.transformation_gamma}"
return DISPLAY_SEPARATOR.join(
[
- format_sample_rate(key.sample_rate),
- format_nes_frequency(nes_frequency),
- format_spectrum_method(key.spectrum_method),
- gamma,
+ format_frequencies(key.sample_rate, nes_frequency),
+ format_transformation(key.spectrum_method, key.transformation_gamma),
]
)
diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py
index 496d1691..76b0b5ee 100644
--- a/src/sampletones_core/library/library.py
+++ b/src/sampletones_core/library/library.py
@@ -7,8 +7,8 @@
from sampletones_core.configs import Config
from sampletones_core.fft import Window
-from sampletones_core.paths import LIBRARY_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY
from .data import InstructionLibraryData
from .key import InstructionLibraryKey
diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py
deleted file mode 100644
index f0564761..00000000
--- a/src/sampletones_core/paths.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from pathlib import Path
-from typing import Final, Tuple
-
-from platformdirs import user_config_dir, user_data_dir, user_documents_path
-
-from sampletones_shared.application import (
- SAMPLETONES_GROUP,
- SAMPLETONES_NAME,
-)
-
-# User paths
-USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
-USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-
-# Application paths
-LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
-RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
-PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
-CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
-APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
-
-# File extensions
-EXT_FILE_JSON: Final[str] = ".json"
-EXT_FILE_YAML: Final[str] = ".yaml"
-EXT_FILE_LIBRARY: Final[str] = ".ins"
-EXT_FILE_INSTRUMENT: Final[str] = ".fti"
-EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
-EXT_FILE_PROJECT: Final[str] = ".stp"
-EXT_FILE_MODULE: Final[str] = ".ftm"
-EXT_FILE_BITPHASE: Final[str] = ".btp"
-EXT_FILE_WAVE: Final[str] = ".wav"
-EXT_FILE_MP3: Final[str] = ".mp3"
-EXT_FILE_FLAC: Final[str] = ".flac"
-EXT_FILE_OGG: Final[str] = ".ogg"
-EXT_FILE_AIFF: Final[str] = ".aiff"
-EXT_FILE_AU: Final[str] = ".au"
-EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
- EXT_FILE_WAVE,
- EXT_FILE_MP3,
- EXT_FILE_FLAC,
- EXT_FILE_OGG,
- EXT_FILE_AIFF,
- EXT_FILE_AU,
-)
-
-# Assets
-ASSETS_DIRECTORY: Final[str] = "assets"
-
-# Icon filenames
-ICON_DIRECTORY: Final[str] = "icons"
-ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
-ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
-
-# Font paths
-FONT_DIRECTORY: Final[str] = "fonts"
-FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
-FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
-FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
-FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
-FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
-FONT_ICON: Final[str] = "DejaVuSans.ttf"
-
-PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
-LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
-RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py
index c5186a2c..375b93e4 100644
--- a/src/sampletones_core/project/container.py
+++ b/src/sampletones_core/project/container.py
@@ -4,7 +4,6 @@
from pydantic import ValidationError
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.project.document import ProjectDocument
from sampletones_core.project.instruments.record import SampleRecord
from sampletones_core.project.instruments.sample import Sample
@@ -27,6 +26,7 @@
NotAValidArchiveError,
UnhandledProjectError,
)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import JSON_INDENT
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py
index 4da06eb4..9ff18843 100644
--- a/src/sampletones_core/reconstructions/converter/paths/fields.py
+++ b/src/sampletones_core/reconstructions/converter/paths/fields.py
@@ -5,10 +5,8 @@
from sampletones_core.configs import Config
from sampletones_core.configs.display import (
DISPLAY_SEPARATOR,
- GAMMA_PREFIX,
- format_nes_frequency,
- format_sample_rate,
- format_spectrum_method,
+ format_frequencies,
+ format_transformation,
)
from sampletones_core.constants.enums import (
GENERATOR_ABBREVIATION_PATTERN,
@@ -93,10 +91,8 @@ def directory_name(self) -> str:
def display_name(self) -> str:
return DISPLAY_SEPARATOR.join(
[
- format_sample_rate(self.sr),
- format_nes_frequency(self.nf),
- format_spectrum_method(self.sm),
- f"{GAMMA_PREFIX}{self.tg}",
+ format_frequencies(self.sr, self.nf),
+ format_transformation(self.sm, self.tg),
self.gn,
]
)
diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py
index 481ccc32..3069f11b 100644
--- a/src/sampletones_core/reconstructions/converter/paths/utils.py
+++ b/src/sampletones_core/reconstructions/converter/paths/utils.py
@@ -2,13 +2,13 @@
from typing import List, Tuple
from sampletones_core.configs import Config
-from sampletones_core.paths import (
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
from sampletones_core.reconstructions.converter.paths.fields import (
ConfigDirectoryFields,
)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
+)
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py
index 1b5a2902..94a9bdcf 100644
--- a/src/sampletones_core/structures/tree/__init__.py
+++ b/src/sampletones_core/structures/tree/__init__.py
@@ -1,11 +1,14 @@
from .arguments import Arguments
-from .node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode
+from .factory import create_directory_node
+from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode
from .traversal import TreeTraversal, traverse
from .tree import Tree
from .type import NodeType
+from .visibility import TreeVisibility, resolve_visibility
__all__ = [
"Arguments",
+ "ConfigNode",
"FileSystemNode",
"GeneratorNode",
"LibraryNode",
@@ -13,5 +16,8 @@
"Tree",
"TreeNode",
"TreeTraversal",
+ "TreeVisibility",
+ "create_directory_node",
+ "resolve_visibility",
"traverse",
]
diff --git a/src/sampletones_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py
new file mode 100644
index 00000000..3b73a97f
--- /dev/null
+++ b/src/sampletones_core/structures/tree/factory.py
@@ -0,0 +1,39 @@
+from pathlib import Path
+from typing import Optional
+
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+
+from .node import ConfigNode, FileSystemNode, TreeNode
+from .type import NodeType
+
+
+def create_directory_node(
+ directory: Path,
+ *,
+ name: str,
+ config: Optional[ConfigDirectoryFields],
+ parent: Optional[TreeNode],
+) -> FileSystemNode:
+ """Builds the directory node that fits the folder, given the configuration its name states.
+
+ A folder stating a reconstruction configuration becomes a :class:`ConfigNode` carrying those
+ fields; a folder stating none becomes a plain :class:`FileSystemNode`. The caller states the
+ fields it read with :meth:`ConfigDirectoryFields.from_directory_name`, so a caller that already
+ read them — a scan of a reconstructions directory — reads each folder name once, and the choice
+ of node class stays here.
+ """
+ if config is None:
+ return FileSystemNode(
+ name,
+ node_type=NodeType.DIRECTORY,
+ filepath=directory,
+ parent=parent,
+ )
+
+ return ConfigNode(
+ name,
+ node_type=NodeType.DIRECTORY,
+ filepath=directory,
+ config=config,
+ parent=parent,
+ )
diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py
index 556f1082..9f6b34c5 100644
--- a/src/sampletones_core/structures/tree/node.py
+++ b/src/sampletones_core/structures/tree/node.py
@@ -7,6 +7,7 @@
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from .type import NodeType
@@ -45,6 +46,41 @@ def copy(self, parent: Optional[TreeNode] = None) -> FileSystemNode:
)
+class ConfigNode(FileSystemNode):
+ """A filesystem node belonging to a reconstruction configuration, carrying the parsed fields.
+
+ A configuration directory encodes its fields in its name, and both the directory itself and the
+ reconstructions inside it are read as belonging to that configuration. Holding the parsed
+ :class:`ConfigDirectoryFields` on the node lets every reader — labels, tooltips, fonts — state
+ the configuration from the node it already has, whatever the node's own filename says.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ node_type: NodeType,
+ filepath: Path,
+ config: ConfigDirectoryFields,
+ parent: Optional[TreeNode] = None,
+ ) -> None:
+ super().__init__(
+ name,
+ node_type=node_type,
+ filepath=filepath,
+ parent=parent,
+ )
+ self.config = config
+
+ def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode:
+ return ConfigNode(
+ self.name,
+ node_type=self.node_type,
+ filepath=self.filepath,
+ config=self.config,
+ parent=parent,
+ )
+
+
class LibraryNode(TreeNode):
def __init__(
self,
diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py
index 07881c24..a21897bc 100644
--- a/src/sampletones_core/structures/tree/tree.py
+++ b/src/sampletones_core/structures/tree/tree.py
@@ -1,75 +1,50 @@
-from typing import Callable, Dict, Optional, Sequence
+from typing import Callable, Optional, Tuple, Type, TypeVar
from anytree import PreOrderIter
from .node import TreeNode
+TreeNodeT = TypeVar("TreeNodeT", bound=TreeNode)
+
class Tree:
+ """The rows a view renders, held as one root the whole shape hangs from.
+
+ The tree states which rows exist, what they are called and how they nest, and every view reading
+ it shows that one shape. What a view narrows to is the view's own, so several views share a tree
+ and each of them filters on its own.
+ """
+
def __init__(self, root: Optional[TreeNode] = None) -> None:
self.root = root
- self._filter_query: Optional[str] = None
- self._node_visibility: Dict[TreeNode, bool] = {}
def set_root(self, root: Optional[TreeNode]) -> None:
self.root = root
- self.clear_filter()
def get_root(self) -> Optional[TreeNode]:
return self.root
- def apply_filter(
+ def find_nodes(
self,
- query: str,
- predicate: Callable[[TreeNode, str], bool],
- ) -> None:
- if not self.root:
- self._filter_query = query
- self._node_visibility = {}
- return
-
- if not query:
- self.clear_filter()
- return
-
- self._filter_query = query
- matching_nodes = {node for node in PreOrderIter(self.root) if predicate(node, query)}
-
- if not matching_nodes:
- self._node_visibility = {node: False for node in PreOrderIter(self.root)}
- return
-
- nodes_to_show = set(matching_nodes)
- for node in matching_nodes:
- current = node.parent
- while current is not None:
- nodes_to_show.add(current)
- current = current.parent
-
- for descendant in PreOrderIter(node):
- nodes_to_show.add(descendant)
-
- self._node_visibility = {node: node in nodes_to_show for node in PreOrderIter(self.root)}
-
- def clear_filter(self) -> None:
- self._filter_query = None
- self._node_visibility = {}
-
- def is_filtered(self) -> bool:
- return self._filter_query is not None
-
- def is_node_visible(self, node: TreeNode) -> bool:
- if not self.is_filtered():
- return True
-
- return self._node_visibility.get(node, False)
-
- def collect_leaves(self) -> Sequence[TreeNode]:
- if not self.root:
- return []
-
- leaves = [node for node in PreOrderIter(self.root) if node.is_leaf]
- if self.is_filtered():
- return [leaf for leaf in leaves if self.is_node_visible(leaf)]
-
- return leaves
+ node_class: Type[TreeNodeT],
+ predicate: Callable[[TreeNodeT], bool],
+ ) -> Tuple[TreeNodeT, ...]:
+ """Answers every node of ``node_class`` the predicate accepts, in reading order.
+
+ One thing can stand in several places in a tree — a file listed by its configuration and
+ again by the sample it came from — so a caller acting on a thing rather than on a row asks
+ for all of its nodes at once. Naming the node class keeps the answer typed, so the caller
+ reads the fields that class carries.
+ """
+ if self.root is None:
+ return ()
+
+ return tuple(
+ node
+ for node in PreOrderIter(self.root)
+ if isinstance(
+ node,
+ node_class,
+ )
+ and predicate(node)
+ )
diff --git a/src/sampletones_core/structures/tree/type.py b/src/sampletones_core/structures/tree/type.py
index 64dc38c1..98309ea0 100644
--- a/src/sampletones_core/structures/tree/type.py
+++ b/src/sampletones_core/structures/tree/type.py
@@ -7,5 +7,6 @@ class NodeType(StrEnum):
FILE = "file"
LIBRARY = "library"
GROUP = "group"
+ SAMPLE = "sample"
GENERATOR = "generator"
INSTRUCTION = "instruction"
diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py
new file mode 100644
index 00000000..7c9fbbc1
--- /dev/null
+++ b/src/sampletones_core/structures/tree/visibility.py
@@ -0,0 +1,42 @@
+from dataclasses import dataclass
+from typing import FrozenSet, Iterable
+
+from .node import TreeNode
+
+
+@dataclass(frozen=True)
+class TreeVisibility:
+ """The rows a criterion keeps on screen, held as the rows it named and the rows standing above them.
+
+ A named row stays, and so do the rows leading down to it and the rows it holds: a named file is
+ read under the folders it sits in, and a named folder shows what it gathers. Keeping the named
+ rows and their ancestors alone holds the memory to the size of what was found, and a row below a
+ match is answered from its own path upwards.
+ """
+
+ matches: FrozenSet[TreeNode]
+ ancestors: FrozenSet[TreeNode]
+
+ def is_visible(self, node: TreeNode) -> bool:
+ """Whether the row stays on screen: it was named, it leads to a named row, or one holds it."""
+ if node in self.matches or node in self.ancestors:
+ return True
+
+ return any(ancestor in self.matches for ancestor in node.ancestors)
+
+ def should_expand(self, node: TreeNode) -> bool:
+ """Whether the row stands open, which a named row does and so does every row above one."""
+ return node in self.matches or node in self.ancestors
+
+
+def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility:
+ """The visibility a set of named rows resolves to, read once per pass over the tree.
+
+ Args:
+ matches: The rows a criterion named, in any order.
+ """
+ matched = frozenset(matches)
+ return TreeVisibility(
+ matches=matched,
+ ancestors=frozenset(ancestor for node in matched for ancestor in node.ancestors),
+ )
diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py
index 83e40cf5..38b3561e 100644
--- a/src/sampletones_core/trackers/implementation/bitphase.py
+++ b/src/sampletones_core/trackers/implementation/bitphase.py
@@ -8,11 +8,11 @@
sample_to_bitphase,
)
from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset
-from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_shared.utils.system.paths import get_filename
DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py
index ee84198e..dfe9bee0 100644
--- a/src/sampletones_core/trackers/implementation/famitracker.py
+++ b/src/sampletones_core/trackers/implementation/famitracker.py
@@ -11,7 +11,6 @@
from sampletones_core.formats.famitracker.specification.sequences import (
MAX_SEQUENCE_ITEMS,
)
-from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import (
@@ -20,6 +19,7 @@
SampleExport,
)
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_shared.utils.system.paths import get_filename
SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py
index 38f9f1e6..05f61f5f 100644
--- a/src/sampletones_shared/constants/symbols.py
+++ b/src/sampletones_shared/constants/symbols.py
@@ -1,6 +1,7 @@
from typing import Final, Tuple
HEXADECIMAL: Final[str] = "0123456789ABCDEF"
+HASH: Final[str] = "#"
DOT: Final[str] = "."
UNDERSCORE: Final[str] = "_"
MIXED: Final[str] = "?"
diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py
index 772c40f5..f19a3899 100644
--- a/src/sampletones_shared/meta/source/packages.py
+++ b/src/sampletones_shared/meta/source/packages.py
@@ -1,6 +1,6 @@
from pathlib import Path
-from sampletones_shared.paths import SOURCE_ROOT
+from sampletones_shared.paths.source import SOURCE_ROOT
def package_directory(name: str, *parts: str) -> Path:
diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py
deleted file mode 100644
index 53464482..00000000
--- a/src/sampletones_shared/paths.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import sys
-from importlib.resources import files
-from pathlib import Path
-from typing import Final, Optional
-
-_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
-
-CONFIG_DIRECTORY: Final[Path] = (
- Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
-)
-SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[1]
-REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent
diff --git a/src/sampletones_shared/paths/__init__.py b/src/sampletones_shared/paths/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py
new file mode 100644
index 00000000..857c1c91
--- /dev/null
+++ b/src/sampletones_shared/paths/extensions.py
@@ -0,0 +1,24 @@
+from typing import Final, Tuple
+
+EXT_FILE_JSON: Final[str] = ".json"
+EXT_FILE_YAML: Final[str] = ".yaml"
+EXT_FILE_LIBRARY: Final[str] = ".ins"
+EXT_FILE_INSTRUMENT: Final[str] = ".fti"
+EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
+EXT_FILE_PROJECT: Final[str] = ".stp"
+EXT_FILE_MODULE: Final[str] = ".ftm"
+EXT_FILE_BITPHASE: Final[str] = ".btp"
+EXT_FILE_WAVE: Final[str] = ".wav"
+EXT_FILE_MP3: Final[str] = ".mp3"
+EXT_FILE_FLAC: Final[str] = ".flac"
+EXT_FILE_OGG: Final[str] = ".ogg"
+EXT_FILE_AIFF: Final[str] = ".aiff"
+EXT_FILE_AU: Final[str] = ".au"
+EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
+ EXT_FILE_WAVE,
+ EXT_FILE_MP3,
+ EXT_FILE_FLAC,
+ EXT_FILE_OGG,
+ EXT_FILE_AIFF,
+ EXT_FILE_AU,
+)
diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py
new file mode 100644
index 00000000..9da9292d
--- /dev/null
+++ b/src/sampletones_shared/paths/resources.py
@@ -0,0 +1,25 @@
+import sys
+from importlib.resources import files
+from pathlib import Path
+from typing import Final, Optional
+
+_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
+
+CONFIG_DIRECTORY: Final[Path] = (
+ Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
+)
+
+ASSETS_DIRECTORY: Final[str] = "assets"
+
+ICON_DIRECTORY: Final[str] = "icons"
+ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
+ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
+ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg"
+
+FONT_DIRECTORY: Final[str] = "fonts"
+FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
+FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
+FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
+FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
+FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
+FONT_ICON: Final[str] = "DejaVuSans.ttf"
diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py
new file mode 100644
index 00000000..51dd98fa
--- /dev/null
+++ b/src/sampletones_shared/paths/source.py
@@ -0,0 +1,5 @@
+from pathlib import Path
+from typing import Final
+
+SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent
diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py
new file mode 100644
index 00000000..6a34673d
--- /dev/null
+++ b/src/sampletones_shared/paths/user.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+from typing import Final
+
+from platformdirs import user_config_dir, user_data_dir, user_documents_path
+
+from sampletones_shared.application import (
+ SAMPLETONES_GROUP,
+ SAMPLETONES_NAME,
+)
+
+USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
+USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+
+LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
+RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
+PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
+CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
+APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
+
+PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
+LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
+RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_shared/utils/text.py b/src/sampletones_shared/utils/text.py
new file mode 100644
index 00000000..1abc9f9e
--- /dev/null
+++ b/src/sampletones_shared/utils/text.py
@@ -0,0 +1,32 @@
+import re
+from typing import Final, Tuple, TypeAlias
+
+NaturalSortKey: TypeAlias = Tuple[Tuple[int, str], ...]
+
+_DIGIT_RUN_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\d+)")
+
+
+def natural_sort_key(text: str) -> NaturalSortKey:
+ """
+ Builds the sort key that orders text the way a reader expects.
+
+ Digit runs compare as the numbers they spell, so `8 kHz` precedes `44.1 kHz`, and the text
+ around them compares case-insensitively, so `Amen` and `amen` sit together. The text itself
+ closes the key, so two labels reading alike keep a fixed order.
+
+ Args:
+ text: The label to order by.
+
+ Returns:
+ A tuple comparing as the reading order of the label.
+
+ Examples:
+ >>> sorted(["44.1 kHz", "8 kHz"], key=natural_sort_key)
+ ['8 kHz', '44.1 kHz']
+ >>> sorted(["track10", "track2"], key=natural_sort_key)
+ ['track2', 'track10']
+ """
+ tokens = tuple(
+ (int(part), "") if part.isdecimal() else (0, part.casefold()) for part in _DIGIT_RUN_PATTERN.split(text)
+ )
+ return tokens + ((0, text),)
diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py
index fee708dc..a265cea4 100644
--- a/tests/integration/tooling/test_check_commands.py
+++ b/tests/integration/tooling/test_check_commands.py
@@ -3,7 +3,7 @@
import yaml
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT
PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml"
MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile"
diff --git a/tests/suite/browser.py b/tests/suite/browser.py
new file mode 100644
index 00000000..d861995d
--- /dev/null
+++ b/tests/suite/browser.py
@@ -0,0 +1,505 @@
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+from textwrap import dedent
+from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple
+
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import TreeFilter
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.spec import NodeSpec
+from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+from tests.suite.language import FakeLanguageManager
+
+PANEL_TAG: Final[str] = "sequencer.browser"
+TREE_TAG: Final[str] = "sequencer.browser.tree"
+
+HASH_A: Final[str] = "aaaaaaaa11111111aaaaaaaa11111111"
+HASH_B: Final[str] = "bbbbbbbb22222222bbbbbbbb22222222"
+HASH_C: Final[str] = "cccccccc33333333cccccccc33333333"
+HASH_D: Final[str] = "dddddddd44444444dddddddd44444444"
+HASH_E: Final[str] = "eeeeeeee55555555eeeeeeee55555555"
+HASH_F: Final[str] = "ffffffff66666666ffffffff66666666"
+
+ARCHIVE: Final[str] = "archive"
+STRAY: Final[str] = "stray"
+
+BROWSER_TEXTS: Final[Mapping[str, str]] = {
+ "global.browser.label.root": "Root",
+ "global.browser.label.by_configuration": "By configuration",
+ "global.browser.label.by_sample": "By sample",
+}
+
+TREE_COLORS: Final[TreeColors] = TreeColors(
+ favorite=LiteralColor((240, 200, 80, 255)),
+ node=LiteralColor((200, 200, 200, 255)),
+ muted=LiteralColor((120, 120, 120, 255)),
+ accent=LiteralColor((80, 160, 240, 255)),
+)
+
+OPEN_MARKER: Final[str] = "v"
+CLOSED_MARKER: Final[str] = ">"
+LEAF_MARKER: Final[str] = "-"
+HIDDEN_MARKER: Final[str] = " [hidden]"
+INDENT: Final[str] = " "
+
+
+def as_view(text: str) -> str:
+ """Reads a view written as an indented block in a test, so the expected rows read as they draw."""
+ return dedent(text).strip("\n")
+
+
+WHOLE_TREE: Final[str] = as_view("""
+ > By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ > 44.1 kHz·30 Hz
+ > CQT·γ0·PTN
+ - beat
+ - solo
+ > FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ > archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ - stray
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·CQT·γ0·PTN
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+
+
+def config_fields(
+ *,
+ sample_rate: int,
+ nes_frequency: int,
+ spectrum_method: SpectrumMethod,
+ transformation_gamma: int,
+ generators: str,
+ config_hash: str,
+) -> ConfigDirectoryFields:
+ return ConfigDirectoryFields(
+ sr=sample_rate,
+ nf=nes_frequency,
+ sm=spectrum_method,
+ tg=transformation_gamma,
+ gn=generators,
+ ch=config_hash,
+ )
+
+
+CONFIG_A: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_A,
+)
+CONFIG_B: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_B,
+)
+CONFIG_C: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PT",
+ config_hash=HASH_C,
+)
+CONFIG_D: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.CQT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_D,
+)
+CONFIG_E: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=8000,
+ nes_frequency=60,
+ spectrum_method=SpectrumMethod.CQT,
+ transformation_gamma=2,
+ generators="P",
+ config_hash=HASH_E,
+)
+CONFIG_F: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=48000,
+ nes_frequency=50,
+ spectrum_method=SpectrumMethod.LOG_SPACED_FFT,
+ transformation_gamma=1,
+ generators="TN",
+ config_hash=HASH_F,
+)
+
+TOP_LEVEL_CONFIGURATIONS: Final[Mapping[str, ConfigDirectoryFields]] = {
+ "A": CONFIG_A,
+ "B": CONFIG_B,
+ "C": CONFIG_C,
+ "D": CONFIG_D,
+ "E": CONFIG_E,
+}
+RECONSTRUCTIONS: Final[Mapping[str, Tuple[str, ...]]] = {
+ "A": ("beat", "melody", "drums/kick", "drums/snare"),
+ "B": ("beat", "melody", "drums/kick"),
+ "C": ("beat", "takes/alt"),
+ "D": ("beat", "solo"),
+ "E": ("sweep",),
+}
+
+
+class FakeConfigManager:
+ """Answers the one thing the browser manager asks of the configuration: where to read."""
+
+ def __init__(self, reconstructions_directory: Path) -> None:
+ self._reconstructions_directory = reconstructions_directory
+
+ def get_reconstructions_directory(self) -> Path:
+ return self._reconstructions_directory
+
+
+class FakeTreeLogic:
+ """Answers the favorite questions a browser asks of its logic while it collects its rows."""
+
+ def __init__(
+ self,
+ favorites: Set[Path],
+ *,
+ auto_expand_reconstructions: bool,
+ auto_expand_directories: bool,
+ ) -> None:
+ self._favorites = favorites
+ self._auto_expand_reconstructions = auto_expand_reconstructions
+ self._auto_expand_directories = auto_expand_directories
+
+ def is_node_favorite(self, node: TreeNode) -> bool:
+ return isinstance(node, FileSystemNode) and node.filepath in self._favorites
+
+ def has_favorite_ancestor(self, node: FileSystemNode) -> bool:
+ return any(directory in self._favorites for directory in node.filepath.parents)
+
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._auto_expand_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._auto_expand_directories
+
+
+@dataclass(frozen=True)
+class BrowserCorpus:
+ """A reconstructions directory read into the tree both browser views render.
+
+ ``paths`` names every place a test can star: a configuration directory by its key, a
+ reconstruction by ``"