Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs/development/browser.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,10 @@ drive, and `get_all_reconstruction_files` reads the scan.
|---|---|---|
| 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 |
| 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 configuration headings and names it by its generators, so the rows leading to it spell its display name; `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 |
| Containers | `tree/containers.py` | `find_or_create_group`, `find_or_create_config_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each heading is looked up among the siblings of its own kind and class, 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
Expand All @@ -78,11 +78,15 @@ the branch which follows the disk.
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.
* `ConfigGroupNode` — a heading gathering the configurations that share a stretch of their display
name: the rates they run at, the spectrum they were built from. It keeps `NodeType.GROUP`, so it
folds, prunes, sorts and behaves as any heading does, and it names the rows whose labels are
configuration text rather than words, which is what the configuration font reads.

`create_directory_node` chooses between `FileSystemNode` and `ConfigNode` 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

Expand Down
3 changes: 3 additions & 0 deletions docs/development/bugs-and-todos.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
* 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
* Application installation progress bar

### Tracker

Expand All @@ -26,6 +27,7 @@

* In-application guide/tutorial
* Language selector
* NSF export

### Technical

Expand All @@ -41,3 +43,4 @@

* No refreshing after library generation
* Misaligned dialog boxes sizes at initialization
* Audible noise instructions when matching near-silent samples for FFT γ0
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
disambiguate_generator_siblings,
)
from sampletones_application.logic.reconstruction.browser.tree.containers import (
find_or_create_group,
find_or_create_config_group,
)
from sampletones_core.configs.display import (
format_frequencies,
Expand All @@ -20,9 +20,10 @@
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.
A config directory moves under ``frequencies`` ▶ ``transformation`` configuration headings and is
renamed to its generator abbreviation, so the three rows leading to it spell its display name.
Any other top-level folder keeps the flat friendly naming for the config directories nested
inside it.
"""
for child in list(branch.children):
match child:
Expand All @@ -39,11 +40,11 @@ def _attach_config_directory_under_groups(
branch: TreeNode,
) -> None:
fields = directory_node.config
frequencies_node = find_or_create_group(
frequencies_node = find_or_create_config_group(
format_frequencies(fields.sr, fields.nf),
parent=branch,
)
transformation_node = find_or_create_group(
transformation_node = find_or_create_config_group(
format_transformation(fields.sm, fields.tg),
parent=frequencies_node,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Final, FrozenSet
from typing import Final, FrozenSet, Type, TypeVar

from sampletones_core.structures.tree import NodeType, TreeNode
from sampletones_core.structures.tree import ConfigGroupNode, NodeType, TreeNode

ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset(
{
Expand All @@ -9,14 +9,36 @@
}
)

NodeT = TypeVar("NodeT", bound=TreeNode)


def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode:
"""Answers the group of this name under ``parent``, adding one where the parent holds none.

A group stands for something the disk states rather than holds — a frequency pair, a spectrum
method, a source folder — so a builder meeting that name again extends the group it already made.
"""
return _find_or_create(name, node_type=NodeType.GROUP, parent=parent)
return _find_or_create(
name,
node_class=TreeNode,
node_type=NodeType.GROUP,
parent=parent,
)


def find_or_create_config_group(name: str, *, parent: TreeNode) -> ConfigGroupNode:
"""Answers the configuration heading of this name under ``parent``, adding one where it holds none.

A configuration heading names a stretch of a configuration's display name, so it is looked up
among the configuration headings its parent already holds and states that much of the
configuration to every reader of the row.
"""
return _find_or_create(
name,
node_class=ConfigGroupNode,
node_type=NodeType.GROUP,
parent=parent,
)


def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode:
Expand All @@ -26,17 +48,23 @@ def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode:
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)
return _find_or_create(
name,
node_class=TreeNode,
node_type=NodeType.SAMPLE,
parent=parent,
)


def _find_or_create(
name: str,
*,
node_class: Type[NodeT],
node_type: NodeType,
parent: TreeNode,
) -> TreeNode:
) -> NodeT:
for child in parent.children:
if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name:
if isinstance(child, node_class) and child.node_type == node_type and child.name == name:
return child

return TreeNode(name, node_type=node_type, parent=parent)
return node_class(name, node_type=node_type, parent=parent)
16 changes: 10 additions & 6 deletions src/sampletones_application/ui/elements/tree/tree.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
ConfigGroupNode,
ConfigNode,
FileSystemNode,
LibraryNode,
Expand Down Expand Up @@ -750,13 +751,16 @@ def _node_header_color(self, node: TreeNode) -> BaseColor:
return self._colors.node

def _resolve_node_name_font(self, node: TreeNode) -> Font:
"""Select the label font for a node: monospace for config-bearing nodes where the panel opts in.

A config-bearing node carries the machine-generated fields a reconstruction or library
directory encodes, so a panel that sets ``_MONOSPACE_CONFIG_NODES`` renders those names in
the fixed-width font for legibility. Every other node keeps the panel's ``_NAME_FONT``.
"""Select the label font for a node: monospace for the rows stating a configuration.

A row states a configuration when its label is the machine-generated text a configuration
carries: the fields a reconstruction directory encodes, the stretch of them a heading gathers
several directories under, the name a library goes by. A panel that sets
``_MONOSPACE_CONFIG_NODES`` renders every one of those rows in the fixed-width font, so a
column of them reads field under field and a heading reads as the row below it does. Every
other row keeps the panel's ``_NAME_FONT``.
"""
if self._MONOSPACE_CONFIG_NODES and self._node_detail_items(node):
if self._MONOSPACE_CONFIG_NODES and isinstance(node, (ConfigNode, ConfigGroupNode, LibraryNode)):
return self._CONFIG_FONT

return self._NAME_FONT
Expand Down
3 changes: 2 additions & 1 deletion src/sampletones_core/structures/tree/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
from .arguments import Arguments
from .factory import create_directory_node
from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode
from .node import ConfigGroupNode, 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",
"ConfigGroupNode",
"ConfigNode",
"FileSystemNode",
"GeneratorNode",
Expand Down
13 changes: 13 additions & 0 deletions src/sampletones_core/structures/tree/node.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,19 @@ def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode:
)


class ConfigGroupNode(TreeNode):
"""A heading gathering the configurations that share a stretch of their display name.

The browser lifts a configuration directory under the rates it runs at and the spectrum it was
built from, naming each heading with that stretch of the configuration's own display name.
Carrying the heading as a class of its own lets a reader of configuration text — a label, a
tooltip, a font — reach it the way it reaches the configuration row below.
"""

def copy(self, parent: Optional[TreeNode] = None) -> ConfigGroupNode:
return ConfigGroupNode(self.name, node_type=self.node_type, parent=parent)


class LibraryNode(TreeNode):
def __init__(
self,
Expand Down
129 changes: 125 additions & 4 deletions tests/suite/browser.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
from textwrap import dedent
from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple
from typing import AbstractSet, Dict, Final, Iterable, List, Mapping, Optional, Sequence, Set, Tuple

from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.ui.elements.tree.colors import TreeColors
Expand Down Expand Up @@ -32,10 +32,13 @@
ARCHIVE: Final[str] = "archive"
STRAY: Final[str] = "stray"

BY_CONFIGURATION: Final[str] = "By configuration"
BY_SAMPLE: Final[str] = "By sample"

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",
"global.browser.label.by_configuration": BY_CONFIGURATION,
"global.browser.label.by_sample": BY_SAMPLE,
}

TREE_COLORS: Final[TreeColors] = TreeColors(
Expand Down Expand Up @@ -104,6 +107,119 @@ def as_view(text: str) -> str:
- takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
""")

STARRED_CONFIGURATION: Final[str] = as_view("""
> By configuration
> 44.1 kHz·30 Hz
> FFT·γ0
> PT
> takes
- alt
- beat
> By sample
> beat
- 44.1 kHz·30 Hz·FFT·γ0·PT
- takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
""")


@dataclass(frozen=True)
class _Row:
"""One row of a rendered view read apart: how deep it sits, the state it stands in, its text."""

depth: int
marker: str
text: str

@property
def label(self) -> str:
return self.text.removesuffix(HIDDEN_MARKER)

def opened(self) -> "_Row":
return replace(self, marker=OPEN_MARKER) if self.marker == CLOSED_MARKER else self


def with_rows_open(rendered: str, *labels: str) -> str:
"""The view these rows read as once the rows under these labels stand open.

A view differing from another by which rows are unfolded is written as the view it varies and the
labels of the rows standing open in it, so the rows themselves are stated the once.
"""
rows = _read_view(rendered)
wanted = frozenset(labels)
_assert_rows_named(rows, wanted)
return _write_view(row.opened() if row.label in wanted else row for row in rows)


def with_branch_open(rendered: str, *labels: str) -> str:
"""The view these rows read as once the branches under these labels stand open throughout.

A branch the browser opened all the way down stands open at every depth, which one label states
for the row carrying it and every row nested under it.
"""
rows = _read_view(rendered)
wanted = frozenset(labels)
_assert_rows_named(rows, wanted)
opened = _rows_of_branches(rows, wanted)
return _write_view(row.opened() if index in opened else row for index, row in enumerate(rows))


def without_branch(rendered: str, label: str) -> str:
"""The view left once the row under this label and the rows nested under it are gone.

A model dropping a folder drops the rows below it as well, so the view of what remains is written
as the whole view apart from that branch.
"""
rows = _read_view(rendered)
wanted = frozenset({label})
_assert_rows_named(rows, wanted)
dropped = _rows_of_branches(rows, wanted)
return _write_view(row for index, row in enumerate(rows) if index not in dropped)


def _read_view(rendered: str) -> List[_Row]:
return [_read_row(line) for line in rendered.splitlines()]


def _read_row(line: str) -> _Row:
marker, _, text = line.lstrip().partition(" ")
return _Row(
depth=(len(line) - len(line.lstrip())) // len(INDENT),
marker=marker,
text=text,
)


def _write_view(rows: Iterable[_Row]) -> str:
return "\n".join(f"{INDENT * row.depth}{row.marker} {row.text}" for row in rows)


def _rows_of_branches(rows: Sequence[_Row], labels: AbstractSet[str]) -> Set[int]:
"""Every row the named branches reach: each row carrying a label, and the rows nested under it."""
reached: Set[int] = set()
for index, row in enumerate(rows):
if row.label in labels:
reached.add(index)
reached.update(_rows_nested_under(rows, index))

return reached


def _rows_nested_under(rows: Sequence[_Row], index: int) -> Set[int]:
nested: Set[int] = set()
for nested_index in range(index + 1, len(rows)):
if rows[nested_index].depth <= rows[index].depth:
break

nested.add(nested_index)

return nested


def _assert_rows_named(rows: Sequence[_Row], labels: AbstractSet[str]) -> None:
"""Holds a derived view to the rows the view it varies reads, so a label naming none is stated."""
missing = labels - {row.label for row in rows}
assert not missing, f"the view holds no row labelled {sorted(missing)}"


def config_fields(
*,
Expand Down Expand Up @@ -396,6 +512,11 @@ def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str:
return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER


def paths_of(corpus: BrowserCorpus, *keys: str) -> Set[Path]:
"""The paths a test stars, named as the corpus lays them out."""
return {corpus.paths[key] for key in keys}


def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]:
"""Every row standing for one path, which is what a favorite reaches across the two views."""
path = corpus.paths[key]
Expand Down
Loading
Loading