From 78dea7977469da5a8bc1f37e89d3e56feeddbe13 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 13:03:58 +0200 Subject: [PATCH 01/45] Updated: todos --- docs/development/bugs-and-todos.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4658b213..4fe88910 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,9 +4,13 @@ * Interface scale * Tree navigation using keys +* Reconstruction method and generator subdirectories +* Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming +* Alt for scrolling graphs * Drag and drop * Multiple Reconstruction views +* In-project sample selection in Reconstruction view * Playing a fragment by clicking on a waveform * Note pitch shown as a transpose offset rather than a note name @@ -17,7 +21,8 @@ ### Workflow * Waveform construction preview for single-file conversion -* Selection and trimming for a reconstruction (reconstruction editing) +* Selection operations on a reconstruction +* Reconstruction trimming ### Features @@ -27,12 +32,11 @@ ### Technical * API documentation -* Code documentation (docstrings) +* Code documentation * Backward compatibility: library/reconstruction upgrade scheme * Respecting FamiTracker limitations -* Carrying the project comment into a Bitphase document, once the format holds it * Per-tab undo routing -* Delete duplicated HistoryAction enumeration +* In-application console ## Bugs From 3993093d832db8158a54f54b6c8341c696f8be0d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 14:48:13 +0200 Subject: [PATCH 02/45] Added: reconstructions subdirectory view --- docs/development/bugs-and-todos.md | 1 - .../logic/reconstruction/browser_manager.py | 101 +++++++++++++++++- .../ui/panels/reconstruction/browser.py | 21 ++++ .../ui/panels/sequencer/browser.py | 25 ++++- src/sampletones_core/configs/display.py | 6 ++ src/sampletones_shared/constants/symbols.py | 1 + .../reconstruction/test_browser_manager.py | 89 +++++++++++++-- 7 files changed, 231 insertions(+), 13 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4fe88910..398ba147 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,7 +4,6 @@ * Interface scale * Tree navigation using keys -* Reconstruction method and generator subdirectories * Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming * Alt for scrolling graphs diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index b5881275..ca0a9a38 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -3,7 +3,14 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.configs.display import DISPLAY_SEPARATOR, short_hash +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + disambiguated_display_name, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, +) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( @@ -43,7 +50,7 @@ def refresh_tree(self) -> None: for path in sorted(self.reconstructions_directory.iterdir()): self._build_tree(path, parent=container_root) - self._assign_directory_display_names(container_root) + self._organize_top_level_config_directories(container_root) self.tree.set_root(container_root) def _build_tree( @@ -81,6 +88,94 @@ def _build_tree( return directory_node + def _organize_top_level_config_directories( + self, + container_root: TreeNode, + ) -> None: + """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. + + A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is + renamed to its generator abbreviation, while any other top-level folder keeps the existing + flat friendly naming for the config directories nested inside it. + """ + for child in list(container_root.children): + if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: + continue + + fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) + if fields is None: + self._assign_directory_display_names(child) + continue + + self._attach_config_directory_under_groups( + child, + fields, + container_root, + ) + + self._disambiguate_generator_siblings(container_root) + + def _attach_config_directory_under_groups( + self, + directory_node: FileSystemNode, + fields: ConfigDirectoryFields, + container_root: TreeNode, + ) -> None: + frequencies_name = DISPLAY_SEPARATOR.join( + [ + format_sample_rate(fields.sr), + format_nes_frequency(fields.nf), + ] + ) + method_name = DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(fields.sm), + f"{GAMMA_PREFIX}{fields.tg}", + ] + ) + frequencies_node = self._find_or_create_group_node( + frequencies_name, + container_root, + ) + method_node = self._find_or_create_group_node( + method_name, + frequencies_node, + ) + + directory_node.name = fields.gn + directory_node.parent = method_node + + def _find_or_create_group_node( + self, + name: str, + parent: TreeNode, + ) -> TreeNode: + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: + return child + + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + def _disambiguate_generator_siblings(self, node: TreeNode) -> None: + """Appends a short config hash to generator leaves that share a name under one method group.""" + if node.node_type == NodeType.GROUP: + by_name: Dict[str, List[FileSystemNode]] = {} + for child in node.children: + if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY: + by_name.setdefault(child.name, []).append(child) + + for name, members in by_name.items(): + if len(members) <= 1: + continue + + for directory_node in members: + fields = ConfigDirectoryFields.from_directory_name(directory_node.filepath.name) + if fields is not None: + directory_node.name = disambiguated_display_name(name, fields.ch) + + for child in node.children: + self._disambiguate_generator_siblings(child) + def _assign_directory_display_names(self, node: TreeNode) -> None: """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. @@ -111,7 +206,7 @@ def _rename_config_directory_children(self, node: TreeNode) -> None: continue for directory_node, fields in members: - directory_node.name = f"{display_name}{DISPLAY_SEPARATOR}#{short_hash(fields.ch)}" + directory_node.name = disambiguated_display_name(display_name, fields.ch) def get_all_reconstruction_files(self) -> List[Path]: file_nodes = [ diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 3873d72f..e82feecb 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -8,6 +8,7 @@ SchedulingBehavior, ) from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) from sampletones_application.tags.reconstructions import ( @@ -112,6 +113,10 @@ def create_panel(self, parent: str) -> None: def _setup_handlers(self) -> None: self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, @@ -187,6 +192,16 @@ def _build_tree_node( if node.node_type == NodeType.ROOT: return + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + if not isinstance(node, FileSystemNode): return @@ -212,6 +227,12 @@ def _build_tree_node( state.parent = node_tag + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item( TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index d6fe796a..74e8eaf2 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -6,7 +6,10 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -101,6 +104,10 @@ def create_panel(self, parent: str) -> None: def _setup_handlers(self) -> None: self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, @@ -176,6 +183,16 @@ def _build_tree_node( if node.node_type == NodeType.ROOT: return + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + if not isinstance(node, FileSystemNode): return @@ -201,6 +218,12 @@ def _build_tree_node( state.parent = node_tag + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled) dpg_configure_item( diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 6d3d00ed..e5b5ab78 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,6 +1,7 @@ from typing import Dict, Final from sampletones_core.constants.enums import SpectrumMethod +from sampletones_shared.constants.symbols import HASH DISPLAY_SEPARATOR: Final[str] = "·" GAMMA_PREFIX: Final[str] = "γ" @@ -39,3 +40,8 @@ def format_spectrum_method(method: SpectrumMethod) -> str: def short_hash(config_hash: str) -> str: return config_hash[:DISPLAY_HASH_LENGTH] + + +def disambiguated_display_name(name: str, config_hash: str) -> str: + """Appends the short config hash, marked with ``#``, so colliding names stay distinct.""" + return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}" diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py index 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/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index bfb3b820..54d30427 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -7,7 +7,7 @@ import pytest from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.structures.tree import FileSystemNode, NodeType +from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode HASH_A = "6edf7c948606917a78b45d153c7ca7e0" HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -16,13 +16,25 @@ def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: root = browser_manager.tree.get_root() assert root is not None + return directory_children(root) + + +def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child - for child in root.children + for child in node.children if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY } +def group_children(node: TreeNode) -> Dict[str, TreeNode]: + return { + child.name: child + for child in node.children + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP + } + + @pytest.fixture def config_manager(tmp_path: Path) -> MagicMock: mock = MagicMock() @@ -117,7 +129,7 @@ def test_empty_subdirectory_is_not_returned( class TestBrowserManagerFriendlyNames: - def test_config_directory_gets_friendly_name( + def test_config_directory_groups_by_frequency_method_generators( self, browser_manager: BrowserManager, tmp_path: Path, @@ -128,7 +140,16 @@ def test_config_directory_gets_friendly_name( browser_manager.refresh_tree() - assert "44.1 kHz·30 Hz·FFT·γ0·PpT" in directory_nodes(browser_manager) + root = browser_manager.tree.get_root() + assert root is not None + + frequencies = group_children(root) + assert set(frequencies) == {"44.1 kHz·30 Hz"} + + methods = group_children(frequencies["44.1 kHz·30 Hz"]) + assert set(methods) == {"FFT·γ0"} + + assert set(directory_children(methods["FFT·γ0"])) == {"PpT"} def test_colliding_config_directories_get_hash_suffix( self, @@ -142,12 +163,64 @@ def test_colliding_config_directories_get_hash_suffix( browser_manager.refresh_tree() - names = set(directory_nodes(browser_manager)) - assert names == { - f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_A[:7]}", - f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_B[:7]}", + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(directory_children(methods["FFT·γ0"])) == { + f"PpT·#{HASH_A[:7]}", + f"PpT·#{HASH_B[:7]}", } + def test_distinct_frequencies_form_separate_groups( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for sample_rate, nes_frequency in ((44100, 30), (48000, 60)): + config_dir = tmp_path / f"sr_{sample_rate}_nf_{nes_frequency}_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert set(group_children(root)) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} + + def test_distinct_methods_form_separate_groups( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for spectrum_method in ("fft", "cqt"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(methods) == {"FFT·γ0", "CQT·γ0"} + + def test_distinct_generators_share_method_group_without_hash( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for generators in ("PTN", "TN"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_{generators}_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} + def test_non_config_directory_keeps_raw_name( self, browser_manager: BrowserManager, From e4f433c67204bb4d7e85ff968cf0eaa7f0e4c7cc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 15:27:55 +0200 Subject: [PATCH 03/45] Refactored: browser view --- .../ui/panels/reconstruction/browser.py | 288 ++-------------- .../ui/panels/sequencer/browser.py | 276 ++-------------- .../ui/panels/shared/browser.py | 310 ++++++++++++++++++ .../sequencer/test_browser_context_menu.py | 6 +- 4 files changed, 360 insertions(+), 520 deletions(-) create mode 100644 src/sampletones_application/ui/panels/shared/browser.py diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index e82feecb..2f30f2d7 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Callable, Optional import dearpygui.dearpygui as dpg @@ -7,10 +7,6 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_DEFAULT, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, -) from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, @@ -19,32 +15,24 @@ TAG_RECONSTRUCTIONS_BROWSER_TREE, TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import ( - FileSystemNode, - NodeType, - Tree, - TreeNode, - TreeTraversal, - traverse, +from sampletones_application.ui.panels.shared.browser import ( + GUIReconstructionBrowserPanel, ) +from sampletones_core.structures.tree import FileSystemNode, Tree from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback, VoidCallback -class GUIBrowserPanel(GUITreePanel): - _MONOSPACE_CONFIG_NODES: bool = True +class GUIBrowserPanel(GUIReconstructionBrowserPanel): + _panel_tag = TAG_RECONSTRUCTIONS_BROWSER_PANEL + _tree_tag = TAG_RECONSTRUCTIONS_BROWSER_TREE + _button_refresh_tag = TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS + _group_controls_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS + _group_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE + _window_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE def __init__( self, @@ -59,189 +47,29 @@ def __init__( initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager - self.on_refresh_tree: Optional[VoidCallback] = None - self.on_reconstruct_file: Optional[VoidCallback] = None - self.on_reconstruct_directory: Optional[VoidCallback] = None - self.on_load_reconstruction: Optional[PathCallback] = None - self.on_reconstruction_remove_requested: Optional[PathCallback] = None - self.on_directory_remove_requested: Optional[PathCallback] = None - - self._is_operation_active = is_operation_active - - self._lbl_reconstructions = language_manager["reconstructions.browser.label.reconstructions_tree"] - - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( tree=tree, - tag=TAG_RECONSTRUCTIONS_BROWSER_PANEL, - tree_tag=TAG_RECONSTRUCTIONS_BROWSER_TREE, tree_logic=tree_logic, scheduling=scheduling, - search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( + reconstructions_label=language_manager["reconstructions.browser.label.reconstructions_tree"], + refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], + refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._lbl_reconstructions, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE) - self.rebuild_tree() - - def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.GROUP: NodeHandler( - tag=self._get_node_handler_tag(NodeType.GROUP), - node_type=NodeType.GROUP, - ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), - ), - } - - super()._setup_handlers() - - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS): - GUIButton( - tag=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - label=self._language_manager["reconstructions.browser.label.refresh_button"], - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - self._language_manager["reconstructions.browser.message.status_refresh"], - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_reconstructions, - tag=self.tree_tag, - default_open=True, - ), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - - def _has_relevant_content(self, node: TreeNode) -> bool: - if node.node_type == NodeType.FILE: - return True - - return bool(node.children) - - @traverse(TreeTraversal.BFS) - def _build_tree_node( - self, - node: TreeNode, - state: TreeNodeState, - **kwargs: Any, - ) -> None: - node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return - - if not isinstance(node, FileSystemNode): - return - - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite - if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=should_expand, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - else: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - leaf=True, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - - state.parent = node_tag - - def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT + self.on_reconstruct_file: Optional[VoidCallback] = None + self.on_reconstruct_directory: Optional[VoidCallback] = None + self.on_load_reconstruction: Optional[PathCallback] = None + self.on_reconstruction_remove_requested: Optional[PathCallback] = None + self.on_directory_remove_requested: Optional[PathCallback] = None - return super()._resolve_other_theme_tag(node) + self._is_operation_active = is_operation_active - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item( - TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, - enabled=enabled, - ) - dpg_configure_item( - TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, - enabled=enabled, - ) + def _open_reconstruction(self, node: FileSystemNode) -> None: + self._load_reconstruction(node) def _reconstruct_file(self) -> None: self.call(self.on_reconstruct_file) @@ -249,73 +77,13 @@ def _reconstruct_file(self) -> None: def _reconstruct_directory(self) -> None: self.call(self.on_reconstruct_directory) - def _on_directory_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Right: - return self._show_directory_context_menu(node) - - return None - - def _on_reconstruction_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, node_tag = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.request_autoplay(node) - - if mouse_button == dpg.mvMouseButton_Right: - self._show_reconstruction_context_menu(node, node_tag) - - def _on_reconstruction_node_double_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.cancel_autoplay() - self._load_reconstruction(node) - - def _show_directory_context_menu(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: - return - - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_details(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_remove_directory_item(node) - self._add_context_menu_favorite_item(node) - - def _show_reconstruction_context_menu( - self, - node: FileSystemNode, - _node_tag: str, - ) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return + def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_remove_directory_item(node) - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_play_item(node) - self._add_context_menu_load_reconstruction_item(node) - self._add_context_menu_remove_reconstruction_item(node) - self._add_context_menu_sequencer_items(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_locate_audio_item(node) - self._add_context_menu_favorite_item(node) + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_load_reconstruction_item(node) + self._add_context_menu_remove_reconstruction_item(node) + self._add_context_menu_sequencer_items(node) def _add_context_menu_load_reconstruction_item( self, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 74e8eaf2..2ed293f6 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,15 +1,7 @@ -from typing import Any, Dict, Optional, Tuple - -import dearpygui.dearpygui as dpg - from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_DEFAULT, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, -) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -18,32 +10,22 @@ TAG_SEQUENCER_BROWSER_TREE, TAG_SEQUENCER_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import ( - FileSystemNode, - NodeType, - Tree, - TreeNode, - TreeTraversal, - traverse, +from sampletones_application.ui.panels.shared.browser import ( + GUIReconstructionBrowserPanel, ) -from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import VoidCallback +from sampletones_core.structures.tree import FileSystemNode, Tree -class GUISequencerBrowserPanel(GUITreePanel): - _MONOSPACE_CONFIG_NODES: bool = True +class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): + _panel_tag = TAG_SEQUENCER_BROWSER_PANEL + _tree_tag = TAG_SEQUENCER_BROWSER_TREE + _button_refresh_tag = TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS + _group_controls_tag = TAG_SEQUENCER_BROWSER_GROUP_CONTROLS + _group_tree_tag = TAG_SEQUENCER_BROWSER_GROUP_TREE + _window_tree_tag = TAG_SEQUENCER_BROWSER_WINDOW_TREE def __init__( self, @@ -56,243 +38,23 @@ def __init__( colors: TreeColors, initial_collapsed: bool = False, ) -> None: - self._language_manager = language_manager - self.on_refresh_tree: Optional[VoidCallback] = None - - self._lbl_reconstructions = language_manager["sequencer.browser.label.reconstructions_tree"] - - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( tree=tree, - tag=TAG_SEQUENCER_BROWSER_PANEL, - tree_tag=TAG_SEQUENCER_BROWSER_TREE, tree_logic=tree_logic, scheduling=scheduling, - search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( + reconstructions_label=language_manager["sequencer.browser.label.reconstructions_tree"], + refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], + refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, - ) - - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._lbl_reconstructions, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_SEQUENCER_BROWSER_WINDOW_TREE) - self.rebuild_tree() - - def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.GROUP: NodeHandler( - tag=self._get_node_handler_tag(NodeType.GROUP), - node_type=NodeType.GROUP, - ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), - ), - } - - super()._setup_handlers() - - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS): - GUIButton( - tag=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - label=self._language_manager["sequencer.browser.label.refresh_button"], - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - self._language_manager["sequencer.browser.message.status_refresh"], - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_reconstructions, - tag=self.tree_tag, - default_open=True, - ), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - - def _has_relevant_content(self, node: TreeNode) -> bool: - if node.node_type == NodeType.FILE: - return True - - return bool(node.children) - - @traverse(TreeTraversal.BFS) - def _build_tree_node( - self, - node: TreeNode, - state: TreeNodeState, - **kwargs: Any, - ) -> None: - node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return - - if not isinstance(node, FileSystemNode): - return - - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite - if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=should_expand, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - else: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - leaf=True, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - - state.parent = node_tag - - def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT - - return super()._resolve_other_theme_tag(node) - - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled) - dpg_configure_item( - TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, - enabled=enabled, ) - def _on_directory_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Right: - return self._show_directory_context_menu(node) - - return None - - def _on_reconstruction_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, node_tag = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.request_autoplay(node) - - if mouse_button == dpg.mvMouseButton_Right: - self._show_reconstruction_context_menu(node, node_tag) - - def _on_reconstruction_node_double_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.cancel_autoplay() - self.call(self.on_add_to_sequencer, node.filepath) - - def _show_directory_context_menu(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: - return - - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_details(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_favorite_item(node) - - def _show_reconstruction_context_menu( - self, - node: FileSystemNode, - _node_tag: str, - ) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return + def _open_reconstruction(self, node: FileSystemNode) -> None: + self._logic.cancel_autoplay() + self.call(self.on_add_to_sequencer, node.filepath) - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_play_item(node) - self._add_context_menu_sequencer_items(node) - self._add_context_menu_replace_item(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_locate_audio_item(node) - self._add_context_menu_favorite_item(node) + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_sequencer_items(node) + self._add_context_menu_replace_item(node) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py new file mode 100644 index 00000000..637322b5 --- /dev/null +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -0,0 +1,310 @@ +from abc import abstractmethod +from typing import Any, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.layout.collapse import CollapseAxis +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_core.structures.tree import ( + FileSystemNode, + NodeType, + Tree, + TreeNode, + TreeTraversal, + traverse, +) +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + + +class GUIReconstructionBrowserPanel(GUITreePanel): + """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs. + + Builds the refresh button and the searchable tree, resolves every node into a spec, and routes + node clicks to the subclass through :meth:`_open_reconstruction`. The subclass supplies its DPG + tags, its displayed labels, and the extra items each context menu offers. + """ + + _MONOSPACE_CONFIG_NODES: bool = True + + _panel_tag: str + _tree_tag: str + _button_refresh_tag: str + _group_controls_tag: str + _group_tree_tag: str + _window_tree_tag: str + + def __init__( + self, + tree: Tree, + tree_logic: TreeLogicProtocol, + *, + scheduling: SchedulingBehavior, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + colors: TreeColors, + reconstructions_label: str, + refresh_button_label: str, + refresh_status_message: str, + initial_collapsed: bool = False, + ) -> None: + self._language_manager = language_manager + self._reconstructions_label = reconstructions_label + self._refresh_button_label = refresh_button_label + self._refresh_status_message = refresh_status_message + self.on_refresh_tree: Optional[VoidCallback] = None + + super().__init__( + tree=tree, + tag=self._panel_tag, + tree_tag=self._tree_tag, + tree_logic=tree_logic, + scheduling=scheduling, + search_label=language_manager["global.browser.label.search"], + language_manager=language_manager, + status_bar=status_bar, + colors=colors, + ) + + self._enable_horizontal_collapse( + initial_collapsed=initial_collapsed, + side=CollapseAxis.HORIZONTAL_LEFT, + ) + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( + self._reconstructions_label, + glyph=self._glyphs.headers.reconstruction, + ), + ): + self._create_buttons() + dpg.add_separator() + self._create_tree_window() + + self._create_detail_tooltip(self._window_tree_tag) + self.rebuild_tree() + + def _setup_handlers(self) -> None: + self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), + NodeType.DIRECTORY: NodeHandler( + tag=self._get_node_handler_tag(NodeType.DIRECTORY), + node_type=NodeType.DIRECTORY, + item_click_callback=self._on_directory_node_clicked, + status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + ), + NodeType.FILE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.FILE), + node_type=NodeType.FILE, + item_click_callback=self._on_reconstruction_node_clicked, + item_double_click_callback=self._on_reconstruction_node_double_clicked, + status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), + ), + } + + super()._setup_handlers() + + def _create_buttons(self) -> None: + with dpg.group(tag=self._group_controls_tag): + GUIButton( + tag=self._button_refresh_tag, + label=self._refresh_button_label, + width=-1, + callback=self.rebuild_tree, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + self._status_bar.bind_to_item( + self._button_refresh_tag, + self._refresh_status_message, + ) + + def _create_tree_window(self) -> None: + self.create_search(self._body_container) + with ( + dpg.child_window( + tag=self._window_tree_tag, + horizontal_scrollbar=True, + ), + dpg.group(tag=self._group_tree_tag), + dpg.tree_node( + label=self._reconstructions_label, + tag=self.tree_tag, + default_open=True, + ), + ): + pass + + def refresh(self) -> None: + self.rebuild_tree() + + @concurrent(wait=False, method_bound=True) + def rebuild_tree(self) -> None: + self._launch_rebuild( + lambda: self.call(self.on_refresh_tree), + lambda: self._collect_specs(self.tree_tag), + root_tag=self.tree_tag, + ) + + def _has_relevant_content(self, node: TreeNode) -> bool: + if node.node_type == NodeType.FILE: + return True + + return bool(node.children) + + @traverse(TreeTraversal.BFS) + def _build_tree_node( + self, + node: TreeNode, + state: TreeNodeState, + **kwargs: Any, + ) -> None: + node_tag = self._generate_node_tag(node) + if node.node_type == NodeType.ROOT: + return + + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + + if not isinstance(node, FileSystemNode): + return + + is_favorite = self._logic.is_node_favorite(node) + state.has_favorite_ancestor |= is_favorite + if node.node_type == NodeType.DIRECTORY: + should_expand = self._should_expand_node(node) + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=should_expand, + has_favorite_ancestor=state.has_favorite_ancestor, + ) + else: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + leaf=True, + has_favorite_ancestor=state.has_favorite_ancestor, + ) + + state.parent = node_tag + + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + + def set_tree_enabled(self, enabled: bool) -> None: + dpg_configure_item(self._group_tree_tag, enabled=enabled) + dpg_configure_item(self._group_controls_tag, enabled=enabled) + + def _on_directory_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Right: + self._show_directory_context_menu(node) + + def _on_reconstruction_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, node_tag = user_data + if mouse_button == dpg.mvMouseButton_Left: + self._logic.request_autoplay(node) + + if mouse_button == dpg.mvMouseButton_Right: + self._show_reconstruction_context_menu(node, node_tag) + + def _on_reconstruction_node_double_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Left: + self._open_reconstruction(node) + + def _show_directory_context_menu(self, node: FileSystemNode) -> None: + if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_details(node) + self._add_context_menu_path_items(node.filepath) + self._add_directory_context_menu_items(node) + self._add_context_menu_favorite_item(node) + + def _show_reconstruction_context_menu( + self, + node: FileSystemNode, + _node_tag: str, + ) -> None: + if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_play_item(node) + self._add_reconstruction_context_menu_items(node) + self._add_context_menu_path_items(node.filepath) + self._add_context_menu_locate_audio_item(node) + self._add_context_menu_favorite_item(node) + + def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: + pass + + @abstractmethod + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: ... + + @abstractmethod + def _open_reconstruction(self, node: FileSystemNode) -> None: ... diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py index f67e0828..87a03b7d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py @@ -5,8 +5,8 @@ import pytest from sampletones_application.ui.elements.tree import tree as tree_module -from sampletones_application.ui.panels.sequencer import browser as browser_module from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared import browser as shared_browser_module from sampletones_core.structures.tree.node import FileSystemNode, NodeType from tests.suite.language import FakeLanguageManager @@ -167,7 +167,7 @@ def test_replace_follows_the_add_item(self, monkeypatch: pytest.MonkeyPatch) -> def _menu() -> Iterator[None]: yield - monkeypatch.setattr(browser_module, "context_menu", _menu) + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) panel._show_reconstruction_context_menu(_node(), "node-tag") @@ -196,7 +196,7 @@ def test_directory_menu_offers_no_replacement(self, monkeypatch: pytest.MonkeyPa def _menu() -> Iterator[None]: yield - monkeypatch.setattr(browser_module, "context_menu", _menu) + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) panel._show_directory_context_menu(_node(NodeType.DIRECTORY)) From f13261e645dcd8347574754dc606a5b97f7f38aa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 16:22:50 +0200 Subject: [PATCH 04/45] Added: multiple browser views --- .../logic/reconstruction/browser_manager.py | 81 ++++++++-- .../ui/panels/reconstruction/browser.py | 1 - .../ui/panels/sequencer/browser.py | 1 - .../ui/panels/shared/browser.py | 7 +- src/sampletones_config/lang/en.yaml | 5 +- .../reconstruction/test_browser_manager.py | 141 +++++++++++++++--- 6 files changed, 197 insertions(+), 39 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index ca0a9a38..86772f85 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -47,10 +47,22 @@ def refresh_tree(self) -> None: name=self._language_manager["global.browser.label.root"], node_type=NodeType.ROOT, ) + reconstructions_node = TreeNode( + name=self._language_manager["global.browser.label.reconstructions"], + node_type=NodeType.GROUP, + parent=container_root, + ) + samples_node = TreeNode( + name=self._language_manager["global.browser.label.samples"], + node_type=NodeType.GROUP, + parent=container_root, + ) + for path in sorted(self.reconstructions_directory.iterdir()): - self._build_tree(path, parent=container_root) + self._build_tree(path, parent=reconstructions_node) - self._organize_top_level_config_directories(container_root) + self._organize_top_level_config_directories(reconstructions_node) + self._build_samples_children(samples_node) self.tree.set_root(container_root) def _build_tree( @@ -90,7 +102,7 @@ def _build_tree( def _organize_top_level_config_directories( self, - container_root: TreeNode, + reconstructions_node: TreeNode, ) -> None: """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. @@ -98,7 +110,7 @@ def _organize_top_level_config_directories( renamed to its generator abbreviation, while any other top-level folder keeps the existing flat friendly naming for the config directories nested inside it. """ - for child in list(container_root.children): + for child in list(reconstructions_node.children): if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: continue @@ -110,16 +122,16 @@ def _organize_top_level_config_directories( self._attach_config_directory_under_groups( child, fields, - container_root, + reconstructions_node, ) - self._disambiguate_generator_siblings(container_root) + self._disambiguate_generator_siblings(reconstructions_node) def _attach_config_directory_under_groups( self, directory_node: FileSystemNode, fields: ConfigDirectoryFields, - container_root: TreeNode, + reconstructions_node: TreeNode, ) -> None: frequencies_name = DISPLAY_SEPARATOR.join( [ @@ -135,7 +147,7 @@ def _attach_config_directory_under_groups( ) frequencies_node = self._find_or_create_group_node( frequencies_name, - container_root, + reconstructions_node, ) method_node = self._find_or_create_group_node( method_name, @@ -208,10 +220,55 @@ def _rename_config_directory_children(self, node: TreeNode) -> None: for directory_node, fields in members: directory_node.name = disambiguated_display_name(display_name, fields.ch) + def _build_samples_children(self, samples_node: TreeNode) -> None: + """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" + variants_by_audio: Dict[Tuple[Tuple[str, ...], str], List[Tuple[ConfigDirectoryFields, Path]]] = {} + + for config_directory in sorted(self.reconstructions_directory.iterdir()): + if not config_directory.is_dir(): + continue + + fields = ConfigDirectoryFields.from_directory_name(config_directory.name) + if fields is None: + continue + + for reconstruction_path in sorted(config_directory.rglob(f"*{EXT_FILE_RECONSTRUCTION}")): + relative = reconstruction_path.relative_to(config_directory) + audio_key = (relative.parent.parts, relative.stem) + variants_by_audio.setdefault(audio_key, []).append((fields, reconstruction_path)) + + for audio_key in sorted(variants_by_audio): + directory_parts, audio_name = audio_key + parent = samples_node + for part in directory_parts: + parent = self._find_or_create_group_node(part, parent) + + audio_node = self._find_or_create_group_node(audio_name, parent) + self._append_config_variants(audio_node, variants_by_audio[audio_key]) + + def _append_config_variants( + self, + audio_node: TreeNode, + variants: List[Tuple[ConfigDirectoryFields, Path]], + ) -> None: + variants_by_display_name: Dict[str, List[Tuple[ConfigDirectoryFields, Path]]] = {} + for fields, reconstruction_path in variants: + variants_by_display_name.setdefault(fields.display_name, []).append((fields, reconstruction_path)) + + for display_name, members in variants_by_display_name.items(): + for fields, reconstruction_path in members: + label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) + FileSystemNode( + label, + filepath=reconstruction_path, + node_type=NodeType.FILE, + parent=audio_node, + ) + def get_all_reconstruction_files(self) -> List[Path]: - file_nodes = [ - node + file_paths = { + node.filepath for node in self.tree.collect_leaves() if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE - ] - return [node.filepath for node in file_nodes] + } + return sorted(file_paths) diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 2f30f2d7..ebcd6101 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -54,7 +54,6 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, - reconstructions_label=language_manager["reconstructions.browser.label.reconstructions_tree"], refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 2ed293f6..c3b2aff7 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -45,7 +45,6 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, - reconstructions_label=language_manager["sequencer.browser.label.reconstructions_tree"], refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 637322b5..e54ce85c 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -61,13 +61,12 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - reconstructions_label: str, refresh_button_label: str, refresh_status_message: str, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager - self._reconstructions_label = reconstructions_label + self._browser_label = language_manager["global.browser.label.browser"] self._refresh_button_label = refresh_button_label self._refresh_status_message = refresh_status_message self.on_refresh_tree: Optional[VoidCallback] = None @@ -100,7 +99,7 @@ def create_panel(self, parent: str) -> None: border=False, ), self._collapsible_section( - self._reconstructions_label, + self._browser_label, glyph=self._glyphs.headers.reconstruction, ), ): @@ -157,7 +156,7 @@ def _create_tree_window(self) -> None: ), dpg.group(tag=self._group_tree_tag), dpg.tree_node( - label=self._reconstructions_label, + label=self._browser_label, tag=self.tree_tag, default_open=True, ), diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7840744f..102e8f80 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -126,6 +126,9 @@ global.traceback.label.hide: "Hide traceback" # Global — Tree # ============================================================================= global.browser.label.root: "Root" +global.browser.label.browser: "Browser" +global.browser.label.reconstructions: "Reconstructions" +global.browser.label.samples: "Samples" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" @@ -359,7 +362,6 @@ main.advanced.message.status_select_output: "Choose the directory for reconstruc # Reconstructions tab — Browser # ============================================================================= reconstructions.browser.label.refresh_button: "Refresh reconstructions" -reconstructions.browser.label.reconstructions_tree: "Reconstructions" reconstructions.browser.label.context_load_reconstruction: "Load reconstruction" reconstructions.browser.label.context_remove_reconstruction: "Remove reconstruction" reconstructions.browser.label.context_remove_directory: "Remove directory" @@ -433,7 +435,6 @@ reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the # Sequencer tab — Browser # ============================================================================= sequencer.browser.label.refresh_button: "Refresh reconstructions" -sequencer.browser.label.reconstructions_tree: "Reconstructions" sequencer.browser.message.status_refresh: "Rescan for available reconstructions." # ============================================================================= diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index 54d30427..54438f22 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -13,10 +13,20 @@ HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" -def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: +def reconstructions_node(browser_manager: BrowserManager) -> TreeNode: + root = browser_manager.tree.get_root() + assert root is not None + return group_children(root)["Reconstructions"] + + +def samples_node(browser_manager: BrowserManager) -> TreeNode: root = browser_manager.tree.get_root() assert root is not None - return directory_children(root) + return group_children(root)["Samples"] + + +def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: + return directory_children(reconstructions_node(browser_manager)) def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: @@ -27,6 +37,14 @@ def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: } +def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE + } + + def group_children(node: TreeNode) -> Dict[str, TreeNode]: return { child.name: child @@ -42,10 +60,18 @@ def config_manager(tmp_path: Path) -> MagicMock: return mock +BROWSER_LABELS = { + "global.browser.label.root": "Root", + "global.browser.label.browser": "Browser", + "global.browser.label.reconstructions": "Reconstructions", + "global.browser.label.samples": "Samples", +} + + @pytest.fixture def language_manager() -> MagicMock: mock = MagicMock() - mock.__getitem__ = MagicMock(return_value="Reconstructions") + mock.__getitem__ = MagicMock(side_effect=BROWSER_LABELS.__getitem__) return mock @@ -140,10 +166,8 @@ def test_config_directory_groups_by_frequency_method_generators( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - - frequencies = group_children(root) + reconstructions = reconstructions_node(browser_manager) + frequencies = group_children(reconstructions) assert set(frequencies) == {"44.1 kHz·30 Hz"} methods = group_children(frequencies["44.1 kHz·30 Hz"]) @@ -163,9 +187,8 @@ def test_colliding_config_directories_get_hash_suffix( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + reconstructions = reconstructions_node(browser_manager) + methods = group_children(group_children(reconstructions)["44.1 kHz·30 Hz"]) assert set(directory_children(methods["FFT·γ0"])) == { f"PpT·#{HASH_A[:7]}", f"PpT·#{HASH_B[:7]}", @@ -183,9 +206,7 @@ def test_distinct_frequencies_form_separate_groups( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - assert set(group_children(root)) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} + assert set(group_children(reconstructions_node(browser_manager))) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} def test_distinct_methods_form_separate_groups( self, @@ -199,9 +220,7 @@ def test_distinct_methods_form_separate_groups( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) assert set(methods) == {"FFT·γ0", "CQT·γ0"} def test_distinct_generators_share_method_group_without_hash( @@ -216,9 +235,7 @@ def test_distinct_generators_share_method_group_without_hash( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} def test_non_config_directory_keeps_raw_name( @@ -235,6 +252,92 @@ def test_non_config_directory_keeps_raw_name( assert "my_songs" in directory_nodes(browser_manager) +class TestBrowserManagerSamplesView: + def test_samples_are_grouped_by_source_directory_and_audio( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + audio_dir = config_dir / "Amen Breaks" / "Amen Breaks vol.1" + audio_dir.mkdir(parents=True) + (audio_dir / "cw_amen02_165.stn").touch() + + browser_manager.refresh_tree() + + samples = samples_node(browser_manager) + amen_breaks = group_children(samples)["Amen Breaks"] + amen_breaks_vol1 = group_children(amen_breaks)["Amen Breaks vol.1"] + audio = group_children(amen_breaks_vol1)["cw_amen02_165"] + variant = file_children(audio)["44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert variant.filepath == audio_dir / "cw_amen02_165.stn" + + def test_one_audio_lists_each_config_variant( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for spectrum_method in ("fft", "cqt"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + assert set(file_children(audio)) == { + "44.1 kHz·30 Hz·FFT·γ0·PTN", + "44.1 kHz·30 Hz·CQT·γ0·PTN", + } + + def test_colliding_variants_of_one_audio_get_hash_suffix( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for config_hash in (HASH_A, HASH_B): + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{config_hash}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + assert set(file_children(audio)) == { + f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_A[:7]}", + f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_B[:7]}", + } + + def test_single_file_conversion_appears_at_samples_root( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + samples = samples_node(browser_manager) + assert set(group_children(samples)) == {"song"} + assert set(file_children(group_children(samples)["song"])) == {"44.1 kHz·30 Hz·FFT·γ0·PTN"} + + def test_non_config_directory_is_excluded_from_samples( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + plain = tmp_path / "my_songs" + plain.mkdir() + (plain / "song.stn").touch() + + browser_manager.refresh_tree() + + assert group_children(samples_node(browser_manager)) == {} + assert "my_songs" in directory_nodes(browser_manager) + + class TestBrowserManagerSetDirectory: def test_set_reconstructions_directory_updates_directory( self, From 229c6e01c1bd76be6b30fef234e4a42d583c37a9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 16:39:59 +0200 Subject: [PATCH 05/45] Improved: reconstruction double view --- src/sampletones_application/ui/panels/shared/browser.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index e54ce85c..d3bb4c1f 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -155,11 +155,7 @@ def _create_tree_window(self) -> None: horizontal_scrollbar=True, ), dpg.group(tag=self._group_tree_tag), - dpg.tree_node( - label=self._browser_label, - tag=self.tree_tag, - default_open=True, - ), + dpg.group(tag=self.tree_tag), ): pass From dece1e299eda1a7fe4469d612ab94f5853845c2c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 02:29:17 +0200 Subject: [PATCH 06/45] Added: parsed configuration fields --- .../logic/main/explorer_manager.py | 11 +- .../logic/reconstruction/browser_manager.py | 64 +++++------ .../ui/elements/tree/tree.py | 11 +- .../structures/tree/__init__.py | 5 +- .../structures/tree/factory.py | 38 +++++++ src/sampletones_core/structures/tree/node.py | 36 ++++++ .../reconstruction/test_browser_manager.py | 62 +++++++++- .../ui/elements/tree/__init__.py | 0 .../ui/elements/tree/test_detail_items.py | 107 ++++++++++++++++++ .../structures/tree/test_factory.py | 40 +++++++ .../structures/tree/test_node.py | 40 +++++++ 11 files changed, 362 insertions(+), 52 deletions(-) create mode 100644 src/sampletones_core/structures/tree/factory.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/__init__.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py create mode 100644 tests/unit/sampletones_core/structures/tree/test_factory.py diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 40b84ed2..11824e23 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -13,6 +13,7 @@ NodeType, Tree, TreeNode, + create_directory_node, ) from sampletones_shared.utils.system.system import System @@ -56,10 +57,9 @@ def _create_directory_node( directory_path: Path, parent: Optional[TreeNode] = None, ) -> FileSystemNode: - node = FileSystemNode( + node = create_directory_node( + directory_path, name=directory_path.name or str(directory_path), - filepath=directory_path, - node_type=NodeType.DIRECTORY, parent=parent, ) @@ -93,10 +93,9 @@ def _load_directory_children( if entry_path.name.startswith("."): continue - child_node = FileSystemNode( + child_node = create_directory_node( + entry_path, name=entry_path.name, - filepath=entry_path, - node_type=NodeType.DIRECTORY, parent=directory_node, ) if level < self.depth: diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index 86772f85..6ff47441 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -14,10 +14,12 @@ from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, NodeType, Tree, TreeNode, + create_directory_node, ) @@ -89,10 +91,9 @@ def _build_tree( if child_node is not None: children_nodes.append(child_node) - directory_node = FileSystemNode( - path.name, - filepath=path, - node_type=NodeType.DIRECTORY, + directory_node = create_directory_node( + path, + name=path.name, parent=parent, ) for child_node in children_nodes: @@ -111,28 +112,23 @@ def _organize_top_level_config_directories( flat friendly naming for the config directories nested inside it. """ for child in list(reconstructions_node.children): - if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: - continue - - fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) - if fields is None: - self._assign_directory_display_names(child) - continue - - self._attach_config_directory_under_groups( - child, - fields, - reconstructions_node, - ) + match child: + case ConfigNode() if child.node_type == NodeType.DIRECTORY: + self._attach_config_directory_under_groups( + child, + reconstructions_node, + ) + case FileSystemNode() if child.node_type == NodeType.DIRECTORY: + self._assign_directory_display_names(child) self._disambiguate_generator_siblings(reconstructions_node) def _attach_config_directory_under_groups( self, - directory_node: FileSystemNode, - fields: ConfigDirectoryFields, + directory_node: ConfigNode, reconstructions_node: TreeNode, ) -> None: + fields = directory_node.config frequencies_name = DISPLAY_SEPARATOR.join( [ format_sample_rate(fields.sr), @@ -171,9 +167,9 @@ def _find_or_create_group_node( def _disambiguate_generator_siblings(self, node: TreeNode) -> None: """Appends a short config hash to generator leaves that share a name under one method group.""" if node.node_type == NodeType.GROUP: - by_name: Dict[str, List[FileSystemNode]] = {} + by_name: Dict[str, List[ConfigNode]] = {} for child in node.children: - if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY: + if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY: by_name.setdefault(child.name, []).append(child) for name, members in by_name.items(): @@ -181,9 +177,7 @@ def _disambiguate_generator_siblings(self, node: TreeNode) -> None: continue for directory_node in members: - fields = ConfigDirectoryFields.from_directory_name(directory_node.filepath.name) - if fields is not None: - directory_node.name = disambiguated_display_name(name, fields.ch) + directory_node.name = disambiguated_display_name(name, directory_node.config.ch) for child in node.children: self._disambiguate_generator_siblings(child) @@ -200,25 +194,20 @@ def _assign_directory_display_names(self, node: TreeNode) -> None: self._assign_directory_display_names(child) def _rename_config_directory_children(self, node: TreeNode) -> None: - groups: Dict[str, List[Tuple[FileSystemNode, ConfigDirectoryFields]]] = {} + groups: Dict[str, List[ConfigNode]] = {} for child in node.children: - if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: + if not isinstance(child, ConfigNode) or child.node_type != NodeType.DIRECTORY: continue - fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) - if fields is None: - continue - - groups.setdefault(fields.display_name, []).append((child, fields)) + groups.setdefault(child.config.display_name, []).append(child) for display_name, members in groups.items(): if len(members) == 1: - directory_node, _ = members[0] - directory_node.name = display_name + members[0].name = display_name continue - for directory_node, fields in members: - directory_node.name = disambiguated_display_name(display_name, fields.ch) + for directory_node in members: + directory_node.name = disambiguated_display_name(display_name, directory_node.config.ch) def _build_samples_children(self, samples_node: TreeNode) -> None: """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" @@ -258,10 +247,11 @@ def _append_config_variants( for display_name, members in variants_by_display_name.items(): for fields, reconstruction_path in members: label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) - FileSystemNode( + ConfigNode( label, - filepath=reconstruction_path, node_type=NodeType.FILE, + filepath=reconstruction_path, + config=fields, parent=audio_node, ) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 18b37a05..110ef731 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -74,6 +74,7 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, LibraryNode, NodeType, @@ -545,8 +546,8 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: match node: case LibraryNode(): return self._library_detail_items(node.library_key) - case FileSystemNode() if node.node_type == NodeType.DIRECTORY: - return self._reconstruction_detail_items(node.filepath.name) + case ConfigNode(): + return self._reconstruction_detail_items(node.config) return [] @@ -561,11 +562,7 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s (self._lbl_detail_configuration, short_hash(key.config_hash)), ] - def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, str]]: - fields = ConfigDirectoryFields.from_directory_name(directory_name) - if fields is None: - return [] - + def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tuple[str, str]]: generators = ", ".join(generator.capitalized for generator in fields.generators) return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py index 1b5a2902..3c1dacf6 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -1,11 +1,13 @@ from .arguments import Arguments -from .node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode +from .factory import create_directory_node +from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode from .traversal import TreeTraversal, traverse from .tree import Tree from .type import NodeType __all__ = [ "Arguments", + "ConfigNode", "FileSystemNode", "GeneratorNode", "LibraryNode", @@ -13,5 +15,6 @@ "Tree", "TreeNode", "TreeTraversal", + "create_directory_node", "traverse", ] diff --git a/src/sampletones_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py new file mode 100644 index 00000000..5ab2d664 --- /dev/null +++ b/src/sampletones_core/structures/tree/factory.py @@ -0,0 +1,38 @@ +from pathlib import Path +from typing import Optional + +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields + +from .node import ConfigNode, FileSystemNode, TreeNode +from .type import NodeType + + +def create_directory_node( + directory: Path, + *, + name: str, + parent: Optional[TreeNode], +) -> FileSystemNode: + """Builds the directory node that fits the folder, reading its configuration where it names one. + + A folder whose name parses as a reconstruction configuration directory becomes a + :class:`ConfigNode` carrying those fields; every other folder becomes a plain + :class:`FileSystemNode`. Routing every directory through here keeps the decision of which node + class carries a configuration in one place. + """ + config = ConfigDirectoryFields.from_directory_name(directory.name) + if config is None: + return FileSystemNode( + name, + node_type=NodeType.DIRECTORY, + filepath=directory, + parent=parent, + ) + + return ConfigNode( + name, + node_type=NodeType.DIRECTORY, + filepath=directory, + config=config, + parent=parent, + ) diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index 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/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index 54438f22..ac27ef6a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -7,7 +7,13 @@ import pytest from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) HASH_A = "6edf7c948606917a78b45d153c7ca7e0" HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -338,6 +344,60 @@ def test_non_config_directory_is_excluded_from_samples( assert "my_songs" in directory_nodes(browser_manager) +class TestBrowserManagerConfigNodes: + def test_config_directory_carries_its_parsed_configuration( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir = tmp_path / directory_name + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) + directory_node = directory_children(methods["FFT·γ0"])["PTN"] + assert isinstance(directory_node, ConfigNode) + assert directory_node.config == ConfigDirectoryFields.from_directory_name(directory_name) + + def test_sample_variant_carries_the_configuration_of_its_directory( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """A leaf in the sample view states the configuration its directory names. + + Its own filename is the audio name, so the configuration reaches the tooltip and the + configuration font from the node rather than from the path. + """ + directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir = tmp_path / directory_name + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + variant = next(iter(file_children(audio).values())) + assert isinstance(variant, ConfigNode) + assert variant.config == ConfigDirectoryFields.from_directory_name(directory_name) + + def test_plain_directory_carries_no_configuration( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + plain = tmp_path / "my_songs" + plain.mkdir() + (plain / "song.stn").touch() + + browser_manager.refresh_tree() + + assert not isinstance(directory_nodes(browser_manager)["my_songs"], ConfigNode) + + class TestBrowserManagerSetDirectory: def test_set_reconstructions_directory_updates_directory( self, diff --git a/tests/unit/sampletones_application/ui/elements/tree/__init__.py b/tests/unit/sampletones_application/ui/elements/tree/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py new file mode 100644 index 00000000..d7461250 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.configs import Config +from sampletones_core.configs.display import format_sample_rate, short_hash +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode +from sampletones_core.structures.tree.type import NodeType +from tests.suite.language import FakeLanguageManager + +CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions") / CONFIG_FIELDS.directory_name +RECONSTRUCTION_PATH: Final[Path] = CONFIG_DIRECTORY / f"song{EXT_FILE_RECONSTRUCTION}" + +DETAIL_LABELS: Final[List[str]] = [ + "sample_rate", + "nes_frequency", + "spectrum_method", + "transformation_gamma", + "window_size", + "generators", + "configuration", +] + + +@pytest.fixture +def panel() -> GUISequencerBrowserPanel: + """Builds a browser panel without its DearPyGui-dependent constructor. + + Resolving a node's detail items reads only the language-resolved detail labels, so the pieces + the constructor would build around a running GUI context are unnecessary here. A concrete + browser stands in for the base because the configuration font is a browser-level opt-in. + """ + instance = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + instance._language_manager = FakeLanguageManager() + for label in DETAIL_LABELS: + setattr(instance, f"_lbl_detail_{label}", label) + + return instance + + +def config_directory_node() -> ConfigNode: + return ConfigNode( + CONFIG_FIELDS.gn, + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + config=CONFIG_FIELDS, + ) + + +def config_variant_node() -> ConfigNode: + return ConfigNode( + CONFIG_FIELDS.display_name, + node_type=NodeType.FILE, + filepath=RECONSTRUCTION_PATH, + config=CONFIG_FIELDS, + ) + + +class TestConfigDetailItems: + def test_config_directory_states_its_configuration( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + items = dict(panel._node_detail_items(config_directory_node())) + assert items["sample_rate"] == format_sample_rate(CONFIG_FIELDS.sr) + assert items["configuration"] == short_hash(CONFIG_FIELDS.ch) + + def test_config_variant_leaf_states_the_same_configuration( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A reconstruction listed by its configuration answers with that configuration. + + In the sample view a leaf carries the configuration its directory names, which its own + filename says nothing about. + """ + assert panel._node_detail_items(config_variant_node()) == panel._node_detail_items(config_directory_node()) + + def test_config_variant_leaf_reads_in_the_configuration_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._resolve_node_name_font(config_variant_node()) == Font.MONO_SMALL + + def test_plain_directory_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + node = FileSystemNode( + "my_songs", + node_type=NodeType.DIRECTORY, + filepath=Path("/reconstructions/my_songs"), + ) + assert panel._node_detail_items(node) == [] + assert panel._resolve_node_name_font(node) == Font.REGULAR_SMALL + + def test_group_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py new file mode 100644 index 00000000..21af2ec2 --- /dev/null +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree.factory import create_directory_node +from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode +from sampletones_core.structures.tree.type import NodeType + +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) +RECONSTRUCTIONS_DIRECTORY = Path("/reconstructions") + + +class TestCreateDirectoryNode: + def test_config_directory_becomes_a_config_node(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name + node = create_directory_node(directory, name=directory.name, parent=None) + assert isinstance(node, ConfigNode) + assert node.config == CONFIG_FIELDS + + def test_plain_directory_becomes_a_file_system_node(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / "my_songs" + node = create_directory_node(directory, name=directory.name, parent=None) + assert isinstance(node, FileSystemNode) + assert not isinstance(node, ConfigNode) + + def test_node_carries_the_given_name_and_path(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name + node = create_directory_node(directory, name="friendly", parent=None) + assert node.name == "friendly" + assert node.filepath == directory + assert node.node_type == NodeType.DIRECTORY + + def test_node_attaches_to_the_given_parent(self) -> None: + parent = TreeNode("root", NodeType.ROOT) + node = create_directory_node( + RECONSTRUCTIONS_DIRECTORY / "my_songs", + name="my_songs", + parent=parent, + ) + assert node.parent is parent diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index d9da8d53..2f91cc30 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -3,7 +3,9 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( + ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, @@ -12,6 +14,7 @@ from sampletones_core.structures.tree.type import NodeType LIBRARY_KEY = InstructionLibraryKey.from_config(Config()) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) class TestTreeNode: @@ -61,6 +64,43 @@ def test_copy_preserves_filepath_and_type(self) -> None: assert copied.node_type == NodeType.FILE +class TestConfigNode: + def test_config_is_stored(self) -> None: + node = ConfigNode( + "config", + NodeType.DIRECTORY, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name, + config=CONFIG_FIELDS, + ) + assert node.config == CONFIG_FIELDS + + def test_config_survives_a_filename_of_its_own(self) -> None: + node = ConfigNode( + "variant", + NodeType.FILE, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name / "song.stn", + config=CONFIG_FIELDS, + ) + assert node.config == CONFIG_FIELDS + + def test_copy_preserves_config_filepath_and_type(self) -> None: + path = Path("/reconstructions") / CONFIG_FIELDS.directory_name + node = ConfigNode("config", NodeType.DIRECTORY, filepath=path, config=CONFIG_FIELDS) + copied = node.copy() + assert copied.config == CONFIG_FIELDS + assert copied.filepath == path + assert copied.node_type == NodeType.DIRECTORY + + def test_node_is_a_file_system_node(self) -> None: + node = ConfigNode( + "config", + NodeType.DIRECTORY, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name, + config=CONFIG_FIELDS, + ) + assert isinstance(node, FileSystemNode) + + class TestLibraryNode: def test_library_key_is_stored(self) -> None: node = LibraryNode("lib", library_key=LIBRARY_KEY) From 155dbd8debb1b479b5ca68899b7832500cc96f9e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 09:37:17 +0200 Subject: [PATCH 07/45] Extracted: the unique sibling label rule --- .../logic/reconstruction/browser_manager.py | 72 +++++++++---------- src/sampletones_core/configs/display.py | 17 ++++- .../sampletones_core/configs/test_display.py | 46 ++++++++++++ 3 files changed, 94 insertions(+), 41 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index 6ff47441..860eebe4 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -1,15 +1,15 @@ from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, GAMMA_PREFIX, - disambiguated_display_name, format_nes_frequency, format_sample_rate, format_spectrum_method, + unique_display_names, ) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -165,19 +165,11 @@ def _find_or_create_group_node( return TreeNode(name, node_type=NodeType.GROUP, parent=parent) def _disambiguate_generator_siblings(self, node: TreeNode) -> None: - """Appends a short config hash to generator leaves that share a name under one method group.""" + """Appends a short config hash to generator directories sharing a name under one method group.""" if node.node_type == NodeType.GROUP: - by_name: Dict[str, List[ConfigNode]] = {} - for child in node.children: - if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY: - by_name.setdefault(child.name, []).append(child) - - for name, members in by_name.items(): - if len(members) <= 1: - continue - - for directory_node in members: - directory_node.name = disambiguated_display_name(name, directory_node.config.ch) + self._rename_config_directories( + [(directory_node, directory_node.config.gn) for directory_node in self._config_directory_children(node)] + ) for child in node.children: self._disambiguate_generator_siblings(child) @@ -194,20 +186,25 @@ def _assign_directory_display_names(self, node: TreeNode) -> None: self._assign_directory_display_names(child) def _rename_config_directory_children(self, node: TreeNode) -> None: - groups: Dict[str, List[ConfigNode]] = {} - for child in node.children: - if not isinstance(child, ConfigNode) or child.node_type != NodeType.DIRECTORY: - continue - - groups.setdefault(child.config.display_name, []).append(child) + self._rename_config_directories( + [ + (directory_node, directory_node.config.display_name) + for directory_node in self._config_directory_children(node) + ] + ) - for display_name, members in groups.items(): - if len(members) == 1: - members[0].name = display_name - continue + @staticmethod + def _config_directory_children(node: TreeNode) -> List[ConfigNode]: + return [ + child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY + ] - for directory_node in members: - directory_node.name = disambiguated_display_name(display_name, directory_node.config.ch) + @staticmethod + def _rename_config_directories(entries: Sequence[Tuple[ConfigNode, str]]) -> None: + """Names each configuration directory, marking those a sibling would otherwise shadow.""" + labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in entries]) + for (directory_node, _), label in zip(entries, labels): + directory_node.name = label def _build_samples_children(self, samples_node: TreeNode) -> None: """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" @@ -240,20 +237,15 @@ def _append_config_variants( audio_node: TreeNode, variants: List[Tuple[ConfigDirectoryFields, Path]], ) -> None: - variants_by_display_name: Dict[str, List[Tuple[ConfigDirectoryFields, Path]]] = {} - for fields, reconstruction_path in variants: - variants_by_display_name.setdefault(fields.display_name, []).append((fields, reconstruction_path)) - - for display_name, members in variants_by_display_name.items(): - for fields, reconstruction_path in members: - label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) - ConfigNode( - label, - node_type=NodeType.FILE, - filepath=reconstruction_path, - config=fields, - parent=audio_node, - ) + labels = unique_display_names([(fields.display_name, fields.ch) for fields, _ in variants]) + for (fields, reconstruction_path), label in zip(variants, labels): + ConfigNode( + label, + node_type=NodeType.FILE, + filepath=reconstruction_path, + config=fields, + parent=audio_node, + ) def get_all_reconstruction_files(self) -> List[Path]: file_paths = { diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index e5b5ab78..02bcb9bf 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,4 +1,5 @@ -from typing import Dict, Final +from collections import Counter +from typing import Dict, Final, Sequence, Tuple from sampletones_core.constants.enums import SpectrumMethod from sampletones_shared.constants.symbols import HASH @@ -45,3 +46,17 @@ def short_hash(config_hash: str) -> str: def disambiguated_display_name(name: str, config_hash: str) -> str: """Appends the short config hash, marked with ``#``, so colliding names stay distinct.""" return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}" + + +def unique_display_names(entries: Sequence[Tuple[str, str]]) -> Tuple[str, ...]: + """Answers labels that tell one group of siblings apart, given ``(name, config hash)`` pairs. + + A name held by a single entry stands as it is. A name shared by several entries takes the short + config hash on every one of them, so each sibling states the configuration that distinguishes + it. The answer is index-aligned with ``entries``. + """ + occurrences = Counter(name for name, _ in entries) + return tuple( + name if occurrences[name] == 1 else disambiguated_display_name(name, config_hash) + for name, config_hash in entries + ) diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index 29b35d48..365c70ce 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -1,10 +1,15 @@ +from typing import List, Tuple + import pytest from sampletones_core.configs.display import ( DISPLAY_HASH_LENGTH, + DISPLAY_SEPARATOR, + disambiguated_display_name, format_nes_frequency, format_sample_rate, short_hash, + unique_display_names, ) @@ -33,3 +38,44 @@ def test_truncates_to_display_length(self) -> None: full = "6edf7c948606917a78b45d153c7ca7e0" assert short_hash(full) == full[:DISPLAY_HASH_LENGTH] assert len(short_hash(full)) == DISPLAY_HASH_LENGTH + + +class TestUniqueDisplayNames: + @pytest.mark.parametrize( + "entries, expected", + [ + ([], []), + ([("PTN", "aaaa1111")], ["PTN"]), + ([("PTN", "aaaa1111"), ("PN", "bbbb2222")], ["PTN", "PN"]), + ( + [("PTN", "aaaa1111"), ("PTN", "bbbb2222")], + [ + disambiguated_display_name("PTN", "aaaa1111"), + disambiguated_display_name("PTN", "bbbb2222"), + ], + ), + ( + [("PTN", "aaaa1111"), ("PN", "bbbb2222"), ("PTN", "cccc3333")], + [ + disambiguated_display_name("PTN", "aaaa1111"), + "PN", + disambiguated_display_name("PTN", "cccc3333"), + ], + ), + ], + ) + def test_marks_only_the_shared_names( + self, + entries: List[Tuple[str, str]], + expected: List[str], + ) -> None: + assert unique_display_names(entries) == tuple(expected) + + def test_keeps_the_given_order(self) -> None: + entries = [("second", "aaaa1111"), ("first", "bbbb2222"), ("second", "cccc3333")] + names = unique_display_names(entries) + assert [name.split(DISPLAY_SEPARATOR)[0] for name in names] == ["second", "first", "second"] + + def test_names_stay_distinct(self) -> None: + entries = [("PTN", "aaaa1111"), ("PTN", "bbbb2222"), ("PTN", "cccc3333")] + assert len(set(unique_display_names(entries))) == len(entries) From 7966af31c7f93e79b217372e934f8a5f9689c9d1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 11:09:42 +0200 Subject: [PATCH 08/45] Refactored: reconstruction browser tree --- src/sampletones_application/application.py | 2 +- .../coordinators/tabs/reconstruction.py | 4 +- .../coordinators/tabs/sequencer.py | 2 +- .../logic/main/explorer_manager.py | 3 + .../logic/reconstruction/browser/__init__.py | 0 .../{browser.py => browser/logic.py} | 2 +- .../logic/reconstruction/browser/manager.py | 73 +++ .../reconstruction/browser/tree/__init__.py | 0 .../browser/tree/configurations.py | 151 ++++++ .../browser/tree/entries/__init__.py | 0 .../browser/tree/entries/directory.py | 29 ++ .../browser/tree/entries/reconstruction.py | 13 + .../browser/tree/entries/scan.py | 41 ++ .../reconstruction/browser/tree/group.py | 15 + .../browser/tree/samples/__init__.py | 0 .../browser/tree/samples/branch.py | 35 ++ .../browser/tree/samples/source.py | 14 + .../browser/tree/samples/variant.py | 14 + .../browser/tree/samples/variants.py | 50 ++ .../logic/reconstruction/browser/tree/scan.py | 49 ++ .../logic/reconstruction/browser_manager.py | 256 ---------- .../logic/sequencer/browser.py | 2 +- .../structures/tree/factory.py | 13 +- .../logic/reconstruction/browser/__init__.py | 0 .../logic/reconstruction/browser/conftest.py | 134 ++++++ .../browser/test_configurations.py | 164 +++++++ .../reconstruction/browser/test_manager.py | 167 +++++++ .../reconstruction/browser/test_samples.py | 113 +++++ .../logic/reconstruction/browser/test_scan.py | 110 +++++ .../reconstruction/test_browser_manager.py | 437 ------------------ .../structures/tree/test_factory.py | 26 +- 31 files changed, 1209 insertions(+), 710 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/__init__.py rename src/sampletones_application/logic/reconstruction/{browser.py => browser/logic.py} (93%) create mode 100644 src/sampletones_application/logic/reconstruction/browser/manager.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/group.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/scan.py delete mode 100644 src/sampletones_application/logic/reconstruction/browser_manager.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py delete mode 100644 tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ede3f176..680c8536 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -46,7 +46,7 @@ ReconstructionTitlePart, document_title, ) -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 5053f44c..9d343ade 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -15,8 +15,8 @@ from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol -from sampletones_application.logic.reconstruction.browser import BrowserLogic -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.instruments import ( OnReconstructionInstrumentUpdatedCallback, ReconstructionInstrumentsLogic, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 5b7f7f4e..ea2b13b8 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -19,7 +19,7 @@ from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.history.transaction import CoalesceKey from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_application.logic.sequencer.clipboard import ( diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 11824e23..5a66fe40 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -8,6 +8,7 @@ EXT_FILE_RECONSTRUCTION, EXT_FILES_AUDIO, ) +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -60,6 +61,7 @@ def _create_directory_node( node = create_directory_node( directory_path, name=directory_path.name or str(directory_path), + config=ConfigDirectoryFields.from_directory_name(directory_path.name), parent=parent, ) @@ -96,6 +98,7 @@ def _load_directory_children( child_node = create_directory_node( entry_path, name=entry_path.name, + config=ConfigDirectoryFields.from_directory_name(entry_path.name), parent=directory_node, ) if level < self.depth: diff --git a/src/sampletones_application/logic/reconstruction/browser/__init__.py b/src/sampletones_application/logic/reconstruction/browser/__init__.py new file mode 100644 index 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..3844efa6 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -0,0 +1,73 @@ +from pathlib import Path +from typing import List + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.tree.configurations import ( + build_configuration_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( + build_sample_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.scan import ( + scan_reconstructions, +) +from sampletones_core.structures.tree import NodeType, Tree, TreeNode + + +class BrowserManager: + """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. + + A refresh scans the directory, builds the configuration branch and the sample branch from that + one reading, and publishes the result as the tree both browser tabs render. + """ + + def __init__( + self, + config_manager: ConfigManager, + *, + language_manager: LanguageManager, + ) -> None: + self._language_manager = language_manager + self.config_manager = config_manager + self.reconstructions_directory = config_manager.get_reconstructions_directory() + + self.tree = Tree() + self._scan = ReconstructionScan(entries=()) + + def set_reconstructions_directory(self, directory: Path) -> None: + self.reconstructions_directory = directory + self.refresh_tree() + + def refresh_tree(self) -> None: + if not self.reconstructions_directory.is_dir(): + self._scan = ReconstructionScan(entries=()) + self.tree.set_root(None) + return + + self._scan = scan_reconstructions(self.reconstructions_directory) + self.tree.set_root(self._build_root(self._scan)) + + def _build_root(self, scan: ReconstructionScan) -> TreeNode: + container_root = TreeNode( + name=self._language_manager["global.browser.label.root"], + node_type=NodeType.ROOT, + ) + build_configuration_branch( + scan, + name=self._language_manager["global.browser.label.reconstructions"], + parent=container_root, + ) + build_sample_branch( + scan, + name=self._language_manager["global.browser.label.samples"], + parent=container_root, + ) + + return container_root + + def get_all_reconstruction_files(self) -> List[Path]: + return sorted({entry.path for entry in self._scan.reconstructions}) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py new file mode 100644 index 00000000..f38d85ad --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py @@ -0,0 +1,151 @@ +from typing import List, Sequence, Tuple + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.group import ( + find_or_create_group, +) +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, + unique_display_names, +) +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, + create_directory_node, +) + + +def build_configuration_branch( + scan: ReconstructionScan, + *, + name: str, + parent: TreeNode, +) -> TreeNode: + """Builds the branch listing reconstructions by the configuration that produced them. + + The scanned folders appear as they sit on disk, and a top-level configuration directory is then + lifted under frequency ▶ method groups and named by its generators, so configurations sharing a + spectrum read side by side. A configuration directory nested inside a plain folder keeps its + friendly name in place, and a reconstruction outside every configuration directory is listed + here, this being the branch that follows the disk. + """ + branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) + for entry in scan.entries: + _append_entry(entry, parent=branch) + + _organize_top_level_config_directories(branch) + return branch + + +def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None: + match entry: + case ReconstructionEntry(): + FileSystemNode( + entry.name, + node_type=NodeType.FILE, + filepath=entry.path, + parent=parent, + ) + case DirectoryEntry(): + directory_node = create_directory_node( + entry.path, + name=entry.name, + config=entry.config, + parent=parent, + ) + for child_entry in entry.entries: + _append_entry(child_entry, parent=directory_node) + + +def _organize_top_level_config_directories(branch: TreeNode) -> None: + """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. + + A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is + renamed to its generator abbreviation, while any other top-level folder keeps the existing + flat friendly naming for the config directories nested inside it. + """ + for child in list(branch.children): + match child: + case ConfigNode() if child.node_type == NodeType.DIRECTORY: + _attach_config_directory_under_groups(child, branch) + case FileSystemNode() if child.node_type == NodeType.DIRECTORY: + _assign_directory_display_names(child) + + _disambiguate_generator_siblings(branch) + + +def _attach_config_directory_under_groups( + directory_node: ConfigNode, + branch: TreeNode, +) -> None: + fields = directory_node.config + frequencies_name = DISPLAY_SEPARATOR.join( + [ + format_sample_rate(fields.sr), + format_nes_frequency(fields.nf), + ] + ) + method_name = DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(fields.sm), + f"{GAMMA_PREFIX}{fields.tg}", + ] + ) + frequencies_node = find_or_create_group(frequencies_name, parent=branch) + method_node = find_or_create_group(method_name, parent=frequencies_node) + + directory_node.name = fields.gn + directory_node.parent = method_node + + +def _disambiguate_generator_siblings(node: TreeNode) -> None: + """Appends a short config hash to generator directories sharing a name under one method group.""" + if node.node_type == NodeType.GROUP: + _rename_config_directories( + [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)] + ) + + for child in node.children: + _disambiguate_generator_siblings(child) + + +def _assign_directory_display_names(node: TreeNode) -> None: + """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. + + Only directories whose names parse as reconstruction config directories are rewritten; + plain folders keep their on-disk name. The check is scoped per parent because duplicate + display names among siblings would otherwise collapse to duplicate widget tags downstream. + """ + _rename_config_directories( + [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)] + ) + for child in node.children: + _assign_directory_display_names(child) + + +def _config_directory_children(node: TreeNode) -> List[ConfigNode]: + return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY] + + +def _rename_config_directories( + proposed_names: Sequence[Tuple[ConfigNode, str]], +) -> None: + """Names each configuration directory, marking those a sibling would otherwise shadow.""" + labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names]) + for (directory_node, _), label in zip(proposed_names, labels): + directory_node.name = label diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py new file mode 100644 index 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..582e6e86 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Tuple + +from sampletones_core.reconstructions.converter.paths.fields import ( + ConfigDirectoryFields, +) + +if TYPE_CHECKING: + from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ScanEntry, + ) + + +@dataclass(frozen=True) +class DirectoryEntry: + """A folder a scan met, holding the configuration its name states and the entries inside it. + + A folder whose name encodes a reconstruction configuration carries those fields, read once here, + so every branch builder states the configuration from the record it already has. + """ + + path: Path + config: Optional[ConfigDirectoryFields] + entries: Tuple["ScanEntry", ...] + + @property + def name(self) -> str: + return self.path.name diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py new file mode 100644 index 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..2cc3edc5 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import List, Sequence, Tuple, TypeAlias, Union + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) + +ScanEntry: TypeAlias = Union[DirectoryEntry, ReconstructionEntry] + + +@dataclass(frozen=True) +class ReconstructionScan: + """One reading of a reconstructions directory, shared by every browser branch. + + Both branches describe the same disk because both describe this record: the configuration view + follows the entries as they sit, and the sample view regroups them by the audio they came from. + """ + + entries: Tuple[ScanEntry, ...] + + @property + def reconstructions(self) -> Tuple[ReconstructionEntry, ...]: + return self.collect_reconstructions(self.entries) + + @staticmethod + def collect_reconstructions( + entries: Sequence[ScanEntry], + ) -> Tuple[ReconstructionEntry, ...]: + """Flattens scanned entries into the reconstructions they hold, in the order the scan met them.""" + collected: List[ReconstructionEntry] = [] + for entry in entries: + match entry: + case ReconstructionEntry(): + collected.append(entry) + case DirectoryEntry(): + collected.extend(ReconstructionScan.collect_reconstructions(entry.entries)) + + return tuple(collected) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/group.py b/src/sampletones_application/logic/reconstruction/browser/tree/group.py new file mode 100644 index 00000000..7b6557ce --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/group.py @@ -0,0 +1,15 @@ +from sampletones_core.structures.tree import NodeType, TreeNode + + +def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the group of this name under ``parent``, adding one where the parent holds none. + + A group stands for something the disk states rather than holds — a frequency pair, a spectrum + method, a source folder — so it is identified by its name and a builder meeting that name again + extends the group it already made. + """ + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: + return child + + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py new file mode 100644 index 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..8baec53d --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py @@ -0,0 +1,35 @@ +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.group import ( + find_or_create_group, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.variants import ( + append_variants, + collect_variants, +) +from sampletones_core.structures.tree import NodeType, TreeNode + + +def build_sample_branch( + scan: ReconstructionScan, + *, + name: str, + parent: TreeNode, +) -> TreeNode: + """Builds the branch listing each source audio with the configurations that reconstructed it. + + Every top-level configuration directory contributes its reconstructions under the source folders + they mirror, so one audio gathers its variants and each variant is labelled by its configuration. + """ + branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) + variants_by_source = collect_variants(scan) + for source in sorted(variants_by_source): + source_node = branch + for part in source.directory_parts: + source_node = find_or_create_group(part, parent=source_node) + + audio_node = find_or_create_group(source.name, parent=source_node) + append_variants(audio_node, variants_by_source[source]) + + return branch diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py new file mode 100644 index 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..fcc7133c --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields + + +def scan_reconstructions(directory: Path) -> ReconstructionScan: + """Reads a reconstructions directory once, recording its folders and the reconstructions inside. + + Every folder is recorded together with the configuration its name states, and every + reconstruction file beneath it. This single reading feeds both browser branches, so the two + views agree on what is on disk. + """ + return ReconstructionScan(entries=_scan_entries(directory)) + + +def _scan_entries(directory: Path) -> Tuple[ScanEntry, ...]: + entries: List[ScanEntry] = [] + for path in sorted(directory.iterdir()): + entry = _scan_path(path) + if entry is not None: + entries.append(entry) + + return tuple(entries) + + +def _scan_path(path: Path) -> Optional[ScanEntry]: + if path.is_dir(): + return DirectoryEntry( + path=path, + config=ConfigDirectoryFields.from_directory_name(path.name), + entries=_scan_entries(path), + ) + + if path.suffix == EXT_FILE_RECONSTRUCTION: + return ReconstructionEntry(path=path) + + return None diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py deleted file mode 100644 index 860eebe4..00000000 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ /dev/null @@ -1,256 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, - unique_display_names, -) -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION -from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - Tree, - TreeNode, - create_directory_node, -) - - -class BrowserManager: - def __init__( - self, - config_manager: ConfigManager, - *, - language_manager: LanguageManager, - ) -> None: - self._language_manager = language_manager - self.config_manager = config_manager - self.reconstructions_directory = config_manager.get_reconstructions_directory() - - self.tree = Tree() - - def set_reconstructions_directory(self, directory: Path) -> None: - self.reconstructions_directory = directory - self.refresh_tree() - - def refresh_tree(self) -> None: - if not self.reconstructions_directory.exists() or not self.reconstructions_directory.is_dir(): - self.tree.set_root(None) - return - - container_root = TreeNode( - name=self._language_manager["global.browser.label.root"], - node_type=NodeType.ROOT, - ) - reconstructions_node = TreeNode( - name=self._language_manager["global.browser.label.reconstructions"], - node_type=NodeType.GROUP, - parent=container_root, - ) - samples_node = TreeNode( - name=self._language_manager["global.browser.label.samples"], - node_type=NodeType.GROUP, - parent=container_root, - ) - - for path in sorted(self.reconstructions_directory.iterdir()): - self._build_tree(path, parent=reconstructions_node) - - self._organize_top_level_config_directories(reconstructions_node) - self._build_samples_children(samples_node) - self.tree.set_root(container_root) - - def _build_tree( - self, - path: Path, - parent: Optional[TreeNode] = None, - ) -> Optional[FileSystemNode]: - if not path.exists(): - return None - - if path.is_file(): - if path.suffix == EXT_FILE_RECONSTRUCTION: - return FileSystemNode( - path.stem, - filepath=path, - node_type=NodeType.FILE, - parent=parent, - ) - return None - - children_nodes = [] - for child_path in sorted(path.iterdir()): - child_node = self._build_tree(child_path, parent=parent) - if child_node is not None: - children_nodes.append(child_node) - - directory_node = create_directory_node( - path, - name=path.name, - parent=parent, - ) - for child_node in children_nodes: - child_node.parent = directory_node - - return directory_node - - def _organize_top_level_config_directories( - self, - reconstructions_node: TreeNode, - ) -> None: - """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. - - A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is - renamed to its generator abbreviation, while any other top-level folder keeps the existing - flat friendly naming for the config directories nested inside it. - """ - for child in list(reconstructions_node.children): - match child: - case ConfigNode() if child.node_type == NodeType.DIRECTORY: - self._attach_config_directory_under_groups( - child, - reconstructions_node, - ) - case FileSystemNode() if child.node_type == NodeType.DIRECTORY: - self._assign_directory_display_names(child) - - self._disambiguate_generator_siblings(reconstructions_node) - - def _attach_config_directory_under_groups( - self, - directory_node: ConfigNode, - reconstructions_node: TreeNode, - ) -> None: - fields = directory_node.config - frequencies_name = DISPLAY_SEPARATOR.join( - [ - format_sample_rate(fields.sr), - format_nes_frequency(fields.nf), - ] - ) - method_name = DISPLAY_SEPARATOR.join( - [ - format_spectrum_method(fields.sm), - f"{GAMMA_PREFIX}{fields.tg}", - ] - ) - frequencies_node = self._find_or_create_group_node( - frequencies_name, - reconstructions_node, - ) - method_node = self._find_or_create_group_node( - method_name, - frequencies_node, - ) - - directory_node.name = fields.gn - directory_node.parent = method_node - - def _find_or_create_group_node( - self, - name: str, - parent: TreeNode, - ) -> TreeNode: - for child in parent.children: - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: - return child - - return TreeNode(name, node_type=NodeType.GROUP, parent=parent) - - def _disambiguate_generator_siblings(self, node: TreeNode) -> None: - """Appends a short config hash to generator directories sharing a name under one method group.""" - if node.node_type == NodeType.GROUP: - self._rename_config_directories( - [(directory_node, directory_node.config.gn) for directory_node in self._config_directory_children(node)] - ) - - for child in node.children: - self._disambiguate_generator_siblings(child) - - def _assign_directory_display_names(self, node: TreeNode) -> None: - """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. - - Only directories whose names parse as reconstruction config directories are rewritten; - plain folders keep their on-disk name. The check is scoped per parent because duplicate - display names among siblings would otherwise collapse to duplicate widget tags downstream. - """ - self._rename_config_directory_children(node) - for child in node.children: - self._assign_directory_display_names(child) - - def _rename_config_directory_children(self, node: TreeNode) -> None: - self._rename_config_directories( - [ - (directory_node, directory_node.config.display_name) - for directory_node in self._config_directory_children(node) - ] - ) - - @staticmethod - def _config_directory_children(node: TreeNode) -> List[ConfigNode]: - return [ - child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY - ] - - @staticmethod - def _rename_config_directories(entries: Sequence[Tuple[ConfigNode, str]]) -> None: - """Names each configuration directory, marking those a sibling would otherwise shadow.""" - labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in entries]) - for (directory_node, _), label in zip(entries, labels): - directory_node.name = label - - def _build_samples_children(self, samples_node: TreeNode) -> None: - """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" - variants_by_audio: Dict[Tuple[Tuple[str, ...], str], List[Tuple[ConfigDirectoryFields, Path]]] = {} - - for config_directory in sorted(self.reconstructions_directory.iterdir()): - if not config_directory.is_dir(): - continue - - fields = ConfigDirectoryFields.from_directory_name(config_directory.name) - if fields is None: - continue - - for reconstruction_path in sorted(config_directory.rglob(f"*{EXT_FILE_RECONSTRUCTION}")): - relative = reconstruction_path.relative_to(config_directory) - audio_key = (relative.parent.parts, relative.stem) - variants_by_audio.setdefault(audio_key, []).append((fields, reconstruction_path)) - - for audio_key in sorted(variants_by_audio): - directory_parts, audio_name = audio_key - parent = samples_node - for part in directory_parts: - parent = self._find_or_create_group_node(part, parent) - - audio_node = self._find_or_create_group_node(audio_name, parent) - self._append_config_variants(audio_node, variants_by_audio[audio_key]) - - def _append_config_variants( - self, - audio_node: TreeNode, - variants: List[Tuple[ConfigDirectoryFields, Path]], - ) -> None: - labels = unique_display_names([(fields.display_name, fields.ch) for fields, _ in variants]) - for (fields, reconstruction_path), label in zip(variants, labels): - ConfigNode( - label, - node_type=NodeType.FILE, - filepath=reconstruction_path, - config=fields, - parent=audio_node, - ) - - def get_all_reconstruction_files(self) -> List[Path]: - file_paths = { - node.filepath - for node in self.tree.collect_leaves() - if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE - } - return sorted(file_paths) diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py index 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_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py index 5ab2d664..3b73a97f 100644 --- a/src/sampletones_core/structures/tree/factory.py +++ b/src/sampletones_core/structures/tree/factory.py @@ -11,16 +11,17 @@ def create_directory_node( directory: Path, *, name: str, + config: Optional[ConfigDirectoryFields], parent: Optional[TreeNode], ) -> FileSystemNode: - """Builds the directory node that fits the folder, reading its configuration where it names one. + """Builds the directory node that fits the folder, given the configuration its name states. - A folder whose name parses as a reconstruction configuration directory becomes a - :class:`ConfigNode` carrying those fields; every other folder becomes a plain - :class:`FileSystemNode`. Routing every directory through here keeps the decision of which node - class carries a configuration in one place. + A folder stating a reconstruction configuration becomes a :class:`ConfigNode` carrying those + fields; a folder stating none becomes a plain :class:`FileSystemNode`. The caller states the + fields it read with :meth:`ConfigDirectoryFields.from_directory_name`, so a caller that already + read them — a scan of a reconstructions directory — reads each folder name once, and the choice + of node class stays here. """ - config = ConfigDirectoryFields.from_directory_name(directory.name) if config is None: return FileSystemNode( name, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py b/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py new file mode 100644 index 00000000..a0b77584 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -0,0 +1,134 @@ +from pathlib import Path +from typing import Dict, Final +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + +HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" +HASH_B: Final[str] = "a1b2c3d4e5f60718293a4b5c6d7e8f90" + +RECONSTRUCTIONS: Final[Path] = Path("/reconstructions") +BRANCH_NAME: Final[str] = "branch" + +CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.reconstructions" +SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.samples" + + +def config_fields( + *, + sample_rate: int = 44100, + nes_frequency: int = 30, + spectrum_method: SpectrumMethod = SpectrumMethod.FFT, + transformation_gamma: int = 0, + generators: str = "PTN", + config_hash: str = HASH_A, +) -> ConfigDirectoryFields: + """Builds configuration fields, so a test states only the field whose effect it examines.""" + return ConfigDirectoryFields( + sr=sample_rate, + nf=nes_frequency, + sm=spectrum_method, + tg=transformation_gamma, + gn=generators, + ch=config_hash, + ) + + +def reconstruction_entry(directory: Path, *relative_parts: str) -> ReconstructionEntry: + return ReconstructionEntry(path=directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION)) + + +def config_entry(fields: ConfigDirectoryFields, *audio_names: str) -> DirectoryEntry: + """Records a configuration directory holding one reconstruction per stated audio name.""" + directory = RECONSTRUCTIONS / fields.directory_name + return DirectoryEntry( + path=directory, + config=fields, + entries=tuple(reconstruction_entry(directory, name) for name in audio_names), + ) + + +def plain_entry(name: str, *entries: ScanEntry) -> DirectoryEntry: + """Records a folder whose name states no configuration.""" + return DirectoryEntry(path=RECONSTRUCTIONS / name, config=None, entries=entries) + + +def scan_of(*entries: ScanEntry) -> ReconstructionScan: + return ReconstructionScan(entries=entries) + + +def config_directory(root: Path, fields: ConfigDirectoryFields) -> Path: + directory = root / fields.directory_name + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def write_reconstruction(directory: Path, *relative_parts: str) -> Path: + """Creates an empty reconstruction file at the stated place, with the folders leading to it.""" + path = directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY + } + + +def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE + } + + +def group_children(node: TreeNode) -> Dict[str, TreeNode]: + return {child.name: child for child in node.children if child.node_type == NodeType.GROUP} + + +def branch_of(browser_manager: BrowserManager, key: str) -> TreeNode: + root = browser_manager.tree.get_root() + assert root is not None + return group_children(root)[key] + + +def configuration_branch(browser_manager: BrowserManager) -> TreeNode: + return branch_of(browser_manager, CONFIGURATION_BRANCH_KEY) + + +def sample_branch(browser_manager: BrowserManager) -> TreeNode: + return branch_of(browser_manager, SAMPLE_BRANCH_KEY) + + +@pytest.fixture +def config_manager(tmp_path: Path) -> MagicMock: + mock = MagicMock() + mock.get_reconstructions_directory.return_value = tmp_path + return mock + + +@pytest.fixture +def browser_manager(config_manager: MagicMock) -> BrowserManager: + return BrowserManager(config_manager, language_manager=FakeLanguageManager()) # type: ignore[arg-type] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py new file mode 100644 index 00000000..718dcf1a --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -0,0 +1,164 @@ +from typing import Dict + +from sampletones_application.logic.reconstruction.browser.tree.configurations import ( + build_configuration_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + disambiguated_display_name, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, +) +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) + +from .conftest import ( + BRANCH_NAME, + HASH_A, + HASH_B, + RECONSTRUCTIONS, + config_entry, + config_fields, + directory_children, + file_children, + group_children, + plain_entry, + reconstruction_entry, + scan_of, +) + + +def build_branch(scan: ReconstructionScan) -> TreeNode: + return build_configuration_branch( + scan, + name=BRANCH_NAME, + parent=TreeNode("Root", node_type=NodeType.ROOT), + ) + + +def frequencies_name(fields: ConfigDirectoryFields) -> str: + return DISPLAY_SEPARATOR.join([format_sample_rate(fields.sr), format_nes_frequency(fields.nf)]) + + +def method_name(fields: ConfigDirectoryFields) -> str: + return DISPLAY_SEPARATOR.join([format_spectrum_method(fields.sm), f"{GAMMA_PREFIX}{fields.tg}"]) + + +def generator_directories(branch: TreeNode, fields: ConfigDirectoryFields) -> Dict[str, FileSystemNode]: + frequencies_node = group_children(branch)[frequencies_name(fields)] + return directory_children(group_children(frequencies_node)[method_name(fields)]) + + +class TestTopLevelConfigDirectories: + def test_config_directory_groups_by_frequency_then_method(self) -> None: + fields = config_fields(generators="PpT") + branch = build_branch(scan_of(config_entry(fields, "song"))) + + frequencies = group_children(branch) + assert set(frequencies) == {frequencies_name(fields)} + + methods = group_children(frequencies[frequencies_name(fields)]) + assert set(methods) == {method_name(fields)} + + assert set(directory_children(methods[method_name(fields)])) == {fields.gn} + + def test_config_directory_keeps_its_reconstructions(self) -> None: + fields = config_fields() + entry = config_entry(fields, "song") + branch = build_branch(scan_of(entry)) + + directory_node = generator_directories(branch, fields)[fields.gn] + assert file_children(directory_node)["song"].filepath == entry.entries[0].path + + def test_config_directory_carries_its_parsed_configuration(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + directory_node = generator_directories(branch, fields)[fields.gn] + assert isinstance(directory_node, ConfigNode) + assert directory_node.config == fields + + def test_colliding_generators_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(generator_directories(branch, first)) == { + disambiguated_display_name(first.gn, HASH_A), + disambiguated_display_name(second.gn, HASH_B), + } + + def test_distinct_generators_share_a_method_group_under_their_own_names(self) -> None: + first = config_fields(generators="PTN") + second = config_fields(generators="TN") + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(generator_directories(branch, first)) == {"PTN", "TN"} + + def test_distinct_frequencies_form_separate_groups(self) -> None: + first = config_fields(sample_rate=44100, nes_frequency=30) + second = config_fields(sample_rate=48000, nes_frequency=60) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(group_children(branch)) == {frequencies_name(first), frequencies_name(second)} + + def test_distinct_methods_form_separate_groups(self) -> None: + first = config_fields(spectrum_method=SpectrumMethod.FFT) + second = config_fields(spectrum_method=SpectrumMethod.CQT) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + methods = group_children(group_children(branch)[frequencies_name(first)]) + assert set(methods) == {method_name(first), method_name(second)} + + +class TestPlainFolders: + def test_plain_folder_keeps_its_name_and_holds_its_reconstructions(self) -> None: + entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song")) + branch = build_branch(scan_of(entry)) + + directory_node = directory_children(branch)["my_songs"] + assert set(file_children(directory_node)) == {"song"} + + def test_empty_folder_stays_in_place(self) -> None: + branch = build_branch(scan_of(plain_entry("empty"))) + + assert set(directory_children(branch)) == {"empty"} + + def test_nested_config_directory_takes_its_friendly_name(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(plain_entry("my_songs", config_entry(fields, "song")))) + + nested = directory_children(directory_children(branch)["my_songs"]) + assert set(nested) == {fields.display_name} + + def test_colliding_nested_config_directories_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch( + scan_of(plain_entry("my_songs", config_entry(first, "song"), config_entry(second, "song"))) + ) + + nested = directory_children(directory_children(branch)["my_songs"]) + assert set(nested) == { + disambiguated_display_name(first.display_name, HASH_A), + disambiguated_display_name(second.display_name, HASH_B), + } + + +class TestLooseReconstructions: + def test_reconstruction_beside_the_config_directories_is_listed_here(self) -> None: + entry = reconstruction_entry(RECONSTRUCTIONS, "song") + branch = build_branch(scan_of(entry)) + + assert file_children(branch)["song"].filepath == entry.path diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py new file mode 100644 index 00000000..b19afc2a --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -0,0 +1,167 @@ +from pathlib import Path +from typing import Iterator, List + +import pytest + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager + +from .conftest import ( + CONFIGURATION_BRANCH_KEY, + SAMPLE_BRANCH_KEY, + config_directory, + config_fields, + configuration_branch, + file_children, + group_children, + sample_branch, + write_reconstruction, +) + + +class TestRefreshTree: + def test_missing_directory_leaves_no_root( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + browser_manager.reconstructions_directory = tmp_path / "does_not_exist" + browser_manager.refresh_tree() + assert browser_manager.tree.root is None + + def test_root_holds_both_branches(self, browser_manager: BrowserManager) -> None: + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + + def test_reconstruction_is_reachable_from_both_branches( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + fields = config_fields() + path = write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + frequencies = next(iter(group_children(configurations).values())) + methods = next(iter(group_children(frequencies).values())) + generators = next(iter(methods.children)) + assert file_children(generators)["song"].filepath == path + + samples = sample_branch(browser_manager) + assert file_children(group_children(samples)["song"])[fields.display_name].filepath == path + + def test_reads_every_folder_once( + self, + browser_manager: BrowserManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Both branches are built from one reading, so no folder is listed twice per refresh.""" + directory = config_directory(tmp_path, config_fields()) + write_reconstruction(directory, "Amen Breaks", "cw_amen02_165") + + listed: List[Path] = [] + original_iterdir = Path.iterdir + + def counting_iterdir(directory_path: Path) -> Iterator[Path]: + listed.append(directory_path) + return original_iterdir(directory_path) + + monkeypatch.setattr(Path, "iterdir", counting_iterdir) + browser_manager.refresh_tree() + + assert tmp_path in listed + assert sorted(listed) == sorted(set(listed)) + + +class TestSetReconstructionsDirectory: + def test_directory_is_taken_over( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory = tmp_path / "new" + directory.mkdir() + browser_manager.set_reconstructions_directory(directory) + assert browser_manager.reconstructions_directory == directory + + def test_directory_change_refreshes_the_tree( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory = tmp_path / "populated" + directory.mkdir() + write_reconstruction(directory, "track") + + browser_manager.set_reconstructions_directory(directory) + + assert len(browser_manager.get_all_reconstruction_files()) == 1 + + +class TestGetAllReconstructionFiles: + def test_empty_directory_holds_no_reconstructions(self, browser_manager: BrowserManager) -> None: + browser_manager.refresh_tree() + assert browser_manager.get_all_reconstruction_files() == [] + + def test_missing_directory_holds_no_reconstructions( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + browser_manager.reconstructions_directory = tmp_path / "does_not_exist" + browser_manager.refresh_tree() + assert browser_manager.get_all_reconstruction_files() == [] + + def test_reconstructions_are_answered_in_path_order( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + root_path = write_reconstruction(tmp_path, "a") + nested_path = write_reconstruction(tmp_path / "sub", "b") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == sorted([root_path, nested_path]) + + def test_other_files_stay_out( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + (tmp_path / "audio.wav").touch() + path = write_reconstruction(tmp_path, "song") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [path] + + def test_folder_without_reconstructions_contributes_nothing( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + audio_only = tmp_path / "audio_only" + audio_only.mkdir() + (audio_only / "track.wav").touch() + (tmp_path / "empty").mkdir() + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [] + + def test_reconstruction_in_both_branches_is_answered_once( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [path] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py new file mode 100644 index 00000000..c69d8103 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py @@ -0,0 +1,113 @@ +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( + build_sample_branch, +) +from sampletones_core.configs.display import disambiguated_display_name +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode + +from .conftest import ( + BRANCH_NAME, + HASH_A, + HASH_B, + RECONSTRUCTIONS, + config_entry, + config_fields, + file_children, + group_children, + plain_entry, + reconstruction_entry, + scan_of, +) + + +def build_branch(scan: ReconstructionScan) -> TreeNode: + return build_sample_branch( + scan, + name=BRANCH_NAME, + parent=TreeNode("Root", node_type=NodeType.ROOT), + ) + + +class TestSampleGrouping: + def test_audio_appears_under_the_folders_it_came_from(self) -> None: + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=(reconstruction_entry(directory, "Amen Breaks", "vol.1", "cw_amen02_165"),), + ) + branch = build_branch(scan_of(entry)) + + amen_breaks = group_children(branch)["Amen Breaks"] + volume = group_children(amen_breaks)["vol.1"] + audio_node = group_children(volume)["cw_amen02_165"] + assert file_children(audio_node)[fields.display_name].filepath == entry.entries[0].path + + def test_audio_at_the_root_of_a_config_directory_appears_at_the_branch_root(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + assert set(group_children(branch)) == {"song"} + assert set(file_children(group_children(branch)["song"])) == {fields.display_name} + + def test_one_audio_lists_every_configuration_that_reconstructed_it(self) -> None: + first = config_fields(spectrum_method=SpectrumMethod.FFT) + second = config_fields(spectrum_method=SpectrumMethod.CQT) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + audio_node = group_children(branch)["song"] + assert set(file_children(audio_node)) == {first.display_name, second.display_name} + + def test_each_audio_gathers_only_its_own_variants(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "first", "second"))) + + assert set(group_children(branch)) == {"first", "second"} + for audio_name in ("first", "second"): + assert set(file_children(group_children(branch)[audio_name])) == {fields.display_name} + + def test_colliding_variants_of_one_audio_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + audio_node = group_children(branch)["song"] + assert set(file_children(audio_node)) == { + disambiguated_display_name(first.display_name, HASH_A), + disambiguated_display_name(second.display_name, HASH_B), + } + + +class TestSampleVariants: + def test_variant_carries_the_configuration_of_its_directory(self) -> None: + """A leaf in the sample view states the configuration its directory names. + + Its own filename is the audio name, so the configuration reaches the tooltip and the + configuration font from the node rather than from the path. + """ + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + variant = next(iter(file_children(group_children(branch)["song"]).values())) + assert isinstance(variant, ConfigNode) + assert variant.config == fields + + +class TestSampleSources: + def test_folder_stating_no_configuration_stays_out(self) -> None: + entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song")) + branch = build_branch(scan_of(entry)) + + assert branch.children == () + + def test_reconstruction_beside_the_config_directories_stays_out(self) -> None: + branch = build_branch(scan_of(reconstruction_entry(RECONSTRUCTIONS, "song"))) + + assert branch.children == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py new file mode 100644 index 00000000..534d3842 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py @@ -0,0 +1,110 @@ +from pathlib import Path + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.scan import ( + scan_reconstructions, +) + +from .conftest import config_directory, config_fields, write_reconstruction + + +class TestScanEntries: + def test_reconstruction_file_becomes_an_entry_named_by_its_audio(self, tmp_path: Path) -> None: + path = write_reconstruction(tmp_path, "song") + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == (ReconstructionEntry(path=path),) + assert scan.entries[0].name == "song" + + def test_other_files_stay_out(self, tmp_path: Path) -> None: + (tmp_path / "audio.wav").touch() + write_reconstruction(tmp_path, "song") + + scan = scan_reconstructions(tmp_path) + + assert [entry.path.name for entry in scan.entries] == ["song.stn"] + + def test_entries_follow_the_sorted_order_of_the_folder(self, tmp_path: Path) -> None: + for name in ("charlie", "alpha", "bravo"): + write_reconstruction(tmp_path, name) + + scan = scan_reconstructions(tmp_path) + + assert [entry.name for entry in scan.entries] == ["alpha", "bravo", "charlie"] + + def test_folder_becomes_an_entry_holding_what_is_inside(self, tmp_path: Path) -> None: + path = write_reconstruction(tmp_path / "my_songs", "song") + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == ( + DirectoryEntry( + path=tmp_path / "my_songs", + config=None, + entries=(ReconstructionEntry(path=path),), + ), + ) + + def test_empty_folder_becomes_an_entry_holding_nothing(self, tmp_path: Path) -> None: + (tmp_path / "empty").mkdir() + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == (DirectoryEntry(path=tmp_path / "empty", config=None, entries=()),) + + +class TestScanConfiguration: + def test_config_directory_states_the_configuration_its_name_encodes(self, tmp_path: Path) -> None: + fields = config_fields() + config_directory(tmp_path, fields) + + scan = scan_reconstructions(tmp_path) + + assert [entry.config for entry in scan.entries] == [fields] + + def test_plain_folder_states_no_configuration(self, tmp_path: Path) -> None: + (tmp_path / "my_songs").mkdir() + + scan = scan_reconstructions(tmp_path) + + assert [entry.config for entry in scan.entries] == [None] + + def test_nested_config_directory_states_its_configuration(self, tmp_path: Path) -> None: + fields = config_fields() + config_directory(tmp_path / "my_songs", fields) + + scan = scan_reconstructions(tmp_path) + + nested = scan.entries[0] + assert isinstance(nested, DirectoryEntry) + assert [entry.config for entry in nested.entries] == [fields] + + +class TestScanReconstructions: + def test_collects_every_reconstruction_beneath_the_directory(self, tmp_path: Path) -> None: + root_path = write_reconstruction(tmp_path, "song") + nested_path = write_reconstruction(tmp_path / "sub" / "deeper", "track") + + scan = scan_reconstructions(tmp_path) + + assert {entry.path for entry in scan.reconstructions} == {root_path, nested_path} + + def test_collects_nothing_from_an_empty_directory(self, tmp_path: Path) -> None: + assert scan_reconstructions(tmp_path).reconstructions == () + + def test_directory_entry_collects_the_reconstructions_beneath_it(self, tmp_path: Path) -> None: + fields = config_fields() + directory = config_directory(tmp_path, fields) + nested_path = write_reconstruction(directory, "Amen Breaks", "cw_amen02_165") + write_reconstruction(tmp_path, "outside") + + scan = scan_reconstructions(tmp_path) + + config_entry = next(entry for entry in scan.entries if isinstance(entry, DirectoryEntry)) + assert [entry.path for entry in scan.collect_reconstructions(config_entry.entries)] == [nested_path] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py deleted file mode 100644 index ac27ef6a..00000000 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ /dev/null @@ -1,437 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Dict -from unittest.mock import MagicMock - -import pytest - -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - TreeNode, -) - -HASH_A = "6edf7c948606917a78b45d153c7ca7e0" -HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" - - -def reconstructions_node(browser_manager: BrowserManager) -> TreeNode: - root = browser_manager.tree.get_root() - assert root is not None - return group_children(root)["Reconstructions"] - - -def samples_node(browser_manager: BrowserManager) -> TreeNode: - root = browser_manager.tree.get_root() - assert root is not None - return group_children(root)["Samples"] - - -def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: - return directory_children(reconstructions_node(browser_manager)) - - -def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: - return { - child.name: child - for child in node.children - if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY - } - - -def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: - return { - child.name: child - for child in node.children - if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE - } - - -def group_children(node: TreeNode) -> Dict[str, TreeNode]: - return { - child.name: child - for child in node.children - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP - } - - -@pytest.fixture -def config_manager(tmp_path: Path) -> MagicMock: - mock = MagicMock() - mock.get_reconstructions_directory.return_value = tmp_path - return mock - - -BROWSER_LABELS = { - "global.browser.label.root": "Root", - "global.browser.label.browser": "Browser", - "global.browser.label.reconstructions": "Reconstructions", - "global.browser.label.samples": "Samples", -} - - -@pytest.fixture -def language_manager() -> MagicMock: - mock = MagicMock() - mock.__getitem__ = MagicMock(side_effect=BROWSER_LABELS.__getitem__) - return mock - - -@pytest.fixture -def browser_manager(config_manager: MagicMock, language_manager: MagicMock) -> BrowserManager: - return BrowserManager(config_manager, language_manager=language_manager) - - -class TestBrowserManagerRefreshTree: - def test_non_existent_directory_sets_root_to_none( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - browser_manager.reconstructions_directory = tmp_path / "does_not_exist" - browser_manager.refresh_tree() - assert browser_manager.tree.root is None - - def test_empty_directory_produces_empty_leaf_list( - self, - browser_manager: BrowserManager, - ) -> None: - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - def test_stn_files_appear_as_leaves( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert files[0] == tmp_path / "song.stn" - - def test_non_stn_files_are_excluded( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "audio.wav").touch() - (tmp_path / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert all(f.suffix == ".stn" for f in files) - - def test_nested_stn_files_are_included( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - subdir = tmp_path / "sub" - subdir.mkdir() - (subdir / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert files[0] == subdir / "song.stn" - - def test_directory_with_only_non_stn_files_is_not_returned( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - subdir = tmp_path / "audio_only" - subdir.mkdir() - (subdir / "track.wav").touch() - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - def test_empty_subdirectory_is_not_returned( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "empty_dir").mkdir() - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - -class TestBrowserManagerFriendlyNames: - def test_config_directory_groups_by_frequency_method_generators( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - reconstructions = reconstructions_node(browser_manager) - frequencies = group_children(reconstructions) - assert set(frequencies) == {"44.1 kHz·30 Hz"} - - methods = group_children(frequencies["44.1 kHz·30 Hz"]) - assert set(methods) == {"FFT·γ0"} - - assert set(directory_children(methods["FFT·γ0"])) == {"PpT"} - - def test_colliding_config_directories_get_hash_suffix( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for config_hash in (HASH_A, HASH_B): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{config_hash}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - reconstructions = reconstructions_node(browser_manager) - methods = group_children(group_children(reconstructions)["44.1 kHz·30 Hz"]) - assert set(directory_children(methods["FFT·γ0"])) == { - f"PpT·#{HASH_A[:7]}", - f"PpT·#{HASH_B[:7]}", - } - - def test_distinct_frequencies_form_separate_groups( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for sample_rate, nes_frequency in ((44100, 30), (48000, 60)): - config_dir = tmp_path / f"sr_{sample_rate}_nf_{nes_frequency}_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - assert set(group_children(reconstructions_node(browser_manager))) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} - - def test_distinct_methods_form_separate_groups( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for spectrum_method in ("fft", "cqt"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - assert set(methods) == {"FFT·γ0", "CQT·γ0"} - - def test_distinct_generators_share_method_group_without_hash( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for generators in ("PTN", "TN"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_{generators}_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} - - def test_non_config_directory_keeps_raw_name( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert "my_songs" in directory_nodes(browser_manager) - - -class TestBrowserManagerSamplesView: - def test_samples_are_grouped_by_source_directory_and_audio( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - audio_dir = config_dir / "Amen Breaks" / "Amen Breaks vol.1" - audio_dir.mkdir(parents=True) - (audio_dir / "cw_amen02_165.stn").touch() - - browser_manager.refresh_tree() - - samples = samples_node(browser_manager) - amen_breaks = group_children(samples)["Amen Breaks"] - amen_breaks_vol1 = group_children(amen_breaks)["Amen Breaks vol.1"] - audio = group_children(amen_breaks_vol1)["cw_amen02_165"] - variant = file_children(audio)["44.1 kHz·30 Hz·FFT·γ0·PTN"] - assert variant.filepath == audio_dir / "cw_amen02_165.stn" - - def test_one_audio_lists_each_config_variant( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for spectrum_method in ("fft", "cqt"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - assert set(file_children(audio)) == { - "44.1 kHz·30 Hz·FFT·γ0·PTN", - "44.1 kHz·30 Hz·CQT·γ0·PTN", - } - - def test_colliding_variants_of_one_audio_get_hash_suffix( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for config_hash in (HASH_A, HASH_B): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{config_hash}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - assert set(file_children(audio)) == { - f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_A[:7]}", - f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_B[:7]}", - } - - def test_single_file_conversion_appears_at_samples_root( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - samples = samples_node(browser_manager) - assert set(group_children(samples)) == {"song"} - assert set(file_children(group_children(samples)["song"])) == {"44.1 kHz·30 Hz·FFT·γ0·PTN"} - - def test_non_config_directory_is_excluded_from_samples( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert group_children(samples_node(browser_manager)) == {} - assert "my_songs" in directory_nodes(browser_manager) - - -class TestBrowserManagerConfigNodes: - def test_config_directory_carries_its_parsed_configuration( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir = tmp_path / directory_name - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - directory_node = directory_children(methods["FFT·γ0"])["PTN"] - assert isinstance(directory_node, ConfigNode) - assert directory_node.config == ConfigDirectoryFields.from_directory_name(directory_name) - - def test_sample_variant_carries_the_configuration_of_its_directory( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - """A leaf in the sample view states the configuration its directory names. - - Its own filename is the audio name, so the configuration reaches the tooltip and the - configuration font from the node rather than from the path. - """ - directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir = tmp_path / directory_name - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - variant = next(iter(file_children(audio).values())) - assert isinstance(variant, ConfigNode) - assert variant.config == ConfigDirectoryFields.from_directory_name(directory_name) - - def test_plain_directory_carries_no_configuration( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert not isinstance(directory_nodes(browser_manager)["my_songs"], ConfigNode) - - -class TestBrowserManagerSetDirectory: - def test_set_reconstructions_directory_updates_directory( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - new_dir = tmp_path / "new" - new_dir.mkdir() - browser_manager.set_reconstructions_directory(new_dir) - assert browser_manager.reconstructions_directory == new_dir - - def test_set_reconstructions_directory_triggers_refresh( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - new_dir = tmp_path / "populated" - new_dir.mkdir() - (new_dir / "track.stn").touch() - browser_manager.set_reconstructions_directory(new_dir) - assert len(browser_manager.get_all_reconstruction_files()) == 1 - - -class TestBrowserManagerGetAllReconstructionFiles: - def test_returns_paths_for_all_stn_leaves( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "a.stn").touch() - subdir = tmp_path / "sub" - subdir.mkdir() - (subdir / "b.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 2 - assert {f.name for f in files} == {"a.stn", "b.stn"} diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py index 21af2ec2..993da11e 100644 --- a/tests/unit/sampletones_core/structures/tree/test_factory.py +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -11,21 +11,36 @@ class TestCreateDirectoryNode: - def test_config_directory_becomes_a_config_node(self) -> None: + def test_stated_configuration_becomes_a_config_node(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name - node = create_directory_node(directory, name=directory.name, parent=None) + node = create_directory_node( + directory, + name=directory.name, + config=CONFIG_FIELDS, + parent=None, + ) assert isinstance(node, ConfigNode) assert node.config == CONFIG_FIELDS - def test_plain_directory_becomes_a_file_system_node(self) -> None: + def test_folder_stating_no_configuration_becomes_a_file_system_node(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / "my_songs" - node = create_directory_node(directory, name=directory.name, parent=None) + node = create_directory_node( + directory, + name=directory.name, + config=None, + parent=None, + ) assert isinstance(node, FileSystemNode) assert not isinstance(node, ConfigNode) def test_node_carries_the_given_name_and_path(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name - node = create_directory_node(directory, name="friendly", parent=None) + node = create_directory_node( + directory, + name="friendly", + config=CONFIG_FIELDS, + parent=None, + ) assert node.name == "friendly" assert node.filepath == directory assert node.node_type == NodeType.DIRECTORY @@ -35,6 +50,7 @@ def test_node_attaches_to_the_given_parent(self) -> None: node = create_directory_node( RECONSTRUCTIONS_DIRECTORY / "my_songs", name="my_songs", + config=None, parent=parent, ) assert node.parent is parent From 853600846eee94aed06769affe89ddc34d701a72 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 11:32:50 +0200 Subject: [PATCH 09/45] Refactored: reconstruction browser tree --- .../browser/tree/configurations.py | 6 +- .../reconstruction/browser/tree/containers.py | 33 ++++++++++ .../reconstruction/browser/tree/group.py | 15 ----- .../browser/tree/samples/branch.py | 9 +-- .../ui/elements/tree/tag.py | 29 +++++++++ .../ui/elements/tree/tree.py | 23 ++++--- .../ui/panels/main/explorer.py | 2 +- .../ui/panels/shared/browser.py | 44 ++++++++----- src/sampletones_config/lang/en.yaml | 1 + src/sampletones_core/structures/tree/type.py | 1 + .../logic/reconstruction/browser/conftest.py | 4 ++ .../reconstruction/browser/test_manager.py | 3 +- .../reconstruction/browser/test_samples.py | 60 +++++++++++++++--- .../ui/elements/tree/test_tag.py | 62 +++++++++++++++++++ 14 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/containers.py delete mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/group.py create mode 100644 src/sampletones_application/ui/elements/tree/tag.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_tag.py diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py index f38d85ad..fa4f372d 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py @@ -1,5 +1,8 @@ from typing import List, Sequence, Tuple +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + find_or_create_group, +) from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, ) @@ -10,9 +13,6 @@ ReconstructionScan, ScanEntry, ) -from sampletones_application.logic.reconstruction.browser.tree.group import ( - find_or_create_group, -) from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, GAMMA_PREFIX, diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py new file mode 100644 index 00000000..b12eb43e --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py @@ -0,0 +1,33 @@ +from sampletones_core.structures.tree import NodeType, TreeNode + + +def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the group of this name under ``parent``, adding one where the parent holds none. + + A group stands for something the disk states rather than holds — a frequency pair, a spectrum + method, a source folder — so a builder meeting that name again extends the group it already made. + """ + return _find_or_create(name, node_type=NodeType.GROUP, parent=parent) + + +def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the sample of this name under ``parent``, adding one where the parent holds none. + + A sample stands for one source audio and gathers the reconstructions made from it. It carries a + node type of its own, so a folder and an audio of the same name stay two rows: each is looked up + among the siblings of its own kind. + """ + return _find_or_create(name, node_type=NodeType.SAMPLE, parent=parent) + + +def _find_or_create( + name: str, + *, + node_type: NodeType, + parent: TreeNode, +) -> TreeNode: + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name: + return child + + return TreeNode(name, node_type=node_type, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/group.py b/src/sampletones_application/logic/reconstruction/browser/tree/group.py deleted file mode 100644 index 7b6557ce..00000000 --- a/src/sampletones_application/logic/reconstruction/browser/tree/group.py +++ /dev/null @@ -1,15 +0,0 @@ -from sampletones_core.structures.tree import NodeType, TreeNode - - -def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: - """Answers the group of this name under ``parent``, adding one where the parent holds none. - - A group stands for something the disk states rather than holds — a frequency pair, a spectrum - method, a source folder — so it is identified by its name and a builder meeting that name again - extends the group it already made. - """ - for child in parent.children: - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: - return child - - return TreeNode(name, node_type=NodeType.GROUP, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py index 8baec53d..7ae3c774 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py @@ -1,9 +1,10 @@ +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + find_or_create_group, + find_or_create_sample, +) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) -from sampletones_application.logic.reconstruction.browser.tree.group import ( - find_or_create_group, -) from sampletones_application.logic.reconstruction.browser.tree.samples.variants import ( append_variants, collect_variants, @@ -29,7 +30,7 @@ def build_sample_branch( for part in source.directory_parts: source_node = find_or_create_group(part, parent=source_node) - audio_node = find_or_create_group(source.name, parent=source_node) + audio_node = find_or_create_sample(source.name, parent=source_node) append_variants(audio_node, variants_by_source[source]) return branch diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py new file mode 100644 index 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/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 110ef731..cac7306b 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -47,6 +47,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tag import compose_node_tag from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.dpg import ( @@ -473,29 +474,37 @@ def _create_status_bar_message_function_for_library_node( ) -> MessageCallback: return self._create_status_bar_message_function(self._language_manager["global.status.message.node_library"]) - def _create_status_bar_message_function_for_directory_node( + def _create_status_bar_message_function_for_expandable_node( self, ) -> MessageCallback: + """Builds the hover message of a row the reader opens, naming what that row holds. + + A folder and a sample are both opened the same way and hold different things, so the message + follows the node it is asked about: the sample names the reconstructions it gathers. + """ + def message_function( *_args: Any, - user_data: Tuple[FileSystemNode, str], + user_data: Tuple[TreeNode, str], **_kwargs: Any, ) -> str: - _, node_tag = user_data + node, node_tag = user_data expand_or_collapse = ( self._language_manager["global.dialog.template.collapse"] if dpg_get_value(node_tag) else self._language_manager["global.dialog.template.expand"] ) - return self._language_manager["global.status.message.node_directory"].format( - expand_or_collapse=expand_or_collapse + message = ( + self._language_manager["global.status.message.node_sample"] + if node.node_type == NodeType.SAMPLE + else self._language_manager["global.status.message.node_directory"] ) + return message.format(expand_or_collapse=expand_or_collapse) return self._create_status_bar_message_function(message_function) def _generate_node_tag(self, node: TreeNode) -> str: - path_parts = [ancestor.name for ancestor in node.path] - return compose_tag(self.tag, f"node_{'_'.join(path_parts)}") + return compose_node_tag(node, panel_tag=self.tag) def _context_menu_header_name(self, node: TreeNode) -> str: """Returns the raw on-disk identifier, complementing the friendly label shown in the tree.""" diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 0f477526..77438177 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -138,7 +138,7 @@ def _setup_handlers(self) -> None: tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.FILE: NodeHandler( tag=self._get_node_handler_tag(NodeType.FILE), diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index d3bb4c1f..09cb9750 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -9,6 +9,7 @@ ) from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FILE_WAVE, TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) from sampletones_application.ui.elements.button import GUIButton @@ -116,11 +117,16 @@ def _setup_handlers(self) -> None: tag=self._get_node_handler_tag(NodeType.GROUP), node_type=NodeType.GROUP, ), + NodeType.SAMPLE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.SAMPLE), + node_type=NodeType.SAMPLE, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.FILE: NodeHandler( tag=self._get_node_handler_tag(NodeType.FILE), @@ -184,18 +190,18 @@ def _build_tree_node( **kwargs: Any, ) -> None: node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return + match node.node_type: + case NodeType.ROOT: + return + case NodeType.GROUP | NodeType.SAMPLE: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return if not isinstance(node, FileSystemNode): return @@ -223,8 +229,16 @@ def _build_tree_node( state.parent = node_tag def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT + """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour. + + A sample row names the audio a set of reconstructions was made from, so it reads in the + colour audio files carry elsewhere in the application. + """ + match node.node_type: + case NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + case NodeType.SAMPLE: + return TAG_GLOBAL_THEME_FILE_WAVE return super()._resolve_other_theme_tag(node) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 102e8f80..c5c0ba9f 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -237,6 +237,7 @@ global.status.message.clear_search: "Clear the search filter." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." +global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample." global.status.message.retuning_samples: "Retuning samples..." # ============================================================================= diff --git a/src/sampletones_core/structures/tree/type.py b/src/sampletones_core/structures/tree/type.py index 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/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index a0b77584..4273ac94 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -108,6 +108,10 @@ def group_children(node: TreeNode) -> Dict[str, TreeNode]: return {child.name: child for child in node.children if child.node_type == NodeType.GROUP} +def sample_children(node: TreeNode) -> Dict[str, TreeNode]: + return {child.name: child for child in node.children if child.node_type == NodeType.SAMPLE} + + def branch_of(browser_manager: BrowserManager, key: str) -> TreeNode: root = browser_manager.tree.get_root() assert root is not None diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index b19afc2a..085ac5ed 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -14,6 +14,7 @@ file_children, group_children, sample_branch, + sample_children, write_reconstruction, ) @@ -52,7 +53,7 @@ def test_reconstruction_is_reachable_from_both_branches( assert file_children(generators)["song"].filepath == path samples = sample_branch(browser_manager) - assert file_children(group_children(samples)["song"])[fields.display_name].filepath == path + assert file_children(sample_children(samples)["song"])[fields.display_name].filepath == path def test_reads_every_folder_once( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py index c69d8103..57eeece4 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py @@ -22,6 +22,7 @@ group_children, plain_entry, reconstruction_entry, + sample_children, scan_of, ) @@ -47,44 +48,87 @@ def test_audio_appears_under_the_folders_it_came_from(self) -> None: amen_breaks = group_children(branch)["Amen Breaks"] volume = group_children(amen_breaks)["vol.1"] - audio_node = group_children(volume)["cw_amen02_165"] + audio_node = sample_children(volume)["cw_amen02_165"] assert file_children(audio_node)[fields.display_name].filepath == entry.entries[0].path def test_audio_at_the_root_of_a_config_directory_appears_at_the_branch_root(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "song"))) - assert set(group_children(branch)) == {"song"} - assert set(file_children(group_children(branch)["song"])) == {fields.display_name} + assert set(sample_children(branch)) == {"song"} + assert set(file_children(sample_children(branch)["song"])) == {fields.display_name} def test_one_audio_lists_every_configuration_that_reconstructed_it(self) -> None: first = config_fields(spectrum_method=SpectrumMethod.FFT) second = config_fields(spectrum_method=SpectrumMethod.CQT) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - audio_node = group_children(branch)["song"] + audio_node = sample_children(branch)["song"] assert set(file_children(audio_node)) == {first.display_name, second.display_name} def test_each_audio_gathers_only_its_own_variants(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "first", "second"))) - assert set(group_children(branch)) == {"first", "second"} + assert set(sample_children(branch)) == {"first", "second"} for audio_name in ("first", "second"): - assert set(file_children(group_children(branch)[audio_name])) == {fields.display_name} + assert set(file_children(sample_children(branch)[audio_name])) == {fields.display_name} def test_colliding_variants_of_one_audio_get_a_hash_suffix(self) -> None: first = config_fields(config_hash=HASH_A) second = config_fields(config_hash=HASH_B) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - audio_node = group_children(branch)["song"] + audio_node = sample_children(branch)["song"] assert set(file_children(audio_node)) == { disambiguated_display_name(first.display_name, HASH_A), disambiguated_display_name(second.display_name, HASH_B), } +class TestSampleNodeTypes: + def test_audio_is_a_sample_and_the_folder_above_it_is_a_group(self) -> None: + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=(reconstruction_entry(directory, "Amen Breaks", "cw_amen02_165"),), + ) + branch = build_branch(scan_of(entry)) + + folder_node = group_children(branch)["Amen Breaks"] + assert folder_node.node_type == NodeType.GROUP + assert sample_children(folder_node)["cw_amen02_165"].node_type == NodeType.SAMPLE + + def test_a_folder_and_the_audio_beside_it_stay_two_rows(self) -> None: + """A configuration directory holding ``song.stn`` beside ``song/inner.stn`` lists both. + + The folder gathers what it holds while the audio gathers its variants, each row found among + the siblings of its own kind. + """ + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=( + DirectoryEntry( + path=directory / "song", + config=None, + entries=(reconstruction_entry(directory, "song", "inner"),), + ), + reconstruction_entry(directory, "song"), + ), + ) + branch = build_branch(scan_of(entry)) + + assert set(group_children(branch)) == {"song"} + assert set(sample_children(branch)) == {"song"} + assert set(sample_children(group_children(branch)["song"])) == {"inner"} + assert set(file_children(sample_children(branch)["song"])) == {fields.display_name} + + class TestSampleVariants: def test_variant_carries_the_configuration_of_its_directory(self) -> None: """A leaf in the sample view states the configuration its directory names. @@ -95,7 +139,7 @@ def test_variant_carries_the_configuration_of_its_directory(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "song"))) - variant = next(iter(file_children(group_children(branch)["song"]).values())) + variant = next(iter(file_children(sample_children(branch)["song"]).values())) assert isinstance(variant, ConfigNode) assert variant.config == fields diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_tag.py b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py new file mode 100644 index 00000000..ac0f5f43 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py @@ -0,0 +1,62 @@ +from typing import Final + +from sampletones_application.ui.elements.tree.tag import compose_node_tag +from sampletones_core.structures.tree import NodeType, TreeNode + +PANEL_TAG: Final[str] = "sequencer.browser.panel" +OTHER_PANEL_TAG: Final[str] = "reconstructions.browser.panel" + + +def root() -> TreeNode: + return TreeNode("Root", node_type=NodeType.ROOT) + + +def group(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + +def tag_of(node: TreeNode) -> str: + return compose_node_tag(node, panel_tag=PANEL_TAG) + + +class TestReadability: + def test_tag_states_the_panel_and_the_names_above_the_row(self) -> None: + node = group("cw_amen02_165", group("Amen Breaks", root())) + + assert tag_of(node).startswith(f"{PANEL_TAG}.") + assert "node_root_amen_breaks_cw_amen02_165" in tag_of(node) + + def test_one_node_keeps_one_tag(self) -> None: + node = group("song", root()) + + assert tag_of(node) == tag_of(node) + + def test_each_panel_names_the_row_its_own_way(self) -> None: + """Both browsers render one tree, so a row reaches each panel under a tag of that panel.""" + node = group("song", root()) + + assert compose_node_tag(node, panel_tag=PANEL_TAG) != compose_node_tag(node, panel_tag=OTHER_PANEL_TAG) + + +class TestDistinctRows: + def test_a_folder_and_the_audio_beside_it_keep_their_own_tags(self) -> None: + container = root() + folder = group("song", container) + audio = TreeNode("song", node_type=NodeType.SAMPLE, parent=container) + + assert tag_of(folder) != tag_of(audio) + + def test_names_differing_in_spacing_keep_their_own_tags(self) -> None: + """``drums/kick`` and ``drums kick`` read alike as a name path and stand as two rows.""" + container = root() + nested = group("kick", group("drums", container)) + spaced = group("drums kick", container) + + assert tag_of(nested) != tag_of(spaced) + + def test_names_differing_in_case_keep_their_own_tags(self) -> None: + container = root() + lowercase = group("song", container) + capitalized = group("Song", container) + + assert tag_of(lowercase) != tag_of(capitalized) From 5dc244b98bff6fe9995c5e056bb46494e2c68029 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 12:50:41 +0200 Subject: [PATCH 10/45] Added: pruning and deterministic ordering of browser groups --- .../logic/reconstruction/browser/manager.py | 15 +- .../browser/tree/configurations.py | 151 ------------------ .../browser/tree/configurations/__init__.py | 0 .../browser/tree/configurations/branch.py | 61 +++++++ .../browser/tree/configurations/grouping.py | 52 ++++++ .../browser/tree/configurations/naming.py | 42 +++++ .../reconstruction/browser/tree/containers.py | 9 ++ .../browser/tree/entries/directory.py | 15 +- .../browser/tree/entries/scan.py | 5 +- .../reconstruction/browser/tree/order.py | 25 +++ .../reconstruction/browser/tree/prune.py | 20 +++ .../logic/reconstruction/browser/tree/scan.py | 2 +- src/sampletones_config/lang/en.yaml | 4 +- src/sampletones_core/configs/display.py | 28 ++++ .../library/filename/utils.py | 13 +- .../reconstructions/converter/paths/fields.py | 12 +- src/sampletones_shared/utils/text.py | 32 ++++ .../logic/reconstruction/browser/conftest.py | 42 ++++- .../browser/test_configurations.py | 38 ++--- .../reconstruction/browser/test_manager.py | 34 +++- .../reconstruction/browser/test_order.py | 88 ++++++++++ .../reconstruction/browser/test_prune.py | 103 ++++++++++++ .../sampletones_core/configs/test_display.py | 19 +++ .../converter/paths/test_fields.py | 4 +- .../sampletones_shared/utils/test_text.py | 53 ++++++ 25 files changed, 656 insertions(+), 211 deletions(-) delete mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/order.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/prune.py create mode 100644 src/sampletones_shared/utils/text.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py create mode 100644 tests/unit/sampletones_shared/utils/test_text.py diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index 3844efa6..e8eed1ba 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -3,12 +3,16 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.logic.reconstruction.browser.tree.configurations import ( +from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) +from sampletones_application.logic.reconstruction.browser.tree.order import order_children +from sampletones_application.logic.reconstruction.browser.tree.prune import ( + prune_empty_containers, +) from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( build_sample_branch, ) @@ -22,7 +26,8 @@ class BrowserManager: """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that - one reading, and publishes the result as the tree both browser tabs render. + one reading, shapes what came out — empty headings pruned, siblings ordered — and publishes the + result as the tree both browser tabs render. """ def __init__( @@ -58,15 +63,17 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: ) build_configuration_branch( scan, - name=self._language_manager["global.browser.label.reconstructions"], + name=self._language_manager["global.browser.label.by_configuration"], parent=container_root, ) build_sample_branch( scan, - name=self._language_manager["global.browser.label.samples"], + name=self._language_manager["global.browser.label.by_sample"], parent=container_root, ) + prune_empty_containers(container_root) + order_children(container_root) return container_root def get_all_reconstruction_files(self) -> List[Path]: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py deleted file mode 100644 index fa4f372d..00000000 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py +++ /dev/null @@ -1,151 +0,0 @@ -from typing import List, Sequence, Tuple - -from sampletones_application.logic.reconstruction.browser.tree.containers import ( - find_or_create_group, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( - DirectoryEntry, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( - ReconstructionEntry, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( - ReconstructionScan, - ScanEntry, -) -from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, - unique_display_names, -) -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - TreeNode, - create_directory_node, -) - - -def build_configuration_branch( - scan: ReconstructionScan, - *, - name: str, - parent: TreeNode, -) -> TreeNode: - """Builds the branch listing reconstructions by the configuration that produced them. - - The scanned folders appear as they sit on disk, and a top-level configuration directory is then - lifted under frequency ▶ method groups and named by its generators, so configurations sharing a - spectrum read side by side. A configuration directory nested inside a plain folder keeps its - friendly name in place, and a reconstruction outside every configuration directory is listed - here, this being the branch that follows the disk. - """ - branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) - for entry in scan.entries: - _append_entry(entry, parent=branch) - - _organize_top_level_config_directories(branch) - return branch - - -def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None: - match entry: - case ReconstructionEntry(): - FileSystemNode( - entry.name, - node_type=NodeType.FILE, - filepath=entry.path, - parent=parent, - ) - case DirectoryEntry(): - directory_node = create_directory_node( - entry.path, - name=entry.name, - config=entry.config, - parent=parent, - ) - for child_entry in entry.entries: - _append_entry(child_entry, parent=directory_node) - - -def _organize_top_level_config_directories(branch: TreeNode) -> None: - """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. - - A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is - renamed to its generator abbreviation, while any other top-level folder keeps the existing - flat friendly naming for the config directories nested inside it. - """ - for child in list(branch.children): - match child: - case ConfigNode() if child.node_type == NodeType.DIRECTORY: - _attach_config_directory_under_groups(child, branch) - case FileSystemNode() if child.node_type == NodeType.DIRECTORY: - _assign_directory_display_names(child) - - _disambiguate_generator_siblings(branch) - - -def _attach_config_directory_under_groups( - directory_node: ConfigNode, - branch: TreeNode, -) -> None: - fields = directory_node.config - frequencies_name = DISPLAY_SEPARATOR.join( - [ - format_sample_rate(fields.sr), - format_nes_frequency(fields.nf), - ] - ) - method_name = DISPLAY_SEPARATOR.join( - [ - format_spectrum_method(fields.sm), - f"{GAMMA_PREFIX}{fields.tg}", - ] - ) - frequencies_node = find_or_create_group(frequencies_name, parent=branch) - method_node = find_or_create_group(method_name, parent=frequencies_node) - - directory_node.name = fields.gn - directory_node.parent = method_node - - -def _disambiguate_generator_siblings(node: TreeNode) -> None: - """Appends a short config hash to generator directories sharing a name under one method group.""" - if node.node_type == NodeType.GROUP: - _rename_config_directories( - [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)] - ) - - for child in node.children: - _disambiguate_generator_siblings(child) - - -def _assign_directory_display_names(node: TreeNode) -> None: - """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. - - Only directories whose names parse as reconstruction config directories are rewritten; - plain folders keep their on-disk name. The check is scoped per parent because duplicate - display names among siblings would otherwise collapse to duplicate widget tags downstream. - """ - _rename_config_directories( - [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)] - ) - for child in node.children: - _assign_directory_display_names(child) - - -def _config_directory_children(node: TreeNode) -> List[ConfigNode]: - return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY] - - -def _rename_config_directories( - proposed_names: Sequence[Tuple[ConfigNode, str]], -) -> None: - """Names each configuration directory, marking those a sibling would otherwise shadow.""" - labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names]) - for (directory_node, _), label in zip(proposed_names, labels): - directory_node.name = label diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py new file mode 100644 index 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 index b12eb43e..18687ede 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py @@ -1,5 +1,14 @@ +from typing import Final, FrozenSet + from sampletones_core.structures.tree import NodeType, TreeNode +ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset( + { + NodeType.GROUP, + NodeType.SAMPLE, + } +) + def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: """Answers the group of this name under ``parent``, adding one where the parent holds none. diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py index 582e6e86..ccc1ec18 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py @@ -1,15 +1,15 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Optional, Tuple +from typing import Optional, Tuple, TypeAlias, Union +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) -if TYPE_CHECKING: - from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( - ScanEntry, - ) +ScanEntry: TypeAlias = Union["DirectoryEntry", ReconstructionEntry] @dataclass(frozen=True) @@ -17,12 +17,13 @@ class DirectoryEntry: """A folder a scan met, holding the configuration its name states and the entries inside it. A folder whose name encodes a reconstruction configuration carries those fields, read once here, - so every branch builder states the configuration from the record it already has. + so every branch builder states the configuration from the record it already has. A folder holds + folders as readily as reconstructions, which is why the entry kinds are named together here. """ path: Path config: Optional[ConfigDirectoryFields] - entries: Tuple["ScanEntry", ...] + entries: Tuple[ScanEntry, ...] @property def name(self) -> str: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py index 2cc3edc5..c5d007e8 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py @@ -1,15 +1,14 @@ from dataclasses import dataclass -from typing import List, Sequence, Tuple, TypeAlias, Union +from typing import List, Sequence, Tuple from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) -ScanEntry: TypeAlias = Union[DirectoryEntry, ReconstructionEntry] - @dataclass(frozen=True) class ReconstructionScan: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/order.py b/src/sampletones_application/logic/reconstruction/browser/tree/order.py new file mode 100644 index 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/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py index fcc7133c..cd34400e 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -3,13 +3,13 @@ from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, - ScanEntry, ) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index c5c0ba9f..0dc0d264 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -127,8 +127,8 @@ global.traceback.label.hide: "Hide traceback" # ============================================================================= global.browser.label.root: "Root" global.browser.label.browser: "Browser" -global.browser.label.reconstructions: "Reconstructions" -global.browser.label.samples: "Samples" +global.browser.label.by_configuration: "By configuration" +global.browser.label.by_sample: "By sample" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 02bcb9bf..80b92f03 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -39,6 +39,34 @@ def format_spectrum_method(method: SpectrumMethod) -> str: return SPECTRUM_METHOD_LABELS[method] +def format_transformation_gamma(transformation_gamma: int) -> str: + """Marks a transformation gamma with ``γ`` (e.g. ``γ0``).""" + return f"{GAMMA_PREFIX}{transformation_gamma}" + + +def format_frequencies(sample_rate: int, nes_frequency: int) -> str: + """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``).""" + return DISPLAY_SEPARATOR.join( + [ + format_sample_rate(sample_rate), + format_nes_frequency(nes_frequency), + ], + ) + + +def format_transformation( + spectrum_method: SpectrumMethod, + transformation_gamma: int, +) -> str: + """Renders the spectrum a library was built from, method before gamma (e.g. ``FFT·γ0``).""" + return DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(spectrum_method), + format_transformation_gamma(transformation_gamma), + ], + ) + + def short_hash(config_hash: str) -> str: return config_hash[:DISPLAY_HASH_LENGTH] diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 194cd700..26b0927b 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -2,10 +2,8 @@ from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, + format_frequencies, + format_transformation, ) from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.library.key import InstructionLibraryKey @@ -39,12 +37,9 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: def get_display_name_from_key(key: InstructionLibraryKey) -> str: nes_frequency = round(key.sample_rate / key.frame_length) - gamma = f"{GAMMA_PREFIX}{key.transformation_gamma}" return DISPLAY_SEPARATOR.join( [ - format_sample_rate(key.sample_rate), - format_nes_frequency(nes_frequency), - format_spectrum_method(key.spectrum_method), - gamma, + format_frequencies(key.sample_rate, nes_frequency), + format_transformation(key.spectrum_method, key.transformation_gamma), ] ) diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index 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_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/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 4273ac94..04664f09 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -7,13 +7,13 @@ from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, - ScanEntry, ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.paths import EXT_FILE_RECONSTRUCTION @@ -27,8 +27,8 @@ RECONSTRUCTIONS: Final[Path] = Path("/reconstructions") BRANCH_NAME: Final[str] = "branch" -CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.reconstructions" -SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.samples" +CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.by_configuration" +SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.by_sample" def config_fields( @@ -88,6 +88,40 @@ def write_reconstruction(directory: Path, *relative_parts: str) -> Path: return path +def container_root() -> TreeNode: + return TreeNode("Root", node_type=NodeType.ROOT) + + +def group_node(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + +def sample_node(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.SAMPLE, parent=parent) + + +def directory_node(name: str, parent: TreeNode) -> FileSystemNode: + return FileSystemNode( + name, + node_type=NodeType.DIRECTORY, + filepath=RECONSTRUCTIONS / name, + parent=parent, + ) + + +def file_node(name: str, parent: TreeNode) -> FileSystemNode: + return FileSystemNode( + name, + node_type=NodeType.FILE, + filepath=(RECONSTRUCTIONS / name).with_suffix(EXT_FILE_RECONSTRUCTION), + parent=parent, + ) + + +def child_names(node: TreeNode) -> List[str]: + return [str(child.name) for child in node.children] + + def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py index 718dcf1a..c8a5bdea 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -1,18 +1,15 @@ from typing import Dict -from sampletones_application.logic.reconstruction.browser.tree.configurations import ( +from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, disambiguated_display_name, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, + format_frequencies, + format_transformation, ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -48,30 +45,33 @@ def build_branch(scan: ReconstructionScan) -> TreeNode: def frequencies_name(fields: ConfigDirectoryFields) -> str: - return DISPLAY_SEPARATOR.join([format_sample_rate(fields.sr), format_nes_frequency(fields.nf)]) + return format_frequencies(fields.sr, fields.nf) -def method_name(fields: ConfigDirectoryFields) -> str: - return DISPLAY_SEPARATOR.join([format_spectrum_method(fields.sm), f"{GAMMA_PREFIX}{fields.tg}"]) +def transformation_name(fields: ConfigDirectoryFields) -> str: + return format_transformation(fields.sm, fields.tg) -def generator_directories(branch: TreeNode, fields: ConfigDirectoryFields) -> Dict[str, FileSystemNode]: +def generator_directories( + branch: TreeNode, + fields: ConfigDirectoryFields, +) -> Dict[str, FileSystemNode]: frequencies_node = group_children(branch)[frequencies_name(fields)] - return directory_children(group_children(frequencies_node)[method_name(fields)]) + return directory_children(group_children(frequencies_node)[transformation_name(fields)]) class TestTopLevelConfigDirectories: - def test_config_directory_groups_by_frequency_then_method(self) -> None: + def test_config_directory_groups_by_frequencies_then_transformation(self) -> None: fields = config_fields(generators="PpT") branch = build_branch(scan_of(config_entry(fields, "song"))) frequencies = group_children(branch) assert set(frequencies) == {frequencies_name(fields)} - methods = group_children(frequencies[frequencies_name(fields)]) - assert set(methods) == {method_name(fields)} + transformations = group_children(frequencies[frequencies_name(fields)]) + assert set(transformations) == {transformation_name(fields)} - assert set(directory_children(methods[method_name(fields)])) == {fields.gn} + assert set(directory_children(transformations[transformation_name(fields)])) == {fields.gn} def test_config_directory_keeps_its_reconstructions(self) -> None: fields = config_fields() @@ -99,7 +99,7 @@ def test_colliding_generators_get_a_hash_suffix(self) -> None: disambiguated_display_name(second.gn, HASH_B), } - def test_distinct_generators_share_a_method_group_under_their_own_names(self) -> None: + def test_distinct_generators_share_a_transformation_group_under_their_own_names(self) -> None: first = config_fields(generators="PTN") second = config_fields(generators="TN") branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) @@ -113,13 +113,13 @@ def test_distinct_frequencies_form_separate_groups(self) -> None: assert set(group_children(branch)) == {frequencies_name(first), frequencies_name(second)} - def test_distinct_methods_form_separate_groups(self) -> None: + def test_distinct_transformations_form_separate_groups(self) -> None: first = config_fields(spectrum_method=SpectrumMethod.FFT) second = config_fields(spectrum_method=SpectrumMethod.CQT) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - methods = group_children(group_children(branch)[frequencies_name(first)]) - assert set(methods) == {method_name(first), method_name(second)} + transformations = group_children(group_children(branch)[frequencies_name(first)]) + assert set(transformations) == {transformation_name(first), transformation_name(second)} class TestPlainFolders: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 085ac5ed..825eefe2 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -11,6 +11,7 @@ config_directory, config_fields, configuration_branch, + directory_children, file_children, group_children, sample_branch, @@ -29,13 +30,44 @@ def test_missing_directory_leaves_no_root( browser_manager.refresh_tree() assert browser_manager.tree.root is None - def test_root_holds_both_branches(self, browser_manager: BrowserManager) -> None: + def test_root_holds_both_branches( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + write_reconstruction(config_directory(tmp_path, config_fields()), "song") + browser_manager.refresh_tree() root = browser_manager.tree.get_root() assert root is not None assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + def test_directory_holding_nothing_to_show_leaves_no_branches( + self, + browser_manager: BrowserManager, + ) -> None: + """Both views are headings over reconstructions, so neither is offered where there are none.""" + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert root.children == () + + def test_the_configuration_branch_still_lists_a_folder_holding_no_reconstruction( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + (tmp_path / "empty").mkdir() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY] + assert set(directory_children(configuration_branch(browser_manager))) == {"empty"} + def test_reconstruction_is_reachable_from_both_branches( self, browser_manager: BrowserManager, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py new file mode 100644 index 00000000..911a9a5f --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py @@ -0,0 +1,88 @@ +from sampletones_application.logic.reconstruction.browser.tree.order import order_children + +from .conftest import ( + CONFIGURATION_BRANCH_KEY, + SAMPLE_BRANCH_KEY, + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestNameOrder: + def test_numbers_read_as_numbers(self) -> None: + """A frequency group sits by the value its label states, whatever the folder name spells.""" + root = container_root() + branch = group_node("branch", root) + for name in ("44.1 kHz·30 Hz", "8 kHz·60 Hz", "22.05 kHz·30 Hz"): + group_node(name, branch) + + order_children(root) + + assert child_names(branch) == ["8 kHz·60 Hz", "22.05 kHz·30 Hz", "44.1 kHz·30 Hz"] + + def test_names_read_as_a_reader_reads_them(self) -> None: + """A capital letter states nothing about order, so names read alphabetically as they look.""" + root = container_root() + branch = group_node("branch", root) + for name in ("Beats", "amen", "Cymbals"): + sample_node(name, branch) + + order_children(root) + + assert child_names(branch) == ["amen", "Beats", "Cymbals"] + + def test_order_reaches_every_level(self) -> None: + root = container_root() + branch = group_node("branch", root) + sample = sample_node("song", branch) + for name in ("FFT·γ0", "CQT·γ0"): + file_node(name, sample) + + order_children(root) + + assert child_names(sample) == ["CQT·γ0", "FFT·γ0"] + + +class TestContainersFirst: + def test_folders_and_groups_precede_reconstructions(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("aaa", branch) + group_node("zzz group", branch) + directory_node("zzz folder", branch) + sample_node("zzz sample", branch) + + order_children(root) + + assert child_names(branch) == ["zzz folder", "zzz group", "zzz sample", "aaa"] + + +class TestBranches: + def test_branches_keep_the_order_the_browser_states(self) -> None: + """The two views read in the order they are built, rather than by the labels they carry.""" + root = container_root() + group_node(CONFIGURATION_BRANCH_KEY, root) + group_node(SAMPLE_BRANCH_KEY, root) + + order_children(root) + + assert child_names(root) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + + +class TestSubtrees: + def test_reordered_rows_keep_what_they_hold(self) -> None: + root = container_root() + branch = group_node("branch", root) + second = group_node("second", branch) + file_node("song", second) + group_node("first", branch) + + order_children(root) + + assert child_names(branch) == ["first", "second"] + assert child_names(second) == ["song"] + assert second.parent is branch diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py new file mode 100644 index 00000000..41fdd30f --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py @@ -0,0 +1,103 @@ +from sampletones_application.logic.reconstruction.browser.tree.prune import ( + prune_empty_containers, +) +from sampletones_core.structures.tree import NodeType + +from .conftest import ( + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestEmptyContainers: + def test_group_holding_nothing_leaves(self) -> None: + root = container_root() + group_node("44.1 kHz·30 Hz", root) + + prune_empty_containers(root) + + assert root.children == () + + def test_sample_holding_nothing_leaves(self) -> None: + root = container_root() + sample_node("cw_amen02_165", root) + + prune_empty_containers(root) + + assert root.children == () + + def test_a_whole_chain_of_empty_containers_leaves(self) -> None: + """The deepest rows go first, so a heading emptied by its own children goes with them.""" + root = container_root() + branch = group_node("branch", root) + sample_node("cw_amen02_165", group_node("Amen Breaks", branch)) + + prune_empty_containers(root) + + assert root.children == () + + def test_the_container_root_stays(self) -> None: + root = container_root() + group_node("branch", root) + + prune_empty_containers(root) + + assert root.node_type == NodeType.ROOT + assert root.parent is None + + +class TestGatheringContainers: + def test_group_holding_a_reconstruction_stays(self) -> None: + root = container_root() + file_node("song", group_node("branch", root)) + + prune_empty_containers(root) + + assert child_names(root) == ["branch"] + + def test_sample_holding_its_variants_stays(self) -> None: + root = container_root() + sample = sample_node("song", root) + file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample) + + prune_empty_containers(root) + + assert child_names(root) == ["song"] + assert child_names(sample) == ["44.1 kHz·30 Hz·FFT·γ0·PTN"] + + def test_a_branch_keeps_the_containers_leading_to_a_reconstruction(self) -> None: + root = container_root() + branch = group_node("branch", root) + kept = group_node("Amen Breaks", branch) + file_node("song", sample_node("cw_amen02_165", kept)) + group_node("Beats", branch) + + prune_empty_containers(root) + + assert child_names(branch) == ["Amen Breaks"] + assert child_names(kept) == ["cw_amen02_165"] + + +class TestFolders: + def test_folder_holding_nothing_stays(self) -> None: + """The configuration branch reads the disk as it is, so an empty folder is still a folder.""" + root = container_root() + branch = group_node("branch", root) + directory_node("empty", branch) + + prune_empty_containers(root) + + assert child_names(branch) == ["empty"] + + def test_group_holding_only_an_empty_folder_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + directory_node("empty", group_node("44.1 kHz·30 Hz", branch)) + + prune_empty_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz"] diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index 365c70ce..a5f3cee1 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -6,11 +6,15 @@ DISPLAY_HASH_LENGTH, DISPLAY_SEPARATOR, disambiguated_display_name, + format_frequencies, format_nes_frequency, format_sample_rate, + format_transformation, + format_transformation_gamma, short_hash, unique_display_names, ) +from sampletones_core.constants.enums import SpectrumMethod class TestFormatSampleRate: @@ -33,6 +37,21 @@ def test_appends_hertz_unit(self) -> None: assert format_nes_frequency(30) == "30 Hz" +class TestFormatTransformationGamma: + def test_marks_the_gamma(self) -> None: + assert format_transformation_gamma(0) == "γ0" + + +class TestFormatFrequencies: + def test_reads_audio_rate_then_frame_rate(self) -> None: + assert format_frequencies(44100, 30) == "44.1 kHz·30 Hz" + + +class TestFormatTransformation: + def test_reads_method_then_gamma(self) -> None: + assert format_transformation(SpectrumMethod.FFT, 2) == "FFT·γ2" + + class TestShortHash: def test_truncates_to_display_length(self) -> None: full = "6edf7c948606917a78b45d153c7ca7e0" diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py index b3040208..ce127850 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -3,10 +3,10 @@ from sampletones_core.configs import Config from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, - GAMMA_PREFIX, format_nes_frequency, format_sample_rate, format_spectrum_method, + format_transformation_gamma, ) from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -89,6 +89,6 @@ def test_display_name_combines_formatted_parts(self, config: Config) -> None: assert format_sample_rate(config.library.sample_rate) in display assert format_nes_frequency(config.library.nes_frequency) in display assert format_spectrum_method(config.library.spectrum_method) in display - assert f"{GAMMA_PREFIX}{config.library.transformation_gamma}" in display + assert format_transformation_gamma(config.library.transformation_gamma) in display assert abbreviate_generator_names(list(config.generation.generators)) in display assert DISPLAY_SEPARATOR in display diff --git a/tests/unit/sampletones_shared/utils/test_text.py b/tests/unit/sampletones_shared/utils/test_text.py new file mode 100644 index 00000000..c5b96079 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_text.py @@ -0,0 +1,53 @@ +from typing import List + +import pytest + +from sampletones_shared.utils.text import natural_sort_key + + +class TestNumbers: + @pytest.mark.parametrize( + ("names", "expected"), + [ + (["44.1 kHz", "8 kHz"], ["8 kHz", "44.1 kHz"]), + (["track10", "track2"], ["track2", "track10"]), + (["10", "9", "100"], ["9", "10", "100"]), + (["γ10", "γ2"], ["γ2", "γ10"]), + ], + ) + def test_digit_runs_compare_as_numbers( + self, + names: List[str], + expected: List[str], + ) -> None: + assert sorted(names, key=natural_sort_key) == expected + + def test_leading_zeros_keep_a_fixed_order(self) -> None: + """``01`` and ``1`` state the same number, and the text itself settles which reads first.""" + assert sorted(["1", "01"], key=natural_sort_key) == ["01", "1"] + + def test_a_number_reads_before_the_text_beside_it(self) -> None: + assert sorted(["kick", "2 kick"], key=natural_sort_key) == ["2 kick", "kick"] + + +class TestText: + def test_case_states_nothing_about_order(self) -> None: + assert sorted(["Beats", "amen", "Cymbals"], key=natural_sort_key) == ["amen", "Beats", "Cymbals"] + + def test_names_reading_alike_keep_a_fixed_order(self) -> None: + assert sorted(["song", "Song"], key=natural_sort_key) == ["Song", "song"] + + def test_a_shorter_name_reads_first(self) -> None: + assert sorted(["amen breaks", "amen"], key=natural_sort_key) == ["amen", "amen breaks"] + + def test_the_empty_name_reads_first(self) -> None: + assert sorted(["", "a"], key=natural_sort_key) == ["", "a"] + + +class TestKey: + def test_one_name_reaches_one_key(self) -> None: + assert natural_sort_key("44.1 kHz") == natural_sort_key("44.1 kHz") + + def test_a_name_the_reader_alone_can_spell(self) -> None: + """A digit-like glyph outside the decimal digits is text, and the key states it as text.""" + assert sorted(["m²", "m1"], key=natural_sort_key) == ["m1", "m²"] From 9f839b81a63ae60a8e3ae6a6107fc93d383ea920 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 13:26:17 +0200 Subject: [PATCH 11/45] Fixed: favourite repainting across both browser views --- src/sampletones_application/application.py | 12 + .../coordinators/tabs/reconstruction.py | 7 +- .../coordinators/tabs/sequencer.py | 8 +- .../logic/shared/tree.py | 17 +- .../ui/elements/tree/tree.py | 16 +- .../ui/panels/main/explorer.py | 6 +- .../ui/panels/shared/browser.py | 3 +- src/sampletones_core/structures/tree/tree.py | 29 ++- .../logic/shared/test_tree.py | 42 ++++ .../ui/elements/tree/test_favorites.py | 217 ++++++++++++++++++ .../structures/tree/test_tree.py | 38 ++- 11 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_favorites.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 680c8536..d05cdd63 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -144,6 +144,7 @@ from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends @@ -392,6 +393,7 @@ def __init__( on_reconstruct_file=self._reconstruct_file_dialog, on_reconstruct_directory=self._reconstruct_directory_dialog, on_change_audio_state=self._update_menu, + on_favorite_changed=self._repaint_reconstruction_favorites, on_reconstruction_instrument_updated=self._regenerate_instrument, is_operation_active=self._is_operation_active, original_audio_locator=self._original_audio_locator, @@ -457,6 +459,7 @@ def __init__( dialogs=self.dialogs, status_bar=self.status_bar, on_edit_sample_requested=self._edit_project_sample, + on_favorite_changed=self._repaint_reconstruction_favorites, on_sample_reconstruction_replaced=self._rebind_replaced_sample, on_tab_switch=self._set_current_tab, on_nes_frequency_changed=self._retune_samples_for_rate, @@ -928,6 +931,15 @@ def _refresh_reconstruction_trees(self) -> None: self._reconstructions_tab.refresh_browser() self._sequencer_tab.refresh_browser() + def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: + """Repaints the toggled path in both browsers, whichever tab the star was clicked in. + + The two browsers render one tree and read one set of favorites, so each of them holds a row + for the path that just changed. + """ + self._reconstructions_tab.repaint_browser_favorites(node) + self._sequencer_tab.repaint_browser_favorites(node) + def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9d343ade..9221ac1c 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -80,6 +80,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.scope import ExportScope @@ -113,6 +114,7 @@ def __init__( on_reconstruct_file: VoidCallback, on_reconstruct_directory: VoidCallback, on_change_audio_state: VoidCallback, + on_favorite_changed: Callable[[FileSystemNode], None], on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, is_operation_active: Callable[[], bool], original_audio_locator: OriginalAudioLocator, @@ -167,7 +169,7 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled - self._browser_tree_logic.on_favorite_changed = self._browser_panel.update_favorite_indicator + self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed) @@ -549,6 +551,9 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def repaint_browser_favorites(self, node: FileSystemNode) -> None: + self._browser_panel.update_favorite_indicator(node) + def display_reconstruction(self) -> None: self._reconstruction_panel_logic.display_reconstruction() self._reconstruction_instruments_logic.update_display() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index ea2b13b8..1123cf15 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -127,6 +127,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback, VoidCallback @@ -157,6 +158,7 @@ def __init__( dialogs: DialogsRenderer, status_bar: GUIStatusBar, on_edit_sample_requested: StringCallback, + on_favorite_changed: Callable[[FileSystemNode], None], on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], on_tab_switch: Callable[[Tab], None], on_nes_frequency_changed: Callable[[int], None], @@ -167,6 +169,7 @@ def __init__( self._history = history self._original_audio_locator = original_audio_locator self._on_edit_sample_requested = on_edit_sample_requested + self._on_favorite_changed = on_favorite_changed self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced self._on_tab_switch = on_tab_switch self._on_nes_frequency_changed = on_nes_frequency_changed @@ -633,7 +636,7 @@ def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled - self._sequencer_tree_logic.on_favorite_changed = self._sequencer_browser_panel.update_favorite_indicator + self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error @@ -947,6 +950,9 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def repaint_browser_favorites(self, node: FileSystemNode) -> None: + self._sequencer_browser_panel.update_favorite_indicator(node) + def _on_song_changed(self) -> None: self._sequencer_tracker_logic.push_settings() self._sequencer_tracker_logic.push_tracker() diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 00473536..e10c4b83 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -147,17 +147,14 @@ def is_node_favorite(self, node: TreeNode) -> bool: return node.filepath in self._session_manager.favorites def has_favorite_ancestor(self, node: FileSystemNode) -> bool: - current_node = node.parent - while current_node is not None: - if not isinstance(current_node, FileSystemNode): - break + """Whether a favorite directory holds this path, at any depth above it. - if self.is_node_favorite(current_node): - return True - - current_node = current_node.parent - - return False + The answer reads the path rather than the rows above it, so it holds wherever a view puts + the node: a reconstruction listed under the sample it came from sits below groups the + browser invented, and the directory that makes it a favorite child is still on its path. + """ + favorites = self._session_manager.favorites + return any(directory in favorites for directory in node.filepath.parents) def toggle_favorite(self, node: FileSystemNode) -> None: self._session_manager.toggle_favorite(node.filepath) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index cac7306b..35957853 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -870,8 +870,20 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) def update_favorite_indicator(self, node: FileSystemNode) -> None: - has_favorite_ancestor = self._logic.has_favorite_ancestor(node) - self._reapply_theme_recursively(node, has_favorite_ancestor) + """Repaints every row standing for the toggled path, and what each of them holds. + + A path reaches the panel as many rows as the views offer it — a reconstruction is listed + both by its configuration and by the sample it came from — and the star belongs to the path, + so each of those rows takes the new theme. + """ + for twin in self._nodes_at(node.filepath): + self._reapply_theme_recursively( + twin, + self._logic.has_favorite_ancestor(twin), + ) + + def _nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: + return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) @abstractmethod def set_tree_enabled(self, enabled: bool) -> None: ... diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 77438177..c20fbd94 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -236,12 +236,11 @@ def _collect_subtree_specs( self._pending_specs = [] if self._explorer_logic.is_directory_expanded(node.filepath): for child in node.children: - has_favorite_ancestor = self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(child) self._build_tree_node( child, TreeNodeState( parent=node_tag, - has_favorite_ancestor=has_favorite_ancestor, + has_favorite_ancestor=self._logic.has_favorite_ancestor(child), ), ) @@ -261,8 +260,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 09cb9750..84475958 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -206,8 +206,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) self._append_spec( diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 07881c24..1b8ed002 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,9 +1,11 @@ -from typing import Callable, Dict, Optional, Sequence +from typing import Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar from anytree import PreOrderIter from .node import TreeNode +TreeNodeT = TypeVar("TreeNodeT", bound=TreeNode) + class Tree: def __init__(self, root: Optional[TreeNode] = None) -> None: @@ -64,6 +66,31 @@ def is_node_visible(self, node: TreeNode) -> bool: return self._node_visibility.get(node, False) + def find_nodes( + self, + node_class: Type[TreeNodeT], + predicate: Callable[[TreeNodeT], bool], + ) -> Tuple[TreeNodeT, ...]: + """Answers every node of ``node_class`` the predicate accepts, in reading order. + + One thing can stand in several places in a tree — a file listed by its configuration and + again by the sample it came from — so a caller acting on a thing rather than on a row asks + for all of its nodes at once. Naming the node class keeps the answer typed, so the caller + reads the fields that class carries. + """ + if self.root is None: + return () + + return tuple( + node + for node in PreOrderIter(self.root) + if isinstance( + node, + node_class, + ) + and predicate(node) + ) + def collect_leaves(self) -> Sequence[TreeNode]: if not self.root: return [] diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index f12e2fce..3a7763e8 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -262,6 +262,48 @@ def test_has_favorite_ancestor_returns_true_when_parent_is_favorite( child.parent = parent assert tree.has_favorite_ancestor(child) is True + def test_has_favorite_ancestor_reads_the_path_rather_than_the_rows_above( + self, + tmp_path: Path, + ) -> None: + """A view may list a file under invented rows, and the favorite directory is still its own.""" + directory_path = tmp_path / "config" + session_manager = MagicMock() + session_manager.favorites = {directory_path} + tree = _tree(session_manager=session_manager) + node = _file_node(directory_path / "song.stn") + node.parent = TreeNode("cw_amen02_165", NodeType.SAMPLE) + assert tree.has_favorite_ancestor(node) is True + + def test_has_favorite_ancestor_reaches_any_depth( + self, + tmp_path: Path, + ) -> None: + session_manager = MagicMock() + session_manager.favorites = {tmp_path} + tree = _tree(session_manager=session_manager) + node = _file_node(tmp_path / "config" / "album" / "song.stn") + assert tree.has_favorite_ancestor(node) is True + + def test_has_favorite_ancestor_returns_false_for_a_favorite_sibling( + self, + tmp_path: Path, + ) -> None: + session_manager = MagicMock() + session_manager.favorites = {tmp_path / "other.wav"} + tree = _tree(session_manager=session_manager) + assert tree.has_favorite_ancestor(_file_node(tmp_path / "audio.wav")) is False + + def test_has_favorite_ancestor_returns_false_for_the_node_itself( + self, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "audio.wav" + session_manager = MagicMock() + session_manager.favorites = {filepath} + tree = _tree(session_manager=session_manager) + assert tree.has_favorite_ancestor(_file_node(filepath)) is False + def test_toggle_favorite_delegates_to_session( self, tmp_path: Path, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py new file mode 100644 index 00000000..2f5f5e36 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -0,0 +1,217 @@ +from pathlib import Path +from typing import Final, List, Set, Tuple + +import pytest + +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FAVORITE_CHILD, +) +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode + +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") +SONG_PATH: Final[Path] = CONFIG_DIRECTORY / "song.stn" +OTHER_PATH: Final[Path] = CONFIG_DIRECTORY / "other.stn" + +Repaints = List[Tuple[TreeNode, bool]] + + +class FakeTreeLogic: + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +def browser_tree() -> Tree: + """Builds the shape both browser views give one reconstructions directory. + + The same reconstruction is listed by its configuration and again by the sample it came from, so + one path reaches the panel as two rows. + """ + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + directory = FileSystemNode( + "PTN", + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + parent=configurations, + ) + FileSystemNode("song", node_type=NodeType.FILE, filepath=SONG_PATH, parent=directory) + FileSystemNode("other", node_type=NodeType.FILE, filepath=OTHER_PATH, parent=directory) + + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("song", node_type=NodeType.SAMPLE, parent=samples) + FileSystemNode( + "44.1 kHz·30 Hz", + node_type=NodeType.FILE, + filepath=SONG_PATH, + parent=sample, + ) + return Tree(root=root) + + +@pytest.fixture +def repaints() -> Repaints: + return [] + + +def build_panel( + tree: Tree, + favorites: Set[Path], + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, +) -> GUISequencerBrowserPanel: + """Builds a browser panel that records the rows it would repaint. + + Repainting binds themes to widgets, so the theme pass stands in as a recorder here and the + panel keeps only the tree and the logic the favorite pass reads. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tree = tree + monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append((node, has_favorite_ancestor)), + raising=False, + ) + return panel + + +def node_at(tree: Tree, filepath: Path) -> FileSystemNode: + return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)[0] + + +def build_specs( + tree: Tree, + favorites: Set[Path], + monkeypatch: pytest.MonkeyPatch, +) -> List[NodeSpec]: + """Collects the rows a browser refresh would emit for a tree, with the themes it resolves. + + The collecting pass runs off the main thread and touches no widget, so it needs only the tree, + the logic it asks about favorites, and a tag per row. + """ + panel = build_panel(tree, favorites, [], monkeypatch) + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + monkeypatch.setattr(panel, "_generate_node_tag", lambda node: f"row.{node.name}", raising=False) + + panel._build_tree_node(tree.get_root(), TreeNodeState(parent="tree")) + return panel._pending_specs + + +def theme_of(specs: List[NodeSpec], label: str) -> str: + return next(spec.theme_tag for spec in specs if spec.label == label) + + +class TestTwinRepaint: + def test_every_row_standing_for_the_path_repaints( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [node.name for node, _ in repaints] == ["song", "44.1 kHz·30 Hz"] + + def test_a_path_listed_once_repaints_once( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {OTHER_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, OTHER_PATH)) + + assert [node.name for node, _ in repaints] == ["other"] + + def test_a_path_the_tree_states_nowhere_repaints_nothing( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, set(), repaints, monkeypatch) + elsewhere = FileSystemNode( + "elsewhere", + node_type=NodeType.FILE, + filepath=Path("/elsewhere/song.stn"), + ) + + panel.update_favorite_indicator(elsewhere) + + assert repaints == [] + + def test_a_favorite_directory_repaints_where_each_view_holds_it( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, CONFIG_DIRECTORY)) + + assert [node.name for node, _ in repaints] == ["PTN"] + + +class TestFavoriteAncestry: + def test_each_row_repaints_with_the_ancestry_of_its_path( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A favorite configuration directory tints the reconstruction in both views.""" + tree = browser_tree() + panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [True, True] + + def test_a_row_no_favorite_holds_repaints_plainly( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [False, False] + + +class TestFavoriteAncestryWhileBuilding: + def test_a_directory_below_a_favorite_the_view_omits_is_tinted( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The reconstructions directory holds the row without being a row itself, and still counts.""" + tree = browser_tree() + specs = build_specs(tree, {CONFIG_DIRECTORY.parent}, monkeypatch) + assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_FAVORITE_CHILD + + def test_a_directory_no_favorite_holds_reads_plainly( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + specs = build_specs(tree, set(), monkeypatch) + assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_DEFAULT diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index 0fd62e7d..a0d2f235 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -1,13 +1,16 @@ from dataclasses import dataclass -from typing import List +from pathlib import Path +from typing import Final, List import pytest -from sampletones_core.structures.tree.node import TreeNode +from sampletones_core.structures.tree.node import FileSystemNode, TreeNode from sampletones_core.structures.tree.tree import Tree from sampletones_core.structures.tree.type import NodeType from tests.suite.case import BaseTestCase +SONG_PATH: Final[Path] = Path("/reconstructions/song.stn") + def name_predicate(node: TreeNode, query: str) -> bool: return query in node.name @@ -171,3 +174,34 @@ def test_filtered_leaves_exclude_hidden(self, tree: Tree) -> None: leaves = tree.collect_leaves() assert len(leaves) == 1 assert leaves[0].name == "leaf_ba" + + +class TestTreeFindNodes: + @staticmethod + def _tree_with_twins() -> Tree: + root = TreeNode("root", NodeType.ROOT) + by_configuration = TreeNode("by_configuration", NodeType.GROUP, parent=root) + by_sample = TreeNode("by_sample", NodeType.GROUP, parent=root) + FileSystemNode("song", NodeType.FILE, SONG_PATH, parent=by_configuration) + FileSystemNode("44.1 kHz", NodeType.FILE, SONG_PATH, parent=by_sample) + FileSystemNode("other", NodeType.FILE, Path("/reconstructions/other.stn"), parent=by_sample) + return Tree(root=root) + + def test_empty_tree_answers_nothing(self) -> None: + assert Tree().find_nodes(TreeNode, lambda node: True) == () + + def test_every_node_standing_for_one_path_is_answered(self) -> None: + tree = self._tree_with_twins() + twins = tree.find_nodes(FileSystemNode, lambda node: node.filepath == SONG_PATH) + assert [twin.name for twin in twins] == ["song", "44.1 kHz"] + + def test_nodes_of_other_classes_stay_out(self) -> None: + tree = self._tree_with_twins() + assert all(isinstance(node, FileSystemNode) for node in tree.find_nodes(FileSystemNode, lambda node: True)) + + def test_the_answer_reads_in_tree_order(self, tree: Tree) -> None: + found = tree.find_nodes(TreeNode, lambda node: node.node_type == NodeType.FILE) + assert [node.name for node in found] == ["leaf_aa", "leaf_ab", "leaf_ba"] + + def test_a_predicate_nothing_answers_gives_nothing(self, tree: Tree) -> None: + assert tree.find_nodes(FileSystemNode, lambda node: True) == () From 0557715b7cce9f97a3e9031b84e5d86016f9774f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 13:42:28 +0200 Subject: [PATCH 12/45] Added: single-child group collapsing in the browser --- docs/development/bugs-and-todos.md | 2 + .../logic/reconstruction/browser/manager.py | 8 +- .../reconstruction/browser/tree/collapse.py | 57 ++++++++ .../logic/reconstruction/browser/conftest.py | 9 ++ .../reconstruction/browser/test_collapse.py | 127 ++++++++++++++++++ .../reconstruction/browser/test_manager.py | 93 +++++++++++-- 6 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/collapse.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 398ba147..f80bda09 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -36,7 +36,9 @@ * Respecting FamiTracker limitations * Per-tab undo routing * In-application console +* Improve performance of browser favorite scan of the entire tree per click ## Bugs * No refreshing after library generation +* Misaligned dialog boxes sizes at initialization diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index e8eed1ba..f9d3b8d3 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -3,6 +3,9 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.tree.collapse import ( + collapse_single_child_containers, +) from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) @@ -26,8 +29,8 @@ class BrowserManager: """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that - one reading, shapes what came out — empty headings pruned, siblings ordered — and publishes the - result as the tree both browser tabs render. + one reading, shapes what came out — empty headings pruned, lone headings folded into the row they + lead to, siblings ordered — and publishes the result as the tree both browser tabs render. """ def __init__( @@ -73,6 +76,7 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: ) prune_empty_containers(container_root) + collapse_single_child_containers(container_root) order_children(container_root) return container_root diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py new file mode 100644 index 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/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 04664f09..41662f90 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -122,6 +122,15 @@ def child_names(node: TreeNode) -> List[str]: return [str(child.name) for child in node.children] +def reconstruction_paths(node: TreeNode) -> List[Path]: + """Answers the reconstructions a branch offers, wherever the rows of that branch put them.""" + return sorted( + descendant.filepath + for descendant in node.descendants + if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE + ) + + def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py new file mode 100644 index 00000000..80c0c9b9 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py @@ -0,0 +1,127 @@ +from sampletones_application.logic.reconstruction.browser.tree.collapse import ( + collapse_single_child_containers, +) +from sampletones_core.structures.tree import NodeType + +from .conftest import ( + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestLoneHeadings: + def test_a_group_leading_to_one_row_folds_into_it(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("song", group_node("44.1 kHz·30 Hz", branch)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·song"] + + def test_a_chain_folds_into_one_row(self) -> None: + """The deepest heading folds first, so each level it passes adds one separator.""" + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("song", group_node("FFT·γ0", frequencies)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·FFT·γ0·song"] + + def test_a_sample_leading_to_one_variant_folds_into_it(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", branch)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["cw_amen02_165·44.1 kHz·30 Hz·FFT·γ0·PTN"] + + def test_the_folded_row_keeps_what_it_carries(self) -> None: + root = container_root() + branch = group_node("branch", root) + reconstruction = file_node("song", group_node("44.1 kHz·30 Hz", branch)) + held = reconstruction.filepath + + collapse_single_child_containers(root) + + folded = branch.children[0] + assert folded is reconstruction + assert folded.node_type == NodeType.FILE + assert folded.filepath == held + + def test_a_folded_group_keeps_the_children_it_led_to(self) -> None: + root = container_root() + branch = group_node("branch", root) + directory = directory_node("Amen Breaks", group_node("44.1 kHz·30 Hz", branch)) + file_node("song", directory) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·Amen Breaks"] + assert child_names(directory) == ["song"] + + +class TestHeadingsThatStay: + def test_a_group_gathering_several_rows_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("first", frequencies) + file_node("second", frequencies) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz"] + assert child_names(frequencies) == ["first", "second"] + + def test_a_branch_root_stays(self) -> None: + """Each branch names a way of reading the whole tree, so it heads its rows however few they are.""" + root = container_root() + branch = group_node("branch", root) + file_node("song", branch) + + collapse_single_child_containers(root) + + assert child_names(root) == ["branch"] + assert child_names(branch) == ["song"] + + def test_a_folder_leading_to_one_row_stays(self) -> None: + """The configuration branch mirrors the disk, so a folder holding one file is still a folder.""" + root = container_root() + branch = group_node("branch", root) + directory = directory_node("Amen Breaks", branch) + file_node("song", directory) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["Amen Breaks"] + assert child_names(directory) == ["song"] + + def test_a_group_whose_fold_would_repeat_a_name_beside_it_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("song", frequencies) + file_node("44.1 kHz·30 Hz·song", branch) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz", "44.1 kHz·30 Hz·song"] + assert child_names(frequencies) == ["song"] + + def test_the_container_root_stays(self) -> None: + root = container_root() + file_node("song", group_node("branch", root)) + + collapse_single_child_containers(root) + + assert root.node_type == NodeType.ROOT + assert root.parent is None + assert child_names(root) == ["branch"] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 825eefe2..85f34530 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -4,9 +4,15 @@ import pytest from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + format_frequencies, + format_transformation, +) from .conftest import ( CONFIGURATION_BRANCH_KEY, + HASH_B, SAMPLE_BRANCH_KEY, config_directory, config_fields, @@ -14,6 +20,7 @@ directory_children, file_children, group_children, + reconstruction_paths, sample_branch, sample_children, write_reconstruction, @@ -73,19 +80,12 @@ def test_reconstruction_is_reachable_from_both_branches( browser_manager: BrowserManager, tmp_path: Path, ) -> None: - fields = config_fields() - path = write_reconstruction(config_directory(tmp_path, fields), "song") + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") browser_manager.refresh_tree() - configurations = configuration_branch(browser_manager) - frequencies = next(iter(group_children(configurations).values())) - methods = next(iter(group_children(frequencies).values())) - generators = next(iter(methods.children)) - assert file_children(generators)["song"].filepath == path - - samples = sample_branch(browser_manager) - assert file_children(sample_children(samples)["song"])[fields.display_name].filepath == path + assert reconstruction_paths(configuration_branch(browser_manager)) == [path] + assert reconstruction_paths(sample_branch(browser_manager)) == [path] def test_reads_every_folder_once( self, @@ -111,6 +111,79 @@ def counting_iterdir(directory_path: Path) -> Iterator[Path]: assert sorted(listed) == sorted(set(listed)) +class TestBranchShape: + def test_a_lone_configuration_reads_as_one_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """One configuration needs no headings to be told apart, so its row carries the whole label.""" + fields = config_fields() + write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + folded = directory_children(configurations)[fields.display_name] + + assert list(directory_children(configurations)) == [fields.display_name] + assert list(file_children(folded)) == ["song"] + + def test_the_heading_telling_two_configurations_apart_stays( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """Two configurations sharing their rates are gathered by rate and read apart by spectrum.""" + first = config_fields() + second = config_fields(transformation_gamma=1, config_hash=HASH_B) + write_reconstruction(config_directory(tmp_path, first), "song") + write_reconstruction(config_directory(tmp_path, second), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + frequencies = group_children(configurations)[format_frequencies(first.sr, first.nf)] + + assert list(directory_children(frequencies)) == [ + DISPLAY_SEPARATOR.join([format_transformation(first.sm, first.tg), first.gn]), + DISPLAY_SEPARATOR.join([format_transformation(second.sm, second.tg), second.gn]), + ] + + def test_a_sample_reconstructed_once_reads_as_one_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + fields = config_fields() + write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + samples = sample_branch(browser_manager) + + assert list(file_children(samples)) == [DISPLAY_SEPARATOR.join(["song", fields.display_name])] + + def test_a_sample_reconstructed_twice_keeps_its_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + first = config_fields() + second = config_fields(transformation_gamma=1, config_hash=HASH_B) + write_reconstruction(config_directory(tmp_path, first), "song") + write_reconstruction(config_directory(tmp_path, second), "song") + + browser_manager.refresh_tree() + + samples = sample_branch(browser_manager) + + assert list(file_children(sample_children(samples)["song"])) == [ + first.display_name, + second.display_name, + ] + + class TestSetReconstructionsDirectory: def test_directory_is_taken_over( self, From e533aab6e076d8572ddecb1e19029a1435326031 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 15:22:28 +0200 Subject: [PATCH 13/45] Extracted: the shared file-browser panel base --- src/sampletones_application/application.py | 12 +- .../coordinators/tabs/instructions.py | 7 +- .../coordinators/tabs/main.py | 7 +- .../coordinators/tabs/reconstruction.py | 18 +-- .../coordinators/tabs/sequencer.py | 6 +- .../logic/reconstruction/browser/manager.py | 13 +- .../ui/elements/tree/browser.py | 151 ++++++++++++++++++ .../ui/elements/tree/tags.py | 19 +++ .../ui/elements/tree/tree.py | 22 ++- .../ui/panels/reconstruction/browser.py | 46 +++--- .../ui/panels/sequencer/browser.py | 35 ++-- .../ui/panels/shared/browser.py | 115 +++---------- .../reconstruction/browser/test_manager.py | 42 +++++ .../ui/elements/tree/test_favorites.py | 24 ++- 14 files changed, 343 insertions(+), 174 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/browser.py create mode 100644 src/sampletones_application/ui/elements/tree/tags.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index d05cdd63..079ae036 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -390,12 +390,9 @@ def __init__( export_service=self.export_service, tracker_backends=self.tracker_backends, on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, - on_reconstruct_file=self._reconstruct_file_dialog, - on_reconstruct_directory=self._reconstruct_directory_dialog, on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, on_reconstruction_instrument_updated=self._regenerate_instrument, - is_operation_active=self._is_operation_active, original_audio_locator=self._original_audio_locator, layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, @@ -934,11 +931,12 @@ def _refresh_reconstruction_trees(self) -> None: def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: """Repaints the toggled path in both browsers, whichever tab the star was clicked in. - The two browsers render one tree and read one set of favorites, so each of them holds a row - for the path that just changed. + The two browsers render one tree and read one set of favorites, so the rows standing for the + toggled path are read once here and handed to each of them. """ - self._reconstructions_tab.repaint_browser_favorites(node) - self._sequencer_tab.repaint_browser_favorites(node) + nodes = self.browser_manager.nodes_at(node.filepath) + self._reconstructions_tab.repaint_browser_favorites(nodes) + self._sequencer_tab.repaint_browser_favorites(nodes) def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 69742232..e2bf4b76 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -69,6 +69,7 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback @@ -142,7 +143,7 @@ def __init__( ) self._library_panel.set_collapse_handler(self._on_library_collapse_changed) self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled - self._library_tree_logic.on_favorite_changed = self._library_panel.update_favorite_indicator + self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility self._library_logic.configure_lock( @@ -352,6 +353,10 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists a centre-column card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) + def _repaint_library_favorites(self, node: FileSystemNode) -> None: + """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row.""" + self._library_panel.update_favorite_indicators((node,)) + def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists the library panel's collapse, then docks or restores the width of the column it fills.""" self._session_manager.set_card_collapsed(card_tag, collapsed) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index a84eac4f..6d665e50 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -62,6 +62,7 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import GeneratorName +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -145,7 +146,7 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL), ) self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled - self._explorer_tree_logic.on_favorite_changed = self._explorer_panel.update_favorite_indicator + self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility self._explorer_tree_logic.on_autoplay_error = self._on_explorer_autoplay_error @@ -252,6 +253,10 @@ def __init__( self._converter_panel.on_convert_requested = self._converter_logic.start_conversion self._converter_panel.on_cancel_requested = self._request_cancel_confirmation + def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: + """Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row.""" + self._explorer_panel.update_favorite_indicators((node,)) + def _on_explorer_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9221ac1c..10f80539 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, Optional, Tuple +from typing import Callable, Dict, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -59,7 +59,9 @@ from sampletones_application.ui.panels.reconstruction.audio import ( GUIReconstructionAudioPanel, ) -from sampletones_application.ui.panels.reconstruction.browser import GUIBrowserPanel +from sampletones_application.ui.panels.reconstruction.browser import ( + GUIReconstructionsBrowserPanel, +) from sampletones_application.ui.panels.reconstruction.instruments.instruments import ( GUIReconstructionInstrumentsPanel, ) @@ -111,12 +113,9 @@ def __init__( export_service: ExportService, tracker_backends: Dict[TrackerFormat, TrackerBackend], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], - on_reconstruct_file: VoidCallback, - on_reconstruct_directory: VoidCallback, on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, - is_operation_active: Callable[[], bool], original_audio_locator: OriginalAudioLocator, *, layout: ReconstructionTabParameters, @@ -158,14 +157,13 @@ def __init__( audio_device_manager, scheduling=layout.scheduling, ) - self._browser_panel: GUIBrowserPanel = GUIBrowserPanel( + self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel( self._browser_logic.tree, self._browser_tree_logic, scheduling=layout.scheduling, language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, - is_operation_active=is_operation_active, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled @@ -220,8 +218,6 @@ def __init__( ) self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree - self._browser_panel.on_reconstruct_file = on_reconstruct_file - self._browser_panel.on_reconstruct_directory = on_reconstruct_directory self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation self._browser_panel.on_reconstruction_remove_requested = self._request_remove_reconstruction self._browser_panel.on_directory_remove_requested = self._request_remove_directory @@ -551,8 +547,8 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() - def repaint_browser_favorites(self, node: FileSystemNode) -> None: - self._browser_panel.update_favorite_indicator(node) + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + self._browser_panel.update_favorite_indicators(nodes) def display_reconstruction(self) -> None: self._reconstruction_panel_logic.display_reconstruction() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 1123cf15..5bd939a6 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional, ParamSpec, Tuple, Union +from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union import dearpygui.dearpygui as dpg @@ -950,8 +950,8 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() - def repaint_browser_favorites(self, node: FileSystemNode) -> None: - self._sequencer_browser_panel.update_favorite_indicator(node) + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + self._sequencer_browser_panel.update_favorite_indicators(nodes) def _on_song_changed(self) -> None: self._sequencer_tracker_logic.push_settings() diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index f9d3b8d3..4fd3deef 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List +from typing import List, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -22,7 +22,7 @@ from sampletones_application.logic.reconstruction.browser.tree.scan import ( scan_reconstructions, ) -from sampletones_core.structures.tree import NodeType, Tree, TreeNode +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode class BrowserManager: @@ -82,3 +82,12 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: def get_all_reconstruction_files(self) -> List[Path]: return sorted({entry.path for entry in self._scan.reconstructions}) + + def nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: + """Answers every row the browser offers for a path, across both views. + + A reconstruction is listed by its configuration and again by the sample it came from, so a + caller acting on the file rather than on one row — repainting a favorite star, for instance — + asks here once and hands the rows to each browser tab. + """ + return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py new file mode 100644 index 00000000..f5b2a70c --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -0,0 +1,151 @@ +from abc import ABC, abstractmethod + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) +from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.layout.collapse import CollapseAxis +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_core.structures.tree import Tree + + +class GUIFileBrowserPanel(GUITreePanel, ABC): + """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card. + + The card holds a refresh control above the search box and the tree it filters. This base builds + that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the + whole card as the tree locks and unlocks. A subclass names its widgets through + :class:`FileBrowserTags`, states what its card and its refresh control read, answers what + refreshing the model means, and shapes each row. + """ + + def __init__( + self, + tree: Tree, + tree_logic: TreeLogicProtocol, + *, + tags: FileBrowserTags, + scheduling: SchedulingBehavior, + search_label: str, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + colors: TreeColors, + initial_collapsed: bool, + ) -> None: + self._tags = tags + + super().__init__( + tree=tree, + tag=tags.panel, + tree_tag=tags.tree, + tree_logic=tree_logic, + scheduling=scheduling, + search_label=search_label, + language_manager=language_manager, + status_bar=status_bar, + colors=colors, + ) + + self._enable_horizontal_collapse( + initial_collapsed=initial_collapsed, + side=CollapseAxis.HORIZONTAL_LEFT, + ) + + @property + @abstractmethod + def section_label(self) -> str: ... + + @property + @abstractmethod + def section_glyph(self) -> str: ... + + @property + @abstractmethod + def refresh_button_label(self) -> str: ... + + @property + @abstractmethod + def refresh_status_message(self) -> str: ... + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( + self.section_label, + glyph=self.section_glyph, + ), + ): + self._create_controls() + dpg.add_separator() + self._create_tree_window() + + self._create_detail_tooltip(self._tags.window_tree) + self.rebuild_tree() + + def _create_controls(self) -> None: + with dpg.group(tag=self._tags.group_controls): + GUIButton( + tag=self._tags.button_refresh, + label=self.refresh_button_label, + width=-1, + callback=self.rebuild_tree, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + + self._status_bar.bind_to_item( + self._tags.button_refresh, + self.refresh_status_message, + ) + + def _create_tree_window(self) -> None: + self.create_search(self._body_container) + with ( + dpg.child_window( + tag=self._tags.window_tree, + horizontal_scrollbar=True, + ), + dpg.group(tag=self._tags.group_tree), + ): + self._create_tree_root() + + def _create_tree_root(self) -> None: + """Opens the container every row attaches to, as a group the rows read directly under.""" + with dpg.group(tag=self.tree_tag): + pass + + def refresh(self) -> None: + self.rebuild_tree() + + @concurrent(wait=False, method_bound=True) + def rebuild_tree(self) -> None: + self._launch_rebuild( + self._refresh_model, + lambda: self._collect_specs(self.tree_tag), + root_tag=self.tree_tag, + ) + + @abstractmethod + def _refresh_model(self) -> None: + """Brings the model the tree renders up to date, on the background rebuild worker.""" + + def set_tree_enabled(self, enabled: bool) -> None: + dpg_configure_item(self._tags.group_tree, enabled=enabled) + dpg_configure_item(self._tags.group_controls, enabled=enabled) diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py new file mode 100644 index 00000000..9dc9c191 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/tags.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FileBrowserTags: + """The DearPyGui tags naming one file browser's widgets, stated together where the browser is declared. + + Every browser builds the same arrangement — a panel card holding a controls group with a refresh + button, and a window holding the group the tree attaches to — so the tags naming those widgets + travel as one value the panel is constructed with. Stating them together makes each browser + declare a complete set at one place, checked where it is written. + """ + + panel: str + tree: str + window_tree: str + group_tree: str + group_controls: str + button_refresh: str diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 35957853..59730542 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import dearpygui.dearpygui as dpg @@ -869,22 +869,20 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) - def update_favorite_indicator(self, node: FileSystemNode) -> None: - """Repaints every row standing for the toggled path, and what each of them holds. + def update_favorite_indicators(self, nodes: Sequence[FileSystemNode]) -> None: + """Repaints the rows a favorite change reaches, and what each of them holds. - A path reaches the panel as many rows as the views offer it — a reconstruction is listed - both by its configuration and by the sample it came from — and the star belongs to the path, - so each of those rows takes the new theme. + A path reaches the panel as many rows as the views offer it — a reconstruction is listed both + by its configuration and by the sample it came from — and the star belongs to the path, so + the caller names every row standing for it and each of them takes the new theme with the + ancestry its own path carries. """ - for twin in self._nodes_at(node.filepath): + for node in nodes: self._reapply_theme_recursively( - twin, - self._logic.has_favorite_ancestor(twin), + node, + self._logic.has_favorite_ancestor(node), ) - def _nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: - return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) - @abstractmethod def set_tree_enabled(self, enabled: bool) -> None: ... diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index ebcd6101..f2edfd4b 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Final, Optional import dearpygui.dearpygui as dpg @@ -18,21 +18,26 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) from sampletones_core.structures.tree import FileSystemNode, Tree from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.types.callback import PathCallback + +_TAGS: Final[FileBrowserTags] = FileBrowserTags( + panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, + tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, + window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, + group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, +) -class GUIBrowserPanel(GUIReconstructionBrowserPanel): - _panel_tag = TAG_RECONSTRUCTIONS_BROWSER_PANEL - _tree_tag = TAG_RECONSTRUCTIONS_BROWSER_TREE - _button_refresh_tag = TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS - _group_controls_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS - _group_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE - _window_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE +class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel): + """The Reconstructions tab's browser, whose reconstructions open in the tab beside it.""" def __init__( self, @@ -43,39 +48,36 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - is_operation_active: Callable[[], bool], - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager + super().__init__( tree=tree, tree_logic=tree_logic, + tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, colors=colors, - refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], - refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, ) - self.on_reconstruct_file: Optional[VoidCallback] = None - self.on_reconstruct_directory: Optional[VoidCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None self.on_reconstruction_remove_requested: Optional[PathCallback] = None self.on_directory_remove_requested: Optional[PathCallback] = None - self._is_operation_active = is_operation_active + @property + def refresh_button_label(self) -> str: + return self._language_manager["reconstructions.browser.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["reconstructions.browser.message.status_refresh"] def _open_reconstruction(self, node: FileSystemNode) -> None: self._load_reconstruction(node) - def _reconstruct_file(self) -> None: - self.call(self.on_reconstruct_file) - - def _reconstruct_directory(self) -> None: - self.call(self.on_reconstruct_directory) - def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: self._add_context_menu_remove_directory_item(node) diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index c3b2aff7..e0ba21e0 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,3 +1,5 @@ +from typing import Final + from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -13,19 +15,24 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) from sampletones_core.structures.tree import FileSystemNode, Tree +_TAGS: Final[FileBrowserTags] = FileBrowserTags( + panel=TAG_SEQUENCER_BROWSER_PANEL, + tree=TAG_SEQUENCER_BROWSER_TREE, + window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, + group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, +) + class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): - _panel_tag = TAG_SEQUENCER_BROWSER_PANEL - _tree_tag = TAG_SEQUENCER_BROWSER_TREE - _button_refresh_tag = TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS - _group_controls_tag = TAG_SEQUENCER_BROWSER_GROUP_CONTROLS - _group_tree_tag = TAG_SEQUENCER_BROWSER_GROUP_TREE - _window_tree_tag = TAG_SEQUENCER_BROWSER_WINDOW_TREE + """The Sequencer tab's browser, whose reconstructions become the song's samples.""" def __init__( self, @@ -36,22 +43,30 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: + self._language_manager = language_manager + super().__init__( tree=tree, tree_logic=tree_logic, + tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, colors=colors, - refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], - refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, ) + @property + def refresh_button_label(self) -> str: + return self._language_manager["sequencer.browser.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["sequencer.browser.message.status_refresh"] + def _open_reconstruction(self, node: FileSystemNode) -> None: - self._logic.cancel_autoplay() self.call(self.on_add_to_sequencer, node.filepath) def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 84475958..5fd9c4c8 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -10,20 +10,15 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) -from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -36,80 +31,53 @@ from sampletones_shared.types.callback import VoidCallback -class GUIReconstructionBrowserPanel(GUITreePanel): +class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs. - Builds the refresh button and the searchable tree, resolves every node into a spec, and routes - node clicks to the subclass through :meth:`_open_reconstruction`. The subclass supplies its DPG - tags, its displayed labels, and the extra items each context menu offers. + Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node + clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and + its refresh control, and adds the items its context menus offer. """ _MONOSPACE_CONFIG_NODES: bool = True - _panel_tag: str - _tree_tag: str - _button_refresh_tag: str - _group_controls_tag: str - _group_tree_tag: str - _window_tree_tag: str - def __init__( self, tree: Tree, tree_logic: TreeLogicProtocol, *, + tags: FileBrowserTags, scheduling: SchedulingBehavior, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - refresh_button_label: str, - refresh_status_message: str, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager - self._browser_label = language_manager["global.browser.label.browser"] - self._refresh_button_label = refresh_button_label - self._refresh_status_message = refresh_status_message self.on_refresh_tree: Optional[VoidCallback] = None super().__init__( tree=tree, - tag=self._panel_tag, - tree_tag=self._tree_tag, tree_logic=tree_logic, + tags=tags, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._browser_label, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() + @property + def section_label(self) -> str: + return self._language_manager["global.browser.label.browser"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.reconstruction - self._create_detail_tooltip(self._window_tree_tag) - self.rebuild_tree() + def _refresh_model(self) -> None: + self.call(self.on_refresh_tree) def _setup_handlers(self) -> None: self._node_handlers = { @@ -139,43 +107,6 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def _create_buttons(self) -> None: - with dpg.group(tag=self._group_controls_tag): - GUIButton( - tag=self._button_refresh_tag, - label=self._refresh_button_label, - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - self._button_refresh_tag, - self._refresh_status_message, - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=self._window_tree_tag, - horizontal_scrollbar=True, - ), - dpg.group(tag=self._group_tree_tag), - dpg.group(tag=self.tree_tag), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - def _has_relevant_content(self, node: TreeNode) -> bool: if node.node_type == NodeType.FILE: return True @@ -241,10 +172,6 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: return super()._resolve_other_theme_tag(node) - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(self._group_tree_tag, enabled=enabled) - dpg_configure_item(self._group_controls_tag, enabled=enabled) - def _on_directory_node_clicked( self, _sender: Sender, @@ -276,9 +203,15 @@ def _on_reconstruction_node_double_clicked( app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: + """Opens the double-clicked reconstruction, dropping the preview the click before it queued. + + A single click queues an autoplay preview, and the second click of a double click means the + reader wants the file itself, so the preview is dropped before the subclass opens it. + """ mouse_button, _ = app_data node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: + self._logic.cancel_autoplay() self._open_reconstruction(node) def _show_directory_context_menu(self, node: FileSystemNode) -> None: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 85f34530..57d8dc29 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -9,6 +9,7 @@ format_frequencies, format_transformation, ) +from sampletones_core.structures.tree import NodeType from .conftest import ( CONFIGURATION_BRANCH_KEY, @@ -184,6 +185,47 @@ def test_a_sample_reconstructed_twice_keeps_its_row( ] +class TestNodesAt: + def test_a_reconstruction_is_answered_once_per_view( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """Both views hold the reconstruction, so a favorite change reaches a row in each of them.""" + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + nodes = browser_manager.nodes_at(path) + assert [node.node_type for node in nodes] == [NodeType.FILE, NodeType.FILE] + assert all(node.filepath == path for node in nodes) + + def test_a_directory_is_answered_where_it_is_listed( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """The configuration branch mirrors the disk, and it is the branch that lists folders.""" + directory = config_directory(tmp_path, config_fields()) + write_reconstruction(directory, "first") + write_reconstruction(directory, "second") + + browser_manager.refresh_tree() + + assert [node.filepath for node in browser_manager.nodes_at(directory)] == [directory] + + def test_a_path_the_tree_holds_nowhere_is_answered_by_nothing( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + assert browser_manager.nodes_at(tmp_path / "elsewhere.stn") == () + + class TestSetReconstructionsDirectory: def test_directory_is_taken_over( self, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 2f5f5e36..488ec672 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -87,8 +87,9 @@ def build_panel( return panel -def node_at(tree: Tree, filepath: Path) -> FileSystemNode: - return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)[0] +def rows_at(tree: Tree, filepath: Path) -> Tuple[FileSystemNode, ...]: + """Answers the rows the tree holds for a path, as the browser's owner hands them to the panel.""" + return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) def build_specs( @@ -116,7 +117,7 @@ def theme_of(specs: List[NodeSpec], label: str) -> str: return next(spec.theme_tag for spec in specs if spec.label == label) -class TestTwinRepaint: +class TestRowRepaint: def test_every_row_standing_for_the_path_repaints( self, repaints: Repaints, @@ -125,7 +126,7 @@ def test_every_row_standing_for_the_path_repaints( tree = browser_tree() panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [node.name for node, _ in repaints] == ["song", "44.1 kHz·30 Hz"] @@ -137,7 +138,7 @@ def test_a_path_listed_once_repaints_once( tree = browser_tree() panel = build_panel(tree, {OTHER_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, OTHER_PATH)) + panel.update_favorite_indicators(rows_at(tree, OTHER_PATH)) assert [node.name for node, _ in repaints] == ["other"] @@ -148,13 +149,8 @@ def test_a_path_the_tree_states_nowhere_repaints_nothing( ) -> None: tree = browser_tree() panel = build_panel(tree, set(), repaints, monkeypatch) - elsewhere = FileSystemNode( - "elsewhere", - node_type=NodeType.FILE, - filepath=Path("/elsewhere/song.stn"), - ) - panel.update_favorite_indicator(elsewhere) + panel.update_favorite_indicators(rows_at(tree, Path("/elsewhere/song.stn"))) assert repaints == [] @@ -166,7 +162,7 @@ def test_a_favorite_directory_repaints_where_each_view_holds_it( tree = browser_tree() panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, CONFIG_DIRECTORY)) + panel.update_favorite_indicators(rows_at(tree, CONFIG_DIRECTORY)) assert [node.name for node, _ in repaints] == ["PTN"] @@ -181,7 +177,7 @@ def test_each_row_repaints_with_the_ancestry_of_its_path( tree = browser_tree() panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [True, True] @@ -193,7 +189,7 @@ def test_a_row_no_favorite_holds_repaints_plainly( tree = browser_tree() panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [False, False] From 67cce88f3ffb3012574d2dd1b61fd6e1627bb3a6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:25:38 +0200 Subject: [PATCH 14/45] Merged: sampletones_core.paths into a sampletones_shared.paths --- .github/workflows/workflow.yml | 5 +- .gitignore | 3 + Makefile | 7 +- docs/development/dependencies.md | 9 + pyproject.toml | 1 + scripts/assets/icons.py | 359 ++++++++++++++++++ scripts/calibration.py | 2 +- scripts/checks/language_keys.py | 2 +- scripts/checks/palette_colors.py | 2 +- scripts/checks/unused_tags.py | 2 +- scripts/linux/build/build.sh | 1 + scripts/linux/build/icons.sh | 12 + scripts/linux/build/sampletones.sh | 2 +- scripts/windows/build/build.bat | 1 + scripts/windows/build/icons.bat | 14 + scripts/windows/build/sampletones.bat | 2 +- src/sampletones/__main__.py | 4 +- src/sampletones_application/application.py | 2 +- .../config/managers/config.py | 2 +- src/sampletones_application/config/profile.py | 2 +- .../config/session/state/paths.py | 2 +- .../coordinators/config.py | 2 +- .../coordinators/project.py | 2 +- .../coordinators/reconstruction.py | 2 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/instruction/library_manager.py | 2 +- .../logic/main/explorer_manager.py | 10 +- .../logic/reconstruction/browser/tree/scan.py | 2 +- .../logic/shared/tree.py | 8 +- src/sampletones_application/paths.py | 4 +- .../ui/elements/tree/tree.py | 8 +- .../ui/panels/main/explorer.py | 24 +- .../ui/resources/items.py | 2 +- .../ui/resources/resources.py | 2 +- .../ui/themes/loader.py | 2 +- .../utils/gui/shortcuts/catalog.py | 2 +- .../utils/palette/catalog.py | 2 +- src/sampletones_assets/icons/sampletones.ico | Bin 57991 -> 0 bytes src/sampletones_assets/icons/sampletones.png | Bin 30500 -> 0 bytes src/sampletones_assets/icons/sampletones.svg | 12 + .../audio/writers/capability.py | 2 +- .../calibration/corpus/writer.py | 2 +- src/sampletones_core/calibration/paths.py | 2 +- src/sampletones_core/configs/config.py | 2 +- src/sampletones_core/configs/general.py | 2 +- .../library/filename/fields.py | 2 +- .../library/filename/utils.py | 2 +- src/sampletones_core/library/library.py | 2 +- src/sampletones_core/paths.py | 66 ---- src/sampletones_core/project/container.py | 2 +- .../reconstructions/converter/paths/utils.py | 8 +- .../trackers/implementation/bitphase.py | 2 +- .../trackers/implementation/famitracker.py | 2 +- .../meta/source/packages.py | 2 +- src/sampletones_shared/paths.py | 12 - src/sampletones_shared/paths/__init__.py | 0 src/sampletones_shared/paths/extensions.py | 24 ++ src/sampletones_shared/paths/resources.py | 24 ++ src/sampletones_shared/paths/source.py | 5 + src/sampletones_shared/paths/user.py | 23 ++ .../tooling/test_check_commands.py | 2 +- tests/suite/scripts.py | 2 +- .../config/test_profile.py | 2 +- .../logic/reconstruction/browser/conftest.py | 2 +- .../reconstruction/test_reconstruction.py | 8 +- .../logic/shared/test_tree.py | 6 +- .../ui/elements/tree/test_detail_items.py | 2 +- .../utils/gui/shortcuts/test_catalog.py | 2 +- .../utils/gui/shortcuts/test_scheme.py | 2 +- .../audio/writers/test_spec.py | 2 +- .../formats/bitphase/test_btp.py | 2 +- .../formats/bitphase/test_preset.py | 2 +- .../library/filename/test_fields.py | 2 +- .../converter/paths/test_utils.py | 2 +- .../trackers/test_bitphase.py | 2 +- .../trackers/test_extensions.py | 12 +- .../trackers/test_famitracker.py | 2 +- .../meta/source/test_packages.py | 2 +- .../unit/sampletones_shared/paths/__init__.py | 0 .../paths/test_resources.py | 7 + .../{test_paths.py => paths/test_source.py} | 10 +- .../sampletones_shared/paths/test_user.py | 16 + .../scripts/checks/test_palette_colors.py | 2 +- tests/unit/scripts/checks/test_tag_names.py | 2 +- tests/unit/scripts/checks/test_unused_tags.py | 2 +- uv.lock | 75 ++++ 86 files changed, 695 insertions(+), 185 deletions(-) create mode 100755 scripts/assets/icons.py create mode 100644 scripts/linux/build/icons.sh create mode 100644 scripts/windows/build/icons.bat delete mode 100644 src/sampletones_assets/icons/sampletones.ico delete mode 100644 src/sampletones_assets/icons/sampletones.png create mode 100644 src/sampletones_assets/icons/sampletones.svg delete mode 100644 src/sampletones_core/paths.py delete mode 100644 src/sampletones_shared/paths.py create mode 100644 src/sampletones_shared/paths/__init__.py create mode 100644 src/sampletones_shared/paths/extensions.py create mode 100644 src/sampletones_shared/paths/resources.py create mode 100644 src/sampletones_shared/paths/source.py create mode 100644 src/sampletones_shared/paths/user.py create mode 100644 tests/unit/sampletones_shared/paths/__init__.py create mode 100644 tests/unit/sampletones_shared/paths/test_resources.py rename tests/unit/sampletones_shared/{test_paths.py => paths/test_source.py} (63%) create mode 100644 tests/unit/sampletones_shared/paths/test_user.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3cdb1383..2e5e7fb8 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,6 +37,9 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Generate the icon suite + run: uv run --only-group assets python scripts/assets/icons.py + - name: Build sdist and wheel run: uv build @@ -126,7 +129,7 @@ jobs: venv_python=.venv-build/bin/python fi "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" + "$venv_python" -m pip install ".[build]" --group assets - name: Build the bundle (Linux) if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index 88d53808..02a45caf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ sampletones !src/sampletones !tests/sampletones +src/sampletones_assets/icons/sampletones.ico +src/sampletones_assets/icons/sampletones.png + **/*.idea **/*.vscode/** **/*.ipynb_checkpoints/** diff --git a/Makefile b/Makefile index 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/docs/development/dependencies.md b/docs/development/dependencies.md index 7d553cb0..1107c943 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -51,6 +51,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser `jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. +## Application icon + +The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the +mark's geometry and writes the vector `sampletones.svg` together with the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, +declared in the `assets` dependency group. The SVG is committed as the design source, and the +rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, +and the bundle scripts write them before PyInstaller embeds them. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/pyproject.toml b/pyproject.toml index 99ca6af6..d5d30602 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ gpu-cuda11 = [ ] [dependency-groups] +assets = ["pillow>=11,<13"] dev = [ "black==26.5.1", "isort==8.0.1", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py new file mode 100755 index 00000000..65a99a35 --- /dev/null +++ b/scripts/assets/icons.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 + +""" +Builds the application icon suite into `src/sampletones_assets/icons`. + +One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a +blue sine wave and leaving as an amber square wave, on the studio palette — and every +shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, +and the multi-resolution `sampletones.ico`. The raster filenames match the resources the +application resolves through `sampletones_shared/paths`. + +Usage: + python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons +""" + +import argparse +import itertools +import sys +from pathlib import Path +from typing import Final, List, Sequence, Tuple + +from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + Image, + ImageDraw, +) + +Point = Tuple[float, float] +Rectangle = Tuple[float, float, float, float] + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" + +# TODO: take the raster filenames from sampletones_shared.paths.resources +VECTOR_FILENAME: Final[str] = "sampletones.svg" +UNIX_ICON_FILENAME: Final[str] = "sampletones.png" +WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" + +# TODO: SVG configuration should be a YAML file based on a validated Pydantic class +# not a set hardcoded constants; I suggest a nested structure, organizing fields into +# logical units +GRID: Final[int] = 64 +CORNER_RADIUS: Final[float] = 14.0 +RIM_INSET: Final[float] = 1.0 +RIM_WIDTH: Final[float] = 2.0 +RIM_OPACITY: Final[float] = 0.14 +WAVE_WIDTH: Final[float] = 4.0 + +BACKGROUND_TOP: Final[str] = "#3a3650" +BACKGROUND_BOTTOM: Final[str] = "#211d30" +SINE_COLOR: Final[str] = "#64c8ff" +SQUARE_COLOR: Final[str] = "#ffc864" +RIM_COLOR: Final[str] = "#cdb6ff" + +SINE_START: Final[Point] = (8.0, 32.0) +SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( + ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), + ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), +) +SQUARE_POINTS: Final[Tuple[Point, ...]] = ( + (28.0, 32.0), + (28.0, 20.0), + (38.0, 20.0), + (38.0, 44.0), + (48.0, 44.0), + (48.0, 20.0), + (56.0, 20.0), + (56.0, 32.0), +) + +SUPERSAMPLE: Final[int] = 16 +CURVE_SAMPLES: Final[int] = 96 +RASTER_SIZE: Final[int] = 256 +ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) + + +def _grid_number(value: float) -> str: + return f"{value:g}" + + +def _sine_path() -> str: + commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] + for curve in SINE_CURVES: + points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) + commands.append(f"C{points}") + + return " ".join(commands) + + +def _square_path() -> str: + start_x, start_y = SQUARE_POINTS[0] + commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] + for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): + commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") + + return " ".join(commands) + + +# TODO: refactor - this should be a proper template as an asset, not hardcoded +def svg_document() -> str: + """The mark as a standalone vector, with coordinates on the even design grid. + + Grid alignment keeps the wave edges on whole pixels when the icon is rasterized + at 32 px and 16 px. + """ + rim_extent = _grid_number(GRID - 2 * RIM_INSET) + return ( + f'\n' + " \n" + ' \n' + f' \n' + f' \n' + " \n" + " \n" + f' \n' + f' \n' + f' \n' + f' \n' + "\n" + ) + + +def _background(canvas: int) -> Image.Image: + top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) + bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) + blend = Image.linear_gradient("L").resize((canvas, canvas)) + shaded = Image.composite(bottom, top, blend) + + mask = Image.new("L", (canvas, canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + fill=255, + ) + + background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + background.paste(shaded, mask=mask) + return background + + +def _cubic_coordinate( + start: float, + control_one: float, + control_two: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_one + + 3 * remainder * progress**2 * control_two + + progress**3 * end + ) + + +def _sine_points() -> List[Point]: + points: List[Point] = [SINE_START] + position = SINE_START + for control_one, control_two, end in SINE_CURVES: + for step in range(1, CURVE_SAMPLES + 1): + progress = step / CURVE_SAMPLES + points.append( + ( + _cubic_coordinate( + position[0], + control_one[0], + control_two[0], + end[0], + progress, + ), + _cubic_coordinate( + position[1], + control_one[1], + control_two[1], + end[1], + progress, + ), + ) + ) + position = end + + return points + + +def _draw_sine(draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve and + keeps the outline smooth, where a single wide polyline call serrates its edges. + """ + radius = WAVE_WIDTH * SUPERSAMPLE / 2 + for x, y in _sine_points(): + center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=SINE_COLOR, + ) + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive + rectangles fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end[0] - start[0]) + direction_y = _direction(end[1] - start[1]) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start[0] - direction_x * start_reach, + start[1] - direction_y * start_reach, + ) + reached_end = ( + end[0] + direction_x * end_reach, + end[1] + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return ( + min(reached_start[0], reached_end[0]) - across_x, + min(reached_start[1], reached_end[1]) - across_y, + max(reached_start[0], reached_end[0]) + across_x, + max(reached_start[1], reached_end[1]) + across_y, + ) + + +def _square_rectangles() -> List[Rectangle]: + final_segment = len(SQUARE_POINTS) - 2 + return [ + _segment_rectangle( + SQUARE_POINTS[index], + SQUARE_POINTS[index + 1], + half_width=WAVE_WIDTH / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index in range(len(SQUARE_POINTS) - 1) + ] + + +def _draw_square(draw: ImageDraw.ImageDraw) -> None: + for left, top, right, bottom in _square_rectangles(): + draw.rectangle( + ( + round(left * SUPERSAMPLE), + round(top * SUPERSAMPLE), + round(right * SUPERSAMPLE) - 1, + round(bottom * SUPERSAMPLE) - 1, + ), + fill=SQUARE_COLOR, + ) + + +def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: + red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) + return red, green, blue, round(opacity * 255) + + +def _rim_overlay(canvas: int) -> Image.Image: + overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + ImageDraw.Draw(overlay).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + outline=_rgba(RIM_COLOR, RIM_OPACITY), + width=round(RIM_WIDTH * SUPERSAMPLE), + ) + return overlay + + +def render_master() -> Image.Image: + """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" + canvas = GRID * SUPERSAMPLE + image = _background(canvas) + draw = ImageDraw.Draw(image) + _draw_sine(draw) + _draw_square(draw) + image.alpha_composite(_rim_overlay(canvas)) + return image + + +def write_suite(directory: Path) -> List[Path]: + """Writes the vector, the raster, and the Windows icon into the directory.""" + directory.mkdir(parents=True, exist_ok=True) + master = render_master() + renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} + + vector_path = directory / VECTOR_FILENAME + vector_path.write_text(svg_document(), encoding="utf-8") + + raster_path = directory / UNIX_ICON_FILENAME + renders[RASTER_SIZE].save(raster_path) + + windows_path = directory / WINDOWS_ICON_FILENAME + primary, *appended = (renders[size] for size in ICO_SIZES) + primary.save( + windows_path, + format="ICO", + sizes=[(size, size) for size in ICO_SIZES], + append_images=appended, + ) + + return [vector_path, raster_path, windows_path] + + +# TODO: this file should be only a thin layer, the rest of the code +# should belong to sampletones_assets +# Read guidelines and architecture docs, follow the current code philosophy +def main(argv: Sequence[str]) -> int: + """Writes the icon suite and reports each file it produced.""" + + parser = argparse.ArgumentParser( + description="Build the application icon suite from the mark's geometry.", + ) + parser.add_argument( + "--directory", + type=Path, + default=ICONS_DIRECTORY, + help="directory receiving the icon files", + ) + arguments = parser.parse_args(list(argv)) + + for path in write_suite(arguments.directory): + print(f"Wrote {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/calibration.py b/scripts/calibration.py index 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/linux/build/build.sh b/scripts/linux/build/build.sh index 75629a66..1271b202 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -28,6 +28,7 @@ else fi bash "$SCRIPT_DIR/preflight.sh" "$@" +bash "$SCRIPT_DIR/icons.sh" if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then echo "Removing the previous artifact: ./bin/sampletones" diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh new file mode 100644 index 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..bbe075f4 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -30,6 +30,7 @@ if "%RELEASE%"=="1" ( ) call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1 +call "%SCRIPT_DIR%icons.bat" || exit /b 1 if exist "bin\sampletones.exe" ( echo Removing the previous artifact: bin\sampletones.exe diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat new file mode 100644 index 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 079ae036..6a266a36 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -141,7 +141,6 @@ from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -156,6 +155,7 @@ ) from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index 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/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/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/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/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 10f80539..bfdfd0f3 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -81,7 +81,6 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat @@ -95,6 +94,7 @@ LoadReconstructionError, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_WAVE from sampletones_shared.types.callback import PathCallback, VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 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_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 5a66fe40..178d8456 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -3,11 +3,6 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.paths import ( - EXT_FILE_LIBRARY, - EXT_FILE_RECONSTRUCTION, - EXT_FILES_AUDIO, -) from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( FileSystemNode, @@ -16,6 +11,11 @@ TreeNode, create_directory_node, ) +from sampletones_shared.paths.extensions import ( + EXT_FILE_LIBRARY, + EXT_FILE_RECONSTRUCTION, + EXT_FILES_AUDIO, +) from sampletones_shared.utils.system.system import System diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py index cd34400e..56a4bb4c 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -11,8 +11,8 @@ from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION def scan_reconstructions(directory: Path) -> ReconstructionScan: diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index e10c4b83..204c7fe9 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -5,12 +5,12 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_core import paths from sampletones_core.audio import AudioDeviceManager from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths import extensions from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool: return False suffix = node.filepath.suffix.lower() - return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO + return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO def _execute_autoplay(self) -> None: if self._pending_autoplay_node is not None: @@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: return match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: try: reconstruction = Reconstruction.load(node.filepath) self._audio_device_manager.play( @@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: f"Failed to play reconstruction file: {node.filepath}", ) self.call(self.on_autoplay_error, exception) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._audio_device_manager.play_file( node.filepath, update=False, diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 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/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 59730542..1c9360cc 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -65,7 +65,6 @@ BackgroundWorkCancelled, SingleThreadExecutor, ) -from sampletones_core import paths from sampletones_core.configs.display import ( format_nes_frequency, format_sample_rate, @@ -82,6 +81,7 @@ Tree, TreeNode, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import ( Callback, @@ -812,11 +812,11 @@ def _resolve_file_theme_tag( return TAG_GLOBAL_THEME_FAVORITE match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return TAG_GLOBAL_THEME_FILE_LIBRARY - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return TAG_GLOBAL_THEME_FILE_WAVE case _: if has_favorite_ancestor: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index c20fbd94..84aaa74b 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -30,7 +30,6 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core import paths from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -39,6 +38,7 @@ TreeTraversal, traverse, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback, PathCallback @@ -300,19 +300,19 @@ def message_function( node, _ = user_data suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return reconstruction_message_function( *args, user_data=user_data, **kwargs, ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return library_message_function( *args, user_data=user_data, **kwargs, ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return audio_message_function( *args, user_data=user_data, @@ -333,9 +333,9 @@ def _on_file_node_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return self._logic.request_autoplay(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self.call(self.on_wave_file_clicked, node.filepath) return self._logic.request_autoplay(node) @@ -354,12 +354,12 @@ def _on_file_node_double_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: self._load_reconstruction(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._logic.cancel_autoplay() return self._reconstruct_file(node) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return self._load_library(node) return None @@ -452,17 +452,17 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: dpg.add_separator() suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_reconstruction"], callback=lambda: self._load_reconstruction(node), ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_library"], callback=lambda: self._load_library(node), ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_reconstruct_file"], callback=lambda: self._context_reconstruct_file(node), diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py index 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/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/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_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico deleted file mode 100644 index 7a82dacedfc1131620e8e554cc93143d3bd6ff43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57991 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 Td8hvVZnf7gj!vR1eR%_7o5&HIzlB0T2bBC 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 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_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/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 26b0927b..5088d498 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -7,7 +7,7 @@ ) from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.library.key import InstructionLibraryKey -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index 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/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/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/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..fa596d93 --- /dev/null +++ b/src/sampletones_shared/paths/resources.py @@ -0,0 +1,24 @@ +import sys +from importlib.resources import files +from pathlib import Path +from typing import Final, Optional + +_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) + +CONFIG_DIRECTORY: Final[Path] = ( + Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) +) + +ASSETS_DIRECTORY: Final[str] = "assets" + +ICON_DIRECTORY: Final[str] = "icons" +ICON_WIN_FILENAME: Final[str] = "sampletones.ico" +ICON_UNIX_FILENAME: Final[str] = "sampletones.png" + +FONT_DIRECTORY: Final[str] = "fonts" +FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" +FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf" +FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf" +FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf" +FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf" +FONT_ICON: Final[str] = "DejaVuSans.ttf" diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py new file mode 100644 index 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/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/scripts.py b/tests/suite/scripts.py index 97a5263e..413fa9d7 100644 --- a/tests/suite/scripts.py +++ b/tests/suite/scripts.py @@ -1,7 +1,7 @@ import importlib.util from types import ModuleType -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT def load_script(relative_path: str) -> ModuleType: diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py index f1d3e5aa..4a123e29 100644 --- a/tests/unit/sampletones_application/config/test_profile.py +++ b/tests/unit/sampletones_application/config/test_profile.py @@ -2,7 +2,7 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.paths import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH class TestUserProfile: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 41662f90..8a1cae3e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -16,9 +16,9 @@ ReconstructionScan, ) from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 8c9f1c40..0d426cfb 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -19,15 +19,15 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.instructions import TriangleInstruction -from sampletones_core.paths import ( +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_MODULE, ) -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends from tests.suite.case import BaseRegularTestCase NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 3a7763e8..afc741b3 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -10,9 +10,9 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic -from sampletones_core import paths from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import InvalidReconstructionError +from sampletones_shared.paths import extensions def _tree( @@ -360,7 +360,7 @@ def test_load_failure_reports_autoplay_error( audio_device_manager = MagicMock() tree = _tree(audio_device_manager=audio_device_manager) tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with patch( "sampletones_application.logic.shared.tree.Reconstruction.load", @@ -374,7 +374,7 @@ def test_load_failure_reports_autoplay_error( def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: tree = _tree() tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with ( patch( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index d7461250..1c3c57ff 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -7,10 +7,10 @@ from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.configs import Config from sampletones_core.configs.display import format_sample_rate, short_hash -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode from sampletones_core.structures.tree.type import NodeType +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py index 4c314e7e..75325efe 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -6,7 +6,7 @@ from sampletones_application.paths import KEYBINDINGS_DIRECTORY from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}" SHIPPED_SCHEME_NAMES = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 4a690e0e..868689bf 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -12,7 +12,7 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import ( PROBE_SCHEME_NAME, RebindScheme, diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py index cf3961be..cdbc328f 100644 --- a/tests/unit/sampletones_core/audio/writers/test_spec.py +++ b/tests/unit/sampletones_core/audio/writers/test_spec.py @@ -15,7 +15,7 @@ mp3_bitrates, ) from sampletones_core.constants.audio import SAMPLE_RATES -from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE from tests.suite.base import BaseTestSuite MPEG_1_RATE: Final[int] = 44100 diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 10485b18..dc7b3aba 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -13,7 +13,7 @@ CHIP_TYPE_NES, TUNING_TABLE_LENGTH, ) -from sampletones_core.paths import EXT_FILE_BITPHASE +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE from .conftest import build_features, build_instrument, build_sample diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 322f0e3f..ed3b8e11 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,7 +19,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) -from sampletones_core.paths import EXT_FILE_JSON +from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument diff --git a/tests/unit/sampletones_core/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py index 466cfe03..1dbc7b07 100644 --- a/tests/unit/sampletones_core/library/filename/test_fields.py +++ b/tests/unit/sampletones_core/library/filename/test_fields.py @@ -7,7 +7,7 @@ FILENAME_SEPARATOR, InstructionsFilenameFields, ) -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 4c38eec0..6955b77c 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,13 +4,13 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ( filter_files, get_audio_files, get_output_path, get_relative_path, ) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @pytest.fixture(scope="module") diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 1bbd52a3..4c9aa41f 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -9,7 +9,6 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.trackers.format import TrackerFormat @@ -23,6 +22,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py index 10ac1651..b9cc907d 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -3,17 +3,17 @@ import pytest -from sampletones_core.paths import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, -) from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.extensions import format_for_extension from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) UNKNOWN_EXTENSION: Final[str] = ".xm" NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 5aedf2c4..3f036b02 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -10,11 +10,11 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py index bc2333ad..e18e8b85 100644 --- a/tests/unit/sampletones_shared/meta/source/test_packages.py +++ b/tests/unit/sampletones_shared/meta/source/test_packages.py @@ -1,7 +1,7 @@ import pytest from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT SHARED_PACKAGE = "sampletones_shared" APPLICATION_PACKAGE = "sampletones_application" diff --git a/tests/unit/sampletones_shared/paths/__init__.py b/tests/unit/sampletones_shared/paths/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_shared/paths/test_resources.py b/tests/unit/sampletones_shared/paths/test_resources.py new file mode 100644 index 00000000..53ca661a --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_resources.py @@ -0,0 +1,7 @@ +from sampletones_shared.paths.resources import CONFIG_DIRECTORY + + +class TestConfigDirectory: + def test_the_configuration_directory_holds_the_shipped_files(self) -> None: + """Read as a package resource, so the bundle finds it beside the executable.""" + assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/test_paths.py b/tests/unit/sampletones_shared/paths/test_source.py similarity index 63% rename from tests/unit/sampletones_shared/test_paths.py rename to tests/unit/sampletones_shared/paths/test_source.py index ae1c2fe2..6d5f1ec8 100644 --- a/tests/unit/sampletones_shared/test_paths.py +++ b/tests/unit/sampletones_shared/paths/test_source.py @@ -1,4 +1,4 @@ -from sampletones_shared.paths import CONFIG_DIRECTORY, REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT PROJECT_FILE = "pyproject.toml" SHARED_PACKAGE = "sampletones_shared" @@ -10,7 +10,7 @@ def test_the_source_root_holds_the_packages(self) -> None: def test_the_source_root_is_where_this_package_lives(self) -> None: """Reading the root off the package keeps it right wherever the packages are installed.""" - assert (SOURCE_ROOT / SHARED_PACKAGE / "paths.py").is_file() + assert (SOURCE_ROOT / SHARED_PACKAGE / "paths" / "source.py").is_file() class TestRepositoryRoot: @@ -19,9 +19,3 @@ def test_the_repository_root_holds_the_project_file(self) -> None: def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None: assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir() - - -class TestConfigDirectory: - def test_the_configuration_directory_holds_the_shipped_files(self) -> None: - """Read as a package resource, so the bundle finds it beside the executable.""" - assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/paths/test_user.py b/tests/unit/sampletones_shared/paths/test_user.py new file mode 100644 index 00000000..d26807b2 --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_user.py @@ -0,0 +1,16 @@ +from sampletones_shared.paths.user import ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, +) + + +class TestUserDirectories: + def test_the_user_directories_exist_after_import(self) -> None: + """Importing the module creates the directories the application saves into.""" + for directory in ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, + ): + assert directory.is_dir() diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 0d51b8a7..2056eb0d 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -5,7 +5,7 @@ from sampletones_application.paths import PALETTES_DIRECTORY from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY from scripts.checks.palette_colors import dpg_module_helper from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index a79a294f..3b03f4fc 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -7,7 +7,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, Widget from sampletones_application.categories.key.tag import TagName from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.scripts import load_script diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py index 4d8456f0..167e4a90 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -4,7 +4,7 @@ import pytest from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/uv.lock b/uv.lock index d8dc1be3..ac66c620 100644 --- a/uv.lock +++ b/uv.lock @@ -1236,6 +1236,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1754,6 +1825,9 @@ gpu-cuda11 = [ ] [package.dev-dependencies] +assets = [ + { name = "pillow" }, +] dev = [ { name = "black" }, { name = "isort" }, @@ -1796,6 +1870,7 @@ requires-dist = [ provides-extras = ["build", "gpu", "gpu-cuda11"] [package.metadata.requires-dev] +assets = [{ name = "pillow", specifier = ">=11,<13" }] dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, From a2f2eadd5682ecd770abf63d65d6d6cfd660bb1b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:58:12 +0200 Subject: [PATCH 15/45] Moved: icon generation into the sampletones_assets --- .github/workflows/workflow.yml | 5 +- .gitignore | 11 +- CHANGELOG.md | 1 + docs/development/dependencies.md | 15 +- pyproject.toml | 9 + scripts/assets/icons.py | 333 +----------------- src/sampletones_assets/mark/__init__.py | 0 src/sampletones_assets/mark/geometry.py | 128 +++++++ src/sampletones_assets/mark/mark.yaml | 43 +++ src/sampletones_assets/mark/paths.py | 7 + src/sampletones_assets/mark/raster.py | 116 ++++++ .../mark/specification/__init__.py | 38 ++ .../mark/specification/colors.py | 29 ++ .../mark/specification/frame.py | 43 +++ .../mark/specification/point.py | 16 + .../mark/specification/render.py | 28 ++ .../mark/specification/waves.py | 59 ++++ src/sampletones_assets/mark/suite.py | 67 ++++ src/sampletones_assets/mark/template.svg | 12 + src/sampletones_assets/mark/vector.py | 71 ++++ src/sampletones_shared/paths/resources.py | 1 + tests/unit/sampletones_assets/__init__.py | 0 .../unit/sampletones_assets/mark/__init__.py | 0 .../sampletones_assets/mark/test_geometry.py | 69 ++++ .../sampletones_assets/mark/test_raster.py | 47 +++ .../mark/test_specification.py | 176 +++++++++ .../sampletones_assets/mark/test_suite.py | 55 +++ .../sampletones_assets/mark/test_vector.py | 47 +++ uv.lock | 2 + 29 files changed, 1090 insertions(+), 338 deletions(-) create mode 100644 src/sampletones_assets/mark/__init__.py create mode 100644 src/sampletones_assets/mark/geometry.py create mode 100644 src/sampletones_assets/mark/mark.yaml create mode 100644 src/sampletones_assets/mark/paths.py create mode 100644 src/sampletones_assets/mark/raster.py create mode 100644 src/sampletones_assets/mark/specification/__init__.py create mode 100644 src/sampletones_assets/mark/specification/colors.py create mode 100644 src/sampletones_assets/mark/specification/frame.py create mode 100644 src/sampletones_assets/mark/specification/point.py create mode 100644 src/sampletones_assets/mark/specification/render.py create mode 100644 src/sampletones_assets/mark/specification/waves.py create mode 100644 src/sampletones_assets/mark/suite.py create mode 100644 src/sampletones_assets/mark/template.svg create mode 100644 src/sampletones_assets/mark/vector.py create mode 100644 tests/unit/sampletones_assets/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/test_geometry.py create mode 100644 tests/unit/sampletones_assets/mark/test_raster.py create mode 100644 tests/unit/sampletones_assets/mark/test_specification.py create mode 100644 tests/unit/sampletones_assets/mark/test_suite.py create mode 100644 tests/unit/sampletones_assets/mark/test_vector.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 2e5e7fb8..8663dcc8 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,8 +37,11 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Install PortAudio + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: Generate the icon suite - run: uv run --only-group assets python scripts/assets/icons.py + run: uv run --group assets python scripts/assets/icons.py - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 02a45caf..5622d69b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,15 +4,15 @@ __pycache__/ .ipynb_checkpoints/ .mypy_cache/ .pytest_cache/ +.ruff_cache/ .venv/ .venv-build/ .vscode/ -bin/ -build/ dist/ wheels/ -!scripts/**/build/ +/bin/ +/build/ sampletones !src/sampletones @@ -21,11 +21,6 @@ sampletones src/sampletones_assets/icons/sampletones.ico src/sampletones_assets/icons/sampletones.png -**/*.idea -**/*.vscode/** -**/*.ipynb_checkpoints/** -**/*__pycache__/** - *.pyc *.pyo *.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 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/docs/development/dependencies.md b/docs/development/dependencies.md index 1107c943..d6b4035c 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -53,12 +53,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser ## Application icon -The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the -mark's geometry and writes the vector `sampletones.svg` together with the rasters the application -ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, -declared in the `assets` dependency group. The SVG is committed as the design source, and the -rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, -and the bundle scripts write them before PyInstaller embeds them. +The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in +`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization +settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The +package writes the whole suite — the vector `sampletones.svg` and the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` +points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the +`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or +an editor, and the rasters are produced where they are consumed: `make setup` writes them before +packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. ## Linux (standalone executable) diff --git a/pyproject.toml b/pyproject.toml index d5d30602..8e54812e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ gpu-cuda11 = [ [dependency-groups] assets = ["pillow>=11,<13"] dev = [ + { include-group = "assets" }, "black==26.5.1", "isort==8.0.1", "mypy==2.1.0", @@ -96,6 +97,12 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] +[tool.hatch.build] +artifacts = [ + "src/sampletones_assets/icons/sampletones.png", + "src/sampletones_assets/icons/sampletones.ico", +] + [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", @@ -130,6 +137,7 @@ addopts = "--import-mode=importlib" [tool.coverage.run] source = [ "sampletones_application", + "sampletones_assets", "sampletones_core", "sampletones_shared", "sampletones_synthesis", @@ -144,6 +152,7 @@ python_version = "3.12" files = [ "src/sampletones", "src/sampletones_application", + "src/sampletones_assets", "src/sampletones_core", "src/sampletones_shared", "src/sampletones_synthesis", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py index 65a99a35..c6484eb9 100755 --- a/scripts/assets/icons.py +++ b/scripts/assets/icons.py @@ -1,345 +1,32 @@ #!/usr/bin/env python3 """ -Builds the application icon suite into `src/sampletones_assets/icons`. +Writes the application icon suite from the packaged mark definition. -One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a -blue sine wave and leaving as an amber square wave, on the studio palette — and every -shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, -and the multi-resolution `sampletones.ico`. The raster filenames match the resources the -application resolves through `sampletones_shared/paths`. +The mark, its template and the code drawing them live in `sampletones_assets/mark`; this +script points them at the directory the icons are shipped from. Usage: python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons """ import argparse -import itertools import sys from pathlib import Path -from typing import Final, List, Sequence, Tuple +from typing import Final, Sequence -from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds - Image, - ImageDraw, -) +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite -Point = Tuple[float, float] -Rectangle = Tuple[float, float, float, float] +REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons" -PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] -ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" -# TODO: take the raster filenames from sampletones_shared.paths.resources -VECTOR_FILENAME: Final[str] = "sampletones.svg" -UNIX_ICON_FILENAME: Final[str] = "sampletones.png" -WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" - -# TODO: SVG configuration should be a YAML file based on a validated Pydantic class -# not a set hardcoded constants; I suggest a nested structure, organizing fields into -# logical units -GRID: Final[int] = 64 -CORNER_RADIUS: Final[float] = 14.0 -RIM_INSET: Final[float] = 1.0 -RIM_WIDTH: Final[float] = 2.0 -RIM_OPACITY: Final[float] = 0.14 -WAVE_WIDTH: Final[float] = 4.0 - -BACKGROUND_TOP: Final[str] = "#3a3650" -BACKGROUND_BOTTOM: Final[str] = "#211d30" -SINE_COLOR: Final[str] = "#64c8ff" -SQUARE_COLOR: Final[str] = "#ffc864" -RIM_COLOR: Final[str] = "#cdb6ff" - -SINE_START: Final[Point] = (8.0, 32.0) -SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( - ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), - ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), -) -SQUARE_POINTS: Final[Tuple[Point, ...]] = ( - (28.0, 32.0), - (28.0, 20.0), - (38.0, 20.0), - (38.0, 44.0), - (48.0, 44.0), - (48.0, 20.0), - (56.0, 20.0), - (56.0, 32.0), -) - -SUPERSAMPLE: Final[int] = 16 -CURVE_SAMPLES: Final[int] = 96 -RASTER_SIZE: Final[int] = 256 -ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) - - -def _grid_number(value: float) -> str: - return f"{value:g}" - - -def _sine_path() -> str: - commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] - for curve in SINE_CURVES: - points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) - commands.append(f"C{points}") - - return " ".join(commands) - - -def _square_path() -> str: - start_x, start_y = SQUARE_POINTS[0] - commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] - for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): - commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") - - return " ".join(commands) - - -# TODO: refactor - this should be a proper template as an asset, not hardcoded -def svg_document() -> str: - """The mark as a standalone vector, with coordinates on the even design grid. - - Grid alignment keeps the wave edges on whole pixels when the icon is rasterized - at 32 px and 16 px. - """ - rim_extent = _grid_number(GRID - 2 * RIM_INSET) - return ( - f'\n' - " \n" - ' \n' - f' \n' - f' \n' - " \n" - " \n" - f' \n' - f' \n' - f' \n' - f' \n' - "\n" - ) - - -def _background(canvas: int) -> Image.Image: - top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) - bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) - blend = Image.linear_gradient("L").resize((canvas, canvas)) - shaded = Image.composite(bottom, top, blend) - - mask = Image.new("L", (canvas, canvas), 0) - ImageDraw.Draw(mask).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - fill=255, - ) - - background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - background.paste(shaded, mask=mask) - return background - - -def _cubic_coordinate( - start: float, - control_one: float, - control_two: float, - end: float, - progress: float, -) -> float: - remainder = 1.0 - progress - return ( - remainder**3 * start - + 3 * remainder**2 * progress * control_one - + 3 * remainder * progress**2 * control_two - + progress**3 * end - ) - - -def _sine_points() -> List[Point]: - points: List[Point] = [SINE_START] - position = SINE_START - for control_one, control_two, end in SINE_CURVES: - for step in range(1, CURVE_SAMPLES + 1): - progress = step / CURVE_SAMPLES - points.append( - ( - _cubic_coordinate( - position[0], - control_one[0], - control_two[0], - end[0], - progress, - ), - _cubic_coordinate( - position[1], - control_one[1], - control_two[1], - end[1], - progress, - ), - ) - ) - position = end - - return points - - -def _draw_sine(draw: ImageDraw.ImageDraw) -> None: - """Sweeps a disk of the stroke's half width along the curve. - - The union of densely stamped disks equals a round-capped stroke of the curve and - keeps the outline smooth, where a single wide polyline call serrates its edges. - """ - radius = WAVE_WIDTH * SUPERSAMPLE / 2 - for x, y in _sine_points(): - center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE - draw.ellipse( - ( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - fill=SINE_COLOR, - ) - - -def _direction(delta: float) -> float: - if delta > 0: - return 1.0 - - if delta < 0: - return -1.0 - - return 0.0 - - -def _segment_rectangle( - start: Point, - end: Point, - *, - half_width: float, - joined_start: bool, - joined_end: bool, -) -> Rectangle: - """The stroke rectangle of one axis-aligned segment. - - A joined end reaches half the stroke width past its corner, so consecutive - rectangles fill their right-angle miter; an open end keeps a butt cap. - """ - direction_x = _direction(end[0] - start[0]) - direction_y = _direction(end[1] - start[1]) - start_reach = half_width if joined_start else 0.0 - end_reach = half_width if joined_end else 0.0 - - reached_start = ( - start[0] - direction_x * start_reach, - start[1] - direction_y * start_reach, - ) - reached_end = ( - end[0] + direction_x * end_reach, - end[1] + direction_y * end_reach, - ) - across_x = half_width * abs(direction_y) - across_y = half_width * abs(direction_x) - - return ( - min(reached_start[0], reached_end[0]) - across_x, - min(reached_start[1], reached_end[1]) - across_y, - max(reached_start[0], reached_end[0]) + across_x, - max(reached_start[1], reached_end[1]) + across_y, - ) - - -def _square_rectangles() -> List[Rectangle]: - final_segment = len(SQUARE_POINTS) - 2 - return [ - _segment_rectangle( - SQUARE_POINTS[index], - SQUARE_POINTS[index + 1], - half_width=WAVE_WIDTH / 2, - joined_start=index > 0, - joined_end=index < final_segment, - ) - for index in range(len(SQUARE_POINTS) - 1) - ] - - -def _draw_square(draw: ImageDraw.ImageDraw) -> None: - for left, top, right, bottom in _square_rectangles(): - draw.rectangle( - ( - round(left * SUPERSAMPLE), - round(top * SUPERSAMPLE), - round(right * SUPERSAMPLE) - 1, - round(bottom * SUPERSAMPLE) - 1, - ), - fill=SQUARE_COLOR, - ) - - -def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: - red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) - return red, green, blue, round(opacity * 255) - - -def _rim_overlay(canvas: int) -> Image.Image: - overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - outline=_rgba(RIM_COLOR, RIM_OPACITY), - width=round(RIM_WIDTH * SUPERSAMPLE), - ) - return overlay - - -def render_master() -> Image.Image: - """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" - canvas = GRID * SUPERSAMPLE - image = _background(canvas) - draw = ImageDraw.Draw(image) - _draw_sine(draw) - _draw_square(draw) - image.alpha_composite(_rim_overlay(canvas)) - return image - - -def write_suite(directory: Path) -> List[Path]: - """Writes the vector, the raster, and the Windows icon into the directory.""" - directory.mkdir(parents=True, exist_ok=True) - master = render_master() - renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} - - vector_path = directory / VECTOR_FILENAME - vector_path.write_text(svg_document(), encoding="utf-8") - - raster_path = directory / UNIX_ICON_FILENAME - renders[RASTER_SIZE].save(raster_path) - - windows_path = directory / WINDOWS_ICON_FILENAME - primary, *appended = (renders[size] for size in ICO_SIZES) - primary.save( - windows_path, - format="ICO", - sizes=[(size, size) for size in ICO_SIZES], - append_images=appended, - ) - - return [vector_path, raster_path, windows_path] - - -# TODO: this file should be only a thin layer, the rest of the code -# should belong to sampletones_assets -# Read guidelines and architecture docs, follow the current code philosophy def main(argv: Sequence[str]) -> int: """Writes the icon suite and reports each file it produced.""" parser = argparse.ArgumentParser( - description="Build the application icon suite from the mark's geometry.", + description="Write the application icon suite from the mark definition.", ) parser.add_argument( "--directory", @@ -349,7 +36,7 @@ def main(argv: Sequence[str]) -> int: ) arguments = parser.parse_args(list(argv)) - for path in write_suite(arguments.directory): + for path in write_icon_suite(arguments.directory, Mark.load()): print(f"Wrote {path}") return 0 diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py new file mode 100644 index 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..c9547af0 --- /dev/null +++ b/src/sampletones_assets/mark/raster.py @@ -0,0 +1,116 @@ +from typing import Final, Tuple + +from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + +from sampletones_assets.mark.geometry import sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction + +TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0) +OPAQUE: Final[int] = 255 + + +class MarkRaster: + """Draws the mark into one supersampled image, ready to scale down to each shipped size. + + Drawing happens at the render factor times the design grid and the result is resampled + down, which is what keeps the curve edges and the rounded corners smooth at 16 px. + """ + + def __init__(self, mark: Mark) -> None: + self.mark = mark + + @property + def scale(self) -> int: + """Factor the design grid is drawn at.""" + return self.mark.render.supersample + + @property + def canvas(self) -> int: + """Edge length in pixels of the image the mark is drawn into.""" + return self.mark.frame.grid * self.scale + + @property + def corner_radius(self) -> float: + """Corner radius of the frame, in the pixels of the drawn image.""" + return self.mark.frame.corner_radius * self.scale + + @property + def frame_box(self) -> Tuple[int, int, int, int]: + """The whole image, as the box the frame and its rim are drawn in.""" + return (0, 0, self.canvas - 1, self.canvas - 1) + + def render(self) -> Image.Image: + """The mark drawn at the supersampled resolution, on a transparent ground.""" + image = self._background() + draw = ImageDraw.Draw(image) + self._draw_sine(draw) + self._draw_square(draw) + image.alpha_composite(self._rim()) + return image + + def _background(self) -> Image.Image: + """The frame: a vertical gradient between the two background colours, rounded at its corners.""" + size = (self.canvas, self.canvas) + top = Image.new("RGB", size, self.mark.colors.background.top) + bottom = Image.new("RGB", size, self.mark.colors.background.bottom) + shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size)) + + background = Image.new("RGBA", size, TRANSPARENT) + background.paste(shaded, mask=self._frame_mask()) + return background + + def _frame_mask(self) -> Image.Image: + mask = Image.new("L", (self.canvas, self.canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + fill=OPAQUE, + ) + return mask + + def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve, which is + what holds the outline smooth along its whole sweep. + """ + radius = self.mark.waves.width * self.scale / 2 + for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples): + center_x, center_y = point.x * self.scale, point.y * self.scale + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=self.mark.colors.sine, + ) + + def _draw_square(self, draw: ImageDraw.ImageDraw) -> None: + for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width): + draw.rectangle( + ( + round(rectangle.left * self.scale), + round(rectangle.top * self.scale), + round(rectangle.right * self.scale) - 1, + round(rectangle.bottom * self.scale) - 1, + ), + fill=self.mark.colors.square, + ) + + def _rim(self) -> Image.Image: + """The hairline along the frame's edge, as a layer to composite over the drawn mark.""" + overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT) + ImageDraw.Draw(overlay).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + outline=self._rim_color(), + width=round(self.mark.frame.rim.width * self.scale), + ) + return overlay + + def _rim_color(self) -> ColorRGBA: + return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity) diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py new file mode 100644 index 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_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py index fa596d93..9da9292d 100644 --- a/src/sampletones_shared/paths/resources.py +++ b/src/sampletones_shared/paths/resources.py @@ -14,6 +14,7 @@ ICON_DIRECTORY: Final[str] = "icons" ICON_WIN_FILENAME: Final[str] = "sampletones.ico" ICON_UNIX_FILENAME: Final[str] = "sampletones.png" +ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg" FONT_DIRECTORY: Final[str] = "fonts" FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" diff --git a/tests/unit/sampletones_assets/__init__.py b/tests/unit/sampletones_assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/tests/unit/sampletones_assets/mark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_assets/mark/test_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py new file mode 100644 index 00000000..78279edb --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_geometry.py @@ -0,0 +1,69 @@ +import itertools +from typing import Final + +import pytest + +from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark + +SAMPLES: Final[int] = 5 + + +def _overlap(first: Rectangle, second: Rectangle) -> float: + width = min(first.right, second.right) - max(first.left, second.left) + height = min(first.bottom, second.bottom) - max(first.top, second.top) + return min(width, height) + + +class TestSinePoints: + def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None: + sine = Mark.load().waves.sine + points = sine_points(sine, SAMPLES) + + assert points[0] == sine.start + assert points[-1].x == pytest.approx(sine.end.x) + assert points[-1].y == pytest.approx(sine.end.y) + + def test_every_segment_contributes_its_samples(self) -> None: + sine = Mark.load().waves.sine + assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1 + + def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None: + """The wave swings between the extremes its control points reach, keeping it inside the frame.""" + sine = Mark.load().waves.sine + controls = [sine.start] + [ + point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end) + ] + lowest = min(point.y for point in controls) + highest = max(point.y for point in controls) + + for point in sine_points(sine, SAMPLES): + assert lowest <= point.y <= highest + + +class TestSquareRectangles: + def test_one_rectangle_covers_each_segment(self) -> None: + square = Mark.load().waves.square + assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1 + + def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None: + square = Mark.load().waves.square + for rectangle in square_rectangles(square, width=4.0): + assert rectangle.left < rectangle.right + assert rectangle.top < rectangle.bottom + + def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None: + width = 4.0 + square = Mark.load().waves.square + for (start, end), rectangle in zip( + itertools.pairwise(square.points), + square_rectangles(square, width=width), + ): + across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left + assert across == pytest.approx(width) + + def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None: + """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke.""" + square = Mark.load().waves.square + for first, second in itertools.pairwise(square_rectangles(square, width=4.0)): + assert _overlap(first, second) > 0.0 diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py new file mode 100644 index 00000000..5ae61ddf --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_raster.py @@ -0,0 +1,47 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_assets.mark.raster import MarkRaster +from sampletones_assets.mark.specification import Mark +from sampletones_shared.utils.color import parse_hex_color + +CORNER: Final[Tuple[int, int]] = (0, 0) +ALPHA: Final[int] = 3 +CHANNELS: Final[int] = 3 + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestMarkRaster: + def test_the_image_covers_the_supersampled_grid(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + edge = mark.frame.grid * mark.render.supersample + assert image.size == (edge, edge) + + def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + assert image.getpixel(CORNER)[ALPHA] == 0 + + def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None: + """The frame reaches the top edge between its rounded corners, so the ground there is opaque.""" + image = MarkRaster(mark).render() + centre = image.size[0] // 2 + assert image.getpixel((centre, 1))[ALPHA] == 255 + + def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + start = mark.waves.sine.start + pixel = image.getpixel((round(start.x * scale), round(start.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS] + + def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + corner = mark.waves.square.points[1] + pixel = image.getpixel((round(corner.x * scale), round(corner.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.square)[:CHANNELS] diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py new file mode 100644 index 00000000..a3f51d36 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_specification.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from sampletones_assets.mark.specification import Mark +from tests.suite.case import BaseRegularTestCase + +VALID_FRAME: Final[Dict[str, Any]] = { + "grid": 64, + "corner_radius": 14, + "rim": {"inset": 1, "width": 2, "opacity": 0.14}, +} + +VALID_COLORS: Final[Dict[str, Any]] = { + "background": {"top": "#3a3650", "bottom": "#211d30"}, + "sine": "#64c8ff", + "square": "#ffc864", + "rim": "#cdb6ff", +} + +VALID_SINE: Final[Dict[str, Any]] = { + "start": {"x": 8, "y": 32}, + "curves": [ + { + "control_start": {"x": 11, "y": 16}, + "control_end": {"x": 15, "y": 16}, + "end": {"x": 18, "y": 32}, + }, + ], +} + +VALID_SQUARE: Final[Dict[str, Any]] = { + "points": [ + {"x": 18, "y": 32}, + {"x": 18, "y": 20}, + {"x": 28, "y": 20}, + ], +} + +VALID_WAVES: Final[Dict[str, Any]] = { + "width": 4, + "sine": VALID_SINE, + "square": VALID_SQUARE, +} + +VALID_RENDER: Final[Dict[str, Any]] = { + "supersample": 16, + "curve_samples": 96, + "raster_size": 256, + "windows_sizes": [256, 128, 64], +} + +VALID_FIELDS: Final[Dict[str, Any]] = { + "frame": VALID_FRAME, + "colors": VALID_COLORS, + "waves": VALID_WAVES, + "render": VALID_RENDER, +} + + +class TestMark: + @dataclass(frozen=True, kw_only=True) + class InvalidFieldCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "grid": 0}, + label="empty_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "corner_radius": 33}, + label="corner_radius_over_half_the_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}}, + label="rim_inset_outside_the_corner_radius", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}}, + label="rim_opacity_over_full", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "64c8ff"}, + label="color_without_a_hash", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "#64c8"}, + label="color_of_four_hex_digits", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "width": 0}, + label="wave_without_width", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}}, + label="smooth_half_without_curves", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}}, + label="stepped_half_without_a_segment", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]}, + }, + label="stepped_segment_turning_on_both_axes", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]}, + }, + label="halves_meeting_apart", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "supersample": 0}, + label="drawing_below_the_design_grid", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": []}, + label="windows_icon_without_a_frame", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [64, 128, 256]}, + label="windows_sizes_in_ascending_order", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [256, 256, 128]}, + label="repeated_windows_size", + ), + ) + + def test_the_packaged_definition_loads(self) -> None: + mark = Mark.load() + assert isinstance(mark, Mark) + + def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None: + """The mark draws one wave, so the stepped half carries on from where the smooth half arrives.""" + mark = Mark.load() + assert mark.waves.square.points[0] == mark.waves.sine.end + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None: + fields = {**VALID_FIELDS, case.field: case.value} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_a_missing_field_is_rejected(self, field: str) -> None: + fields = {key: value for key, value in VALID_FIELDS.items() if key != field} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + def test_an_unknown_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}}) diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py new file mode 100644 index 00000000..b0a8f3e3 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_suite.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + +RGBA_MODE = "RGBA" +ICO_SIZES_KEY = "sizes" + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestWriteIconSuite: + def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None: + paths = write_icon_suite(tmp_path, mark) + assert [path.name for path in paths] == [ + ICON_VECTOR_FILENAME, + ICON_UNIX_FILENAME, + ICON_WIN_FILENAME, + ] + assert all(path.is_file() for path in paths) + + def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None: + directory = tmp_path / "icons" + write_icon_suite(directory, mark) + assert directory.is_dir() + + def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_UNIX_FILENAME) as image: + assert image.size == (mark.render.raster_size, mark.render.raster_size) + assert image.mode == RGBA_MODE + + def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_WIN_FILENAME) as image: + carried = {width for width, _ in image.info[ICO_SIZES_KEY]} + + assert carried == set(mark.render.windows_sizes) + + def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None: + """One definition produces one suite, so a rebuild leaves the shipped files as they were.""" + first = write_icon_suite(tmp_path / "first", mark) + second = write_icon_suite(tmp_path / "second", mark) + assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second] diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py new file mode 100644 index 00000000..081cb682 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_vector.py @@ -0,0 +1,47 @@ +from importlib.resources import files +from pathlib import Path + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.vector import render_vector +from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME + +PLACEHOLDER_PREFIX = "$" +REPLACEMENT_COLOR = "#010203" + + +class TestRenderVector: + def test_the_shipped_vector_is_what_the_definition_renders(self) -> None: + """The committed vector is the mark's design source, so it stays in step with the definition.""" + shipped = Path(str(files("sampletones_assets.icons"))) / ICON_VECTOR_FILENAME + assert render_vector(Mark.load()) == shipped.read_text(encoding="utf-8") + + def test_the_template_is_filled_throughout(self) -> None: + assert PLACEHOLDER_PREFIX not in render_vector(Mark.load()) + + def test_every_colour_reaches_the_document(self) -> None: + mark = Mark.load() + document = render_vector(mark) + colors = ( + mark.colors.background.top, + mark.colors.background.bottom, + mark.colors.sine, + mark.colors.square, + mark.colors.rim, + ) + + for color in colors: + assert color in document + + def test_the_document_follows_the_definition(self) -> None: + """A colour changed in the definition is the colour the vector is drawn with.""" + mark = Mark.load() + recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})}) + document = render_vector(recolored) + + assert REPLACEMENT_COLOR in document + assert mark.colors.sine not in document + + def test_the_wave_starts_where_the_definition_places_it(self) -> None: + mark = Mark.load() + start = mark.waves.sine.start + assert f'd="M{start.x:g} {start.y:g}' in render_vector(mark) diff --git a/uv.lock b/uv.lock index ac66c620..4699d98d 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,6 +1832,7 @@ dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "pillow" }, { name = "pre-commit" }, { name = "pylint" }, { name = "pylint-pydantic" }, @@ -1875,6 +1876,7 @@ dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, { name = "mypy", specifier = "==2.1.0" }, + { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pylint", specifier = "==4.0.6" }, { name = "pylint-pydantic", specifier = "==0.4.1" }, From e9ef7b4804dcb7b15d90dc0e0a75898f80439123 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 17:22:11 +0200 Subject: [PATCH 16/45] Excluded: build-time Pillow from the standalone bundles --- LICENSE | 4 ++- THIRD-PARTY-NOTICES.md | 15 +++++++++++ docs/development/dependencies.md | 6 +++++ scripts/ci/checks/bundle.py | 21 ++++++++++++++- scripts/linux/build/build.sh | 1 + scripts/windows/build/build.bat | 1 + src/sampletones_assets/mark/raster.py | 2 +- tests/unit/scripts/ci/checks/test_bundle.py | 29 +++++++++++++++++++++ 8 files changed, 76 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 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/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/dependencies.md b/docs/development/dependencies.md index d6b4035c..9dd7da6a 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -63,6 +63,12 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow an editor, and the rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: +`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is +installed, and PyInstaller follows that import into the bundle. The application reads its icons as +files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries +that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py index 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 1271b202..3fd6327e 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -45,6 +45,7 @@ echo "Building executable..." --add-data "src/sampletones_assets/fonts:assets/fonts" \ --add-data "src/sampletones_config:config" \ --copy-metadata sampletones \ + --exclude-module PIL \ "${RELEASE_HOOK_ARGS[@]}" \ "src/sampletones/__main__.py" diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index bbe075f4..915dc471 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -52,6 +52,7 @@ echo Building executable... --add-data "src\sampletones_assets\fonts;assets\fonts" ^ --add-data "src\sampletones_config;config" ^ --copy-metadata sampletones ^ + --exclude-module PIL ^ %RELEASE_HOOK% ^ "src\sampletones\__main__.py" || exit /b diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py index c9547af0..7c437fe0 100644 --- a/src/sampletones_assets/mark/raster.py +++ b/src/sampletones_assets/mark/raster.py @@ -1,6 +1,6 @@ from typing import Final, Tuple -from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds +from PIL import Image, ImageDraw from sampletones_assets.mark.geometry import sine_points, square_rectangles from sampletones_assets.mark.specification import Mark diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py index 28793dfa..06e339dc 100644 --- a/tests/unit/scripts/ci/checks/test_bundle.py +++ b/tests/unit/scripts/ci/checks/test_bundle.py @@ -111,6 +111,21 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None: assert check_bundle.missing_notices(bundle) == ["LICENSE"] +class TestCarriedBuildTools: + def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None: + assert check_bundle.carried_build_tools(bundle) == [] + + def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None: + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None: + (bundle / "PIL").mkdir() + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + class TestMain: def test_a_complete_bundle_passes( self, @@ -137,6 +152,20 @@ def test_a_missing_notice_is_annotated_as_an_error( assert output.startswith("::error::") assert "THIRD-PARTY-NOTICES.md" in output + def test_bundled_build_tooling_is_annotated_as_an_error( + self, + bundle: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + _install_launcher(bundle) + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.main([str(bundle)]) == 1 + + output = capsys.readouterr().out + assert output.startswith("::error::") + assert "PIL" in output + def test_a_missing_launcher_is_annotated_as_an_error( self, bundle: Path, From fafc6715f27f6914ebcdce5c3c7864754dd07180 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 18:21:49 +0200 Subject: [PATCH 17/45] Added: logo to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9688052a..c088a007 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +
+ SampleToNES +
+ ## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). From c14429b461783500aa66222aca321fd2669eb4ce Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 19:07:31 +0200 Subject: [PATCH 18/45] Tracked: the generated icon suite --- .github/workflows/ci.yml | 5 +++++ .github/workflows/workflow.yml | 6 ------ .gitignore | 3 --- README.md | 6 +++--- docs/development/dependencies.md | 9 ++++++--- pyproject.toml | 6 ------ src/sampletones_assets/icons/sampletones.ico | Bin 0 -> 32381 bytes src/sampletones_assets/icons/sampletones.png | Bin 0 -> 14609 bytes 8 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 src/sampletones_assets/icons/sampletones.ico create mode 100644 src/sampletones_assets/icons/sampletones.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8772a4..99ace251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: - name: Run every pre-commit hook run: uv run pre-commit run --all-files --show-diff-on-failure --color always + - name: Check the committed icons match the mark + run: | + uv run --group assets python scripts/assets/icons.py + git diff --exit-code -- src/sampletones_assets/icons + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 8663dcc8..f94d5553 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,12 +37,6 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" - - name: Install PortAudio - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev - - - name: Generate the icon suite - run: uv run --group assets python scripts/assets/icons.py - - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 5622d69b..585ea73d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ sampletones !src/sampletones !tests/sampletones -src/sampletones_assets/icons/sampletones.ico -src/sampletones_assets/icons/sampletones.png - *.pyc *.pyo *.coverage diff --git a/README.md b/README.md index c088a007..9d505bfb 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) -
- SampleToNES +
+ SampleToNES
## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +SampleToNES The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 9dd7da6a..924d7cce 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -59,9 +59,12 @@ settings, validated as a `Mark`, and `template.svg` is the vector the rendered g package writes the whole suite — the vector `sampletones.svg` and the rasters the application ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the -`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or -an editor, and the rasters are produced where they are consumed: `make setup` writes them before -packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +`assets` dependency group. + +The whole suite is committed, so a plain checkout carries the icons the application opens its window +with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes +them again from the mark, and CI regenerates them on each change to confirm the committed files are +the ones the mark describes. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is diff --git a/pyproject.toml b/pyproject.toml index 8e54812e..ac47fab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,12 +97,6 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] -[tool.hatch.build] -artifacts = [ - "src/sampletones_assets/icons/sampletones.png", - "src/sampletones_assets/icons/sampletones.ico", -] - [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico new file mode 100644 index 0000000000000000000000000000000000000000..5d37a4a25e8883ab82f4ac76d0ae472a7daddcbf GIT binary patch literal 32381 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

17XfK0~M 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 Date: Mon, 17 Aug 2026 19:23:48 +0200 Subject: [PATCH 19/45] Improved: workflow --- .github/workflows/ci.yml | 4 +--- .pre-commit-config.yaml | 10 ++++++++++ docs/development/dependencies.md | 6 +++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99ace251..8a9cc876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,7 @@ jobs: run: uv run pre-commit run --all-files --show-diff-on-failure --color always - name: Check the committed icons match the mark - run: | - uv run --group assets python scripts/assets/icons.py - git diff --exit-code -- src/sampletones_assets/icons + run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 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/docs/development/dependencies.md b/docs/development/dependencies.md index 924d7cce..e0de8283 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -62,9 +62,9 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow `assets` dependency group. The whole suite is committed, so a plain checkout carries the icons the application opens its window -with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes -them again from the mark, and CI regenerates them on each change to confirm the committed files are -the ones the mark describes. +with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again +from the mark, and the `icons` pre-push hook writes them for a push that touches either directory, +holding the committed files to what the mark describes. CI runs that same hook. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is From ef37024805cdcc5da1e4d220c441cf1735f6df88 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 20:26:04 +0200 Subject: [PATCH 20/45] Refactored: explorer and library onto the file-browser base --- .../ui/elements/tree/browser.py | 104 ++++++++++-- .../ui/elements/tree/tags.py | 4 +- .../ui/panels/instruction/library.py | 142 +++++++--------- .../ui/panels/main/explorer.py | 151 ++++++------------ .../ui/panels/reconstruction/browser.py | 21 ++- .../ui/panels/sequencer/browser.py | 21 ++- .../ui/panels/shared/browser.py | 22 +-- 7 files changed, 224 insertions(+), 241 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index f5b2a70c..d759daad 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Dict import dearpygui.dearpygui as dpg @@ -11,13 +12,16 @@ from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import Tree +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree +from sampletones_shared.types.callback import Callback, MessageCallback class GUIFileBrowserPanel(GUITreePanel, ABC): @@ -25,17 +29,18 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): The card holds a refresh control above the search box and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the - whole card as the tree locks and unlocks. A subclass names its widgets through + whole card as the tree locks and unlocks. A subclass declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control read, answers what refreshing the model means, and shapes each row. """ + _REBUILD_ON_CREATE: bool = True + def __init__( self, tree: Tree, tree_logic: TreeLogicProtocol, *, - tags: FileBrowserTags, scheduling: SchedulingBehavior, search_label: str, language_manager: LanguageManager, @@ -43,12 +48,10 @@ def __init__( colors: TreeColors, initial_collapsed: bool, ) -> None: - self._tags = tags - super().__init__( tree=tree, - tag=tags.panel, - tree_tag=tags.tree, + tag=self._tags.panel, + tree_tag=self._tags.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=search_label, @@ -62,6 +65,11 @@ def __init__( side=CollapseAxis.HORIZONTAL_LEFT, ) + @property + @abstractmethod + def _tags(self) -> FileBrowserTags: + """The tags naming this browser's widgets, which a panel states as a class attribute.""" + @property @abstractmethod def section_label(self) -> str: ... @@ -79,6 +87,11 @@ def refresh_button_label(self) -> str: ... def refresh_status_message(self) -> str: ... def create_panel(self, parent: str) -> None: + """Builds the card, and fills the tree where the panel is the one reading its model. + + A browser reading the filesystem shows its rows as it appears, while a catalogue filled by + the owner that gathers it waits for that reading to arrive. + """ self._setup_handlers() with ( dpg.child_window( @@ -98,23 +111,34 @@ def create_panel(self, parent: str) -> None: self._create_tree_window() self._create_detail_tooltip(self._tags.window_tree) - self.rebuild_tree() + if self._REBUILD_ON_CREATE: + self.rebuild_tree() def _create_controls(self) -> None: with dpg.group(tag=self._tags.group_controls): - GUIButton( - tag=self._tags.button_refresh, - label=self.refresh_button_label, - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + self._create_refresh_button() + + self._bind_refresh_message() + + def _create_refresh_button(self) -> None: + GUIButton( + tag=self._tags.button_refresh, + label=self.refresh_button_label, + width=-1, + callback=self._on_refresh_clicked, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + def _bind_refresh_message(self) -> None: self._status_bar.bind_to_item( self._tags.button_refresh, self.refresh_status_message, ) + def _on_refresh_clicked(self) -> None: + """Answers the refresh control, by default with a rebuild of the tree as the model stands.""" + self.rebuild_tree() + def _create_tree_window(self) -> None: self.create_search(self._body_container) with ( @@ -131,6 +155,52 @@ def _create_tree_root(self) -> None: with dpg.group(tag=self.tree_tag): pass + def _create_tree_root_heading(self, label: str) -> None: + """Opens the root container as a labelled row the whole tree folds under.""" + with dpg.tree_node( + label=label, + tag=self.tree_tag, + default_open=True, + ): + pass + + def _create_file_system_handlers( + self, + *, + on_directory_clicked: Callback, + on_file_clicked: Callback, + on_file_double_clicked: Callback, + file_status_message: MessageCallback, + ) -> Dict[NodeType, NodeHandler]: + """The two rows a browser of files offers: a folder that expands, and a file it opens. + + A folder row reads the same wherever it appears — the status bar says it expands — so the pair + is shaped here, and each browser states what a click on one of its own rows means. + """ + return { + NodeType.DIRECTORY: NodeHandler( + tag=self._get_node_handler_tag(NodeType.DIRECTORY), + node_type=NodeType.DIRECTORY, + item_click_callback=on_directory_clicked, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), + ), + NodeType.FILE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.FILE), + node_type=NodeType.FILE, + item_click_callback=on_file_clicked, + item_double_click_callback=on_file_double_clicked, + status_bar_callback=file_status_message, + ), + } + + def _mark_favorite_ancestry( + self, + node: FileSystemNode, + state: TreeNodeState, + ) -> None: + """Carries a favorite down the branch, so every row under one reads as part of it.""" + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + def refresh(self) -> None: self.rebuild_tree() @@ -140,12 +210,16 @@ def rebuild_tree(self) -> None: self._refresh_model, lambda: self._collect_specs(self.tree_tag), root_tag=self.tree_tag, + on_finished=self._on_rebuild_finished, ) @abstractmethod def _refresh_model(self) -> None: """Brings the model the tree renders up to date, on the background rebuild worker.""" + def _on_rebuild_finished(self) -> None: + """Runs on the main thread with the rows on screen, where a browser reads something out.""" + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(self._tags.group_tree, enabled=enabled) dpg_configure_item(self._tags.group_controls, enabled=enabled) diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py index 9dc9c191..d9c65ea7 100644 --- a/src/sampletones_application/ui/elements/tree/tags.py +++ b/src/sampletones_application/ui/elements/tree/tags.py @@ -7,8 +7,8 @@ class FileBrowserTags: Every browser builds the same arrangement — a panel card holding a controls group with a refresh button, and a window holding the group the tree attaches to — so the tags naming those widgets - travel as one value the panel is constructed with. Stating them together makes each browser - declare a complete set at one place, checked where it is written. + are one value the panel declares beside its class. Stating them together makes each browser name + a complete set at one place, checked where it is written. """ panel: str diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index af186d27..e32d0f75 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Dict, Optional, Protocol, Tuple +from typing import Any, Callable, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -7,10 +7,7 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_PRIMARY_BUTTON, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, -) +from sampletones_application.tags.general import TAG_GLOBAL_THEME_PRIMARY_BUTTON from sampletones_application.tags.instructions import ( TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, @@ -31,17 +28,16 @@ from sampletones_application.ui.elements.context_menu import context_menu from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip -from sampletones_application.utils.parallelization.thread import concurrent from sampletones_application.view_model.instruction.library import LibraryPanelViewModel from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey @@ -81,9 +77,20 @@ def update_status(self) -> None: ... def get_path(self, key: InstructionLibraryKey) -> Path: ... -class GUIInstructionsLibraryPanel(GUITreePanel): +class GUIInstructionsLibraryPanel(GUIFileBrowserPanel): + """The Instructions tab's catalogue of instruction libraries and the generators inside them.""" + _NAME_FONT: Font = Font.REGULAR_SMALL _MONOSPACE_CONFIG_NODES: bool = True + _REBUILD_ON_CREATE: bool = False + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_INSTRUCTIONS_LIBRARY_PANEL, + tree=TAG_INSTRUCTIONS_LIBRARY_TREE, + window_tree=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE, + group_tree=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE, + group_controls=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS, + button_refresh=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, + ) def __init__( self, @@ -91,7 +98,7 @@ def __init__( tree_logic: TreeLogicProtocol, *, scheduling: SchedulingBehavior, - initial_collapsed: bool = False, + initial_collapsed: bool, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, @@ -109,25 +116,33 @@ def __init__( self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( - self._library_logic.tree, - tag=TAG_INSTRUCTIONS_LIBRARY_PANEL, - tree_tag=TAG_INSTRUCTIONS_LIBRARY_TREE, + tree=library_logic.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) + @property + def section_label(self) -> str: + return self._language_manager["instructions.library.label.libraries_text"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.instruction_data + + @property + def refresh_button_label(self) -> str: + return self._language_manager["instructions.library.label.refresh_libraries_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["instructions.library.message.status_refresh"] + def _setup_handlers(self) -> None: self._node_handlers = { NodeType.LIBRARY: NodeHandler( @@ -146,41 +161,16 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._language_manager["instructions.library.label.libraries_text"], - glyph=self._glyphs.headers.instruction_data, - ), - ): - self._create_library_status() - self._create_library_controls() - self._create_library_tree() - - self._create_detail_tooltip(TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE) - - def _create_library_status(self) -> None: - text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS) - FontRegistry.bind_to_item(text, Font.MONO_SMALL) + def _create_controls(self) -> None: + """Reads out what the catalogue holds, and offers what can be done to it. - def _create_library_controls(self) -> None: - with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS): + The controls come in two sets: the ones a reader picks from while the catalogue sits still, + and the progress bar and cancel button a generation replaces them with. + """ + self._create_library_status() + with dpg.group(tag=self._tags.group_controls): with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_IDLE): - GUIButton( - tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, - label=self._language_manager["instructions.library.label.refresh_libraries_button"], - width=-1, - callback=self._on_refresh_clicked, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + self._create_refresh_button() with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_GENERATE): GUIButton( tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, @@ -214,10 +204,7 @@ def _create_library_controls(self) -> None: width=-1, callback=self._on_cancel_clicked, ) - self._status_bar.bind_to_item( - TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, - self._language_manager["instructions.library.message.status_refresh"], - ) + self._bind_refresh_message() self._status_bar.bind_to_item( TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, self._language_manager["instructions.library.message.status_generate"], @@ -227,26 +214,15 @@ def _create_library_controls(self) -> None: self._language_manager["instructions.library.message.status_cancel_generation"], ) - def _create_library_tree(self) -> None: - dpg.add_separator() - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE, - width=-1, - height=-1, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE), - dpg.tree_node( - label=self._language_manager["instructions.library.label.available_libraries_text"], - tag=self.tree_tag, - default_open=True, - ), - ): - pass + def _create_library_status(self) -> None: + text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS) + FontRegistry.bind_to_item(text, Font.MONO_SMALL) + + def _create_tree_root(self) -> None: + self._create_tree_root_heading(self._language_manager["instructions.library.label.available_libraries_text"]) def _on_refresh_clicked(self) -> None: + """Answers the refresh control by reading the libraries again, which rebuilds the tree.""" self.call(self.on_refresh_requested) def _on_generate_clicked(self) -> None: @@ -282,12 +258,13 @@ def update_view(self, view_model: LibraryPanelViewModel) -> None: ) def set_tree_enabled(self, enabled: bool) -> None: + """Locks the tree and the control reading it again, leaving a running generation cancellable.""" dpg_configure_item( - TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE, + self._tags.group_tree, enabled=enabled, ) dpg_configure_item( - TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, + self._tags.button_refresh, enabled=enabled, ) self._apply_action_button_states() @@ -312,14 +289,11 @@ def _apply_action_button_states(self) -> None: show=operation_active, ) - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - self._library_logic.rebuild_tree, - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - on_finished=self._library_logic.update_status, - ) + def _refresh_model(self) -> None: + self._library_logic.rebuild_tree() + + def _on_rebuild_finished(self) -> None: + self._library_logic.update_status() def _has_relevant_content(self, node: TreeNode) -> bool: return True diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 84aaa74b..7fd945ff 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, List, Optional, Protocol, Tuple +from typing import Any, List, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -7,7 +7,6 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON from sampletones_application.tags.main import ( TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, TAG_MAIN_EXPLORER_BUTTON_REFRESH, @@ -19,16 +18,13 @@ ) from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import ( FileSystemNode, @@ -66,7 +62,18 @@ def is_directory_expanded(self, filepath: Path) -> bool: ... def has_relevant_content(self, filepath: Path) -> bool: ... -class GUIExplorerPanel(GUITreePanel): +class GUIExplorerPanel(GUIFileBrowserPanel): + """The Main tab's browser of the filesystem, whose rows are the folders and files on disk.""" + + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_MAIN_EXPLORER_PANEL, + tree=TAG_MAIN_EXPLORER_TREE, + window_tree=TAG_MAIN_EXPLORER_WINDOW_TREE, + group_tree=TAG_MAIN_EXPLORER_GROUP_TREE, + group_controls=TAG_MAIN_EXPLORER_GROUP_CONTROLS, + button_refresh=TAG_MAIN_EXPLORER_BUTTON_REFRESH, + ) + def __init__( self, explorer_logic: ExplorerLogicProtocol, @@ -76,14 +83,11 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager self._explorer_logic = explorer_logic - self._lbl_section = language_manager["main.explorer.label.section"] - self._node_handlers: Dict[NodeType, NodeHandler] - self.on_wave_file_clicked: Optional[PathCallback] = None self.on_directory_clicked: Optional[PathCallback] = None self.on_reconstruct_directory: Optional[PathCallback] = None @@ -94,104 +98,64 @@ def __init__( self.on_set_as_library_directory: Optional[PathCallback] = None super().__init__( - tree=self._explorer_logic.tree, - tag=TAG_MAIN_EXPLORER_PANEL, - tree_tag=TAG_MAIN_EXPLORER_TREE, + tree=explorer_logic.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=language_manager["global.browser.label.filter"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._lbl_section, - glyph=self._glyphs.headers.filesystem, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_MAIN_EXPLORER_WINDOW_TREE) - self.rebuild_tree() + @property + def section_label(self) -> str: + return self._language_manager["main.explorer.label.section"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.filesystem + + @property + def refresh_button_label(self) -> str: + return self._language_manager["main.explorer.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["main.explorer.message.status_refresh"] def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_file_node_clicked, - item_double_click_callback=self._on_file_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_file_node(), - ), - } + self._node_handlers = self._create_file_system_handlers( + on_directory_clicked=self._on_directory_node_clicked, + on_file_clicked=self._on_file_node_clicked, + on_file_double_clicked=self._on_file_node_double_clicked, + file_status_message=self._create_status_bar_message_function_for_file_node(), + ) super()._setup_handlers() - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_CONTROLS): - GUIButton( - tag=TAG_MAIN_EXPLORER_BUTTON_REFRESH, - label=self._language_manager["main.explorer.label.refresh_button"], - parent=self._body_container, - width=-1, - callback=self.refresh, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + def _create_controls(self) -> None: + """Offers the refresh control and, beside it, the one folding every folder away at once.""" + with dpg.group(tag=self._tags.group_controls): + self._create_refresh_button() GUIButton( tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, label=self._language_manager["main.explorer.label.collapse_all_button"], - parent=self._body_container, width=-1, callback=self.collapse_all, ) - self._status_bar.bind_to_item( - TAG_MAIN_EXPLORER_BUTTON_REFRESH, - self._language_manager["main.explorer.message.status_refresh"], - ) + + self._bind_refresh_message() self._status_bar.bind_to_item( TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, self._language_manager["main.explorer.message.status_collapse_all"], ) - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_MAIN_EXPLORER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_section, - tag=self.tree_tag, - default_open=True, - ), - ): - pass + def _create_tree_root(self) -> None: + self._create_tree_root_heading(self.section_label) + + def _refresh_model(self) -> None: + self._explorer_logic.refresh_tree() def collapse_all( self, @@ -205,17 +169,6 @@ def collapse_all( for node_tag in children: dpg.set_value(node_tag, False) - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - self._explorer_logic.refresh_tree, - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - @concurrent(wait=False, method_bound=True) def _rebuild_node_subtree( self, @@ -260,7 +213,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) @@ -419,10 +372,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool: return True - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_TREE, enabled=enabled) - dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_CONTROLS, enabled=enabled) - def _reconstruct_file(self, node: FileSystemNode) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index f2edfd4b..748397b1 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, Optional +from typing import Optional import dearpygui.dearpygui as dpg @@ -26,19 +26,19 @@ from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback -_TAGS: Final[FileBrowserTags] = FileBrowserTags( - panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, - tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, - window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, - group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, - group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, - button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, -) - class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel): """The Reconstructions tab's browser, whose reconstructions open in the tab beside it.""" + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, + tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, + window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, + group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, + ) + def __init__( self, tree: Tree, @@ -55,7 +55,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index e0ba21e0..685b1641 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,5 +1,3 @@ -from typing import Final - from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -21,19 +19,19 @@ ) from sampletones_core.structures.tree import FileSystemNode, Tree -_TAGS: Final[FileBrowserTags] = FileBrowserTags( - panel=TAG_SEQUENCER_BROWSER_PANEL, - tree=TAG_SEQUENCER_BROWSER_TREE, - window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, - group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, - group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, - button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, -) - class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): """The Sequencer tab's browser, whose reconstructions become the song's samples.""" + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_SEQUENCER_BROWSER_PANEL, + tree=TAG_SEQUENCER_BROWSER_TREE, + window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, + group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, + ) + def __init__( self, tree: Tree, @@ -50,7 +48,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5fd9c4c8..5e1ee81e 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -18,7 +18,6 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -46,7 +45,6 @@ def __init__( tree: Tree, tree_logic: TreeLogicProtocol, *, - tags: FileBrowserTags, scheduling: SchedulingBehavior, language_manager: LanguageManager, status_bar: GUIStatusBar, @@ -59,7 +57,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=tags, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, @@ -90,18 +87,11 @@ def _setup_handlers(self) -> None: node_type=NodeType.SAMPLE, status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), + **self._create_file_system_handlers( + on_directory_clicked=self._on_directory_node_clicked, + on_file_clicked=self._on_reconstruction_node_clicked, + on_file_double_clicked=self._on_reconstruction_node_double_clicked, + file_status_message=self._create_status_bar_message_function_for_reconstruction_node(), ), } @@ -137,7 +127,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) self._append_spec( From d436b211a20c59351eb95b07e5ac0b2d8dd67c54 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 21:11:49 +0200 Subject: [PATCH 21/45] Added: context menu on browser container rows --- .../ui/elements/tree/tree.py | 56 ++- .../ui/panels/shared/browser.py | 95 ++++- src/sampletones_config/lang/en.yaml | 7 +- .../ui/elements/tree/test_status_messages.py | 31 ++ .../ui/panels/shared/__init__.py | 0 .../shared/test_container_context_menu.py | 398 ++++++++++++++++++ 6 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py create mode 100644 tests/unit/sampletones_application/ui/panels/shared/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 1c9360cc..521e135e 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -282,7 +282,11 @@ def _append_spec( ) ) - def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> None: + def _finish_emit( + self, + root_tag: str, + on_finished: Optional[VoidCallback], + ) -> None: """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. The emitter runs this once its last batch has attached. When a filtered tree @@ -291,7 +295,10 @@ def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> No an active search, and releasing the lock hands control back to interactive rebuilds. """ if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None: - dpg.add_text(self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag) + dpg.add_text( + self._language_manager["global.dialog.message.tree_no_results"], + parent=root_tag, + ) if on_finished is not None: on_finished() @@ -380,6 +387,7 @@ def single_click_callback( user_data = dpg.get_item_user_data(app_data[1]) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) + if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) @@ -479,8 +487,8 @@ def _create_status_bar_message_function_for_expandable_node( ) -> MessageCallback: """Builds the hover message of a row the reader opens, naming what that row holds. - A folder and a sample are both opened the same way and hold different things, so the message - follows the node it is asked about: the sample names the reconstructions it gathers. + A folder, a group and a sample are all opened the same way and hold different things, so the + message follows the node it is asked about: the sample names the reconstructions it gathers. """ def message_function( @@ -494,15 +502,19 @@ def message_function( if dpg_get_value(node_tag) else self._language_manager["global.dialog.template.expand"] ) - message = ( - self._language_manager["global.status.message.node_sample"] - if node.node_type == NodeType.SAMPLE - else self._language_manager["global.status.message.node_directory"] - ) - return message.format(expand_or_collapse=expand_or_collapse) + return self._expandable_node_message(node).format(expand_or_collapse=expand_or_collapse) return self._create_status_bar_message_function(message_function) + def _expandable_node_message(self, node: TreeNode) -> str: + match node.node_type: + case NodeType.SAMPLE: + return self._language_manager["global.status.message.node_sample"] + case NodeType.GROUP: + return self._language_manager["global.status.message.node_group"] + + return self._language_manager["global.status.message.node_directory"] + def _generate_node_tag(self, node: TreeNode) -> str: return compose_node_tag(node, panel_tag=self.tag) @@ -560,7 +572,10 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: return [] - def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, str]]: + def _library_detail_items( + self, + key: InstructionLibraryKey, + ) -> List[Tuple[str, str]]: nes_frequency = round(key.sample_rate / key.frame_length) return [ (self._lbl_detail_sample_rate, format_sample_rate(key.sample_rate)), @@ -571,8 +586,13 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s (self._lbl_detail_configuration, short_hash(key.config_hash)), ] - def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tuple[str, str]]: - generators = ", ".join(generator.capitalized for generator in fields.generators) + def _reconstruction_detail_items( + self, + fields: ConfigDirectoryFields, + ) -> List[Tuple[str, str]]: + generators = ", ".join( + generator.capitalized for generator in fields.generators + ) # TODO: operation deserves a helper function return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), (self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)), @@ -583,7 +603,10 @@ def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tu ] def _add_context_menu_details(self, node: TreeNode) -> None: - add_detail_items(self._node_detail_items(node), color=self._colors.muted) + add_detail_items( + self._node_detail_items(node), + color=self._colors.muted, + ) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: if not self._logic.is_playable_file(node): @@ -869,7 +892,10 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) - def update_favorite_indicators(self, nodes: Sequence[FileSystemNode]) -> None: + def update_favorite_indicators( + self, + nodes: Sequence[FileSystemNode], + ) -> None: """Repaints the rows a favorite change reaches, and what each of them holds. A path reaches the panel as many rows as the views offer it — a reconstruction is listed both diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5e1ee81e..a5349f24 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -11,13 +11,14 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, ) -from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.context_menu import add_detail_items, context_menu from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -81,10 +82,13 @@ def _setup_handlers(self) -> None: NodeType.GROUP: NodeHandler( tag=self._get_node_handler_tag(NodeType.GROUP), node_type=NodeType.GROUP, + item_click_callback=self._on_container_node_clicked, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.SAMPLE: NodeHandler( tag=self._get_node_handler_tag(NodeType.SAMPLE), node_type=NodeType.SAMPLE, + item_click_callback=self._on_container_node_clicked, status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), **self._create_file_system_handlers( @@ -162,6 +166,17 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: return super()._resolve_other_theme_tag(node) + def _on_container_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[TreeNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Right: + self._show_container_context_menu(node) + def _on_directory_node_clicked( self, _sender: Sender, @@ -204,6 +219,84 @@ def _on_reconstruction_node_double_clicked( self._logic.cancel_autoplay() self._open_reconstruction(node) + def _show_container_context_menu(self, node: TreeNode) -> None: + """Offers what a row the browser invents can answer: what it gathers, and how it folds. + + A group or a sample stands for a facet of the reconstructions below it rather than for a path + on disk, so its menu reads the subtree — how many reconstructions it gathers, the rows folding + under it, the label the tree shows it by, and for a sample the audio its reconstructions were + made from. + """ + if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE): + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_reconstruction_count(node) + self._add_context_menu_expansion_items(node) + self._add_context_menu_copy_name_item(node) + self._add_context_menu_sample_audio_item(node) + + def _add_context_menu_reconstruction_count(self, node: TreeNode) -> None: + """States how many reconstructions the row gathers, which is what the row stands for.""" + count = sum(1 for descendant in node.descendants if descendant.node_type == NodeType.FILE) + add_detail_items( + [(self._language_manager["global.context.label.detail_reconstructions"], str(count))], + color=self._colors.muted, + ) + + def _add_context_menu_expansion_items(self, node: TreeNode) -> None: + dpg.add_separator() + dpg.add_menu_item( + label=self._language_manager["global.context.label.expand_all"], + callback=lambda: self._set_subtree_expanded(node, expanded=True), + ) + dpg.add_menu_item( + label=self._language_manager["global.context.label.collapse_all"], + callback=lambda: self._set_subtree_expanded(node, expanded=False), + ) + + def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: + """Folds or unfolds the row together with every row below it holding something. + + Whether a row stands open is a fact of the widget alone, so each row is reached by the tag it + was built under and set directly. + """ + for container in (node, *node.descendants): + if container.children: + dpg_set_value(self._generate_node_tag(container), expanded) + + def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: + """Offers the label the tree reads the row by, which for a folded chain names every level.""" + dpg.add_separator() + dpg.add_menu_item( + label=self._language_manager["global.context.label.copy_name"], + callback=lambda: dpg.set_clipboard_text(str(node.name)), + ) + + def _add_context_menu_sample_audio_item(self, node: TreeNode) -> None: + """Offers the audio behind a sample row, through any one reconstruction gathered under it. + + Every reconstruction under one sample was made from the same audio, so the first of them + answers for the row. + """ + if node.node_type != NodeType.SAMPLE: + return + + reconstruction = self._first_reconstruction_below(node) + if reconstruction is None: + return + + dpg.add_separator() + self._add_context_menu_locate_audio_item(reconstruction) + + def _first_reconstruction_below(self, node: TreeNode) -> Optional[FileSystemNode]: + for descendant in node.descendants: + if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE: + return descendant + + return None + def _show_directory_context_menu(self, node: FileSystemNode) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: return diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 0dc0d264..ea606aad 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -145,7 +145,10 @@ global.context.label.mark_as_favorite: "Mark as favorite" global.context.label.unmark_as_favorite: "Unmark as favorite" global.context.label.copy_filename: "Copy filename to clipboard" global.context.label.copy_path: "Copy path to clipboard" +global.context.label.copy_name: "Copy name to clipboard" global.context.label.open_in_explorer: "Open in explorer" +global.context.label.expand_all: "Expand all" +global.context.label.collapse_all: "Collapse all" global.context.label.add_to_sequencer: "Add to Sequencer" global.context.template.replace_sample: "Replace {sample}" global.context.label.locate_original_audio: "Locate original audio" @@ -160,6 +163,7 @@ global.context.label.detail_spectrum_method: "Generation method" global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" global.context.label.detail_configuration: "Configuration" +global.context.label.detail_reconstructions: "Reconstructions" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" global.context.template.size_bytes: "{bytes} B" @@ -237,7 +241,8 @@ global.status.message.clear_search: "Clear the search filter." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." -global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample." +global.status.message.node_group: "Click to {expand_or_collapse} this group. Right-click to open context menu." +global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu." global.status.message.retuning_samples: "Retuning samples..." # ============================================================================= diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py new file mode 100644 index 00000000..6b9d9e89 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py @@ -0,0 +1,31 @@ +import pytest + +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + + +def _panel() -> GUISequencerBrowserPanel: + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel._language_manager = FakeLanguageManager() + return panel + + +class TestExpandableNodeMessage: + @pytest.mark.parametrize( + ("node_type", "key"), + [ + (NodeType.GROUP, "global.status.message.node_group"), + (NodeType.SAMPLE, "global.status.message.node_sample"), + (NodeType.DIRECTORY, "global.status.message.node_directory"), + ], + ) + def test_the_message_names_what_the_row_holds( + self, + node_type: NodeType, + key: str, + ) -> None: + """Each row the reader opens holds something of its own, and its hover message says so.""" + panel = _panel() + + assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key diff --git a/tests/unit/sampletones_application/ui/panels/shared/__init__.py b/tests/unit/sampletones_application/ui/panels/shared/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py new file mode 100644 index 00000000..482eaecb --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -0,0 +1,398 @@ +import contextlib +from pathlib import Path +from typing import Any, Dict, Final, Iterator, List, Optional, Sequence, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.tag import compose_node_tag +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared import browser as shared_browser_module +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_core.structures.tree.node import FileSystemNode, NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + +PANEL_TAG = "sequencer_browser" + +TEXT_COLOR = LiteralColor((128, 128, 128, 255)) + +EXPAND_LABEL = "Expand all" +COLLAPSE_LABEL = "Collapse all" +COPY_NAME_LABEL = "Copy name" +LOCATE_AUDIO_LABEL = "Locate original audio" +RECONSTRUCTIONS_LABEL = "Reconstructions" + +TEXTS: Final[Dict[str, str]] = { + "global.context.label.expand_all": EXPAND_LABEL, + "global.context.label.collapse_all": COLLAPSE_LABEL, + "global.context.label.copy_name": COPY_NAME_LABEL, + "global.context.label.locate_original_audio": LOCATE_AUDIO_LABEL, + "global.context.label.detail_reconstructions": RECONSTRUCTIONS_LABEL, +} + +CONTAINER_BUILDERS: Final[Tuple[str, ...]] = ( + "_add_context_menu_text", + "_add_context_menu_reconstruction_count", + "_add_context_menu_expansion_items", + "_add_context_menu_copy_name_item", + "_add_context_menu_sample_audio_item", +) + + +def _panel() -> GUISequencerBrowserPanel: + """Builds a panel without its DearPyGui-dependent constructor. + + The container menu reads the tree, the language manager and the panel tag its node tags are + composed under, so a running GUI context is unnecessary. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel._language_manager = FakeLanguageManager(TEXTS) + panel._colors = TreeColors( + favorite=TEXT_COLOR, + node=TEXT_COLOR, + muted=TEXT_COLOR, + accent=TEXT_COLOR, + ) + panel.on_locate_original_audio = None + return panel + + +def _sample_tree() -> Tuple[TreeNode, TreeNode, Sequence[FileSystemNode]]: + """One sample gathering two configuration variants, under a frequency group.""" + root = TreeNode("root", node_type=NodeType.ROOT) + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE, parent=group) + variants = [ + FileSystemNode( + name, + node_type=NodeType.FILE, + filepath=Path("/reconstructions") / name, + parent=sample, + ) + for name in ("fft.stn", "cqt.stn") + ] + return group, sample, variants + + +class _MenuItemRecorder: + """Captures the keyword arguments of every menu item the builders register.""" + + def __init__(self) -> None: + self.items: List[Dict[str, Any]] = [] + self.separators = 0 + self.clipboard: List[str] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append(kwargs) + return 0 + + def add_separator(self, **kwargs: Any) -> int: + self.separators += 1 + return 0 + + def set_clipboard_text(self, text: str) -> None: + self.clipboard.append(text) + + @property + def labels(self) -> List[str]: + return [item["label"] for item in self.items] + + def item(self, label: str) -> Dict[str, Any]: + return next(item for item in self.items if item["label"] == label) + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder: + instance = _MenuItemRecorder() + monkeypatch.setattr(tree_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(tree_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(tree_module.dpg, "set_clipboard_text", instance.set_clipboard_text) + return instance + + +@pytest.fixture +def expanded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the tag and open state of every row the expansion items reach.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + shared_browser_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +@pytest.fixture +def details(monkeypatch: pytest.MonkeyPatch) -> List[Sequence[Tuple[str, str]]]: + """Records each block of read-only lines the menu states.""" + blocks: List[Sequence[Tuple[str, str]]] = [] + monkeypatch.setattr( + shared_browser_module, + "add_detail_items", + lambda items, **_kwargs: blocks.append(items), + ) + return blocks + + +@pytest.fixture +def built(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """Replaces every container-menu builder with a record of its name, in call order.""" + names: List[str] = [] + + @contextlib.contextmanager + def _menu() -> Iterator[None]: + yield + + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) + return names + + +def _record_builders( + panel: GUISequencerBrowserPanel, + built: List[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + for builder in CONTAINER_BUILDERS: + monkeypatch.setattr(panel, builder, lambda _argument, name=builder: built.append(name)) + + +class TestContainerMenuComposition: + def test_group_row_states_what_it_holds_before_what_it_offers( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = _panel() + group, _, _ = _sample_tree() + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(group) + + assert built == list(CONTAINER_BUILDERS) + + def test_sample_row_offers_the_same_items( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = _panel() + _, sample, _ = _sample_tree() + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(sample) + + assert built == list(CONTAINER_BUILDERS) + + @pytest.mark.parametrize("node_type", [NodeType.FILE, NodeType.DIRECTORY, NodeType.ROOT]) + def test_row_standing_for_a_path_opens_no_container_menu( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + node_type: NodeType, + ) -> None: + """The rows with a path of their own have menus of their own, offering the path items.""" + panel = _panel() + node = FileSystemNode("kick.stn", node_type=node_type, filepath=Path("/kick.stn")) + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(node) + + assert built == [] + + +class TestReconstructionCount: + def test_sample_row_counts_the_variants_it_gathers( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + panel = _panel() + _, sample, _ = _sample_tree() + + panel._add_context_menu_reconstruction_count(sample) + + assert details == [[(RECONSTRUCTIONS_LABEL, "2")]] + + def test_group_row_counts_every_reconstruction_below_it( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + """A group reports the whole subtree, so the containers between it and the files add nothing.""" + panel = _panel() + group, sample, _ = _sample_tree() + second_sample = TreeNode("snare.wav", node_type=NodeType.SAMPLE, parent=group) + FileSystemNode( + "fft.stn", + node_type=NodeType.FILE, + filepath=Path("/reconstructions/snare/fft.stn"), + parent=second_sample, + ) + + panel._add_context_menu_reconstruction_count(group) + + assert details == [[(RECONSTRUCTIONS_LABEL, "3")]] + + def test_row_gathering_nothing_reports_no_reconstruction( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + panel = _panel() + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP) + + panel._add_context_menu_reconstruction_count(group) + + assert details == [[(RECONSTRUCTIONS_LABEL, "0")]] + + +class TestExpansionItems: + def test_both_directions_are_offered(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + + assert recorder.labels == [EXPAND_LABEL, COLLAPSE_LABEL] + + def test_expanding_reaches_the_row_and_every_container_below_it( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + recorder.item(EXPAND_LABEL)["callback"]() + + assert expanded == [ + (compose_node_tag(group, panel_tag=PANEL_TAG), True), + (compose_node_tag(sample, panel_tag=PANEL_TAG), True), + ] + + def test_collapsing_closes_the_same_rows( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + recorder.item(COLLAPSE_LABEL)["callback"]() + + assert expanded == [ + (compose_node_tag(group, panel_tag=PANEL_TAG), False), + (compose_node_tag(sample, panel_tag=PANEL_TAG), False), + ] + + def test_leaf_rows_are_left_alone( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + """A reconstruction row holds nothing to fold, so no expansion state is stated for it.""" + panel = _panel() + _, sample, variants = _sample_tree() + + panel._add_context_menu_expansion_items(sample) + recorder.item(EXPAND_LABEL)["callback"]() + + variant_tags = [compose_node_tag(variant, panel_tag=PANEL_TAG) for variant in variants] + assert [tag for tag, _ in expanded] == [compose_node_tag(sample, panel_tag=PANEL_TAG)] + assert all(tag not in variant_tags for tag, _ in expanded) + + +class TestCopyNameItem: + def test_clicking_copies_the_label_the_tree_reads(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_copy_name_item(group) + recorder.item(COPY_NAME_LABEL)["callback"]() + + assert recorder.clipboard == ["44.1 kHz"] + + def test_a_folded_chain_copies_every_level_of_its_label(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + folded = TreeNode("44.1 kHz·30 Hz·FFT", node_type=NodeType.GROUP) + + panel._add_context_menu_copy_name_item(folded) + recorder.item(COPY_NAME_LABEL)["callback"]() + + assert recorder.clipboard == ["44.1 kHz·30 Hz·FFT"] + + +class TestSampleAudioItem: + def test_sample_row_delegates_to_a_reconstruction_below_it(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + + panel._add_context_menu_sample_audio_item(sample) + + assert recorder.labels == [LOCATE_AUDIO_LABEL] + assert recorder.item(LOCATE_AUDIO_LABEL)["user_data"] is variants[0] + + def test_clicking_reports_the_reconstruction_path(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + located: List[Path] = [] + panel.on_locate_original_audio = located.append + + panel._add_context_menu_sample_audio_item(sample) + item = recorder.item(LOCATE_AUDIO_LABEL) + item["callback"](0, None, item["user_data"]) + + assert located == [variants[0].filepath] + + def test_group_row_offers_no_audio(self, recorder: _MenuItemRecorder) -> None: + """A group gathers reconstructions of many samples, so no one audio stands behind it.""" + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_sample_audio_item(group) + + assert recorder.labels == [] + + def test_sample_row_holding_no_reconstruction_offers_no_audio(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE) + + panel._add_context_menu_sample_audio_item(sample) + + assert recorder.labels == [] + + +class TestFirstReconstructionBelow: + def test_the_nearest_reconstruction_answers_for_the_row(self) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + + assert panel._first_reconstruction_below(sample) is variants[0] + + def test_a_row_gathering_none_names_nothing(self) -> None: + panel = _panel() + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE) + + assert panel._first_reconstruction_below(sample) is None + + def test_containers_below_the_row_are_passed_over(self) -> None: + """A mirrored source folder under a group is not itself a reconstruction.""" + panel = _panel() + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP) + directory = FileSystemNode( + "drums", + node_type=NodeType.DIRECTORY, + filepath=Path("/reconstructions/drums"), + parent=group, + ) + reconstruction = FileSystemNode( + "kick.stn", + node_type=NodeType.FILE, + filepath=Path("/reconstructions/drums/kick.stn"), + parent=directory, + ) + + found: Optional[FileSystemNode] = panel._first_reconstruction_below(group) + + assert found is reconstruction From faef770c434cdc6a0947f359e7fa215980a0c6b1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 21:43:10 +0200 Subject: [PATCH 22/45] Added: reconstruction browser document --- docs/development/architecture.md | 4 +- docs/development/browser.md | 144 +++++++++++++++++++++++++++++ docs/development/bugs-and-todos.md | 1 - docs/guide/interface.md | 7 +- docs/index.md | 1 + 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 docs/development/browser.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 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..3eaf1fc9 --- /dev/null +++ b/docs/development/browser.md @@ -0,0 +1,144 @@ +# The Reconstruction Browser + +This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs +share: how a reconstructions directory becomes rows, what a row stands for, and what it answers. +Consult it when changing what the browser lists, how a row reads, or what a click on one does. It +complements `docs/development/architecture.md` (layering and ownership) and +`docs/development/guidelines.md` (coding rules). + +--- + +## Principles + +1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once + into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree + about what exists by construction, and a folder name is parsed into its configuration fields once + per refresh. +2. **The model carries the shape; the panel carries the widgets.** Which rows exist, what they are + called, which of them fold together and in what order they sit are decided on the tree. Both tabs + render one model, so they show one shape, and each rule is exercised without a window. +3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path, + playback and opening a reconstruction all key on `filepath`. That is what frees a name to be + rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings + joined into one row, a colliding label marked with its configuration hash. +4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a + transformation, a source folder, one source audio: each becomes a row that carries no path of its + own. What such a row offers follows from the subtree beneath it. +5. **One thing may stand in several places.** A reconstruction is listed by the configuration that + produced it and again by the audio it was made from, so an action on the thing rather than on the + row asks for every row standing for it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands + them to both tabs. +6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the + background worker — tag, label, font, theme, handler, open state — and the main thread creates the + widgets from those specs, spread across frames. + +--- + +## The pipeline + +`BrowserManager` (`logic/reconstruction/browser/manager.py`) owns the tree and runs a refresh in four +steps: **scan** the directory, **build** each branch from that one scan, **shape** what came out, and +**publish** it through `Tree.set_root`. `BrowserLogic` sits above it as the surface the coordinators +drive, and `get_all_reconstruction_files` reads the scan. + +| Stage | Module | What it does | +|---|---|---| +| Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it | +| Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree | +| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation groups and names it by its generators; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | +| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration | +| Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first | +| Containers | `tree/containers.py` | `find_or_create_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each node type is looked up among the siblings of its own kind, so a folder and an audio sharing a name stay two rows | + +The policy the two branches share: a configuration directory sitting at the top level of the +reconstructions directory is the one lifted under groups and transposed into the sample view. A +configuration directory nested inside a plain folder keeps its friendly name where it sits, and a +reconstruction outside every configuration directory appears in the configuration branch, that being +the branch which follows the disk. + +## The node vocabulary + +`sampletones_core/structures/tree/` holds the nodes, all anytree-backed: + +* `TreeNode(name, node_type)` — a row and its kind. `NodeType.ROOT` for the container both branches + hang from, `GROUP` and `SAMPLE` for the headings the browser writes, `DIRECTORY` and `FILE` for what + the disk holds. +* `FileSystemNode(filepath)` — a row standing for a path. Favorites, playability, themes and the path + items all test for this class. +* `ConfigNode(config)` — a filesystem row belonging to a reconstruction configuration, carrying the + parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps + working, and the fields travel with the row, which is what lets a label, a tooltip and a font state + the configuration from the node already in hand. + +`create_directory_node` chooses between the last two from the fields the scan read. Which row carries +the configuration follows the branch: in the configuration branch it is the directory that names it, +and in the sample branch it is the variant leaf, since there the configuration is what distinguishes +one row from the next. + +## The shaping rules + +* **Prune** (`prune_empty_containers`) — a heading the browser wrote that gathers nothing leaves, + deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to + show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk. +* **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into + that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place. + The surviving row keeps its node type, path, configuration and children, so its click behaviour, + theme, context menu and favorite star carry over. A fold that would repeat a name already beside it + stays open instead, and the branch roots stay in place. With a single configuration present the + configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a + second configuration arrives. +* **Order** (`order_children`) — containers ahead of leaves, then `natural_sort_key` over the label, so + a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every + label is final; the branches directly under the container root keep the order the builder states them + in. +* **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where + siblings would read alike, every member of that label takes its short configuration hash. One rule + serves the generator directories under a transformation group, the nested configuration directories, + and the variants under a sample. + +## The panels + +The browsers form one line of inheritance, each level owning what it shares: + +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the search box, the rebuild handshake, + spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the + context-menu items every browser can offer. +* `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the + refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree + locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states + what its card and refresh control read. +* `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the + rows the two branches hold, the colour a group and a sample read in, and the context menus. The + Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what + opening a reconstruction means in that tab. + +The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on +`GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them. + +**A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to +date, collects the rows into specs, and hands them to `TreeEmitter`, which clears the old rows and +stages the new ones in budget-sized batches so interactive callbacks run between slices. The +completion callback shows the empty state where one is called for, runs the panel's hook, and releases +the lock. Because a browser is asked to rebuild from either tab and from several places in the +application, exactly one rebuild is in flight at a time. + +**A row's tag** (`compose_node_tag`, `ui/elements/tree/tag.py`) joins the names above it, which reads +the row back to whoever inspects the widget tree, and appends a digest over the exact path of +`(node_type, name)` pairs. Rows the names alone spell alike — a folder and the audio beside it, two +labels differing only in spacing or case — therefore keep tags of their own. A tag is composed rather +than stored, so any holder of a node can address its row: this is how expanding a subtree, repainting a +star and applying a filter reach the widgets. + +**What a row answers** follows its kind. A reconstruction plays on a click, opens on a double click, +and offers its path items, the tab's own actions and the favorite mark. A directory offers its path +items and the favorite mark. A group or a sample stands for no path, so its menu reads the subtree: how +many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows +it by, and — on a sample — the audio its reconstructions were made from, answered through any one of +them. + +**Favorites are paths.** `TreeLogic.is_node_favorite` tests the row's path against the session's set, +and `has_favorite_ancestor` tests the path's parents, so a reconstruction reads as part of a favorite +folder wherever a view puts it — including the sample branch, whose headings carry no path. Since one +path reaches the panel as several rows, `application.py` resolves the toggled path into every row +standing for it and hands them to both tabs, and each row repaints with the ancestry its own path +carries. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f80bda09..198ab3bc 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,7 +4,6 @@ * Interface scale * Tree navigation using keys -* Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming * Alt for scrolling graphs * Drag and drop diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f77ba657..a893dad5 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -37,8 +37,11 @@ reveals. [Configuration](configuration.md) explains each one. The **Reconstructions** tab is where you audition a reconstruction against the original, fine-tune it, and export it. -Open a saved reconstruction from the list on the left; if the current one has -unsaved edits, you are asked whether to save it first. You can play it back and +Open a saved reconstruction from the **Browser** on the left, which offers the +same files two ways: **By configuration** groups them by the settings they were +made with, and **By sample** gathers every version of one source audio together. +If the current reconstruction has unsaved edits, you are asked whether to save it +first. You can play it back and switch **Play audio source:** between **Reconstruction** and **Original audio** to compare the two, and **Locate original audio** re-links the source file if it has moved. diff --git a/docs/index.md b/docs/index.md index 3abf6df1..a2357a27 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,6 +58,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. +- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From 91e148391a6e90a37ad6f7d25dd2df1703047aa6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 22:01:34 +0200 Subject: [PATCH 23/45] Refactored: tree filtering --- .../ui/elements/tree/filter.py | 28 +++ .../ui/elements/tree/tree.py | 96 ++++++---- .../structures/tree/__init__.py | 3 + src/sampletones_core/structures/tree/tree.py | 64 +------ .../structures/tree/visibility.py | 42 ++++ .../ui/elements/tree/test_favorites.py | 3 + .../ui/elements/tree/test_filter.py | 181 ++++++++++++++++++ .../structures/tree/test_tree.py | 122 +----------- .../structures/tree/test_visibility.py | 123 ++++++++++++ 9 files changed, 458 insertions(+), 204 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/filter.py create mode 100644 src/sampletones_core/structures/tree/visibility.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_filter.py create mode 100644 tests/unit/sampletones_core/structures/tree/test_visibility.py diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py new file mode 100644 index 00000000..c1615fc7 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/filter.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Final + + +@dataclass(frozen=True) +class TreeFilter: + """What a browser is currently asked to show, held by the panel showing it. + + Several browsers render one tree, so what each of them narrows to belongs to the panel: a query + typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole, + so the panel resolves what it shows in one place. + """ + + query: str + + @property + def is_active(self) -> bool: + """Whether the filter narrows what the browser shows.""" + return bool(self.query) + + def with_query(self, query: str) -> TreeFilter: + """The filter reading a new query, keeping everything else it states.""" + return replace(self, query=query) + + +NO_FILTER: Final[TreeFilter] = TreeFilter(query="") diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 521e135e..54c54362 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -43,6 +43,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.emitter import TreeEmitter +from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -80,6 +81,8 @@ NodeType, Tree, TreeNode, + TreeVisibility, + resolve_visibility, ) from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender @@ -122,6 +125,9 @@ def __init__( self._pending_specs: List[NodeSpec] = [] self._emitter = TreeEmitter(scheduling=scheduling) + self._filter: TreeFilter = NO_FILTER + self._search_visibility: Optional[TreeVisibility] = None + self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None self._search_button_tag: Optional[str] = None @@ -170,9 +176,9 @@ def _launch_rebuild( 1. A rebuild already in flight holds the lock, so return and let it finish. 2. Acquire the lock; responsibility for releasing it passes to the emit pipeline. - 3. ``refresh`` updates the model, then ``collect`` resolves it into a flat - :class:`NodeSpec` list -- every per-node decision, including the filesystem - content check, happens here on the worker. + 3. ``refresh`` updates the model and the filter is resolved against it, then + ``collect`` resolves it into a flat :class:`NodeSpec` list -- every per-node + decision, including the filesystem content check, happens here on the worker. 4. Post the specs to :class:`TreeEmitter` through the queue. This crosses back to the main thread, where the emitter clears the old tree and stages the new nodes across frames. @@ -188,12 +194,18 @@ def _launch_rebuild( handed_off = False try: refresh() + self._resolve_filter() specs = collect() CallbackQueue.add( self._emitter.emit, tuple(specs), root_tag, - partial(self._finish_emit, root_tag, on_finished), + partial( + self._finish_emit, + root_tag, + on_finished, + len(specs), + ), priority=self._scheduling.emit.priority, ) handed_off = True @@ -286,15 +298,16 @@ def _finish_emit( self, root_tag: str, on_finished: Optional[VoidCallback], + drawn_rows: int, ) -> None: """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. - The emitter runs this once its last batch has attached. When a filtered tree - resolved to an empty model, the no-results message fills the cleared tree so the - filter outcome is visible. Applying the filter here lets late-emitted nodes honour + The emitter runs this once its last batch has attached. A filtered rebuild that drew no + row fills the cleared tree with the no-results message, so the filter's outcome is + legible where the rows would be. Applying the filter here lets late-emitted nodes honour an active search, and releasing the lock hands control back to interactive rebuilds. """ - if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None: + if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows: dpg.add_text( self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag, @@ -303,7 +316,7 @@ def _finish_emit( if on_finished is not None: on_finished() - if self.tree.is_filtered(): + if self._filter.is_active: self.update_tree_visibility() self.unlock() @@ -451,14 +464,11 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - if not self.tree.is_filtered(): + """Whether the row is emitted standing open, which the rows leading to a search result are.""" + if self._search_visibility is None: return False - for descendant in node.descendants: - if self.tree.is_node_visible(descendant): - return True - - return False + return self._search_visibility.should_expand(node) def _create_status_bar_message_function( self, @@ -714,21 +724,42 @@ def _on_replace_in_sequencer( self.call(self.on_replace_in_sequencer, user_data.filepath) def _on_search_changed(self, _sender: Sender, query: str) -> None: - if query: - self.apply_filter(query, self._default_search_predicate) - else: - self.clear_filter() - + self._set_filter(self._filter.with_query(query)) self._logic.schedule_search_update(query) def _on_clear_search_clicked(self) -> None: if self._search_input_tag is not None: dpg.set_value(self._search_input_tag, "") - self.clear_filter() - + self._set_filter(self._filter.with_query("")) self._logic.schedule_search_update("") + def _set_filter(self, tree_filter: TreeFilter) -> None: + """Take the filter the browser is now asked to show, and resolve what it leaves on screen.""" + self._filter = tree_filter + self._resolve_filter() + + def _resolve_filter(self) -> None: + """Resolve the filter against the model as it stands, which a rebuild does once per pass. + + Reading the model rather than the rows lets the resolution run on the rebuild worker, and + keeps a filter typed before a refresh answering for the rows that refresh brings. + """ + self._search_visibility = self._resolve_search_visibility() + + def _resolve_search_visibility(self) -> Optional[TreeVisibility]: + """The rows the search query names, and nothing to narrow by while no query is typed.""" + query = self._filter.query + if not query: + return None + + return resolve_visibility( + self.tree.find_nodes( + TreeNode, + lambda node: self._default_search_predicate(node, query), + ) + ) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() @@ -736,6 +767,11 @@ def _default_search_predicate(self, node: TreeNode, query: str) -> bool: def rebuild_tree(self) -> None: ... def update_tree_visibility(self) -> None: + """Show the rows the search names and hide the rest, over the rows already on screen. + + Runs on the main thread once the typing settles, so a query narrows what is drawn in place + of asking for a rebuild. + """ root = self.tree.get_root() if root is None: return @@ -748,21 +784,17 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None: if not dpg.does_item_exist(node_tag): return - is_visible = self.tree.is_node_visible(node) - dpg.configure_item(node_tag, show=is_visible) + dpg.configure_item(node_tag, show=self._is_node_visible(node)) for child in node.children: self._update_node_visibility_recursive(child) - def apply_filter( - self, - query: str, - predicate: Callable[[TreeNode, str], bool], - ) -> None: - self.tree.apply_filter(query, predicate) + def _is_node_visible(self, node: TreeNode) -> bool: + """Whether the search shows the row, which every row on screen reads as while none is typed.""" + if self._search_visibility is None: + return True - def clear_filter(self) -> None: - self.tree.clear_filter() + return self._search_visibility.is_visible(node) def _apply_node_theme( self, diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py index 3c1dacf6..94a9bdcf 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -4,6 +4,7 @@ from .traversal import TreeTraversal, traverse from .tree import Tree from .type import NodeType +from .visibility import TreeVisibility, resolve_visibility __all__ = [ "Arguments", @@ -15,6 +16,8 @@ "Tree", "TreeNode", "TreeTraversal", + "TreeVisibility", "create_directory_node", + "resolve_visibility", "traverse", ] diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 1b8ed002..143b6e52 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar +from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar from anytree import PreOrderIter @@ -8,64 +8,22 @@ class Tree: + """The rows a view renders, held as one root the whole shape hangs from. + + The tree states which rows exist, what they are called and how they nest, and every view reading + it shows that one shape. What a view narrows to is the view's own, so several views share a tree + and each of them filters on its own. + """ + def __init__(self, root: Optional[TreeNode] = None) -> None: self.root = root - self._filter_query: Optional[str] = None - self._node_visibility: Dict[TreeNode, bool] = {} def set_root(self, root: Optional[TreeNode]) -> None: self.root = root - self.clear_filter() def get_root(self) -> Optional[TreeNode]: return self.root - def apply_filter( - self, - query: str, - predicate: Callable[[TreeNode, str], bool], - ) -> None: - if not self.root: - self._filter_query = query - self._node_visibility = {} - return - - if not query: - self.clear_filter() - return - - self._filter_query = query - matching_nodes = {node for node in PreOrderIter(self.root) if predicate(node, query)} - - if not matching_nodes: - self._node_visibility = {node: False for node in PreOrderIter(self.root)} - return - - nodes_to_show = set(matching_nodes) - for node in matching_nodes: - current = node.parent - while current is not None: - nodes_to_show.add(current) - current = current.parent - - for descendant in PreOrderIter(node): - nodes_to_show.add(descendant) - - self._node_visibility = {node: node in nodes_to_show for node in PreOrderIter(self.root)} - - def clear_filter(self) -> None: - self._filter_query = None - self._node_visibility = {} - - def is_filtered(self) -> bool: - return self._filter_query is not None - - def is_node_visible(self, node: TreeNode) -> bool: - if not self.is_filtered(): - return True - - return self._node_visibility.get(node, False) - def find_nodes( self, node_class: Type[TreeNodeT], @@ -95,8 +53,4 @@ def collect_leaves(self) -> Sequence[TreeNode]: if not self.root: return [] - leaves = [node for node in PreOrderIter(self.root) if node.is_leaf] - if self.is_filtered(): - return [leaf for leaf in leaves if self.is_node_visible(leaf)] - - return leaves + return [node for node in PreOrderIter(self.root) if node.is_leaf] diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py new file mode 100644 index 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/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 488ec672..411f89b5 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -7,6 +7,7 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE_CHILD, ) +from sampletones_application.ui.elements.tree.filter import NO_FILTER from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState @@ -77,6 +78,8 @@ def build_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tree = tree + panel._filter = NO_FILTER + panel._search_visibility = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py new file mode 100644 index 00000000..e7a60b19 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -0,0 +1,181 @@ +from typing import Dict, List, Set, Type + +from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter +from sampletones_application.ui.panels.reconstruction.browser import GUIReconstructionsBrowserPanel +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared.browser import GUIReconstructionBrowserPanel +from sampletones_core.structures.tree import NodeType, Tree, TreeNode + + +class FakeTreeLogic: + """Stands in for the logic a panel schedules the search on, recording what it was asked for.""" + + def __init__(self) -> None: + self.scheduled_queries: List[str] = [] + + def schedule_search_update(self, query: str) -> None: + self.scheduled_queries.append(query) + + +def browser_tree() -> Tree: + """Builds the shape both browser views give one reconstructions directory, a row per label.""" + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + TreeNode("song", node_type=NodeType.FILE, parent=configurations) + TreeNode("other", node_type=NodeType.FILE, parent=configurations) + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("sample", node_type=NodeType.SAMPLE, parent=samples) + TreeNode("variant", node_type=NodeType.FILE, parent=sample) + return Tree(root=root) + + +def rows_of(tree: Tree) -> Dict[str, TreeNode]: + """The rows a tree holds, read by the label each of them carries.""" + root = tree.get_root() + assert root is not None + return {node.name: node for node in (root, *root.descendants)} + + +def build_panel( + tree: Tree, + panel_class: Type[GUIReconstructionBrowserPanel] = GUISequencerBrowserPanel, +) -> GUIReconstructionBrowserPanel: + """Builds a browser panel holding a filter, with the tree it reads and the logic it schedules on. + + Resolving a filter reads the model alone, so the panel needs neither widgets nor a search box. + """ + panel = panel_class.__new__(panel_class) + panel.tree = tree + panel._logic = FakeTreeLogic() + panel._search_input_tag = None + panel._filter = NO_FILTER + panel._search_visibility = None + return panel + + +def visible_rows(panel: GUIReconstructionBrowserPanel, tree: Tree) -> Set[str]: + return {name for name, node in rows_of(tree).items() if panel._is_node_visible(node)} + + +class TestFilterComposition: + def test_a_filter_stating_nothing_narrows_nothing(self) -> None: + assert not NO_FILTER.is_active + + def test_a_filter_carrying_a_query_narrows(self) -> None: + assert NO_FILTER.with_query("song").is_active + + def test_dropping_the_query_leaves_the_filter_narrowing_nothing(self) -> None: + assert not NO_FILTER.with_query("song").with_query("").is_active + + def test_the_filter_a_new_one_was_taken_from_reads_as_it_did(self) -> None: + original = TreeFilter(query="song") + original.with_query("other") + assert original.query == "song" + + +class TestPanelOwnedFilter: + def test_a_query_shows_the_rows_it_names_and_the_rows_above_them(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert visible_rows(panel, tree) == {"Root", "By configuration", "other"} + + def test_a_query_naming_a_container_shows_what_it_gathers(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "sample") + + assert visible_rows(panel, tree) == {"Root", "By sample", "sample", "variant"} + + def test_no_query_shows_every_row(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + assert visible_rows(panel, tree) == set(rows_of(tree)) + + def test_clearing_the_search_shows_every_row_again(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + panel._on_clear_search_clicked() + + assert visible_rows(panel, tree) == set(rows_of(tree)) + + def test_the_search_is_scheduled_as_it_is_typed_and_as_it_is_cleared(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + logic = panel._logic + + panel._on_search_changed(None, "oth") + panel._on_clear_search_clicked() + + assert logic.scheduled_queries == ["oth", ""] + + +class TestTwoPanelsOverOneTree: + """Both reconstruction browsers render one tree, and each of them narrows to its own filter.""" + + def test_a_query_in_one_panel_leaves_the_other_reading_as_it_was(self) -> None: + tree = browser_tree() + searching = build_panel(tree, GUISequencerBrowserPanel) + untouched = build_panel(tree, GUIReconstructionsBrowserPanel) + + searching._on_search_changed(None, "other") + + assert visible_rows(untouched, tree) == set(rows_of(tree)) + assert not untouched._filter.is_active + + def test_each_panel_narrows_to_the_query_it_was_given(self) -> None: + tree = browser_tree() + first = build_panel(tree, GUISequencerBrowserPanel) + second = build_panel(tree, GUIReconstructionsBrowserPanel) + + first._on_search_changed(None, "other") + second._on_search_changed(None, "variant") + + assert visible_rows(first, tree) == {"Root", "By configuration", "other"} + assert visible_rows(second, tree) == {"Root", "By sample", "sample", "variant"} + + +class TestFilterAcrossARebuild: + def test_a_query_answers_for_the_rows_a_refresh_brings(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + panel._on_search_changed(None, "arrival") + + root = TreeNode("Root", node_type=NodeType.ROOT) + group = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + arrival = TreeNode("arrival", node_type=NodeType.FILE, parent=group) + tree.set_root(root) + panel._resolve_filter() + + assert panel._is_node_visible(arrival) + assert visible_rows(panel, tree) == {"Root", "By configuration", "arrival"} + + +class TestExpandedRows: + def test_a_row_leading_to_a_result_is_emitted_open(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert panel._should_expand_node(rows_of(tree)["By configuration"]) + + def test_a_row_beside_the_way_in_is_emitted_folded(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert not panel._should_expand_node(rows_of(tree)["By sample"]) + + def test_no_query_leaves_every_row_as_it_stands(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + assert not any(panel._should_expand_node(node) for node in rows_of(tree).values()) diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index a0d2f235..0ec7dad6 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from pathlib import Path from typing import Final, List @@ -7,15 +6,10 @@ from sampletones_core.structures.tree.node import FileSystemNode, TreeNode from sampletones_core.structures.tree.tree import Tree from sampletones_core.structures.tree.type import NodeType -from tests.suite.case import BaseTestCase SONG_PATH: Final[Path] = Path("/reconstructions/song.stn") -def name_predicate(node: TreeNode, query: str) -> bool: - return query in node.name - - @pytest.fixture def all_nodes() -> List[TreeNode]: root = TreeNode("root", NodeType.ROOT) @@ -48,110 +42,10 @@ def test_get_root_returns_root( ) -> None: assert tree.get_root() is all_nodes[0] - def test_set_root_clears_existing_filter( - self, - all_nodes: List[TreeNode], - tree: Tree, - ) -> None: - tree.apply_filter("child_a", name_predicate) - assert tree.is_filtered() - tree.set_root(all_nodes[0]) - assert not tree.is_filtered() - - -class TestTreeFilter: - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseTestCase): - label: str - query: str - expected_visible_names: frozenset[str] - expected_hidden_names: frozenset[str] - - test_cases = ( - TestCase( - label="match_leaf", - query="leaf_ba", - expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}), - expected_hidden_names=frozenset({"child_a", "leaf_aa", "leaf_ab"}), - ), - TestCase( - label="match_internal", - query="child_a", - expected_visible_names=frozenset( - { - "root", - "child_a", - "leaf_aa", - "leaf_ab", - } - ), - expected_hidden_names=frozenset({"child_b", "leaf_ba"}), - ), - TestCase( - label="no_match", - query="xyz", - expected_visible_names=frozenset(), - expected_hidden_names=frozenset( - { - "root", - "child_a", - "child_b", - "leaf_aa", - "leaf_ab", - "leaf_ba", - } - ), - ), - ) - - def test_no_filter_all_nodes_visible( - self, - tree: Tree, - all_nodes: List[TreeNode], - ) -> None: - for node in all_nodes: - assert tree.is_node_visible(node) - - def test_is_filtered_false_initially(self, tree: Tree) -> None: - assert not tree.is_filtered() - - def test_is_filtered_true_after_apply(self, tree: Tree) -> None: - tree.apply_filter("root", name_predicate) - assert tree.is_filtered() - - def test_filter_empty_query_clears_filter(self, tree: Tree) -> None: - tree.apply_filter("child_a", name_predicate) - tree.apply_filter("", name_predicate) - assert not tree.is_filtered() - - def test_clear_filter_makes_all_nodes_visible( - self, - tree: Tree, - all_nodes: List[TreeNode], - ) -> None: - tree.apply_filter("leaf_ba", name_predicate) - tree.clear_filter() - for node in all_nodes: - assert tree.is_node_visible(node) - - def test_filter_on_empty_tree_is_active(self) -> None: - t = Tree() - t.apply_filter("x", name_predicate) - assert t.is_filtered() - - @pytest.mark.parametrize("case", test_cases, ids=lambda c: c.label) - def test_filter_visibility( - self, - tree: Tree, - all_nodes: List[TreeNode], - case: TestCase, - ) -> None: - tree.apply_filter(case.query, name_predicate) - for node in all_nodes: - if node.name in case.expected_visible_names: - assert tree.is_node_visible(node), f"{node.name!r} should be visible for query {case.query!r}" - elif node.name in case.expected_hidden_names: - assert not tree.is_node_visible(node), f"{node.name!r} should be hidden for query {case.query!r}" + def test_set_root_replaces_the_shape(self, tree: Tree) -> None: + replacement = TreeNode("replacement", NodeType.ROOT) + tree.set_root(replacement) + assert tree.get_root() is replacement class TestTreeCollectLeaves: @@ -165,16 +59,10 @@ def test_singleton_root_is_its_own_leaf(self) -> None: assert len(leaves) == 1 assert leaves[0] is root - def test_returns_all_leaves_without_filter(self, tree: Tree) -> None: + def test_every_leaf_the_shape_holds_is_answered(self, tree: Tree) -> None: leaf_names = {leaf.name for leaf in tree.collect_leaves()} assert leaf_names == {"leaf_aa", "leaf_ab", "leaf_ba"} - def test_filtered_leaves_exclude_hidden(self, tree: Tree) -> None: - tree.apply_filter("leaf_ba", name_predicate) - leaves = tree.collect_leaves() - assert len(leaves) == 1 - assert leaves[0].name == "leaf_ba" - class TestTreeFindNodes: @staticmethod diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py new file mode 100644 index 00000000..abd27830 --- /dev/null +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass +from typing import Dict, List + +import pytest + +from sampletones_core.structures.tree.node import TreeNode +from sampletones_core.structures.tree.type import NodeType +from sampletones_core.structures.tree.visibility import TreeVisibility, resolve_visibility +from tests.suite.case import BaseTestCase + + +@pytest.fixture +def nodes() -> Dict[str, TreeNode]: + root = TreeNode("root", NodeType.ROOT) + child_a = TreeNode("child_a", NodeType.DIRECTORY, parent=root) + child_b = TreeNode("child_b", NodeType.DIRECTORY, parent=root) + leaf_aa = TreeNode("leaf_aa", NodeType.FILE, parent=child_a) + leaf_ab = TreeNode("leaf_ab", NodeType.FILE, parent=child_a) + leaf_ba = TreeNode("leaf_ba", NodeType.FILE, parent=child_b) + return { + node.name: node + for node in ( + root, + child_a, + child_b, + leaf_aa, + leaf_ab, + leaf_ba, + ) + } + + +def visibility_of( + nodes: Dict[str, TreeNode], + matched_names: List[str], +) -> TreeVisibility: + return resolve_visibility(nodes[name] for name in matched_names) + + +class TestVisibleRows: + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseTestCase): + label: str + matched_names: List[str] + expected_visible_names: frozenset[str] + + test_cases = ( + TestCase( + label="a_named_leaf_is_read_under_the_rows_holding_it", + matched_names=["leaf_ba"], + expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}), + ), + TestCase( + label="a_named_row_shows_what_it_gathers", + matched_names=["child_a"], + expected_visible_names=frozenset({"root", "child_a", "leaf_aa", "leaf_ab"}), + ), + TestCase( + label="two_named_rows_each_keep_their_own_way_in", + matched_names=["leaf_aa", "leaf_ba"], + expected_visible_names=frozenset( + { + "root", + "child_a", + "leaf_aa", + "child_b", + "leaf_ba", + } + ), + ), + TestCase( + label="nothing_named_keeps_nothing", + matched_names=[], + expected_visible_names=frozenset(), + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_rows_a_match_keeps( + self, + nodes: Dict[str, TreeNode], + case: TestCase, + ) -> None: + visibility = visibility_of(nodes, case.matched_names) + visible_names = {name for name, node in nodes.items() if visibility.is_visible(node)} + assert visible_names == case.expected_visible_names + + +class TestOpenRows: + def test_every_row_above_a_match_stands_open(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + open_names = {name for name, node in nodes.items() if visibility.should_expand(node)} + assert open_names == {"root", "child_b", "leaf_ba"} + + def test_a_row_beside_the_way_in_stays_folded(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + assert not visibility.should_expand(nodes["child_a"]) + + def test_a_row_below_a_match_stays_folded(self, nodes: Dict[str, TreeNode]) -> None: + """A match shows what it gathers as it stands, so its own rows keep the shape they had.""" + visibility = visibility_of(nodes, ["child_a"]) + assert visibility.is_visible(nodes["leaf_aa"]) + assert not visibility.should_expand(nodes["leaf_aa"]) + + def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, []) + assert not any(visibility.should_expand(node) for node in nodes.values()) + + +class TestResolvedSets: + def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) + assert visibility.matches == frozenset({nodes["leaf_aa"], nodes["leaf_ab"]}) + + def test_only_the_rows_above_a_match_are_held_beside_them(self, nodes: Dict[str, TreeNode]) -> None: + """What a match holds is answered from a path, so the sets stay the size of what was found.""" + visibility = visibility_of(nodes, ["child_a"]) + assert visibility.ancestors == frozenset({nodes["root"]}) + + def test_a_match_above_another_is_held_in_both_sets(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) + assert nodes["child_a"] in visibility.matches + assert nodes["child_a"] in visibility.ancestors From 3ea5bd09b4f8621c0dc070c2234ff9534ac1b231 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 22:29:37 +0200 Subject: [PATCH 24/45] Added: favorites-only tree filter --- .../categories/elements/global_.py | 1 + .../ui/elements/tree/browser.py | 15 +- .../ui/elements/tree/filter.py | 15 +- .../ui/elements/tree/tree.py | 81 ++++- src/sampletones_config/lang/en.yaml | 1 + .../ui/elements/tree/test_favorites.py | 1 + .../ui/elements/tree/test_favorites_filter.py | 288 ++++++++++++++++++ .../ui/elements/tree/test_filter.py | 13 +- 8 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 7d1fbec7..1325284d 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -160,6 +160,7 @@ class GraphElements(AbstractElement): class GlobalMessageElements(AbstractElement): TREE_NO_RESULTS = "tree_no_results" + TREE_NO_FAVORITES = "tree_no_favorites" INVALID_METADATA_ERROR = "invalid_metadata_error" RECONSTRUCTION_NO_DATA = "reconstruction_no_data" RECONSTRUCTION_SAVED_SUCCESSFULLY = "reconstruction_saved_successfully" diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index d759daad..097a349d 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -21,7 +21,7 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree -from sampletones_shared.types.callback import Callback, MessageCallback +from sampletones_shared.types.callback import Callback, MessageCallback, VoidCallback class GUIFileBrowserPanel(GUITreePanel, ABC): @@ -206,8 +206,16 @@ def refresh(self) -> None: @concurrent(wait=False, method_bound=True) def rebuild_tree(self) -> None: + self._launch_tree_rebuild(self._refresh_model) + + @concurrent(wait=False, method_bound=True) + def redraw_tree(self) -> None: + self._launch_tree_rebuild(self._keep_model) + + def _launch_tree_rebuild(self, refresh: VoidCallback) -> None: + """Fills the whole tree from the model ``refresh`` leaves behind, off the main thread.""" self._launch_rebuild( - self._refresh_model, + refresh, lambda: self._collect_specs(self.tree_tag), root_tag=self.tree_tag, on_finished=self._on_rebuild_finished, @@ -217,6 +225,9 @@ def rebuild_tree(self) -> None: def _refresh_model(self) -> None: """Brings the model the tree renders up to date, on the background rebuild worker.""" + def _keep_model(self) -> None: + """Leaves the model as the last refresh brought it, which is what a redraw reads.""" + def _on_rebuild_finished(self) -> None: """Runs on the main thread with the rows on screen, where a browser reads something out.""" diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py index c1615fc7..310ea91a 100644 --- a/src/sampletones_application/ui/elements/tree/filter.py +++ b/src/sampletones_application/ui/elements/tree/filter.py @@ -11,18 +11,29 @@ class TreeFilter: Several browsers render one tree, so what each of them narrows to belongs to the panel: a query typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole, so the panel resolves what it shows in one place. + + The two criteria answer different questions: the query decides which of the rows on screen are + shown, while showing favorites alone decides which rows are drawn at all. """ query: str + favorites_only: bool @property def is_active(self) -> bool: """Whether the filter narrows what the browser shows.""" - return bool(self.query) + return bool(self.query) or self.favorites_only def with_query(self, query: str) -> TreeFilter: """The filter reading a new query, keeping everything else it states.""" return replace(self, query=query) + def with_favorites_only(self, favorites_only: bool) -> TreeFilter: + """The filter showing the favorites alone or the whole tree, keeping the query it states.""" + return replace(self, favorites_only=favorites_only) + -NO_FILTER: Final[TreeFilter] = TreeFilter(query="") +NO_FILTER: Final[TreeFilter] = TreeFilter( + query="", + favorites_only=False, +) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 54c54362..f2d66f69 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -127,6 +127,7 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None + self._favorites_visibility: Optional[TreeVisibility] = None self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -269,10 +270,17 @@ def _append_spec( Runs on the background traversal worker, so the theme and handler tags — including the directory content check that touches the filesystem — are chosen here, off the main thread. A shutdown request raises to unwind the traversal promptly. + + Which rows are recorded is the favorites mode's to state, and it shows a row together with + every row above it: a row it holds back therefore stands above rows it holds back too, so one + decision covers the whole subtree and the traversal walks on. """ if SingleThreadExecutor.is_shutting_down(): raise BackgroundWorkCancelled + if not self._is_node_drawn(node): + return + theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, @@ -303,24 +311,34 @@ def _finish_emit( """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. The emitter runs this once its last batch has attached. A filtered rebuild that drew no - row fills the cleared tree with the no-results message, so the filter's outcome is + row fills the cleared tree with the message naming that outcome, so the filter's answer is legible where the rows would be. Applying the filter here lets late-emitted nodes honour an active search, and releasing the lock hands control back to interactive rebuilds. """ if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows: dpg.add_text( - self._language_manager["global.dialog.message.tree_no_results"], + self._empty_filter_message(), parent=root_tag, ) if on_finished is not None: on_finished() - if self._filter.is_active: + if self._filter.query: self.update_tree_visibility() self.unlock() + def _empty_filter_message(self) -> str: + """Names the filter a rebuild came back empty from: the favorites mode, or the search.""" + return self._language_manager[ + ( + "global.dialog.message.tree_no_favorites" + if self._filter.favorites_only + else "global.dialog.message.tree_no_results" + ) + ] + def _create_hover_callback( self, status_bar_callback: Optional[MessageCallback], @@ -464,11 +482,16 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which the rows leading to a search result are.""" - if self._search_visibility is None: - return False + """Whether the row is emitted standing open, which a row leading to a match is. - return self._search_visibility.should_expand(node) + A search result and a favorite are both matches the reader is looking for, so the way down to + either one opens and the filter's answer reads at a glance. + """ + return any( + visibility.should_expand(node) + for visibility in (self._search_visibility, self._favorites_visibility) + if visibility is not None + ) def _create_status_bar_message_function( self, @@ -746,6 +769,7 @@ def _resolve_filter(self) -> None: keeps a filter typed before a refresh answering for the rows that refresh brings. """ self._search_visibility = self._resolve_search_visibility() + self._favorites_visibility = self._resolve_favorites_visibility() def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -760,12 +784,38 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) + def _resolve_favorites_visibility(self) -> Optional[TreeVisibility]: + """The rows the favorites mode names, and nothing to narrow by while the whole tree shows. + + One walk of the model answers the whole mode, and what it keeps is the starred rows together + with the rows above them, so a corpus of any size resolves into a pair of sets. + """ + if not self._filter.favorites_only: + return None + + return resolve_visibility(self.tree.find_nodes(TreeNode, self._is_node_starred)) + + def _is_node_starred(self, node: TreeNode) -> bool: + """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. + + Being held by a starred folder is a fact about the path, so a reconstruction listed under the + sample it came from answers the same as the row standing for it beside its configuration. + """ + if self._logic.is_node_favorite(node): + return True + + return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() @abstractmethod def rebuild_tree(self) -> None: ... + @abstractmethod + def redraw_tree(self) -> None: + """Draws the rows again from the model in hand, which a change of filter asks for.""" + def update_tree_visibility(self) -> None: """Show the rows the search names and hide the rest, over the rows already on screen. @@ -796,6 +846,13 @@ def _is_node_visible(self, node: TreeNode) -> bool: return self._search_visibility.is_visible(node) + def _is_node_drawn(self, node: TreeNode) -> bool: + """Whether the favorites mode draws the row, which it does for every row while it is off.""" + if self._favorites_visibility is None: + return True + + return self._favorites_visibility.is_visible(node) + def _apply_node_theme( self, node_tag: str, @@ -928,13 +985,21 @@ def update_favorite_indicators( self, nodes: Sequence[FileSystemNode], ) -> None: - """Repaints the rows a favorite change reaches, and what each of them holds. + """Follows a favorite change through the rows it reaches, and what each of them holds. A path reaches the panel as many rows as the views offer it — a reconstruction is listed both by its configuration and by the sample it came from — and the star belongs to the path, so the caller names every row standing for it and each of them takes the new theme with the ancestry its own path carries. + + While the mode shows the favorites alone the star decides which rows exist, so the change is + answered by drawing the tree again from the model in hand: starring a row brings it in, and + unstarring one takes it out along with what it held. """ + if self._filter.favorites_only: + self.redraw_tree() + return + for node in nodes: self._reapply_theme_recursively( node, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ea606aad..3b66d81c 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -59,6 +59,7 @@ global.dialog.filter.mp3: "MP3 audio" # Global — Dialog messages global.dialog.message.tree_no_results: "No results found." +global.dialog.message.tree_no_favorites: "No favorites found." global.dialog.message.invalid_metadata_error: "Invalid file metadata." global.dialog.message.reconstruction_no_data: "No reconstruction loaded." global.dialog.message.reconstruction_saved_successfully: "Reconstruction saved successfully." diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 411f89b5..fb713bec 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -80,6 +80,7 @@ def build_panel( panel.tree = tree panel._filter = NO_FILTER panel._search_visibility = None + panel._favorites_visibility = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py new file mode 100644 index 00000000..e0d50195 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -0,0 +1,288 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, FrozenSet, List, Set + +import pytest + +from sampletones_application.ui.elements.tree.filter import TreeFilter +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from tests.suite.language import FakeLanguageManager + +PANEL_TAG: Final[str] = "sequencer.browser" + +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") +STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" +PLAIN_PATH: Final[Path] = CONFIG_DIRECTORY / "plain.stn" +VARIANT_LABEL: Final[str] = "44.1 kHz·30 Hz" + +STARRED_ROWS: Final[FrozenSet[str]] = frozenset( + { + "configurations", + "directory", + "starred", + "samples", + "starred_sample", + "starred_variant", + } +) +SAMPLE_VIEW_ROWS: Final[FrozenSet[str]] = frozenset( + { + "samples", + "starred_sample", + "starred_variant", + "plain_sample", + "plain_variant", + } +) + + +class FakeTreeLogic: + """Answers the favorite questions a browser asks of its logic while it collects its rows.""" + + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +@dataclass(frozen=True) +class BrowserTree: + """The shape both browser views give one configuration directory, with a handle on every row.""" + + tree: Tree + rows: Dict[str, TreeNode] + + +@pytest.fixture +def browser() -> BrowserTree: + """Two reconstructions of one configuration, listed by that configuration and by their samples. + + A sample row carries the name of the reconstruction it gathers, the way the builder names it, so + each row is held by a key of its own rather than by the label it reads under. + """ + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + directory = FileSystemNode( + "PTN", + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + parent=configurations, + ) + starred = FileSystemNode("starred", node_type=NodeType.FILE, filepath=STARRED_PATH, parent=directory) + plain = FileSystemNode("plain", node_type=NodeType.FILE, filepath=PLAIN_PATH, parent=directory) + + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + starred_sample = TreeNode("starred", node_type=NodeType.SAMPLE, parent=samples) + starred_variant = FileSystemNode( + VARIANT_LABEL, + node_type=NodeType.FILE, + filepath=STARRED_PATH, + parent=starred_sample, + ) + plain_sample = TreeNode("plain", node_type=NodeType.SAMPLE, parent=samples) + plain_variant = FileSystemNode( + VARIANT_LABEL, + node_type=NodeType.FILE, + filepath=PLAIN_PATH, + parent=plain_sample, + ) + + return BrowserTree( + tree=Tree(root=root), + rows={ + "configurations": configurations, + "directory": directory, + "starred": starred, + "plain": plain, + "samples": samples, + "starred_sample": starred_sample, + "starred_variant": starred_variant, + "plain_sample": plain_sample, + "plain_variant": plain_variant, + }, + ) + + +def build_panel( + browser: BrowserTree, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> GUISequencerBrowserPanel: + """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. + + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel.tree = browser.tree + panel._logic = FakeTreeLogic(favorites) + panel._language_manager = FakeLanguageManager() + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + return panel + + +def collect_specs(panel: GUISequencerBrowserPanel) -> List[NodeSpec]: + """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + + root = panel.tree.get_root() + assert root is not None + panel._build_tree_node(root, TreeNodeState(parent="tree")) + return panel._pending_specs + + +def drawn_keys( + browser: BrowserTree, + specs: List[NodeSpec], +) -> Set[str]: + drawn = {spec.node for spec in specs} + return {key for key, node in browser.rows.items() if node in drawn} + + +def open_keys( + browser: BrowserTree, + specs: List[NodeSpec], +) -> Set[str]: + standing_open = {spec.node for spec in specs if spec.should_expand} + return {key for key, node in browser.rows.items() if node in standing_open} + + +class TestDrawnRows: + def test_a_starred_reconstruction_is_drawn_under_the_rows_holding_it_in_both_views( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert drawn_keys(browser, collect_specs(panel)) == STARRED_ROWS + + def test_a_starred_directory_brings_the_reconstructions_it_holds( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) + assert {"directory", "starred", "plain"} <= drawn_keys(browser, collect_specs(panel)) + + def test_a_starred_directory_reaches_the_view_holding_no_row_for_it( + self, + browser: BrowserTree, + ) -> None: + """The sample view lists reconstructions under their samples, and no row stands for a folder. + + Being held by a starred folder is read from the path, so each variant answers for itself and + the sample gathering it comes along. + """ + panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) + assert SAMPLE_VIEW_ROWS <= drawn_keys(browser, collect_specs(panel)) + + def test_nothing_starred_draws_no_row(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=True) + assert collect_specs(panel) == [] + + def test_the_mode_off_draws_every_row(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert drawn_keys(browser, collect_specs(panel)) == set(browser.rows) + + +class TestOpenRows: + def test_the_rows_leading_to_a_favorite_stand_open(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert open_keys(browser, collect_specs(panel)) == { + "configurations", + "directory", + "samples", + "starred_sample", + } + + def test_the_mode_off_leaves_every_row_as_it_stands(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert open_keys(browser, collect_specs(panel)) == set() + + +class TestSearchInsideTheMode: + def test_the_mode_states_the_drawn_rows_while_the_query_states_the_shown_ones( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="starred") + specs = collect_specs(panel) + + assert drawn_keys(browser, specs) == STARRED_ROWS + assert panel._is_node_visible(browser.rows["starred"]) + assert not panel._is_node_visible(browser.rows["plain"]) + + def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="plain") + assert "plain" not in drawn_keys(browser, collect_specs(panel)) + + +class TestEmptyAnswer: + """A rebuild drawing no row names the filter that answered so, where the rows would be.""" + + def test_the_mode_finding_no_favorite_names_the_favorites(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=True) + assert panel._empty_filter_message() == "global.dialog.message.tree_no_favorites" + + def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=False, query="nothing") + assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" + + +class TestFavoriteChange: + def test_a_change_draws_the_tree_again_while_the_mode_is_on( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + redraws: List[bool] = [] + repaints: List[TreeNode] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append(node), + raising=False, + ) + + panel.update_favorite_indicators([browser.rows["starred"]]) + + assert redraws == [True] + assert repaints == [] + + def test_a_change_repaints_the_rows_while_the_mode_is_off( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + redraws: List[bool] = [] + repaints: List[TreeNode] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append(node), + raising=False, + ) + + panel.update_favorite_indicators([browser.rows["starred"], browser.rows["starred_variant"]]) + + assert redraws == [] + assert repaints == [browser.rows["starred"], browser.rows["starred_variant"]] diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index e7a60b19..5849f65a 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -50,6 +50,7 @@ def build_panel( panel._search_input_tag = None panel._filter = NO_FILTER panel._search_visibility = None + panel._favorites_visibility = None return panel @@ -67,10 +68,20 @@ def test_a_filter_carrying_a_query_narrows(self) -> None: def test_dropping_the_query_leaves_the_filter_narrowing_nothing(self) -> None: assert not NO_FILTER.with_query("song").with_query("").is_active + def test_a_filter_showing_the_favorites_alone_narrows(self) -> None: + assert NO_FILTER.with_favorites_only(True).is_active + + def test_the_query_and_the_favorites_mode_are_stated_side_by_side(self) -> None: + tree_filter = NO_FILTER.with_query("song").with_favorites_only(True) + assert tree_filter.query == "song" + assert tree_filter.favorites_only + def test_the_filter_a_new_one_was_taken_from_reads_as_it_did(self) -> None: - original = TreeFilter(query="song") + original = TreeFilter(query="song", favorites_only=False) original.with_query("other") + original.with_favorites_only(True) assert original.query == "song" + assert not original.favorites_only class TestPanelOwnedFilter: From 19f883cac68b1e5839b8c066db86547173219db0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 23:01:03 +0200 Subject: [PATCH 25/45] Added: favorites-only browser control --- .../categories/elements/global_.py | 2 + .../config/managers/session.py | 6 + .../config/managers/state.py | 6 + .../config/session/state/state.py | 4 + .../coordinators/tabs/reconstruction.py | 10 ++ .../coordinators/tabs/sequencer.py | 6 + src/sampletones_application/tags/general.py | 8 + .../ui/elements/tree/browser.py | 8 + .../ui/elements/tree/tree.py | 98 ++++++++++- .../ui/panels/reconstruction/browser.py | 2 + .../ui/panels/sequencer/browser.py | 2 + .../ui/panels/shared/browser.py | 7 + src/sampletones_config/lang/en.yaml | 2 + .../theme/input/checkbox_muted.yaml | 9 + .../config/managers/test_session.py | 8 + .../config/managers/test_state.py | 42 +++++ .../ui/elements/tree/test_favorites_filter.py | 159 +++++++++++++++++- 17 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 src/sampletones_config/theme/input/checkbox_muted.yaml diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 1325284d..c5c3f308 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -29,6 +29,7 @@ class TreeElements(AbstractElement): SEARCH = "search" FILTER = "filter" CLEAR_SEARCH = "clear_search" + FAVORITES_ONLY = "favorites_only" class ContextElements(AbstractElement): @@ -129,6 +130,7 @@ class StatusElements(AbstractElement): NODE_LIBRARY = "node_library" TREE_SEARCH = "tree_search" CLEAR_SEARCH = "clear_search" + FAVORITES_ONLY = "favorites_only" INPUT = "input" COMBO = "combo" NODE_DIRECTORY = "node_directory" diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 7725786b..a6cb8852 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -53,6 +53,12 @@ def is_card_collapsed(self, card_tag: str) -> bool: def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None: self._state_manager.set_card_collapsed(card_tag, collapsed) + def is_favorites_filter_active(self, panel_tag: str) -> bool: + return self._state_manager.is_favorites_filter_active(panel_tag) + + def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: + self._state_manager.set_favorites_filter_active(panel_tag, active) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index 9d12c5f2..eb90f83d 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -94,6 +94,12 @@ def is_card_collapsed(self, card_tag: str) -> bool: def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None: self.state.collapsed_cards[card_tag] = collapsed + def is_favorites_filter_active(self, panel_tag: str) -> bool: + return self.state.favorites_filters.get(panel_tag, False) + + def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: + self.state.favorites_filters[panel_tag] = active + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 00853aec..818fbb38 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -20,6 +20,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="Collapsed state of each card, keyed by the card's tag.", ) + favorites_filters: Dict[str, bool] = Field( + default_factory=dict, + description="Whether each browser shows its favorites alone, keyed by the panel's tag.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index bfdfd0f3..4ad395a7 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -165,12 +165,14 @@ def __init__( status_bar=status_bar, colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), + initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed) + self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed self._reconstruction_player_logic = PlayerLogic( audio_device_manager, on_change_audio_state, @@ -494,6 +496,14 @@ def _on_browser_collapse_changed( self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_browser_width() + def _on_browser_favorites_filter_changed( + self, + panel_tag: str, + favorites_only: bool, + ) -> None: + """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" + self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + def _on_instruments_collapse_changed( self, card_tag: str, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 5bd939a6..af1ba993 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -207,6 +207,7 @@ def __init__( status_bar=status_bar, colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), + initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) @@ -629,6 +630,7 @@ def _wire_samples_callbacks(self) -> None: def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) + self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction @@ -662,6 +664,10 @@ def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_browser_width() + def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None: + """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" + self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + def sync_responsive_layout(self) -> None: """Refits this tab's side column to the current viewport, the entry the resize handler calls.""" self._sync_browser_width() diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index da52f490..3927b318 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,6 +308,12 @@ Widget.THEME, "file_not_expanded_directory", ) +TAG_GLOBAL_THEME_CHECKBOX_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "checkbox_muted", +) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -690,9 +696,11 @@ SUF_LABEL = "label" SUF_PATH = "path" SUF_TEXT = "text" +SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites") SUF_INPUT = "input" SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search") SUF_CHECKBOX = "checkbox" +SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") SUF_TABLE = "table" SUF_TOOLTIP = "tooltip" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 097a349d..ad105e1b 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -32,9 +32,13 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): whole card as the tree locks and unlocks. A subclass declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control read, answers what refreshing the model means, and shapes each row. + + A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control + showing those favorites alone to the card. """ _REBUILD_ON_CREATE: bool = True + _OFFERS_FAVORITES_FILTER: bool = False def __init__( self, @@ -141,6 +145,9 @@ def _on_refresh_clicked(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) + if self._OFFERS_FAVORITES_FILTER: + self.create_favorites_filter(self._body_container) + with ( dpg.child_window( tag=self._tags.window_tree, @@ -234,3 +241,4 @@ def _on_rebuild_finished(self) -> None: def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(self._tags.group_tree, enabled=enabled) dpg_configure_item(self._tags.group_controls, enabled=enabled) + self.set_favorites_filter_enabled(enabled) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index f2d66f69..633893ac 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -12,10 +12,13 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON_SEARCH, + SUF_CHECKBOX_FAVORITES, SUF_HANDLER_DETAIL_TOOLTIP, SUF_HANDLER_NODE, SUF_INPUT_SEARCH, + SUF_TEXT_FAVORITES, SUF_TOOLTIP_DETAIL, + TAG_GLOBAL_THEME_CHECKBOX_MUTED, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE, TAG_GLOBAL_THEME_FAVORITE_CHILD, @@ -132,6 +135,8 @@ def __init__( self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None self._search_button_tag: Optional[str] = None + self._favorites_checkbox_tag: Optional[str] = None + self._favorites_glyph_tag: Optional[str] = None self._detail_tooltip_tag = compose_tag(tag, SUF_TOOLTIP_DETAIL) self._detail_tooltip_handler_tag = compose_tag(tag, SUF_HANDLER_DETAIL_TOOLTIP) @@ -151,6 +156,7 @@ def __init__( self._lbl_detail_generators = language_manager["global.context.label.detail_generators"] self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"] + self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None self.on_add_to_sequencer: Optional[PathCallback] = None self.can_add_to_sequencer: Optional[Callable[[], bool]] = None self.on_replace_in_sequencer: Optional[PathCallback] = None @@ -250,6 +256,79 @@ def create_search(self, parent: str) -> None: self._language_manager["global.status.message.clear_search"], ) + def create_favorites_filter(self, parent: str) -> None: + """Builds the control showing the favorites alone, as a row of its own under the search box. + + The checkbox carries the label, so the words are part of what the reader clicks, and the star + beside it reads in the colour the mode it stands for is drawn in. + """ + self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES) + self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES) + + with dpg.group(horizontal=True, parent=parent): + dpg.add_checkbox( + tag=self._favorites_checkbox_tag, + label=self._language_manager["global.browser.label.favorites_only"], + default_value=self._filter.favorites_only, + callback=self._on_favorites_only_changed, + ) + dpg.add_text( + self._glyphs.common.favorite, + tag=self._favorites_glyph_tag, + ) + + ThemeRegistry.get(TAG_GLOBAL_THEME_CHECKBOX_MUTED).bind_to_item(self._favorites_checkbox_tag) + FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON) + self._apply_favorites_glyph_color() + self._status_bar.bind_to_item( + self._favorites_checkbox_tag, + self._language_manager["global.status.message.favorites_only"], + ) + + def _on_favorites_only_changed( + self, + _sender: Sender, + favorites_only: bool, + ) -> None: + """Takes the mode the control now reads, and draws the rows that mode names. + + The rebuild resolves the filter against the model as it collects the rows, so the mode is + stated here and answered there, and turning it on walks the model once. + """ + self._filter = self._filter.with_favorites_only(favorites_only) + self._apply_favorites_glyph_color() + self.call( + self.on_favorites_filter_changed, + self.tag, + favorites_only, + ) + self.redraw_tree() + + def _apply_favorites_glyph_color(self) -> None: + """Colours the star by the mode the control reads, wherever the browser offers one.""" + if self._favorites_glyph_tag is None: + return + + dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color()) + + def _favorites_glyph_color(self) -> BaseColor: + """The colour the star takes: the favorite colour while the mode is on, muted while it is off.""" + if self._filter.favorites_only: + return self._colors.favorite + + return self._colors.muted + + def set_favorites_filter_enabled(self, enabled: bool) -> None: + """Follows the tree's lock through to the control, which asks for a rebuild of that tree.""" + if self._favorites_checkbox_tag is None: + return + + dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled) + + def _restore_favorites_only(self, favorites_only: bool) -> None: + """Takes the mode a session left the browser in, which its first rebuild then draws by.""" + self._filter = self._filter.with_favorites_only(favorites_only) + def _get_node_handler_tag(self, node_type: NodeType) -> str: return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE) @@ -747,20 +826,23 @@ def _on_replace_in_sequencer( self.call(self.on_replace_in_sequencer, user_data.filepath) def _on_search_changed(self, _sender: Sender, query: str) -> None: - self._set_filter(self._filter.with_query(query)) - self._logic.schedule_search_update(query) + self._set_query(query) def _on_clear_search_clicked(self) -> None: if self._search_input_tag is not None: dpg.set_value(self._search_input_tag, "") - self._set_filter(self._filter.with_query("")) - self._logic.schedule_search_update("") + self._set_query("") - def _set_filter(self, tree_filter: TreeFilter) -> None: - """Take the filter the browser is now asked to show, and resolve what it leaves on screen.""" - self._filter = tree_filter - self._resolve_filter() + def _set_query(self, query: str) -> None: + """Take the query the browser is now asked to show, and resolve the rows it names. + + The rows already drawn are the favorites mode's to state, so a keystroke resolves the search + alone and the tree on screen answers the one after it. + """ + self._filter = self._filter.with_query(query) + self._search_visibility = self._resolve_search_visibility() + self._logic.schedule_search_update(query) def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 748397b1..57d6d337 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -49,6 +49,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager @@ -60,6 +61,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, ) self.on_load_reconstruction: Optional[PathCallback] = None diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 685b1641..1053e17a 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -42,6 +42,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager @@ -53,6 +54,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index a5349f24..387d6009 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -37,9 +37,13 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and its refresh control, and adds the items its context menus offer. + + Reconstructions carry favorites, so this browser offers the control showing them alone and opens + in the mode the session left it in. """ _MONOSPACE_CONFIG_NODES: bool = True + _OFFERS_FAVORITES_FILTER: bool = True def __init__( self, @@ -51,6 +55,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager self.on_refresh_tree: Optional[VoidCallback] = None @@ -66,6 +71,8 @@ def __init__( initial_collapsed=initial_collapsed, ) + self._restore_favorites_only(initial_favorites_only) + @property def section_label(self) -> str: return self._language_manager["global.browser.label.browser"] diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 3b66d81c..6b079f01 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -133,6 +133,7 @@ global.browser.label.by_sample: "By sample" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" +global.browser.label.favorites_only: "Favorites only" # ============================================================================= # Global — Context menu @@ -239,6 +240,7 @@ global.status.message.node_reconstruction: "Click to play reconstruction. Double global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu." global.status.message.tree_search: "Type query to filter nodes." global.status.message.clear_search: "Clear the search filter." +global.status.message.favorites_only: "Show the favorites alone, or the whole tree." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." diff --git a/src/sampletones_config/theme/input/checkbox_muted.yaml b/src/sampletones_config/theme/input/checkbox_muted.yaml new file mode 100644 index 00000000..5921e68a --- /dev/null +++ b/src/sampletones_config/theme/input/checkbox_muted.yaml @@ -0,0 +1,9 @@ +name: input_checkbox_muted +tag: global.theme.checkbox_muted + +components: + - item_type: Checkbox + entries: + - type: color + key: Text + value: .text_inactive diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py index 13c1aff0..03e31c70 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -5,6 +5,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile +from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL @pytest.fixture @@ -149,3 +150,10 @@ def test_toggle_favorite_twice_removes_path(self, session: SessionManager, tmp_p def test_favorites_returns_set(self, session: SessionManager) -> None: assert isinstance(session.favorites, set) + + def test_a_browser_reads_the_favorites_filter_it_was_given(self, session: SessionManager) -> None: + session.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_a_browser_a_first_run_finds_shows_the_whole_tree(self, session: SessionManager) -> None: + assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index 21aae3e8..e583a25c 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -8,6 +8,8 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.managers.state import ApplicationStateManager from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.tags.reconstructions import TAG_RECONSTRUCTIONS_BROWSER_PANEL +from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL @pytest.fixture @@ -91,6 +93,34 @@ def test_toggle_show_advanced_settings_changes_value(self, manager: ApplicationS assert manager.advanced_settings == (not initial) +class TestApplicationStateManagerCardsAndFilters: + """The per-panel state a card keeps: whether it is collapsed, and what its browser narrows to.""" + + def test_a_card_no_run_has_touched_reads_expanded(self, manager: ApplicationStateManager) -> None: + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + + def test_a_card_reads_the_collapse_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL, True) + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_a_browser_no_run_has_touched_shows_the_whole_tree(self, manager: ApplicationStateManager) -> None: + assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False + + def test_a_browser_reads_the_filter_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_each_browser_keeps_the_filter_of_its_own_panel(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + + assert manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False + + def test_the_filter_and_the_collapse_of_one_panel_stand_apart(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + + class TestApplicationStateManagerCurrentPaths: def test_set_current_reconstruction_updates_property( self, @@ -207,6 +237,18 @@ def test_save_and_reload_preserves_advanced_settings(self, tmp_path: Path) -> No assert reloaded.advanced_settings == manager.advanced_settings + def test_save_and_reload_preserves_each_browser_filter(self, tmp_path: Path) -> None: + """The mode a browser was left in returns on the next launch, for that browser alone.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False + @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) def test_save_recovers_from_file_error(self, tmp_path: Path, exception_type: Type[OSError]) -> None: """State persistence degrades to logging when the disk rejects the write.""" diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index e0d50195..73835a6f 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -1,18 +1,31 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, Final, FrozenSet, List, Set +from typing import Any, Dict, Final, FrozenSet, List, Set, Tuple import pytest +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode from tests.suite.language import FakeLanguageManager PANEL_TAG: Final[str] = "sequencer.browser" +CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" +GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" + +TREE_COLORS: Final[TreeColors] = TreeColors( + favorite=LiteralColor((240, 200, 80, 255)), + node=LiteralColor((200, 200, 200, 255)), + muted=LiteralColor((120, 120, 120, 255)), + accent=LiteralColor((80, 160, 240, 255)), +) CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" @@ -120,13 +133,18 @@ def build_panel( ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. - Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box. + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, + and the control stands where a browser that has yet to build one leaves it. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG panel.tree = browser.tree panel._logic = FakeTreeLogic(favorites) panel._language_manager = FakeLanguageManager() + panel._colors = TREE_COLORS + panel._favorites_checkbox_tag = None + panel._favorites_glyph_tag = None + panel.on_favorites_filter_changed = None panel._filter = TreeFilter(query=query, favorites_only=favorites_only) panel._resolve_filter() return panel @@ -244,6 +262,143 @@ def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) - assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" +class TestControl: + """What the checkbox beside the search box answers for: the mode, the memory of it, the rows.""" + + def test_the_mode_the_control_reads_reaches_the_filter( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert panel._filter.favorites_only + + def test_a_change_is_handed_to_the_hook_remembering_it( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + remembered: List[Tuple[str, bool]] = [] + panel.on_favorites_filter_changed = lambda panel_tag, favorites_only: remembered.append( + (panel_tag, favorites_only) + ) + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert remembered == [(PANEL_TAG, True)] + + def test_a_change_draws_the_rows_the_new_mode_names( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + redraws: List[bool] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + + panel._on_favorites_only_changed(None, True) + + assert redraws == [True] + + def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + + panel._restore_favorites_only(True) + + assert panel._filter.favorites_only + + def test_a_query_typed_earlier_survives_a_change_of_mode( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False, query="starred") + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert panel._filter.query == "starred" + + +class TestStarColor: + """The star beside the label reads in the colour of the mode it stands for.""" + + def test_the_star_reads_favorite_while_the_mode_is_on(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert panel._favorites_glyph_color() == TREE_COLORS.favorite + + def test_the_star_reads_muted_while_the_mode_is_off(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert panel._favorites_glyph_color() == TREE_COLORS.muted + + def test_the_star_is_coloured_with_the_token_the_mode_names( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The colour reaches the star as a token, so the star follows a palette swapped in place.""" + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel._favorites_glyph_tag = GLYPH_TAG + coloured: List[Tuple[str, BaseColor]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_palette_color", + lambda item, color: coloured.append((item, color)), + ) + + panel._apply_favorites_glyph_color() + + assert coloured == [(GLYPH_TAG, TREE_COLORS.favorite)] + + +class TestControlLock: + """A rebuild is what the control asks for, so the tree's lock reaches it.""" + + def test_the_lock_reaches_the_control( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel._favorites_checkbox_tag = CHECKBOX_TAG + configured: List[Tuple[str, Any]] = [] + monkeypatch.setattr( + tree_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + + panel.set_favorites_filter_enabled(False) + + assert configured == [(CHECKBOX_TAG, False)] + + def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + configured: List[Tuple[str, Any]] = [] + monkeypatch.setattr( + tree_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + + panel.set_favorites_filter_enabled(False) + + assert configured == [] + + class TestFavoriteChange: def test_a_change_draws_the_tree_again_while_the_mode_is_on( self, From c0f3df7dcfff7f6a2b782a4def942ac7185e698e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 23:17:36 +0200 Subject: [PATCH 26/45] Documented: favorites-only browser filter --- docs/development/browser.md | 55 +++++++++++++++++++++++++++++++++++-- docs/guide/interface.md | 5 ++++ docs/index.md | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index 3eaf1fc9..a2bdb010 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -31,6 +31,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the background worker — tag, label, font, theme, handler, open state — and the main thread creates the widgets from those specs, spread across frames. +7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows + is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, + and each browser opens in the mode a session left it in. --- @@ -100,9 +103,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: -* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the search box, the rebuild handshake, - spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the - context-menu items every browser can offer. +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter + they compose, the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the + status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states @@ -142,3 +145,49 @@ folder wherever a view puts it — including the sample branch, whose headings c path reaches the panel as several rows, `application.py` resolves the toggled path into every row standing for it and hands them to both tabs, and each row repaints with the ancestry its own path carries. + +## Filtering + +`TreeFilter` (`ui/elements/tree/filter.py`) holds what a browser is currently asked to show, and the +panel showing it owns the filter. It is stated whole and replaced whole — `with_query`, +`with_favorites_only` — so one place resolves what the browser shows, and `NO_FILTER` is the filter a +browser showing its whole tree holds. + +The two criteria answer different questions, so each lands in a different place: + +| Criterion | What it decides | Where it lands | What a change costs | +|---|---|---|---| +| `favorites_only` | which rows the browser **draws** | `_append_spec` records the rows the mode shows, so `TreeEmitter` creates widgets for those alone | `redraw_tree` collects the rows again from the model in hand, on the tree worker | +| `query` | which of the drawn rows are **shown** | `update_tree_visibility` flips `show` over the rows already on screen, once the typing settles | a resolution of the query, debounced | + +One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibility.py`) takes the +rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row +one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in +memory follows the size of what was found, and a row beneath a match is answered from its own path +upwards. The same two sets state which rows stand open, which is what makes a filter legible: the +starred rows come up with their headings open. + +**A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on +the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so +declining a row declines its subtree, and one decision covers it while the traversal walks on. + +**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing +each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents`. +What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the +drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only +browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, +the drawn rows being the mode's to state. A favorite toggled while the mode is on redraws the browser, +so starring a row brings it in and unstarring one takes it out along with what it held. + +A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back +empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the +filter's answer reads where the rows would be. + +**The control** is a checkbox under the search box carrying the favorite glyph, which reads in the +favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states +which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It +follows the tree's lock, a rebuild being what it asks for. + +Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its +own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, +which is how a collapsed card is remembered too. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index a893dad5..154147f8 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -46,6 +46,11 @@ switch **Play audio source:** between **Reconstruction** and **Original audio** compare the two, and **Locate original audio** re-links the source file if it has moved. +To keep the reconstructions you return to within reach, right-click one — or a +whole folder — and choose **Mark as favorite**, which highlights it in both views. +Tick **Favorites only** under the search box to narrow the browser to your +favorites and everything inside them. + To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase presets...** writes the same as `.json`, and **Export to WAV...** renders the diff --git a/docs/index.md b/docs/index.md index a2357a27..1b02957d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,7 +58,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. -- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render. +- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From 7249ab1126229447eb1630fe239207add59ff32d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 00:54:11 +0200 Subject: [PATCH 27/45] Fixed: favorites filter label reading as disabled --- src/sampletones_application/tags/general.py | 6 ------ src/sampletones_application/ui/elements/tree/tree.py | 6 +++--- src/sampletones_config/theme/input/checkbox_muted.yaml | 9 --------- 3 files changed, 3 insertions(+), 18 deletions(-) delete mode 100644 src/sampletones_config/theme/input/checkbox_muted.yaml diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 3927b318..baa6030e 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,12 +308,6 @@ Widget.THEME, "file_not_expanded_directory", ) -TAG_GLOBAL_THEME_CHECKBOX_MUTED = TagName( - Page.GLOBAL, - Panel.IMPLICIT, - Widget.THEME, - "checkbox_muted", -) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 633893ac..b167853e 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -18,7 +18,6 @@ SUF_INPUT_SEARCH, SUF_TEXT_FAVORITES, SUF_TOOLTIP_DETAIL, - TAG_GLOBAL_THEME_CHECKBOX_MUTED, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE, TAG_GLOBAL_THEME_FAVORITE_CHILD, @@ -260,7 +259,9 @@ def create_favorites_filter(self, parent: str) -> None: """Builds the control showing the favorites alone, as a row of its own under the search box. The checkbox carries the label, so the words are part of what the reader clicks, and the star - beside it reads in the colour the mode it stands for is drawn in. + beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair + every checkbox reads — the text colour while the control is live, the muted one while a + rebuild holds it — so the shade states whether the control can be acted on. """ self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES) self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES) @@ -277,7 +278,6 @@ def create_favorites_filter(self, parent: str) -> None: tag=self._favorites_glyph_tag, ) - ThemeRegistry.get(TAG_GLOBAL_THEME_CHECKBOX_MUTED).bind_to_item(self._favorites_checkbox_tag) FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON) self._apply_favorites_glyph_color() self._status_bar.bind_to_item( diff --git a/src/sampletones_config/theme/input/checkbox_muted.yaml b/src/sampletones_config/theme/input/checkbox_muted.yaml deleted file mode 100644 index 5921e68a..00000000 --- a/src/sampletones_config/theme/input/checkbox_muted.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: input_checkbox_muted -tag: global.theme.checkbox_muted - -components: - - item_type: Checkbox - entries: - - type: color - key: Text - value: .text_inactive From e94921a4f836ff42f058708d877ecd1e3c0d9414 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:00:59 +0200 Subject: [PATCH 28/45] Added: browser view-state test fixture --- tests/suite/browser.py | 342 ++++++++++++++++++ .../ui/elements/tree/conftest.py | 11 + .../ui/elements/tree/test_browser_view.py | 64 ++++ 3 files changed, 417 insertions(+) create mode 100644 tests/suite/browser.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/conftest.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py diff --git a/tests/suite/browser.py b/tests/suite/browser.py new file mode 100644 index 00000000..14f2b3b2 --- /dev/null +++ b/tests/suite/browser.py @@ -0,0 +1,342 @@ +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent +from typing import Dict, Final, List, Mapping, Sequence, Set, Tuple + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.filter import TreeFilter +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.language import FakeLanguageManager + +PANEL_TAG: Final[str] = "sequencer.browser" +TREE_TAG: Final[str] = "sequencer.browser.tree" + +HASH_A: Final[str] = "aaaaaaaa11111111aaaaaaaa11111111" +HASH_B: Final[str] = "bbbbbbbb22222222bbbbbbbb22222222" +HASH_C: Final[str] = "cccccccc33333333cccccccc33333333" +HASH_D: Final[str] = "dddddddd44444444dddddddd44444444" +HASH_E: Final[str] = "eeeeeeee55555555eeeeeeee55555555" +HASH_F: Final[str] = "ffffffff66666666ffffffff66666666" + +ARCHIVE: Final[str] = "archive" +STRAY: Final[str] = "stray" + +BROWSER_TEXTS: Final[Mapping[str, str]] = { + "global.browser.label.root": "Root", + "global.browser.label.by_configuration": "By configuration", + "global.browser.label.by_sample": "By sample", +} + +TREE_COLORS: Final[TreeColors] = TreeColors( + favorite=LiteralColor((240, 200, 80, 255)), + node=LiteralColor((200, 200, 200, 255)), + muted=LiteralColor((120, 120, 120, 255)), + accent=LiteralColor((80, 160, 240, 255)), +) + +OPEN_MARKER: Final[str] = "v" +CLOSED_MARKER: Final[str] = ">" +LEAF_MARKER: Final[str] = "-" +HIDDEN_MARKER: Final[str] = " [hidden]" +INDENT: Final[str] = " " + + +def config_fields( + *, + sample_rate: int, + nes_frequency: int, + spectrum_method: SpectrumMethod, + transformation_gamma: int, + generators: str, + config_hash: str, +) -> ConfigDirectoryFields: + return ConfigDirectoryFields( + sr=sample_rate, + nf=nes_frequency, + sm=spectrum_method, + tg=transformation_gamma, + gn=generators, + ch=config_hash, + ) + + +CONFIG_A: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_A, +) +CONFIG_B: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_B, +) +CONFIG_C: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PT", + config_hash=HASH_C, +) +CONFIG_D: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.CQT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_D, +) +CONFIG_E: Final[ConfigDirectoryFields] = config_fields( + sample_rate=8000, + nes_frequency=60, + spectrum_method=SpectrumMethod.CQT, + transformation_gamma=2, + generators="P", + config_hash=HASH_E, +) +CONFIG_F: Final[ConfigDirectoryFields] = config_fields( + sample_rate=48000, + nes_frequency=50, + spectrum_method=SpectrumMethod.LOG_SPACED_FFT, + transformation_gamma=1, + generators="TN", + config_hash=HASH_F, +) + +TOP_LEVEL_CONFIGURATIONS: Final[Mapping[str, ConfigDirectoryFields]] = { + "A": CONFIG_A, + "B": CONFIG_B, + "C": CONFIG_C, + "D": CONFIG_D, + "E": CONFIG_E, +} +RECONSTRUCTIONS: Final[Mapping[str, Tuple[str, ...]]] = { + "A": ("beat", "melody", "drums/kick", "drums/snare"), + "B": ("beat", "melody", "drums/kick"), + "C": ("beat", "takes/alt"), + "D": ("beat", "solo"), + "E": ("sweep",), +} + + +class FakeConfigManager: + """Answers the one thing the browser manager asks of the configuration: where to read.""" + + def __init__(self, reconstructions_directory: Path) -> None: + self._reconstructions_directory = reconstructions_directory + + def get_reconstructions_directory(self) -> Path: + return self._reconstructions_directory + + +class FakeTreeLogic: + """Answers the favorite questions a browser asks of its logic while it collects its rows.""" + + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +@dataclass(frozen=True) +class BrowserCorpus: + """A reconstructions directory read into the tree both browser views render. + + ``paths`` names every place a test can star: a configuration directory by its key, a + reconstruction by ``"/"``, and the folders standing beside them. + """ + + tree: Tree + paths: Mapping[str, Path] + + +def write_corpus(root: Path) -> Dict[str, Path]: + """Writes the corpus the browser tests read, and answers where each part of it landed. + + The layout carries what the browser has to tell apart: two configurations differing by hash + alone, a frequency holding several methods beside one holding a single chain, audio shared by + every configuration and audio held by one, a configuration directory nested in a plain folder, + and a reconstruction sitting outside every configuration directory. + """ + paths: Dict[str, Path] = {} + for key, fields in TOP_LEVEL_CONFIGURATIONS.items(): + directory = root / fields.directory_name + paths[key] = directory + for relative in RECONSTRUCTIONS[key]: + paths[f"{key}/{relative}"] = _write_reconstruction(directory / relative) + + archive = root / ARCHIVE + paths[ARCHIVE] = archive + paths[f"{ARCHIVE}/F"] = archive / CONFIG_F.directory_name + paths[f"{ARCHIVE}/F/song"] = _write_reconstruction(paths[f"{ARCHIVE}/F"] / "song") + paths[STRAY] = _write_reconstruction(root / STRAY) + return paths + + +def _write_reconstruction(path: Path) -> Path: + reconstruction = path.with_suffix(EXT_FILE_RECONSTRUCTION) + reconstruction.parent.mkdir(parents=True, exist_ok=True) + reconstruction.touch() + return reconstruction + + +def build_corpus(root: Path) -> BrowserCorpus: + """Writes the corpus and reads it through the real pipeline, so the labels are the real ones.""" + paths = write_corpus(root) + manager = BrowserManager( + FakeConfigManager(root), # type: ignore[arg-type] + language_manager=FakeLanguageManager(texts=dict(BROWSER_TEXTS)), + ) + manager.refresh_tree() + return BrowserCorpus( + tree=manager.tree, + paths=paths, + ) + + +def build_browser_panel( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> GUISequencerBrowserPanel: + """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. + + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, + and the control stands where a browser that has yet to build one leaves it. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel.tree_tag = TREE_TAG + panel.tree = corpus.tree + panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] + panel._language_manager = FakeLanguageManager() + panel._colors = TREE_COLORS + _state_detail_labels(panel) + panel._favorites_checkbox_tag = None + panel._favorites_glyph_tag = None + panel.on_favorites_filter_changed = None + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + return panel + + +def _state_detail_labels(panel: GUITreePanel) -> None: + """States the labels a row's details read under, which a configuration row asks for by name.""" + panel._lbl_detail_sample_rate = "sample_rate" + panel._lbl_detail_nes_frequency = "nes_frequency" + panel._lbl_detail_spectrum_method = "spectrum_method" + panel._lbl_detail_transformation_gamma = "transformation_gamma" + panel._lbl_detail_window_size = "window_size" + panel._lbl_detail_generators = "generators" + panel._lbl_detail_configuration = "configuration" + + +def collect_specs(panel: GUITreePanel) -> List[NodeSpec]: + """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + + root = panel.tree.get_root() + assert root is not None + panel._build_tree_node(root, TreeNodeState(parent=panel.tree_tag)) + return panel._pending_specs + + +def render_view(panel: GUITreePanel) -> str: + """Renders the view a rebuild would leave on screen: the rows, their nesting and their state. + + Each row reads as its marker and its label, indented under the row holding it: ``v`` a container + standing open, ``>`` one standing closed, ``-`` a leaf. A row the search hides is marked, since + its widget stands there either way, and a row under a closed container is rendered where it is. + """ + children: Dict[str, List[NodeSpec]] = defaultdict(list) + for spec in collect_specs(panel): + children[spec.parent_tag].append(spec) + + lines: List[str] = [] + _render_rows( + panel, + children, + parent_tag=panel.tree_tag, + depth=0, + lines=lines, + ) + return "\n".join(lines) + + +def _render_rows( + panel: GUITreePanel, + children: Mapping[str, Sequence[NodeSpec]], + *, + parent_tag: str, + depth: int, + lines: List[str], +) -> None: + for spec in children.get(parent_tag, ()): + lines.append(f"{INDENT * depth}{_row_marker(spec)} {spec.label}{_row_state(panel, spec)}") + _render_rows( + panel, + children, + parent_tag=spec.node_tag, + depth=depth + 1, + lines=lines, + ) + + +def _row_marker(spec: NodeSpec) -> str: + if spec.leaf: + return LEAF_MARKER + + return OPEN_MARKER if spec.should_expand else CLOSED_MARKER + + +def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str: + return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER + + +def as_view(text: str) -> str: + """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" + return dedent(text).strip("\n") + + +def view( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> str: + """The view a browser showing the corpus under this filter leaves on screen.""" + return render_view( + build_browser_panel( + corpus, + favorites, + favorites_only=favorites_only, + query=query, + ) + ) diff --git a/tests/unit/sampletones_application/ui/elements/tree/conftest.py b/tests/unit/sampletones_application/ui/elements/tree/conftest.py new file mode 100644 index 00000000..79551b95 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/conftest.py @@ -0,0 +1,11 @@ +from pathlib import Path + +import pytest + +from tests.suite.browser import BrowserCorpus, build_corpus + + +@pytest.fixture +def corpus(tmp_path: Path) -> BrowserCorpus: + """The reconstructions directory the browser tests read, as both views shape it.""" + return build_corpus(tmp_path) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py new file mode 100644 index 00000000..78360a42 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py @@ -0,0 +1,64 @@ +from typing import Final + +from tests.suite.browser import BrowserCorpus, as_view, view + +WHOLE_TREE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + +class TestWholeTree: + """What a reconstructions directory reads as with nothing to narrow it, in both views. + + The corpus states what the browser has to tell apart, and this is the shape it gives it: two + configurations differing by hash alone marked with that hash, a frequency holding two methods + beside one whose whole chain folded into a single row, an audio gathering the configurations + that reconstructed it, a sample of one variant folded into that variant, a configuration + directory nested in a plain folder, and a reconstruction outside every configuration directory. + """ + + def test_the_whole_tree_is_drawn_with_every_row_folded(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=False) == WHOLE_TREE From c273054afe747ede21ad67a9a988066a8c0ff478 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:08:26 +0200 Subject: [PATCH 29/45] Fixed: favorites filter opening a starred folder's whole subtree --- .../ui/elements/tree/tree.py | 53 +- tests/suite/browser.py | 60 +- .../ui/elements/tree/test_browser_view.py | 51 +- .../ui/elements/tree/test_favorites.py | 1 + .../ui/elements/tree/test_favorites_filter.py | 586 ++++++++++-------- .../ui/elements/tree/test_filter.py | 1 + 6 files changed, 432 insertions(+), 320 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b167853e..d5592209 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -130,6 +130,7 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None + self._favorites_anchors: Optional[TreeVisibility] = None self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -561,14 +562,16 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which a row leading to a match is. + """Whether the row is emitted standing open, which a row leading to a named row is. - A search result and a favorite are both matches the reader is looking for, so the way down to - either one opens and the filter's answer reads at a glance. + A search result and a favorite are both what the reader is looking for, so the way down to + either one opens and the filter's answer reads at a glance. What each criterion names is the + row the reader is pointed at rather than everything that row brings along, so a folder opens + to show what it holds while the rows inside it stand as they are. """ return any( visibility.should_expand(node) - for visibility in (self._search_visibility, self._favorites_visibility) + for visibility in (self._search_visibility, self._favorites_anchors) if visibility is not None ) @@ -851,7 +854,10 @@ def _resolve_filter(self) -> None: keeps a filter typed before a refresh answering for the rows that refresh brings. """ self._search_visibility = self._resolve_search_visibility() - self._favorites_visibility = self._resolve_favorites_visibility() + ( + self._favorites_visibility, + self._favorites_anchors, + ) = self._resolve_favorites() def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -866,16 +872,24 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) - def _resolve_favorites_visibility(self) -> Optional[TreeVisibility]: - """The rows the favorites mode names, and nothing to narrow by while the whole tree shows. + def _resolve_favorites( + self, + ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: + """The rows the favorites mode keeps, and the rows it points the reader at. - One walk of the model answers the whole mode, and what it keeps is the starred rows together - with the rows above them, so a corpus of any size resolves into a pair of sets. + The two answer different questions — which rows the browser draws, and which of them stand + open — so each is resolved from a set of its own, the second being a part of the first. One + walk of the model finds the rows the star reaches, and the anchors are read out of that + answer, so a corpus of any size resolves into a walk and a pair of sets. """ if not self._filter.favorites_only: - return None + return None, None - return resolve_visibility(self.tree.find_nodes(TreeNode, self._is_node_starred)) + reached = self.tree.find_nodes(TreeNode, self._is_node_starred) + return ( + resolve_visibility(reached), + resolve_visibility([node for node in reached if self._is_node_anchored(node)]), + ) def _is_node_starred(self, node: TreeNode) -> bool: """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. @@ -888,6 +902,23 @@ def _is_node_starred(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) + def _is_node_anchored(self, node: TreeNode) -> bool: + """Whether the mode points the reader at the row, which is what opens the way down to it. + + A star sits on a row the reader marked, so the way to that row opens wherever it sits — + inside another starred folder among the rest. A row a starred folder merely holds is where + the star first reaches only while no row above it is reached, which is how the sample branch + answers: its headings carry no path, so the variants are where the star arrives. + + Asked of the rows the star reaches, so a row it declines stands under a row it named, and + the reader is pointed at the folder rather than at everything inside it. + """ + if self._logic.is_node_favorite(node): + return True + + parent = node.parent + return parent is None or not self._is_node_starred(parent) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 14f2b3b2..a8e74ce3 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -52,6 +52,59 @@ INDENT: Final[str] = " " +def as_view(text: str) -> str: + """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" + return dedent(text).strip("\n") + + +WHOLE_TREE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + def config_fields( *, sample_rate: int, @@ -319,9 +372,10 @@ def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str: return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER -def as_view(text: str) -> str: - """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" - return dedent(text).strip("\n") +def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]: + """Every row standing for one path, which is what a favorite reaches across the two views.""" + path = corpus.paths[key] + return corpus.tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) def view( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py index 78360a42..b483a703 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py @@ -1,53 +1,4 @@ -from typing import Final - -from tests.suite.browser import BrowserCorpus, as_view, view - -WHOLE_TREE: Final[str] = as_view(""" - > By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - > 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - > FFT·γ0 - > PT - > takes - - alt - - beat - > PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - - stray - > By sample - > beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) +from tests.suite.browser import WHOLE_TREE, BrowserCorpus, view class TestWholeTree: diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index fb713bec..0b0bccd3 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -81,6 +81,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None + panel._favorites_anchors = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 73835a6f..c995c1ab 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -1,264 +1,339 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, Final, FrozenSet, List, Set, Tuple +from typing import Any, Final, List, Tuple import pytest from sampletones_application.ui.elements.tree import tree as tree_module -from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.filter import TreeFilter -from sampletones_application.ui.elements.tree.handler import NodeHandler -from sampletones_application.ui.elements.tree.spec import NodeSpec -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.utils.palette.colors.literal import LiteralColor -from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode -from tests.suite.language import FakeLanguageManager +from sampletones_core.structures.tree import TreeNode +from tests.suite.browser import ( + PANEL_TAG, + TREE_COLORS, + WHOLE_TREE, + BrowserCorpus, + as_view, + build_browser_panel, + nodes_at, + view, +) -PANEL_TAG: Final[str] = "sequencer.browser" CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" -TREE_COLORS: Final[TreeColors] = TreeColors( - favorite=LiteralColor((240, 200, 80, 255)), - node=LiteralColor((200, 200, 200, 255)), - muted=LiteralColor((120, 120, 120, 255)), - accent=LiteralColor((80, 160, 240, 255)), -) +STARRED_RECONSTRUCTION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_LONE_AUDIO: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v CQT·γ0·PTN + - solo + v By sample + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + """) +STARRED_IN_SUBFOLDER: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + v drums + - kick + v By sample + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + > takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +STARRED_PLAIN_FOLDER: Final[str] = as_view(""" + v By configuration + v archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_FOLDER_AND_WHAT_IT_HOLDS: Final[str] = as_view(""" + v By configuration + v archive + v 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_STRAY: Final[str] = as_view(""" + v By configuration + - stray + """) +STARRED_OF_TWO_ALIKE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_FOLDED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 8 kHz·60 Hz·CQT·γ2·P + - sweep + v By sample + - sweep·8 kHz·60 Hz·CQT·γ2·P + """) +STARRED_CONFIGURATION_B: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + > drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_FOLDER_HOLDING_A_STAR: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + v drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +QUERY_INSIDE_THE_MODE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + > drums [hidden] + - kick [hidden] + - beat [hidden] + - melody + v By sample + v beat [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v drums [hidden] + v kick [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +QUERY_PAST_THE_MODE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + - beat [hidden] + v By sample + v beat [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + """) +QUERY_ALONE: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P [hidden] + - sweep [hidden] + v 44.1 kHz·30 Hz + > CQT·γ0·PTN [hidden] + - beat [hidden] + - solo [hidden] + v FFT·γ0 + > PT [hidden] + > takes [hidden] + - alt [hidden] + - beat [hidden] + v PTN·#aaaaaaa + v drums + - kick + - snare [hidden] + - beat [hidden] + - melody [hidden] + v PTN·#bbbbbbb + v drums + - kick + - beat [hidden] + - melody [hidden] + > archive [hidden] + > 48 kHz·50 Hz·LogFFT·γ1·TN [hidden] + - song [hidden] + - stray [hidden] + v By sample + > beat [hidden] + - 44.1 kHz·30 Hz·CQT·γ0·PTN [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PT [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN [hidden] + > melody [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN [hidden] + - sweep·8 kHz·60 Hz·CQT·γ2·P [hidden] + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT [hidden] + """) -CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") -STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" -PLAIN_PATH: Final[Path] = CONFIG_DIRECTORY / "plain.stn" -VARIANT_LABEL: Final[str] = "44.1 kHz·30 Hz" - -STARRED_ROWS: Final[FrozenSet[str]] = frozenset( - { - "configurations", - "directory", - "starred", - "samples", - "starred_sample", - "starred_variant", - } -) -SAMPLE_VIEW_ROWS: Final[FrozenSet[str]] = frozenset( - { - "samples", - "starred_sample", - "starred_variant", - "plain_sample", - "plain_variant", - } -) +class TestDrawnRows: + """Which rows the mode draws: what the star reaches, and the rows leading down to it.""" -class FakeTreeLogic: - """Answers the favorite questions a browser asks of its logic while it collects its rows.""" - - def __init__(self, favorites: Set[Path]) -> None: - self._favorites = favorites - - def is_node_favorite(self, node: TreeNode) -> bool: - return isinstance(node, FileSystemNode) and node.filepath in self._favorites - - def has_favorite_ancestor(self, node: FileSystemNode) -> bool: - return any(directory in self._favorites for directory in node.filepath.parents) - - -@dataclass(frozen=True) -class BrowserTree: - """The shape both browser views give one configuration directory, with a handle on every row.""" - - tree: Tree - rows: Dict[str, TreeNode] - - -@pytest.fixture -def browser() -> BrowserTree: - """Two reconstructions of one configuration, listed by that configuration and by their samples. - - A sample row carries the name of the reconstruction it gathers, the way the builder names it, so - each row is held by a key of its own rather than by the label it reads under. - """ - root = TreeNode("Root", node_type=NodeType.ROOT) - configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) - directory = FileSystemNode( - "PTN", - node_type=NodeType.DIRECTORY, - filepath=CONFIG_DIRECTORY, - parent=configurations, - ) - starred = FileSystemNode("starred", node_type=NodeType.FILE, filepath=STARRED_PATH, parent=directory) - plain = FileSystemNode("plain", node_type=NodeType.FILE, filepath=PLAIN_PATH, parent=directory) - - samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) - starred_sample = TreeNode("starred", node_type=NodeType.SAMPLE, parent=samples) - starred_variant = FileSystemNode( - VARIANT_LABEL, - node_type=NodeType.FILE, - filepath=STARRED_PATH, - parent=starred_sample, - ) - plain_sample = TreeNode("plain", node_type=NodeType.SAMPLE, parent=samples) - plain_variant = FileSystemNode( - VARIANT_LABEL, - node_type=NodeType.FILE, - filepath=PLAIN_PATH, - parent=plain_sample, - ) - - return BrowserTree( - tree=Tree(root=root), - rows={ - "configurations": configurations, - "directory": directory, - "starred": starred, - "plain": plain, - "samples": samples, - "starred_sample": starred_sample, - "starred_variant": starred_variant, - "plain_sample": plain_sample, - "plain_variant": plain_variant, - }, - ) - - -def build_panel( - browser: BrowserTree, - favorites: Set[Path], - *, - favorites_only: bool, - query: str = "", -) -> GUISequencerBrowserPanel: - """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. - - Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, - and the control stands where a browser that has yet to build one leaves it. - """ - panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) - panel.tag = PANEL_TAG - panel.tree = browser.tree - panel._logic = FakeTreeLogic(favorites) - panel._language_manager = FakeLanguageManager() - panel._colors = TREE_COLORS - panel._favorites_checkbox_tag = None - panel._favorites_glyph_tag = None - panel.on_favorites_filter_changed = None - panel._filter = TreeFilter(query=query, favorites_only=favorites_only) - panel._resolve_filter() - return panel - - -def collect_specs(panel: GUISequencerBrowserPanel) -> List[NodeSpec]: - """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" - panel._pending_specs = [] - panel._node_handlers = { - node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType - } - - root = panel.tree.get_root() - assert root is not None - panel._build_tree_node(root, TreeNodeState(parent="tree")) - return panel._pending_specs - - -def drawn_keys( - browser: BrowserTree, - specs: List[NodeSpec], -) -> Set[str]: - drawn = {spec.node for spec in specs} - return {key for key, node in browser.rows.items() if node in drawn} - - -def open_keys( - browser: BrowserTree, - specs: List[NodeSpec], -) -> Set[str]: - standing_open = {spec.node for spec in specs if spec.should_expand} - return {key for key, node in browser.rows.items() if node in standing_open} + def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + def test_a_starred_reconstruction_of_an_audio_one_configuration_holds(self, corpus: BrowserCorpus) -> None: + """A sample of a single variant folded into that variant, and the fold carries the star.""" + assert view(corpus, {corpus.paths["D/solo"]}, favorites_only=True) == STARRED_LONE_AUDIO -class TestDrawnRows: - def test_a_starred_reconstruction_is_drawn_under_the_rows_holding_it_in_both_views( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) - assert drawn_keys(browser, collect_specs(panel)) == STARRED_ROWS + def test_a_starred_reconstruction_in_a_mirrored_subfolder(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/drums/kick"]}, favorites_only=True) == STARRED_IN_SUBFOLDER - def test_a_starred_directory_brings_the_reconstructions_it_holds( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) - assert {"directory", "starred", "plain"} <= drawn_keys(browser, collect_specs(panel)) + def test_a_starred_configuration_directory_brings_what_it_holds(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION - def test_a_starred_directory_reaches_the_view_holding_no_row_for_it( - self, - browser: BrowserTree, - ) -> None: - """The sample view lists reconstructions under their samples, and no row stands for a folder. + def test_a_starred_plain_folder_reaches_the_configuration_nested_in_it(self, corpus: BrowserCorpus) -> None: + """The sample branch reads the top-level configurations, so a nested one stands there alone.""" + assert view(corpus, {corpus.paths["archive"]}, favorites_only=True) == STARRED_PLAIN_FOLDER - Being held by a starred folder is read from the path, so each variant answers for itself and - the sample gathering it comes along. - """ - panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) - assert SAMPLE_VIEW_ROWS <= drawn_keys(browser, collect_specs(panel)) + def test_a_starred_reconstruction_outside_every_configuration(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["stray"]}, favorites_only=True) == STARRED_STRAY - def test_nothing_starred_draws_no_row(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=True) - assert collect_specs(panel) == [] + def test_a_star_on_one_of_two_configurations_reading_alike(self, corpus: BrowserCorpus) -> None: + """The star belongs to a path, so the sibling marked with the other hash stays out.""" + assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE - def test_the_mode_off_draws_every_row(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) - assert drawn_keys(browser, collect_specs(panel)) == set(browser.rows) + def test_a_starred_configuration_whose_chain_folded_into_one_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["E"]}, favorites_only=True) == STARRED_FOLDED_CONFIGURATION + def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=True) == "" -class TestOpenRows: - def test_the_rows_leading_to_a_favorite_stand_open(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) - assert open_keys(browser, collect_specs(panel)) == { - "configurations", - "directory", - "samples", - "starred_sample", - } + def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE - def test_the_mode_off_leaves_every_row_as_it_stands(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) - assert open_keys(browser, collect_specs(panel)) == set() +class TestOpenRows: + """Which rows stand open: the way down to a star, and a starred folder showing what it holds.""" -class TestSearchInsideTheMode: - def test_the_mode_states_the_drawn_rows_while_the_query_states_the_shown_ones( + def test_a_starred_folder_opens_and_a_subfolder_holding_no_star_stays_closed( self, - browser: BrowserTree, + corpus: BrowserCorpus, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="starred") - specs = collect_specs(panel) + assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION - assert drawn_keys(browser, specs) == STARRED_ROWS - assert panel._is_node_visible(browser.rows["starred"]) - assert not panel._is_node_visible(browser.rows["plain"]) + def test_a_starred_folder_opens_one_level(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE - def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it( + def test_a_star_inside_a_starred_folder_opens_the_way_down_to_itself(self, corpus: BrowserCorpus) -> None: + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_HOLDING_A_STAR + + def test_a_starred_folder_inside_a_starred_folder_opens(self, corpus: BrowserCorpus) -> None: + favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} + assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_AND_WHAT_IT_HOLDS + + def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + + def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( self, - browser: BrowserTree, + corpus: BrowserCorpus, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="plain") - assert "plain" not in drawn_keys(browser, collect_specs(panel)) + """No row stands for the folder there, so the variants are where the star arrives.""" + assert view(corpus, {corpus.paths["B"]}, favorites_only=True) == STARRED_CONFIGURATION_B + + +class TestSearchInsideTheMode: + """The mode states which rows are drawn, and the query states which of them are shown.""" + + def test_a_query_hides_the_drawn_rows_it_leaves_out(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + query="melody", + ) + == QUERY_INSIDE_THE_MODE + ) + + def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + query="melody", + ) + == QUERY_PAST_THE_MODE + ) + + def test_a_query_cleared_shows_the_rows_the_mode_draws(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + query="", + ) + == STARRED_CONFIGURATION_B + ) + + def test_a_query_alone_draws_every_row_and_shows_the_matches(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=False, query="kick") == QUERY_ALONE class TestEmptyAnswer: """A rebuild drawing no row names the filter that answered so, where the rows would be.""" - def test_the_mode_finding_no_favorite_names_the_favorites(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=True) + def test_the_mode_finding_no_favorite_names_the_favorites(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=True) assert panel._empty_filter_message() == "global.dialog.message.tree_no_favorites" - def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=False, query="nothing") + def test_a_query_finding_nothing_names_the_results(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False, query="nothing") assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" @@ -267,10 +342,10 @@ class TestControl: def test_the_mode_the_control_reads_reaches_the_filter( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) panel._on_favorites_only_changed(None, True) @@ -279,10 +354,10 @@ def test_the_mode_the_control_reads_reaches_the_filter( def test_a_change_is_handed_to_the_hook_remembering_it( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) remembered: List[Tuple[str, bool]] = [] panel.on_favorites_filter_changed = lambda panel_tag, favorites_only: remembered.append( (panel_tag, favorites_only) @@ -295,10 +370,10 @@ def test_a_change_is_handed_to_the_hook_remembering_it( def test_a_change_draws_the_rows_the_new_mode_names( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) redraws: List[bool] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -306,11 +381,8 @@ def test_a_change_draws_the_rows_the_new_mode_names( assert redraws == [True] - def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + def test_the_mode_a_session_left_on_stands_before_the_first_rebuild(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) panel._restore_favorites_only(True) @@ -318,35 +390,35 @@ def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( def test_a_query_typed_earlier_survives_a_change_of_mode( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False, query="starred") + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False, query="beat") monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) panel._on_favorites_only_changed(None, True) - assert panel._filter.query == "starred" + assert panel._filter.query == "beat" class TestStarColor: """The star beside the label reads in the colour of the mode it stands for.""" - def test_the_star_reads_favorite_while_the_mode_is_on(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + def test_the_star_reads_favorite_while_the_mode_is_on(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=True) assert panel._favorites_glyph_color() == TREE_COLORS.favorite - def test_the_star_reads_muted_while_the_mode_is_off(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + def test_the_star_reads_muted_while_the_mode_is_off(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) assert panel._favorites_glyph_color() == TREE_COLORS.muted def test_the_star_is_coloured_with_the_token_the_mode_names( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: """The colour reaches the star as a token, so the star follows a palette swapped in place.""" - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_glyph_tag = GLYPH_TAG coloured: List[Tuple[str, BaseColor]] = [] monkeypatch.setattr( @@ -365,10 +437,10 @@ class TestControlLock: def test_the_lock_reaches_the_control( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_checkbox_tag = CHECKBOX_TAG configured: List[Tuple[str, Any]] = [] monkeypatch.setattr( @@ -383,10 +455,10 @@ def test_the_lock_reaches_the_control( def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, set(), favorites_only=False) configured: List[Tuple[str, Any]] = [] monkeypatch.setattr( tree_module, @@ -402,10 +474,10 @@ def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( class TestFavoriteChange: def test_a_change_draws_the_tree_again_while_the_mode_is_on( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) redraws: List[bool] = [] repaints: List[TreeNode] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -416,17 +488,18 @@ def test_a_change_draws_the_tree_again_while_the_mode_is_on( raising=False, ) - panel.update_favorite_indicators([browser.rows["starred"]]) + panel.update_favorite_indicators(nodes_at(corpus, "A/beat")) assert redraws == [True] assert repaints == [] - def test_a_change_repaints_the_rows_while_the_mode_is_off( + def test_a_change_repaints_every_row_standing_for_the_path_while_the_mode_is_off( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + """One path reaches the panel as a row in each view, and each takes its own ancestry.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) redraws: List[bool] = [] repaints: List[TreeNode] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -436,8 +509,9 @@ def test_a_change_repaints_the_rows_while_the_mode_is_off( lambda node, has_favorite_ancestor=False: repaints.append(node), raising=False, ) + rows = nodes_at(corpus, "A/beat") - panel.update_favorite_indicators([browser.rows["starred"], browser.rows["starred_variant"]]) + panel.update_favorite_indicators(rows) assert redraws == [] - assert repaints == [browser.rows["starred"], browser.rows["starred_variant"]] + assert repaints == list(rows) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index 5849f65a..d538bc63 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -51,6 +51,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None + panel._favorites_anchors = None return panel From ec466212bfcdecac83c337630756a61132fab12d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:32:25 +0200 Subject: [PATCH 30/45] Added: browser remembering which rows stand open --- .../ui/elements/tree/tree.py | 81 ++++- .../ui/panels/shared/browser.py | 12 +- tests/suite/browser.py | 40 ++- .../ui/elements/tree/test_expansion_memory.py | 277 ++++++++++++++++++ .../ui/elements/tree/test_favorites.py | 1 + .../shared/test_container_context_menu.py | 36 +++ 6 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index d5592209..bccbfdd8 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union import dearpygui.dearpygui as dpg @@ -101,6 +101,7 @@ class GUITreePanel(GUIPanel, ABC): _NAME_FONT: Font = Font.REGULAR_SMALL _CONFIG_FONT: Font = Font.MONO_SMALL _MONOSPACE_CONFIG_NODES: bool = False + _REMEMBERS_EXPANSION: bool = False def __init__( self, @@ -125,6 +126,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] + self._expanded_rows: Set[str] = set() self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -227,8 +229,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: if root is not None: self._build_tree_node(root, state=TreeNodeState(parent=root_tag)) + self._forget_rows_the_model_dropped() return self._pending_specs + def _forget_rows_the_model_dropped(self) -> None: + """Holds the memory of open rows to the rows a pass over the whole tree found. + + A pass showing everything states which rows exist, so a row it left out belongs to a folder + the disk no longer holds and its place in the memory goes with it. A pass narrowed to the + favorites speaks for those rows alone, and leaves the memory of the rest as it stands. + """ + if not self._REMEMBERS_EXPANSION or self._filter.favorites_only: + return + + self._expanded_rows &= {spec.node_tag for spec in self._pending_specs} + def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) self._search_button_tag = compose_tag(self.tag, SUF_BUTTON_SEARCH) @@ -366,6 +381,11 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, is_node_expanded=is_node_expanded, ) + stands_open = self._stands_open( + node, + node_tag, + should_expand=should_expand, + ) self._pending_specs.append( NodeSpec( node=node, @@ -376,12 +396,40 @@ def _append_spec( leaf=leaf, open_on_arrow=open_on_arrow, open_on_double_click=open_on_double_click, - should_expand=should_expand, + should_expand=stands_open, theme_tag=theme_tag, handler_tag=self._node_handlers[node.node_type].tag, ) ) + def _stands_open( + self, + node: TreeNode, + node_tag: str, + *, + should_expand: bool, + ) -> bool: + """Whether the row is created standing open: the filter points at it, or the memory holds it. + + The shape the reader built is theirs to keep, so a row they opened comes back open and the + filter adds the way down to what it names. Recording the answer here is what carries that + shape into the pass after this one. + """ + if not self._REMEMBERS_EXPANSION: + return should_expand + + stands_open = should_expand or node_tag in self._expanded_rows + self._set_row_expanded(node_tag, stands_open and bool(node.children)) + return stands_open + + def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: + """Holds whether a row stands open, which is what a later pass brings it back by.""" + if expanded: + self._expanded_rows.add(node_tag) + return + + self._expanded_rows.discard(node_tag) + def _finish_emit( self, root_tag: str, @@ -496,6 +544,7 @@ def single_click_callback( app_data: Tuple[int, int], ) -> None: user_data = dpg.get_item_user_data(app_data[1]) + self._remember_clicked_row(user_data) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) @@ -522,6 +571,34 @@ def double_click_callback( return double_click_callback + def _remember_clicked_row(self, user_data: Any) -> None: + """Follows a click through to what it left the row standing as, a frame after it landed. + + A click on a row the reader can open is how that row folds and unfolds, and the row states + its own answer once the frame carrying the click has drawn. Reading it the frame after + therefore reports what the reader did, whichever button they pressed, and a row holding + nothing has nothing to remember. + """ + if not self._REMEMBERS_EXPANSION or not isinstance(user_data, tuple): + return + + node, node_tag = user_data + if not node.children: + return + + CallbackQueue.add( + self._read_row_expansion, + node_tag, + delay=1, + ) + + def _read_row_expansion(self, node_tag: str) -> None: + """Takes the state a row stands in into the memory, on the main thread that owns the row.""" + if not dpg.does_item_exist(node_tag): + return + + self._set_row_expanded(node_tag, bool(dpg_get_value(node_tag))) + def _setup_handlers(self) -> None: for handler in self._node_handlers.values(): with dpg.item_handler_registry(tag=handler.tag): diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 387d6009..d3805a33 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -39,11 +39,13 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): its refresh control, and adds the items its context menus offer. Reconstructions carry favorites, so this browser offers the control showing them alone and opens - in the mode the session left it in. + in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild + — a refresh, a change of mode — brings the rows back standing as they were left. """ _MONOSPACE_CONFIG_NODES: bool = True _OFFERS_FAVORITES_FILTER: bool = True + _REMEMBERS_EXPANSION: bool = True def __init__( self, @@ -266,12 +268,14 @@ def _add_context_menu_expansion_items(self, node: TreeNode) -> None: def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. - Whether a row stands open is a fact of the widget alone, so each row is reached by the tag it - was built under and set directly. + Each row is reached by the tag it was built under and set directly, and the browser is told + what it now stands as, so a rebuild brings the whole subtree back the way this left it. """ for container in (node, *node.descendants): if container.children: - dpg_set_value(self._generate_node_tag(container), expanded) + node_tag = self._generate_node_tag(container) + dpg_set_value(node_tag, expanded) + self._set_row_expanded(node_tag, expanded) def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: """Offers the label the tree reads the row by, which for a folded chain names every level.""" diff --git a/tests/suite/browser.py b/tests/suite/browser.py index a8e74ce3..cbb9cff6 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -9,7 +9,6 @@ from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec -from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.utils.palette.colors.literal import LiteralColor @@ -274,6 +273,7 @@ def build_browser_panel( *, favorites_only: bool, query: str = "", + panel_tag: str = PANEL_TAG, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. @@ -281,7 +281,8 @@ def build_browser_panel( and the control stands where a browser that has yet to build one leaves it. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) - panel.tag = PANEL_TAG + panel.tag = panel_tag + panel._expanded_rows = set() panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] @@ -309,15 +310,10 @@ def _state_detail_labels(panel: GUITreePanel) -> None: def collect_specs(panel: GUITreePanel) -> List[NodeSpec]: """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" - panel._pending_specs = [] panel._node_handlers = { node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType } - - root = panel.tree.get_root() - assert root is not None - panel._build_tree_node(root, TreeNodeState(parent=panel.tree_tag)) - return panel._pending_specs + return panel._collect_specs(panel.tree_tag) def render_view(panel: GUITreePanel) -> str: @@ -378,6 +374,34 @@ def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]: return corpus.tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) +def row_named(corpus: BrowserCorpus, label: str) -> TreeNode: + """The row reading under this label, which is how a test names a heading the browser wrote.""" + rows = corpus.tree.find_nodes(TreeNode, lambda node: str(node.name) == label) + assert len(rows) == 1 + return rows[0] + + +def set_row_expanded( + panel: GUITreePanel, + node: TreeNode, + *, + expanded: bool, +) -> None: + """Leaves a row standing the way the reader would leave it, which the browser then remembers.""" + panel._set_row_expanded(panel._generate_node_tag(node), expanded) + + +def set_filter( + panel: GUITreePanel, + *, + favorites_only: bool, + query: str = "", +) -> None: + """States what the browser is now asked to show, as a change of the control or the search box.""" + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + + def view( corpus: BrowserCorpus, favorites: Set[Path], diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py new file mode 100644 index 00000000..d18fa5ab --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -0,0 +1,277 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from tests.suite.browser import ( + WHOLE_TREE, + BrowserCorpus, + as_view, + build_browser_panel, + build_corpus, + nodes_at, + render_view, + row_named, + set_filter, + set_row_expanded, +) + +STARRED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + > takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +SUBFOLDER_THE_READER_OPENED: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + v takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +WHOLE_TREE_AFTER_THE_MODE: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + v 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + v FFT·γ0 + > PT + > takes + - alt + - beat + v PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + v By sample + v beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + +WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + +class TestTheReadersShape: + """A row stands where the reader left it, and a later pass brings it back that way.""" + + def test_a_row_the_reader_opened_is_drawn_open(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) + assert render_view(panel) == STARRED_CONFIGURATION + + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + + assert render_view(panel) == SUBFOLDER_THE_READER_OPENED + + def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + render_view(panel) + + set_row_expanded(panel, row_named(corpus, "takes"), expanded=False) + + assert render_view(panel) == STARRED_CONFIGURATION + + def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: + """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) + render_view(panel) + + set_filter(panel, favorites_only=False) + + assert render_view(panel) == WHOLE_TREE_AFTER_THE_MODE + + def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + render_view(panel) + + set_filter(panel, favorites_only=True) + render_view(panel) + set_filter(panel, favorites_only=False) + + assert "v archive" in render_view(panel) + + def test_a_refresh_brings_the_rows_back_standing_as_they_were( + self, + corpus: BrowserCorpus, + tmp_path: Path, + ) -> None: + """A rebuilt model states the same rows, and a row is remembered by the ancestry it reads.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + render_view(panel) + + panel.tree = build_corpus(tmp_path).tree + + assert "v archive" in render_view(panel) + + def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( + self, + corpus: BrowserCorpus, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive = row_named(corpus, "archive") + set_row_expanded(panel, archive, expanded=True) + render_view(panel) + + archive.parent = None + + assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE + assert panel._expanded_rows == set() + + def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: BrowserCorpus) -> None: + """A row is remembered under the tag of the browser showing it, so neither reaches the other.""" + sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser") + reconstruction = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="reconstruction.browser") + + set_row_expanded(sequencer, row_named(corpus, "archive"), expanded=True) + + assert "v archive" in render_view(sequencer) + assert render_view(reconstruction) == WHOLE_TREE + + +class TestFollowingTheReader: + """A click on a row is how it folds, and the browser reads what it stands as afterwards.""" + + def test_a_click_reads_the_row_the_frame_after_it_landed( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = [] + monkeypatch.setattr( + tree_module.CallbackQueue, + "add", + lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)), + ) + + panel._remember_clicked_row((row_named(corpus, "archive"), "row.tag")) + + assert scheduled == [(panel._read_row_expansion, ("row.tag",), {"delay": 1})] + + def test_a_row_holding_nothing_has_nothing_to_remember( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = [] + monkeypatch.setattr( + tree_module.CallbackQueue, + "add", + lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)), + ) + + panel._remember_clicked_row((nodes_at(corpus, "stray")[0], "row.tag")) + + assert scheduled == [] + + def test_the_reading_takes_the_state_the_row_stands_in( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(tree_module, "dpg_get_value", lambda tag: True) + + panel._read_row_expansion("row.tag") + + assert panel._expanded_rows == {"row.tag"} + + def test_a_row_that_left_the_tree_is_read_no_further( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: False) + + panel._read_row_expansion("row.tag") + + assert panel._expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 0b0bccd3..47668357 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -82,6 +82,7 @@ def build_panel( panel._search_visibility = None panel._favorites_visibility = None panel._favorites_anchors = None + panel._expanded_rows = set() monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index 482eaecb..ddf2d1f7 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -48,6 +48,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG + panel._expanded_rows = set() panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, @@ -286,6 +287,41 @@ def test_collapsing_closes_the_same_rows( (compose_node_tag(sample, panel_tag=PANEL_TAG), False), ] + def test_the_browser_remembers_the_shape_the_item_left( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + """A rebuild brings the subtree back the way the item left it, so what it set is recorded.""" + panel = _panel() + group, sample, _ = _sample_tree() + rows = { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + + panel._add_context_menu_expansion_items(group) + recorder.item(EXPAND_LABEL)["callback"]() + + assert panel._expanded_rows == rows + + def test_the_browser_forgets_the_shape_the_item_folded( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + panel._expanded_rows = { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + + panel._add_context_menu_expansion_items(group) + recorder.item(COLLAPSE_LABEL)["callback"]() + + assert panel._expanded_rows == set() + def test_leaf_rows_are_left_alone( self, recorder: _MenuItemRecorder, From d53af227d035d5d222e1d3510eeb03ccf705f374 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:37:16 +0200 Subject: [PATCH 31/45] Documented: the favorites filter's rules --- docs/development/browser.md | 44 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index a2bdb010..67fd025d 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -34,6 +34,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, and each browser opens in the mode a session left it in. +8. **The reader's shape survives a rebuild.** Which rows stand open is what the reader made of the + tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave + the tree standing as it was, and a filter adds the way down to what it names. --- @@ -104,8 +107,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: * `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the - status-bar messages, and the context-menu items every browser can offer. + they compose, the shape it holds across rebuilds, the rebuild handshake, spec collection, themes and + fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser + can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states @@ -164,20 +168,36 @@ One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibi rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in memory follows the size of what was found, and a row beneath a match is answered from its own path -upwards. The same two sets state which rows stand open, which is what makes a filter legible: the -starred rows come up with their headings open. +upwards. + +**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows +and brings others along with them, and only the first kind is worth unfolding to: a search names the +rows whose label matched, and the favorites mode names its **anchors** — a row the star sits on, and, +where no row stands for the starred path, the shallowest rows that path reaches. So a starred folder +comes up open showing what it holds, a folder inside it stays as it was, and a star nested deeper +opens the way down to itself, since a starred row anchors wherever it sits. In the sample branch the +headings carry no path, which makes the variants the rows the star arrives at, and the way down to +them opens. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. +**The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records +the rows standing open, by the tag those rows are addressed under, and a later pass creates them open +again: the filter adds the way down to what it names, and everything else comes back as it was left. +A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click +is read a frame later, once the row has answered it, and the expansion items record what they set. A +pass over the whole tree states which rows exist, so the rows it left out leave the memory with them. + **What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing -each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents`. -What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the -drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only -browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, -the drawn rows being the mode's to state. A favorite toggled while the mode is on redraws the browser, -so starring a row brings it in and unstarring one takes it out along with what it held. +each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — +and the anchors are read out of that one answer. What it materialises is the starred rows and the rows +above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of +thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their +headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite +toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring one +takes it out along with what it held. A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the @@ -186,7 +206,9 @@ filter's answer reads where the rows would be. **The control** is a checkbox under the search box carrying the favorite glyph, which reads in the favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It -follows the tree's lock, a rebuild being what it asks for. +follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every +checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so +the shade states whether the control is live. Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, From 192f924c820024af8398048073bd769ee4e04151 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 11:44:45 +0200 Subject: [PATCH 32/45] Added: Collapse all control in every file browser card --- .../categories/elements/main.py | 2 - src/sampletones_application/tags/general.py | 1 + src/sampletones_application/tags/main.py | 6 - .../ui/elements/tree/browser.py | 49 ++++++++- .../ui/elements/tree/tree.py | 13 +++ .../ui/panels/main/explorer.py | 36 ++---- .../ui/panels/shared/browser.py | 13 --- src/sampletones_config/lang/en.yaml | 4 +- .../ui/elements/tree/test_collapse_all.py | 74 +++++++++++++ .../ui/panels/main/test_explorer_controls.py | 104 ++++++++++++++++++ .../shared/test_container_context_menu.py | 2 +- 11 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py create mode 100644 tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index 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/tags/general.py b/src/sampletones_application/tags/general.py index baa6030e..88c526d7 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -676,6 +676,7 @@ SUF_BUTTON_SAVE = compose_tag(SUF_BUTTON, "save") SUF_BUTTON_CANCEL = compose_tag(SUF_BUTTON, "cancel") SUF_BUTTON_SEARCH = compose_tag(SUF_BUTTON, "search") +SUF_BUTTON_COLLAPSE_ALL = compose_tag(SUF_BUTTON, "collapse_all") SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback") SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement") SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment") diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 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/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index ad105e1b..9d2410f6 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -7,7 +7,11 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON_COLLAPSE_ALL, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar @@ -27,11 +31,11 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card. - The card holds a refresh control above the search box and the tree it filters. This base builds - that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the - whole card as the tree locks and unlocks. A subclass declares its widgets as a - :class:`FileBrowserTags`, states what its card and its refresh control read, answers what - refreshing the model means, and shapes each row. + The card holds the controls bringing the tree up to date and folding it away, above the search box + and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread + on demand, and enables or disables the whole card as the tree locks and unlocks. A subclass + declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control + read, answers what refreshing the model means, and shapes each row. A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control showing those favorites alone to the card. @@ -52,6 +56,9 @@ def __init__( colors: TreeColors, initial_collapsed: bool, ) -> None: + self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] + self._msg_collapse_all = language_manager["global.status.message.collapse_all"] + super().__init__( tree=tree, tag=self._tags.panel, @@ -119,8 +126,10 @@ def create_panel(self, parent: str) -> None: self.rebuild_tree() def _create_controls(self) -> None: + """Offers the two controls every browser of files carries: bring it up to date, fold it away.""" with dpg.group(tag=self._tags.group_controls): self._create_refresh_button() + self._create_collapse_all_button() self._bind_refresh_message() @@ -133,6 +142,21 @@ def _create_refresh_button(self) -> None: theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), ) + def _create_collapse_all_button(self) -> None: + """Offers the control folding the whole tree away, reading as the utility the refresh one does.""" + collapse_all_tag = compose_tag(self.tag, SUF_BUTTON_COLLAPSE_ALL) + GUIButton( + tag=collapse_all_tag, + label=self._lbl_collapse_all, + width=-1, + callback=self._on_collapse_all_clicked, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + self._status_bar.bind_to_item( + collapse_all_tag, + self._msg_collapse_all, + ) + def _bind_refresh_message(self) -> None: self._status_bar.bind_to_item( self._tags.button_refresh, @@ -143,6 +167,19 @@ def _on_refresh_clicked(self) -> None: """Answers the refresh control, by default with a rebuild of the tree as the model stands.""" self.rebuild_tree() + def _on_collapse_all_clicked(self) -> None: + """Folds every row of the tree away, leaving the reader the level the tree opens at. + + The rows are reached through the model rather than the widget tree, so one pass covers a + branch however deep it runs, and the browser is told what each row now stands as. + """ + root = self.tree.get_root() + if root is None: + return + + for child in root.children: + self._set_subtree_expanded(child, expanded=False) + def _create_tree_window(self) -> None: self.create_search(self._body_container) if self._OFFERS_FAVORITES_FILTER: diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index bccbfdd8..be211540 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -57,6 +57,7 @@ dpg_configure_item, dpg_get_value, dpg_is_item_hovered, + dpg_set_value, ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import ( @@ -430,6 +431,18 @@ def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: self._expanded_rows.discard(node_tag) + def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: + """Folds or unfolds the row together with every row below it holding something. + + Each row is reached by the tag it was built under and set directly, and the browser is told + what it now stands as, so a rebuild brings the whole subtree back the way this left it. + """ + for container in (node, *node.descendants): + if container.children: + node_tag = self._generate_node_tag(container) + dpg_set_value(node_tag, expanded) + self._set_row_expanded(node_tag, expanded) + def _finish_emit( self, root_tag: str, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 7fd945ff..4adbe398 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -8,7 +8,6 @@ SchedulingBehavior, ) from sampletones_application.tags.main import ( - TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, TAG_MAIN_EXPLORER_BUTTON_REFRESH, TAG_MAIN_EXPLORER_GROUP_CONTROLS, TAG_MAIN_EXPLORER_GROUP_TREE, @@ -16,7 +15,6 @@ TAG_MAIN_EXPLORER_TREE, TAG_MAIN_EXPLORER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel @@ -134,40 +132,20 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def _create_controls(self) -> None: - """Offers the refresh control and, beside it, the one folding every folder away at once.""" - with dpg.group(tag=self._tags.group_controls): - self._create_refresh_button() - GUIButton( - tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, - label=self._language_manager["main.explorer.label.collapse_all_button"], - width=-1, - callback=self.collapse_all, - ) - - self._bind_refresh_message() - self._status_bar.bind_to_item( - TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, - self._language_manager["main.explorer.message.status_collapse_all"], - ) - def _create_tree_root(self) -> None: self._create_tree_root_heading(self.section_label) def _refresh_model(self) -> None: self._explorer_logic.refresh_tree() - def collapse_all( - self, - _sender: Sender, - _app_data: int, - _user_data: Any, - ) -> None: + def _on_collapse_all_clicked(self) -> None: + """Folds every folder away and drops the children it had loaded, so opening one reads it again. + + The rows fold while the model still states them, and the folders the model held go afterwards, + which is what makes a later open list the folder as it stands on disk. + """ + super()._on_collapse_all_clicked() self._explorer_logic.collapse_all() - children = dpg.get_item_children(self.tree_tag, 1) - assert children is not None, "Explorer tree has no children." - for node_tag in children: - dpg.set_value(node_tag, False) @concurrent(wait=False, method_bound=True) def _rebuild_node_subtree( diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index d3805a33..ff6acb73 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -18,7 +18,6 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -265,18 +264,6 @@ def _add_context_menu_expansion_items(self, node: TreeNode) -> None: callback=lambda: self._set_subtree_expanded(node, expanded=False), ) - def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: - """Folds or unfolds the row together with every row below it holding something. - - Each row is reached by the tag it was built under and set directly, and the browser is told - what it now stands as, so a rebuild brings the whole subtree back the way this left it. - """ - for container in (node, *node.descendants): - if container.children: - node_tag = self._generate_node_tag(container) - dpg_set_value(node_tag, expanded) - self._set_row_expanded(node_tag, expanded) - def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: """Offers the label the tree reads the row by, which for a folded chain names every level.""" dpg.add_separator() diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6b079f01..e2643b08 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -134,6 +134,7 @@ global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" global.browser.label.favorites_only: "Favorites only" +global.browser.label.collapse_all: "Collapse all" # ============================================================================= # Global — Context menu @@ -241,6 +242,7 @@ global.status.message.node_library: "Double-click to open instructions library. global.status.message.tree_search: "Type query to filter nodes." global.status.message.clear_search: "Clear the search filter." global.status.message.favorites_only: "Show the favorites alone, or the whole tree." +global.status.message.collapse_all: "Fold every row of the tree away." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." @@ -278,7 +280,6 @@ global.graph.message.waveform_regenerating: "Regenerating reconstruction..." # ============================================================================= main.explorer.label.section: "Filesystem" main.explorer.label.refresh_button: "Refresh" -main.explorer.label.collapse_all_button: "Collapse all" main.explorer.label.context_load_reconstruction: "Load reconstruction" main.explorer.label.context_load_library: "Load instructions library" main.explorer.label.context_reconstruct_file: "Reconstruct file" @@ -288,7 +289,6 @@ main.explorer.label.context_set_output_directory: "Set as output directory" main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu." main.explorer.message.status_node_audio: "Click to play audio. Double-click to reconstruct audio. Right-click to open context menu." main.explorer.message.status_refresh: "Rescan the filesystem for audio files." -main.explorer.message.status_collapse_all: "Collapse every folder in the tree." main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one." main.explorer.title.converter_running_dialog: "Conversion in progress" diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py new file mode 100644 index 00000000..6dc8aca8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py @@ -0,0 +1,74 @@ +from typing import List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_core.structures.tree import NodeType +from tests.suite.browser import ( + WHOLE_TREE, + BrowserCorpus, + build_browser_panel, + render_view, + row_named, + set_row_expanded, +) + + +@pytest.fixture +def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the tag and open state of every row the control reaches.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +class TestCollapseAllControl: + """The control folds the whole tree away, and the browser is left holding that shape.""" + + def test_every_row_holding_something_is_folded( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + + panel._on_collapse_all_clicked() + + containers = {panel._generate_node_tag(node) for node in corpus.tree.get_root().descendants if node.children} + assert {tag for tag, _ in folded} == containers + assert all(not expanded for _, expanded in folded) + + def test_a_row_holding_nothing_is_left_alone( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + + panel._on_collapse_all_clicked() + + leaves = { + panel._generate_node_tag(node) + for node in corpus.tree.get_root().descendants + if node.node_type == NodeType.FILE + } + assert not leaves & {tag for tag, _ in folded} + + def test_the_shape_the_control_left_is_what_the_next_pass_draws( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + render_view(panel) + + panel._on_collapse_all_clicked() + + assert panel._expanded_rows == set() + assert render_view(panel) == WHOLE_TREE diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py new file mode 100644 index 00000000..88c435e5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode + +PANEL_TAG = "main_explorer" +ROOT = Path("/") +MUSIC = ROOT / "music" + + +class FakeExplorerLogic: + """Answers what the panel asks of its model, recording the folders it is told to drop.""" + + def __init__(self, tree: Tree) -> None: + self.tree = tree + self.cleared: List[Tuple[str, ...]] = [] + + def collapse_all(self) -> None: + root = self.tree.get_root() + assert root is not None + self.cleared.append(tuple(str(node.name) for node in root.descendants)) + for filesystem_node in list(root.children): + for child in list(filesystem_node.children): + child.parent = None + + +def explorer_tree() -> Tree: + """A filesystem root holding a folder that holds a file, as the explorer lists them.""" + root = TreeNode("Root", node_type=NodeType.ROOT) + filesystem = FileSystemNode( + str(ROOT), + node_type=NodeType.DIRECTORY, + filepath=ROOT, + parent=root, + ) + music = FileSystemNode( + MUSIC.name, + node_type=NodeType.DIRECTORY, + filepath=MUSIC, + parent=filesystem, + ) + FileSystemNode( + "song.wav", + node_type=NodeType.FILE, + filepath=MUSIC / "song.wav", + parent=music, + ) + return Tree(root=root) + + +@pytest.fixture +def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +def build_panel(tree: Tree) -> GUIExplorerPanel: + """Builds an explorer panel holding a tree, which is all folding its rows away reads.""" + panel = GUIExplorerPanel.__new__(GUIExplorerPanel) + panel.tag = PANEL_TAG + panel.tree = tree + panel._expanded_rows = set() + panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] + return panel + + +class TestCollapseAll: + def test_the_rows_fold_while_the_model_still_states_them( + self, + folded: List[Tuple[str, bool]], + ) -> None: + """A folder is reached through the model, so the fold runs before its children are dropped.""" + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + music_tag = panel._generate_node_tag(music) + + panel._on_collapse_all_clicked() + + assert music_tag in {tag for tag, _ in folded} + assert all(not expanded for _, expanded in folded) + + def test_the_folders_the_model_held_are_dropped_afterwards( + self, + folded: List[Tuple[str, bool]], + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + + panel._on_collapse_all_clicked() + + assert panel._explorer_logic.cleared == [(str(ROOT), MUSIC.name, "song.wav")] + root = tree.get_root() + assert root is not None + assert [str(node.name) for node in root.descendants] == [str(ROOT)] diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index ddf2d1f7..7807c7b6 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -118,7 +118,7 @@ def expanded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: """Records the tag and open state of every row the expansion items reach.""" calls: List[Tuple[str, bool]] = [] monkeypatch.setattr( - shared_browser_module, + tree_module, "dpg_set_value", lambda tag, value: calls.append((tag, value)), ) From 14ad477f38b802e09f4d51fe1c95d5e70b3cec4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:06:40 +0200 Subject: [PATCH 33/45] Added: Auto-expand favorites preference per kind of favorite --- src/sampletones_application/application.py | 28 ++ .../categories/elements/global_.py | 3 + .../categories/elements/settings.py | 2 + .../config/managers/application.py | 14 + .../config/managers/session.py | 14 + .../config/session/application/browser.py | 12 + .../config/session/application/config.py | 5 + .../coordinators/tabs/reconstruction.py | 4 + .../coordinators/tabs/sequencer.py | 4 + .../logic/shared/tree.py | 8 + src/sampletones_application/shell.py | 6 + src/sampletones_application/tags/general.py | 12 + .../ui/elements/tree/protocol.py | 6 + .../ui/elements/tree/tree.py | 58 ++- src/sampletones_application/ui/menu.py | 36 ++ .../utils/gui/shortcuts/ids.py | 8 + .../view_model/shared/menu.py | 2 + .../keybindings/default.yaml | 2 + src/sampletones_config/keybindings/macos.yaml | 2 + src/sampletones_config/lang/en.yaml | 5 + .../structures/tree/visibility.py | 8 + tests/suite/browser.py | 34 +- .../ui/elements/tree/test_expansion_memory.py | 31 +- .../ui/elements/tree/test_favorites_filter.py | 342 ++++++++++++++++-- .../sampletones_application/ui/test_menu.py | 73 ++++ .../view_model/shared/test_menu.py | 4 + .../structures/tree/test_visibility.py | 22 ++ 27 files changed, 686 insertions(+), 59 deletions(-) create mode 100644 src/sampletones_application/config/session/application/browser.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6a266a36..227a05e3 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -619,6 +619,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: display_settings=self._display_coordinator.open, keyboard_settings=self._keybindings_coordinator.open, toggle_advanced_settings=self._toggle_advanced_settings, + toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions, + toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories, toggle_fullscreen=self._shell.toggle_fullscreen, about=self._open_about_dialog, next_tab=self._next_tab, @@ -715,6 +717,8 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: channels=self._sequencer_tab.channels, fullscreen=self.session_manager.fullscreen, advanced_settings=self.session_manager.advanced_settings, + auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) def _is_sequencer_tab_current(self) -> bool: @@ -754,6 +758,8 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: channels=self._sequencer_tab.channels, fullscreen=self.session_manager.fullscreen, advanced_settings=self.session_manager.advanced_settings, + auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) def _on_history_changed(self) -> None: @@ -806,6 +812,28 @@ def _toggle_advanced_settings( self._main_tab.toggle_advanced_settings() self._update_menu() + def _toggle_auto_expand_favorite_reconstructions(self) -> None: + self.session_manager.set_auto_expand_favorite_reconstructions( + not self.session_manager.auto_expand_favorite_reconstructions + ) + self._redraw_browsers() + + def _toggle_auto_expand_favorite_directories(self) -> None: + self.session_manager.set_auto_expand_favorite_directories( + not self.session_manager.auto_expand_favorite_directories + ) + self._redraw_browsers() + + def _redraw_browsers(self) -> None: + """Marks the choice in the menu and draws both browsers again from the model each holds. + + What the favorites mode opens is decided as a rebuild collects the rows, so a change of the + preference is answered by collecting them again rather than by reaching into the tree. + """ + self._update_menu() + self._reconstructions_tab.redraw_browser() + self._sequencer_tab.redraw_browser() + def _reconstruct_file_dialog(self) -> None: if self._is_operation_active(): logger.warning("A conversion or library generation is already in progress; cannot start a new one") diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index c5c3f308..c032172e 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -113,6 +113,9 @@ class MenuElements(AbstractElement): GROUP_VIEW = "group_view" ITEM_VIEW_SHOW_ADVANCED_SETTINGS = "item_view_show_advanced_settings" ITEM_VIEW_FULLSCREEN = "item_view_fullscreen" + GROUP_VIEW_AUTO_EXPAND_FAVORITES = "group_view_auto_expand_favorites" + ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "item_view_auto_expand_favorite_reconstructions" + ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = "item_view_auto_expand_favorite_directories" ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings" ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings" GROUP_HELP = "group_help" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 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/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/session.py b/src/sampletones_application/config/managers/session.py index a6cb8852..5693f309 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -62,6 +62,12 @@ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() + def set_auto_expand_favorite_reconstructions(self, value: bool) -> None: + self._config_manager.set_auto_expand_favorite_reconstructions(value) + + def set_auto_expand_favorite_directories(self, value: bool) -> None: + self._config_manager.set_auto_expand_favorite_directories(value) + def set_follow_mode(self, value: FollowMode) -> None: self._config_manager.set_follow_mode(value) @@ -232,6 +238,14 @@ def advanced_settings(self) -> bool: def autoplay(self) -> bool: return self._config_manager.autoplay + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._config_manager.auto_expand_favorite_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._config_manager.auto_expand_favorite_directories + @property def follow_mode(self) -> FollowMode: return self._config_manager.follow_mode diff --git a/src/sampletones_application/config/session/application/browser.py b/src/sampletones_application/config/session/application/browser.py new file mode 100644 index 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/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 4ad395a7..3907c008 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -557,6 +557,10 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def redraw_browser(self) -> None: + """Draws the browser again from the model it holds, which a change of filter asks for.""" + self._browser_panel.redraw_tree() + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index af1ba993..26b1255a 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -956,6 +956,10 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def redraw_browser(self) -> None: + """Draws the browser again from the model it holds, which a change of filter asks for.""" + self._sequencer_browser_panel.redraw_tree() + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._sequencer_browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 204c7fe9..c42332c9 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -176,3 +176,11 @@ def _execute_search_update(self) -> None: @property def autoplay_enabled(self) -> bool: return self._session_manager.autoplay + + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._session_manager.auto_expand_favorite_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._session_manager.auto_expand_favorite_directories diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 46bc618c..e3e21f06 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -103,6 +103,8 @@ class ShortcutBindings: display_settings: Callback keyboard_settings: Callback toggle_advanced_settings: Callback + toggle_auto_expand_favorite_reconstructions: Callback + toggle_auto_expand_favorite_directories: Callback toggle_fullscreen: Callback about: Callback next_tab: Callback @@ -246,6 +248,10 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.DISPLAY_SETTINGS: bindings.display_settings, ShortcutId.KEYBOARD_SETTINGS: bindings.keyboard_settings, ShortcutId.TOGGLE_ADVANCED_SETTINGS: bindings.toggle_advanced_settings, + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: ( + bindings.toggle_auto_expand_favorite_reconstructions + ), + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories, ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen, ShortcutId.ABOUT_DIALOG: bindings.about, ShortcutId.NEXT_TAB: bindings.next_tab, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 88c526d7..99533da8 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -560,6 +560,18 @@ Widget.MENU, "item_view_fullscreen", ) +TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_view_auto_expand_favorite_reconstructions", +) +TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_view_auto_expand_favorite_directories", +) TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/protocol.py b/src/sampletones_application/ui/elements/tree/protocol.py index 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/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index be211540..b0f86d44 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -654,16 +654,14 @@ def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: """Whether the row is emitted standing open, which a row leading to a named row is. - A search result and a favorite are both what the reader is looking for, so the way down to - either one opens and the filter's answer reads at a glance. What each criterion names is the - row the reader is pointed at rather than everything that row brings along, so a folder opens - to show what it holds while the rows inside it stand as they are. + A search names the rows whose label matched and shows what each of them gathers, so a folder + it named opens. The favorites mode points the reader at a star and opens the way down to it + alone, which leaves the starred row standing as the reader left it. """ - return any( - visibility.should_expand(node) - for visibility in (self._search_visibility, self._favorites_anchors) - if visibility is not None - ) + if self._search_visibility is not None and self._search_visibility.should_expand(node): + return True + + return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) def _create_status_bar_message_function( self, @@ -965,7 +963,7 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: def _resolve_favorites( self, ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: - """The rows the favorites mode keeps, and the rows it points the reader at. + """The rows the favorites mode keeps, and the rows it opens the way down to. The two answer different questions — which rows the browser draws, and which of them stand open — so each is resolved from a set of its own, the second being a part of the first. One @@ -978,9 +976,36 @@ def _resolve_favorites( reached = self.tree.find_nodes(TreeNode, self._is_node_starred) return ( resolve_visibility(reached), - resolve_visibility([node for node in reached if self._is_node_anchored(node)]), + resolve_visibility(self._auto_expanded_anchors(reached)), ) + def _auto_expanded_anchors( + self, + reached: Sequence[TreeNode], + ) -> List[TreeNode]: + """The anchors whose star the reader asked the browser to open the way down to. + + Which stars are followed is a preference stated per kind and read once per pass: a starred + reconstruction answers for itself, and a starred folder answers for itself together with the + rows it brings in where no row stands for the folder. + """ + reconstructions = self._logic.auto_expand_favorite_reconstructions + directories = self._logic.auto_expand_favorite_directories + return [ + node + for node in reached + if self._is_node_anchored(node) + and (reconstructions if self._is_starred_reconstruction(node) else directories) + ] + + def _is_starred_reconstruction(self, node: TreeNode) -> bool: + """Whether the star the mode reaches this row through sits on a reconstruction. + + A row the reader starred answers by its own kind. A row a starred folder brings in answers by + that folder, a folder being the only thing that holds another row. + """ + return node.node_type == NodeType.FILE and self._logic.is_node_favorite(node) + def _is_node_starred(self, node: TreeNode) -> bool: """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. @@ -993,12 +1018,13 @@ def _is_node_starred(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) def _is_node_anchored(self, node: TreeNode) -> bool: - """Whether the mode points the reader at the row, which is what opens the way down to it. + """Whether the mode points the reader at the row, which is what the way down opens to. - A star sits on a row the reader marked, so the way to that row opens wherever it sits — - inside another starred folder among the rest. A row a starred folder merely holds is where - the star first reaches only while no row above it is reached, which is how the sample branch - answers: its headings carry no path, so the variants are where the star arrives. + A star sits on a row the reader marked, so that row is pointed at wherever it sits — inside + another starred folder among the rest, which is what lets an explicit favorite open the folder + above it. A row a starred folder merely holds is where the star first reaches only while no + row above it is reached, which is how the sample branch answers: its headings carry no path, + so the variants are where the star arrives. Asked of the rows the star reaches, so a row it declines stands under a row it named, and the reader is pointed at the folder rather than at everything inside it. diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 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/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/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_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 e2643b08..8c1dc25e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -222,6 +222,9 @@ global.menu.label.item_playback_audio_settings: "Audio settings..." global.menu.label.group_view: "View" global.menu.label.item_view_show_advanced_settings: "Show advanced settings" global.menu.label.item_view_fullscreen: "Fullscreen" +global.menu.label.group_view_auto_expand_favorites: "Auto-expand favorites" +global.menu.label.item_view_auto_expand_favorite_reconstructions: "Reconstructions" +global.menu.label.item_view_auto_expand_favorite_directories: "Directories" global.menu.label.item_view_display_settings: "Display settings..." global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..." global.menu.label.group_help: "Help" @@ -788,6 +791,8 @@ settings.keybindings.label.audio_settings: "Audio settings" settings.keybindings.label.display_settings: "Display settings" settings.keybindings.label.keyboard_settings: "Keyboard shortcuts" settings.keybindings.label.toggle_advanced_settings: "Advanced settings" +settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions" +settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories" settings.keybindings.label.toggle_fullscreen: "Fullscreen" settings.keybindings.label.about_dialog: "About" settings.keybindings.label.next_tab: "Next tab" diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 7c9fbbc1..815de44c 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -28,6 +28,14 @@ def should_expand(self, node: TreeNode) -> bool: """Whether the row stands open, which a named row does and so does every row above one.""" return node in self.matches or node in self.ancestors + def leads_to(self, node: TreeNode) -> bool: + """Whether the row stands on the way down to a named row, being none of the named rows itself. + + Answers the reader who is pointed at what was named rather than at what it holds, so opening + by this leaves a named row standing as it was while the rows above it show where it sits. + """ + return node in self.ancestors + def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility: """The visibility a set of named rows resolves to, read once per pass over the tree. diff --git a/tests/suite/browser.py b/tests/suite/browser.py index cbb9cff6..1d57bbef 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -201,8 +201,16 @@ def get_reconstructions_directory(self) -> Path: class FakeTreeLogic: """Answers the favorite questions a browser asks of its logic while it collects its rows.""" - def __init__(self, favorites: Set[Path]) -> None: + def __init__( + self, + favorites: Set[Path], + *, + auto_expand_reconstructions: bool, + auto_expand_directories: bool, + ) -> None: self._favorites = favorites + self._auto_expand_reconstructions = auto_expand_reconstructions + self._auto_expand_directories = auto_expand_directories def is_node_favorite(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and node.filepath in self._favorites @@ -210,6 +218,14 @@ def is_node_favorite(self, node: TreeNode) -> bool: def has_favorite_ancestor(self, node: FileSystemNode) -> bool: return any(directory in self._favorites for directory in node.filepath.parents) + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._auto_expand_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._auto_expand_directories + @dataclass(frozen=True) class BrowserCorpus: @@ -274,18 +290,26 @@ def build_browser_panel( favorites_only: bool, query: str = "", panel_tag: str = PANEL_TAG, + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, - and the control stands where a browser that has yet to build one leaves it. + and the control stands where a browser that has yet to build one leaves it. The pair of + auto-expand answers states which stars the mode opens the way down to, as the reader's preference + does. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag panel._expanded_rows = set() panel.tree_tag = TREE_TAG panel.tree = corpus.tree - panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] + panel._logic = FakeTreeLogic( # type: ignore[assignment] + favorites, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, + ) panel._language_manager = FakeLanguageManager() panel._colors = TREE_COLORS _state_detail_labels(panel) @@ -408,6 +432,8 @@ def view( *, favorites_only: bool, query: str = "", + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, ) -> str: """The view a browser showing the corpus under this filter leaves on screen.""" return render_view( @@ -416,5 +442,7 @@ def view( favorites, favorites_only=favorites_only, query=query, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, ) ) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index d18fa5ab..c0fe1658 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -18,28 +18,28 @@ ) STARRED_CONFIGURATION: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PT + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT > takes - alt - beat - v By sample - v beat + > By sample + > beat - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) SUBFOLDER_THE_READER_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PT + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT v takes - alt - beat - v By sample - v beat + > By sample + > beat - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) @@ -157,7 +157,12 @@ def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) render_view(panel) set_filter(panel, favorites_only=False) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index c995c1ab..66c0a654 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -6,6 +6,8 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.structures.tree import TreeNode from tests.suite.browser import ( + CLOSED_MARKER, + OPEN_MARKER, PANEL_TAG, TREE_COLORS, WHOLE_TREE, @@ -19,7 +21,23 @@ CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" + +def rows_of(rendered: str) -> List[str]: + """The rows a view holds, read apart from the state each of them stands in.""" + return [line.replace(OPEN_MARKER, CLOSED_MARKER, 1) for line in rendered.splitlines()] + + STARRED_RECONSTRUCTION: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + - beat + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_RECONSTRUCTION_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -30,6 +48,14 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_LONE_AUDIO: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - solo + > By sample + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + """) +STARRED_LONE_AUDIO_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v CQT·γ0·PTN @@ -38,6 +64,18 @@ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN """) STARRED_IN_SUBFOLDER: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + > drums + - kick + > By sample + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_IN_SUBFOLDER_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -50,10 +88,23 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_CONFIGURATION: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT + > takes + - alt + - beat + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +STARRED_CONFIGURATION_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PT + > PT > takes - alt - beat @@ -63,26 +114,56 @@ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) STARRED_PLAIN_FOLDER: Final[str] = as_view(""" + > By configuration + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_PLAIN_FOLDER_OPENED: Final[str] = as_view(""" v By configuration - v archive + > archive > 48 kHz·50 Hz·LogFFT·γ1·TN - song """) -STARRED_FOLDER_AND_WHAT_IT_HOLDS: Final[str] = as_view(""" +STARRED_FOLDER_IN_STARRED_FOLDER_OPENED: Final[str] = as_view(""" v By configuration v archive - v 48 kHz·50 Hz·LogFFT·γ1·TN + > 48 kHz·50 Hz·LogFFT·γ1·TN - song """) STARRED_STRAY: Final[str] = as_view(""" + > By configuration + - stray + """) +STARRED_STRAY_OPENED: Final[str] = as_view(""" v By configuration - stray """) STARRED_OF_TWO_ALIKE: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_OF_TWO_ALIKE_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PTN·#aaaaaaa + > PTN·#aaaaaaa > drums - kick - snare @@ -99,17 +180,42 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_FOLDED_CONFIGURATION: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > By sample + - sweep·8 kHz·60 Hz·CQT·γ2·P + """) +STARRED_FOLDED_CONFIGURATION_OPENED: Final[str] = as_view(""" v By configuration - v 8 kHz·60 Hz·CQT·γ2·P + > 8 kHz·60 Hz·CQT·γ2·P - sweep v By sample - sweep·8 kHz·60 Hz·CQT·γ2·P """) STARRED_CONFIGURATION_B: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_CONFIGURATION_B_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PTN·#bbbbbbb + > PTN·#bbbbbbb > drums - kick - beat @@ -123,7 +229,7 @@ v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb """) -STARRED_FOLDER_HOLDING_A_STAR: Final[str] = as_view(""" +STARRED_FOLDER_HOLDING_A_STAR_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -133,11 +239,29 @@ - beat - melody v By sample - v beat + > beat - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb v drums v kick - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb """) @@ -151,10 +275,10 @@ - beat [hidden] - melody v By sample - v beat [hidden] + > beat [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] - v drums [hidden] - v kick [hidden] + > drums [hidden] + > kick [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb @@ -166,7 +290,7 @@ v PTN·#aaaaaaa - beat [hidden] v By sample - v beat [hidden] + > beat [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] """) QUERY_ALONE: Final[str] = as_view(""" @@ -218,7 +342,11 @@ class TestDrawnRows: - """Which rows the mode draws: what the star reaches, and the rows leading down to it.""" + """Which rows the mode draws: what the star reaches, and the rows leading down to it. + + What is drawn is the star's to state and nothing else, so every row stands folded here — which is + what a browser opening with the preference off comes back as. + """ def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION @@ -253,36 +381,196 @@ def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None: def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None: assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE + def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: BrowserCorpus) -> None: + """Opening the way down to a star is a separate answer, so it moves no row in or out.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert rows_of( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_reconstructions=True, + auto_expand_directories=True, + ) + ) == rows_of(view(corpus, favorites, favorites_only=True)) + class TestOpenRows: - """Which rows stand open: the way down to a star, and a starred folder showing what it holds.""" + """Which rows stand open: the way down to a star the reader asked the browser to follow.""" + + def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION - def test_a_starred_folder_opens_and_a_subfolder_holding_no_star_stays_closed( + def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_RECONSTRUCTION_OPENED + ) + + def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( self, corpus: BrowserCorpus, ) -> None: - assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION + assert ( + view( + corpus, + {corpus.paths["D/solo"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_LONE_AUDIO_OPENED + ) - def test_a_starred_folder_opens_one_level(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE + def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/drums/kick"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_IN_SUBFOLDER_OPENED + ) - def test_a_star_inside_a_starred_folder_opens_the_way_down_to_itself(self, corpus: BrowserCorpus) -> None: - favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} - assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_HOLDING_A_STAR + def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_opens( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["stray"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_STRAY_OPENED + ) + + def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["A"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_OF_TWO_ALIKE + ) + + def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_followed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_RECONSTRUCTION + ) + + def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["C"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_CONFIGURATION_OPENED + ) + + def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["archive"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_PLAIN_FOLDER_OPENED + ) - def test_a_starred_folder_inside_a_starred_folder_opens(self, corpus: BrowserCorpus) -> None: + def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it( + self, + corpus: BrowserCorpus, + ) -> None: + """The folder above stands on the way to the star below, which is what opens it.""" favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} - assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_AND_WHAT_IT_HOLDS + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED + ) - def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["E"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDED_CONFIGURATION_OPENED + ) def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( self, corpus: BrowserCorpus, ) -> None: """No row stands for the folder there, so the variants are where the star arrives.""" - assert view(corpus, {corpus.paths["B"]}, favorites_only=True) == STARRED_CONFIGURATION_B + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_CONFIGURATION_B_OPENED + ) + + def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserCorpus) -> None: + """A reconstruction answers by its own preference, so following those opens the folder above.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_FOLDER_HOLDING_A_STAR_OPENED + ) + + def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: BrowserCorpus) -> None: + """Following folders alone opens the way to the folder, leaving the star inside it folded away.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER + ) class TestSearchInsideTheMode: diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index e63f59d3..a040aac8 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -11,6 +11,8 @@ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, ) from sampletones_application.ui import menu as menu_module from sampletones_application.ui.menu import MenuBar @@ -119,6 +121,8 @@ def _state( *, reconstruction_loaded: bool = False, follow_mode: FollowMode = FollowMode.OFF, + auto_expand_favorite_reconstructions: bool = False, + auto_expand_favorite_directories: bool = False, ) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, @@ -143,6 +147,8 @@ def _state( channels=SequencerChannelsViewModel(muted=muted), fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=auto_expand_favorite_directories, ) @@ -420,6 +426,73 @@ def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: return instance +class TestAutoExpandFavoritesMenu: + """Each kind of favorite is answered on its own, so the submenu offers one item per kind.""" + + def test_both_kinds_are_offered( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert shortcuts.labels == ["Reconstructions", "Directories"] + + def test_each_kind_carries_its_own_action( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert [item["shortcut_id"] for item in shortcuts.items] == [ + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES, + ] + + def test_each_kind_is_offered_as_a_check( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert all(item["check"] for item in shortcuts.items) + + def test_the_submenu_is_named_by_what_it_governs( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert [entry["label"] for entry in framework.menus] == ["Auto-expand favorites"] + + +class TestAutoExpandFavoritesUpdate: + @pytest.mark.parametrize("reconstructions", [True, False]) + @pytest.mark.parametrize("directories", [True, False]) + def test_each_check_reads_the_preference_in_place( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + reconstructions: bool, + directories: bool, + ) -> None: + menu_bar._update_auto_expand_favorites( + _state( + frozenset(), + auto_expand_favorite_reconstructions=reconstructions, + auto_expand_favorite_directories=directories, + ) + ) + + assert framework.values == { + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: reconstructions, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES: directories, + } + + class TestEditActionsSection: """The Edit menu carries the actions of the grid holding the cursor, and names them itself while no grid holds one.""" diff --git a/tests/unit/sampletones_application/view_model/shared/test_menu.py b/tests/unit/sampletones_application/view_model/shared/test_menu.py index 0b0dd2e3..f85be234 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -83,6 +83,8 @@ def test_enablement_follows_project_and_history_state( channels=EVERY_CHANNEL_AUDIBLE, fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=False, + auto_expand_favorite_directories=False, ) assert view_model.undo_enabled is case.undo_enabled @@ -120,6 +122,8 @@ def test_save_flag_is_carried_verbatim( channels=EVERY_CHANNEL_AUDIBLE, fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=False, + auto_expand_favorite_directories=False, ) assert view_model.reconstruction_saveable is reconstruction_saveable diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py index abd27830..4f3ec4d6 100644 --- a/tests/unit/sampletones_core/structures/tree/test_visibility.py +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -107,6 +107,28 @@ def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) assert not any(visibility.should_expand(node) for node in nodes.values()) +class TestTheWayDownToARow: + """What ``leads_to`` answers: the rows above a named row, and none of the named rows.""" + + def test_every_row_above_a_match_leads_to_it(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + names = {name for name, node in nodes.items() if visibility.leads_to(node)} + assert names == {"root", "child_b"} + + def test_a_match_leads_to_nothing_of_its_own(self, nodes: Dict[str, TreeNode]) -> None: + """The reader is pointed at the match, so opening by this leaves it standing as it was.""" + visibility = visibility_of(nodes, ["child_a"]) + assert not visibility.leads_to(nodes["child_a"]) + + def test_a_match_above_another_leads_to_the_one_below_it(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) + assert visibility.leads_to(nodes["child_a"]) + + def test_nothing_named_leads_nowhere(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, []) + assert not any(visibility.leads_to(node) for node in nodes.values()) + + class TestResolvedSets: def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) From 3fc640427459ecb2b7da2ff56abd6d73e0e98592 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:19:05 +0200 Subject: [PATCH 34/45] Added: browser shape surviving between application runs --- src/sampletones_application/application.py | 11 +++++ .../config/managers/session.py | 6 +++ .../config/managers/state.py | 9 +++- .../config/session/state/state.py | 6 ++- .../coordinators/tabs/instructions.py | 8 ++++ .../coordinators/tabs/reconstruction.py | 8 ++++ .../coordinators/tabs/sequencer.py | 8 ++++ .../ui/elements/tree/browser.py | 6 ++- .../ui/elements/tree/tree.py | 42 +++++++++++++---- .../ui/panels/instruction/library.py | 5 +- .../ui/panels/reconstruction/browser.py | 4 +- .../ui/panels/sequencer/browser.py | 4 ++ .../ui/panels/shared/browser.py | 7 ++- tests/suite/browser.py | 5 +- .../config/managers/test_state.py | 30 ++++++++++++ .../ui/elements/tree/test_expansion_memory.py | 47 +++++++++++++++++++ 16 files changed, 188 insertions(+), 18 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 227a05e3..82b0666c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1364,10 +1364,21 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]: def _persist_application_state(self) -> None: self.session_manager.set_current_audio_device(self.audio_device_manager) self._viewport_manager.save_window_state() + self._save_browser_shapes() current_tab = self._shell.get_current_tab() self.session_manager.set_current_tab(current_tab) self.session_manager.save_config() + def _save_browser_shapes(self) -> None: + """Asks every tab holding a tree to write down which of its rows stand open. + + The shape belongs to the browser showing it, and it is read the once here rather than followed + row by row, a pass over the rows running on the tree worker. + """ + self._reconstructions_tab.save_browser_shape() + self._sequencer_tab.save_browser_shape() + self._instructions_tab.save_browser_shape() + def _build_edit_actions(self) -> bool: """States the actions of the grid holding the cursor into the Edit menu being built.""" return self._edit_router.build_menu_actions() diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 5693f309..980ea540 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -59,6 +59,12 @@ def is_favorites_filter_active(self, panel_tag: str) -> bool: def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: self._state_manager.set_favorites_filter_active(panel_tag, active) + def expanded_rows(self, panel_tag: str) -> Set[str]: + return self._state_manager.expanded_rows(panel_tag) + + def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: + self._state_manager.set_expanded_rows(panel_tag, rows) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index eb90f83d..d6972243 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Set from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.session.state.state import ApplicationState @@ -100,6 +100,13 @@ def is_favorites_filter_active(self, panel_tag: str) -> bool: def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: self.state.favorites_filters[panel_tag] = active + def expanded_rows(self, panel_tag: str) -> Set[str]: + return set(self.state.expanded_rows.get(panel_tag, ())) + + def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: + """Writes the rows a browser stands open, in a settled order so the file reads the same twice.""" + self.state.expanded_rows[panel_tag] = sorted(rows) + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 818fbb38..7747b9f5 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, List from pydantic import BaseModel, Field @@ -24,6 +24,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="Whether each browser shows its favorites alone, keyed by the panel's tag.", ) + expanded_rows: Dict[str, List[str]] = Field( + default_factory=dict, + description="The rows each browser stands open, keyed by the panel's tag.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index e2bf4b76..fb6d0b4f 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -136,6 +136,7 @@ def __init__( self._library_tree_logic, scheduling=layout.scheduling, initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_LIBRARY_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_INSTRUCTIONS_LIBRARY_PANEL), language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, @@ -485,6 +486,13 @@ def load_library_safely(self, filepath: Path) -> None: except (SampleToNESError, OSError) as exception: logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}") + def save_browser_shape(self) -> None: + """Writes down the rows the catalogue stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._library_panel.tag, + self._library_panel.expanded_rows, + ) + def is_library_generating(self) -> bool: return self._library_logic.is_library_generating() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 3907c008..9846cc55 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -166,6 +166,7 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed @@ -557,6 +558,13 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def save_browser_shape(self) -> None: + """Writes down the rows the browser stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._browser_panel.tag, + self._browser_panel.expanded_rows, + ) + def redraw_browser(self) -> None: """Draws the browser again from the model it holds, which a change of filter asks for.""" self._browser_panel.redraw_tree() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 26b1255a..faa630e0 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -208,6 +208,7 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL), ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) @@ -956,6 +957,13 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def save_browser_shape(self) -> None: + """Writes down the rows the browser stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._sequencer_browser_panel.tag, + self._sequencer_browser_panel.expanded_rows, + ) + def redraw_browser(self) -> None: """Draws the browser again from the model it holds, which a change of filter asks for.""" self._sequencer_browser_panel.redraw_tree() diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 9d2410f6..eae1cdab 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict +from typing import AbstractSet, Dict import dearpygui.dearpygui as dpg @@ -20,7 +20,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags -from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS, GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent @@ -55,6 +55,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, ) -> None: self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] self._msg_collapse_all = language_manager["global.status.message.collapse_all"] @@ -69,6 +70,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + initial_expanded_rows=initial_expanded_rows, ) self._enable_horizontal_collapse( diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b0f86d44..412b6da1 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,20 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union +from typing import ( + AbstractSet, + Any, + Callable, + Dict, + Final, + FrozenSet, + List, + Optional, + Sequence, + Set, + Tuple, + Union, +) import dearpygui.dearpygui as dpg @@ -97,6 +110,8 @@ ) from sampletones_shared.utils.system.paths import open_path_in_explorer +NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset() + class GUITreePanel(GUIPanel, ABC): _NAME_FONT: Font = Font.REGULAR_SMALL @@ -118,6 +133,7 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, ) -> None: self._language_manager = language_manager self._logic = tree_logic @@ -127,7 +143,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._expanded_rows: Set[str] = set() + self._expanded_rows: Set[str] = set(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -234,16 +250,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: return self._pending_specs def _forget_rows_the_model_dropped(self) -> None: - """Holds the memory of open rows to the rows a pass over the whole tree found. + """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A pass showing everything states which rows exist, so a row it left out belongs to a folder - the disk no longer holds and its place in the memory goes with it. A pass narrowed to the - favorites speaks for those rows alone, and leaves the memory of the rest as it stands. + A row the memory holds that the model no longer states belongs to a folder the disk has lost, + so its place in the memory goes with it. Reading the model rather than the rows a pass drew is + what lets a browser opening in the favorites mode — or opening on a session written before the + reconstructions directory moved — drop what is gone. """ - if not self._REMEMBERS_EXPANSION or self._filter.favorites_only: + if not self._REMEMBERS_EXPANSION: return - self._expanded_rows &= {spec.node_tag for spec in self._pending_specs} + root = self.tree.get_root() + if root is None: + return + + self._expanded_rows &= {self._generate_node_tag(node) for node in root.descendants if node.children} def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -423,6 +444,11 @@ def _stands_open( self._set_row_expanded(node_tag, stands_open and bool(node.children)) return stands_open + @property + def expanded_rows(self) -> Set[str]: + """The rows the browser stands open, which is the shape a session writes down.""" + return set(self._expanded_rows) + def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: """Holds whether a row stands open, which is what a later pass brings it back by.""" if expanded: diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index e32d0f75..4196ef4c 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Optional, Protocol, Tuple +from typing import AbstractSet, Any, Callable, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -83,6 +83,7 @@ class GUIInstructionsLibraryPanel(GUIFileBrowserPanel): _NAME_FONT: Font = Font.REGULAR_SMALL _MONOSPACE_CONFIG_NODES: bool = True _REBUILD_ON_CREATE: bool = False + _REMEMBERS_EXPANSION: bool = True _tags: FileBrowserTags = FileBrowserTags( panel=TAG_INSTRUCTIONS_LIBRARY_PANEL, tree=TAG_INSTRUCTIONS_LIBRARY_TREE, @@ -99,6 +100,7 @@ def __init__( *, scheduling: SchedulingBehavior, initial_collapsed: bool, + initial_expanded_rows: AbstractSet[str], language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, @@ -125,6 +127,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_expanded_rows=initial_expanded_rows, ) @property diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 57d6d337..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 Optional +from typing import AbstractSet, Optional import dearpygui.dearpygui as dpg @@ -50,6 +50,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager @@ -62,6 +63,7 @@ def __init__( colors=colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, + initial_expanded_rows=initial_expanded_rows, ) self.on_load_reconstruction: Optional[PathCallback] = None diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 1053e17a..9bfe14e3 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,3 +1,5 @@ +from typing import AbstractSet + from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -43,6 +45,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager @@ -55,6 +58,7 @@ def __init__( colors=colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, + initial_expanded_rows=initial_expanded_rows, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index ff6acb73..5e69b11d 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Any, Optional, Tuple +from typing import AbstractSet, Any, Optional, Tuple import dearpygui.dearpygui as dpg @@ -39,7 +39,8 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): Reconstructions carry favorites, so this browser offers the control showing them alone and opens in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild - — a refresh, a change of mode — brings the rows back standing as they were left. + — a refresh, a change of mode — brings the rows back standing as they were left, and so does the + next run of the application. """ _MONOSPACE_CONFIG_NODES: bool = True @@ -57,6 +58,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager self.on_refresh_tree: Optional[VoidCallback] = None @@ -70,6 +72,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_expanded_rows=initial_expanded_rows, ) self._restore_favorites_only(initial_favorites_only) diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 1d57bbef..e82be92a 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path from textwrap import dedent -from typing import Dict, Final, List, Mapping, Sequence, Set, Tuple +from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors @@ -292,6 +292,7 @@ def build_browser_panel( panel_tag: str = PANEL_TAG, auto_expand_reconstructions: bool = False, auto_expand_directories: bool = False, + expanded_rows: Optional[Set[str]] = None, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. @@ -302,7 +303,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._expanded_rows = set() + panel._expanded_rows = set() if expanded_rows is None else set(expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index e583a25c..43ee4955 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -120,6 +120,24 @@ def test_the_filter_and_the_collapse_of_one_panel_stand_apart(self, manager: App assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + def test_a_browser_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None: + assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == set() + + def test_a_browser_reads_the_rows_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"}) + assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} + + def test_each_browser_keeps_the_rows_of_its_own_panel(self, manager: ApplicationStateManager) -> None: + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a"}) + + assert manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + + def test_the_rows_are_written_in_a_settled_order(self, manager: ApplicationStateManager) -> None: + """The file reads the same twice, whichever order the browser answered its rows in.""" + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.b", "row.a"}) + + assert manager.state.expanded_rows[TAG_SEQUENCER_BROWSER_PANEL] == ["row.a", "row.b"] + class TestApplicationStateManagerCurrentPaths: def test_set_current_reconstruction_updates_property( @@ -247,6 +265,18 @@ def test_save_and_reload_preserves_each_browser_filter(self, tmp_path: Path) -> reloaded = ApplicationStateManager(path) assert reloaded.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_save_and_reload_preserves_the_rows_each_browser_stands_open(self, tmp_path: Path) -> None: + """The shape the reader unfolded returns on the next launch, for that browser alone.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"}) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} + assert reloaded.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index c0fe1658..91665372 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -208,6 +208,53 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE assert panel._expanded_rows == set() + def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None: + """The shape outlives the run it was made in, so a browser is handed it as it is built.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) + + opened = build_browser_panel( + corpus, + set(), + favorites_only=False, + expanded_rows={archive_tag}, + ) + + assert "v archive" in render_view(opened) + + def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( + self, + corpus: BrowserCorpus, + ) -> None: + """The model states which rows exist whatever the mode narrows to, so a lost row is dropped.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) + archive = row_named(corpus, "archive") + set_row_expanded(panel, archive, expanded=True) + render_view(panel) + + archive.parent = None + set_filter(panel, favorites_only=True) + render_view(panel) + + assert panel._expanded_rows == set() + + def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + + assert panel.expanded_rows == {archive_tag} + + def test_the_shape_a_save_reads_is_taken_apart_from_the_browser(self, corpus: BrowserCorpus) -> None: + """The browser keeps writing its own memory, so what a save carries is a reading of it.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + written = panel.expanded_rows + + set_row_expanded(panel, row_named(corpus, "archive"), expanded=False) + + assert written != panel.expanded_rows + def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: BrowserCorpus) -> None: """A row is remembered under the tag of the browser showing it, so neither reaches the other.""" sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser") From eb7b1d3b44b5dd1b65e4ea69ba0193ce66bb28e2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:33:54 +0200 Subject: [PATCH 35/45] Added: explorer folders standing open between application runs --- src/sampletones_application/application.py | 1 + .../config/managers/session.py | 7 + .../config/managers/state.py | 8 + .../config/session/state/state.py | 5 + .../coordinators/tabs/main.py | 5 + .../logic/main/explorer.py | 17 +- .../logic/main/explorer_manager.py | 122 +++++++--- .../ui/panels/main/explorer.py | 26 ++- .../config/managers/test_state.py | 22 ++ .../logic/main/test_explorer_manager.py | 220 ++++++++++++++++++ .../ui/panels/main/test_explorer_controls.py | 97 +++++++- 11 files changed, 481 insertions(+), 49 deletions(-) create mode 100644 tests/unit/sampletones_application/logic/main/test_explorer_manager.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 82b0666c..87ca0cf3 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1375,6 +1375,7 @@ def _save_browser_shapes(self) -> None: The shape belongs to the browser showing it, and it is read the once here rather than followed row by row, a pass over the rows running on the tree worker. """ + self._main_tab.save_browser_shape() self._reconstructions_tab.save_browser_shape() self._sequencer_tab.save_browser_shape() self._instructions_tab.save_browser_shape() diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 980ea540..c5ee6fa1 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -65,6 +65,13 @@ def expanded_rows(self, panel_tag: str) -> Set[str]: def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: self._state_manager.set_expanded_rows(panel_tag, rows) + @property + def expanded_directories(self) -> Set[Path]: + return self._state_manager.expanded_directories + + def set_expanded_directories(self, directories: Set[Path]) -> None: + self._state_manager.set_expanded_directories(directories) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index d6972243..97936b04 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -107,6 +107,14 @@ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: """Writes the rows a browser stands open, in a settled order so the file reads the same twice.""" self.state.expanded_rows[panel_tag] = sorted(rows) + @property + def expanded_directories(self) -> Set[Path]: + return set(self.state.expanded_directories) + + def set_expanded_directories(self, directories: Set[Path]) -> None: + """Writes the folders the explorer stands open, in a settled order for a file read twice.""" + self.state.expanded_directories = sorted(directories) + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 7747b9f5..2524a797 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Dict, List from pydantic import BaseModel, Field @@ -28,6 +29,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="The rows each browser stands open, keyed by the panel's tag.", ) + expanded_directories: List[Path] = Field( + default_factory=list, + description="The folders the Main tab's explorer stands open.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6d665e50..3cc17ada 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -130,6 +130,7 @@ def __init__( self._explorer_logic: ExplorerLogic = ExplorerLogic( config_manager, language_manager=language_manager, + open_directories=session_manager.expanded_directories, ) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, @@ -493,6 +494,10 @@ def refresh_converter_view(self) -> None: def set_input_path(self, path: Path, convert: bool) -> None: self._converter_logic.set_input_path(path, convert=convert) + def save_browser_shape(self) -> None: + """Writes down the folders the explorer stands open, so a later run reads down to them.""" + self._session_manager.set_expanded_directories(self._explorer_logic.open_directories) + def refresh_browser(self) -> None: self._explorer_panel.refresh() diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py index 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 178d8456..5fdaf755 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional +from typing import AbstractSet, List, Optional, Set from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -20,21 +20,36 @@ class ExplorerManager: + """Reads the filesystem into the Main tab's tree, a folder at a time as the reader opens it. + + Two facts are held about a folder: whether its children have been read, and whether its row stands + open. They part company — a folder the reader read and then folded away is loaded and closed — and + the shape a session is left in is the open one, which is what a later run is handed back. + """ + def __init__( self, config_manager: ConfigManager, depth: int = 0, *, language_manager: LanguageManager, + open_directories: AbstractSet[Path], ) -> None: self._language_manager = language_manager self.tree = Tree() self.config_manager = config_manager - self._expanded_directories: Dict[Path, bool] = {} + self._loaded_directories: Set[Path] = set() + self._open_directories: Set[Path] = {path for path in open_directories if path.is_dir()} self.depth = depth def refresh_tree(self) -> None: + """Reads the filesystem afresh, down to every folder the tree has to show a row for. + + A refresh builds the tree from nothing, so each folder it needs is read once into it: reading a + folder twice would replace the rows below it, and with them the folders already read under it. + """ + self._loaded_directories.clear() container_root = TreeNode( name=self._language_manager["global.browser.label.root"], node_type=NodeType.ROOT, @@ -47,12 +62,23 @@ def refresh_tree(self) -> None: parent=container_root, ) - selected_path = self._get_ancestor_of_selected(filesystem_path) - if selected_path is not None: - self._expand_path_to_selected(filesystem_node, selected_path) + for path in self._paths_to_reveal(filesystem_path): + self._expand_path_to(filesystem_node, path) self.tree.set_root(container_root) + def _paths_to_reveal(self, filesystem_path: Path) -> List[Path]: + """The folders a refresh reads down to, among the ones this filesystem holds. + + A folder standing open is read again so it comes back open, and the directories the + application works in are revealed so the reader finds them without walking there. + """ + candidates = (*sorted(self._open_directories), *self.selected_directories) + return [path for path in candidates if self._holds(filesystem_path, path)] + + def _holds(self, filesystem_path: Path, path: Path) -> bool: + return path == filesystem_path or filesystem_path in path.parents + def _create_directory_node( self, directory_path: Path, @@ -66,6 +92,7 @@ def _create_directory_node( ) self._load_directory_children(node) + self._open_directories.add(node.filepath) return node def _load_directory_children( @@ -74,13 +101,10 @@ def _load_directory_children( level: int = 0, ) -> None: directory_path = directory_node.filepath - if not directory_path.is_dir(): + if not directory_path.is_dir() or directory_path in self._loaded_directories: return - self._expanded_directories[directory_path] = level == 0 - for existing_child in list(directory_node.children): - existing_child.parent = None - + self._loaded_directories.add(directory_path) try: entries = sorted( directory_path.iterdir(), @@ -145,7 +169,9 @@ def has_relevant_content(self, directory_path: Path) -> bool: return False def collapse_all(self) -> None: - self._expanded_directories.clear() + """Folds every folder away and drops what was read, so opening one lists it as it stands.""" + self._loaded_directories.clear() + self._open_directories.clear() root = self.tree.get_root() if not root: @@ -157,15 +183,32 @@ def collapse_all(self) -> None: child.parent = None def expand_directory(self, directory_node: FileSystemNode) -> None: + """Reads a folder's children the first time it is opened, which is what fills its row.""" if directory_node.node_type != NodeType.DIRECTORY: return - directory_path = directory_node.filepath - if not self.is_directory_expanded(directory_path): - self._load_directory_children(directory_node) + self._load_directory_children(directory_node) + + def has_loaded_children(self, directory_path: Path) -> bool: + """Whether the folder's children have been read, which is what a row below it needs.""" + return directory_path in self._loaded_directories - def is_directory_expanded(self, directory_path: Path) -> bool: - return self._expanded_directories.get(directory_path, False) + def is_directory_open(self, directory_path: Path) -> bool: + """Whether the folder's row stands open, which a refresh brings it back as.""" + return directory_path in self._open_directories + + def set_directory_open(self, directory_path: Path, is_open: bool) -> None: + """Takes what a click left the folder standing as, which is the shape a session writes down.""" + if is_open: + self._open_directories.add(directory_path) + return + + self._open_directories.discard(directory_path) + + @property + def open_directories(self) -> Set[Path]: + """The folders standing open, which is the shape a later run is handed back.""" + return set(self._open_directories) def _get_filesystems(self) -> List[Path]: system = System.current() @@ -184,23 +227,18 @@ def _get_windows_drives(self) -> List[Path]: return drives - def _get_ancestor_of_selected(self, path: Path) -> Optional[Path]: - for selected_path in self.selected_directories: - try: - selected_path.relative_to(path) - return selected_path - except ValueError: - continue - - return None - - def _expand_path_to_selected( + def _expand_path_to( self, filesystem_node: FileSystemNode, - selected_path: Path, + path: Path, ) -> None: + """Reads the folders down to a path, so a row stands for it and for every folder above it. + + Each folder walked through is opened, that being what shows the row below it. The folder at the + end is read as well where it stands open, so it comes back holding what it held. + """ try: - relative_parts = selected_path.relative_to(filesystem_node.filepath).parts + relative_parts = path.relative_to(filesystem_node.filepath).parts except ValueError: return @@ -210,13 +248,27 @@ def _expand_path_to_selected( for part in relative_parts: current_path = current_path / part self._load_directory_children(current_node) + self._open_directories.add(current_node.filepath) + + child = self._child_at(current_node, current_path) + if child is None: + return + + current_node = child + + if self.is_directory_open(current_node.filepath): + self._load_directory_children(current_node) - for child in current_node.children: - if isinstance(child, FileSystemNode) and child.filepath == current_path: - current_node = child - break - else: - break + def _child_at( + self, + directory_node: FileSystemNode, + path: Path, + ) -> Optional[FileSystemNode]: + for child in directory_node.children: + if isinstance(child, FileSystemNode) and child.filepath == path: + return child + + return None @property def selected_directories(self) -> List[Path]: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 4adbe398..1cbe05c8 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -55,7 +55,11 @@ def collapse_all(self) -> None: ... def expand_directory(self, node: FileSystemNode) -> None: ... - def is_directory_expanded(self, filepath: Path) -> bool: ... + def has_loaded_children(self, filepath: Path) -> bool: ... + + def is_directory_open(self, filepath: Path) -> bool: ... + + def set_directory_open(self, filepath: Path, is_open: bool) -> None: ... def has_relevant_content(self, filepath: Path) -> bool: ... @@ -165,7 +169,7 @@ def _collect_subtree_specs( node_tag: str, ) -> List[NodeSpec]: self._pending_specs = [] - if self._explorer_logic.is_directory_expanded(node.filepath): + if self._explorer_logic.has_loaded_children(node.filepath): for child in node.children: self._build_tree_node( child, @@ -194,16 +198,13 @@ def _build_tree_node( self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) - is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath) self._append_spec( node, node_tag, state.parent, open_on_double_click=True, - should_expand=should_expand, + should_expand=self._should_expand_node(node) or self._explorer_logic.is_directory_open(node.filepath), has_favorite_ancestor=state.has_favorite_ancestor, - is_node_expanded=is_directory_expanded, ) else: self._append_spec( @@ -361,19 +362,24 @@ def _toggle_directory_expansion( node: FileSystemNode, node_tag: str, ) -> None: + """Folds or unfolds a folder, reading its children the first time it is opened. + + The folder is told what it now stands as, which is the shape a refresh and a later run of the + application bring it back in. + """ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: return if not dpg.does_item_exist(node_tag): return - is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath) - state = dpg.get_value(node_tag) - if not is_directory_expanded: + is_open = not dpg.get_value(node_tag) + if not self._explorer_logic.has_loaded_children(node.filepath): self._explorer_logic.expand_directory(node) self._rebuild_node_subtree(node, node_tag) - dpg.set_value(node_tag, not state) + dpg.set_value(node_tag, is_open) + self._explorer_logic.set_directory_open(node.filepath, is_open) def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: dpg.add_separator() diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index 43ee4955..a01acd0a 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -132,6 +132,17 @@ def test_each_browser_keeps_the_rows_of_its_own_panel(self, manager: Application assert manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + def test_an_explorer_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None: + assert manager.expanded_directories == set() + + def test_the_explorer_reads_the_folders_it_was_given( + self, + manager: ApplicationStateManager, + tmp_path: Path, + ) -> None: + manager.set_expanded_directories({tmp_path}) + assert manager.expanded_directories == {tmp_path} + def test_the_rows_are_written_in_a_settled_order(self, manager: ApplicationStateManager) -> None: """The file reads the same twice, whichever order the browser answered its rows in.""" manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.b", "row.a"}) @@ -277,6 +288,17 @@ def test_save_and_reload_preserves_the_rows_each_browser_stands_open(self, tmp_p assert reloaded.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} assert reloaded.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + + def test_save_and_reload_preserves_the_folders_the_explorer_stands_open(self, tmp_path: Path) -> None: + """The folders the reader walked into return on the next launch, read down to as they were.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_expanded_directories({tmp_path / "music", tmp_path / "notes"}) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.expanded_directories == {tmp_path / "music", tmp_path / "notes"} assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) diff --git a/tests/unit/sampletones_application/logic/main/test_explorer_manager.py b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py new file mode 100644 index 00000000..ae9d1820 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py @@ -0,0 +1,220 @@ +from pathlib import Path +from typing import AbstractSet, Dict, List, Optional, Set + +import pytest + +from sampletones_application.logic.main.explorer_manager import ExplorerManager +from sampletones_core.structures.tree import FileSystemNode, Tree +from tests.suite.language import FakeLanguageManager + +MUSIC = "music" +DRUMS = "drums" +NOTES = "notes" + + +class FakeConfigManager: + """Answers the directories the explorer reveals, which a test points at its own corpus.""" + + def __init__(self, directory: Path) -> None: + self._directory = directory + + def get_library_directory(self) -> Path: + return self._directory + + def get_reconstructions_directory(self) -> Path: + return self._directory + + +def write_corpus(root: Path) -> Dict[str, Path]: + """A folder holding a folder, beside a folder of its own, each carrying a file to be listed.""" + paths = { + MUSIC: root / MUSIC, + DRUMS: root / MUSIC / DRUMS, + NOTES: root / NOTES, + } + for path in paths.values(): + path.mkdir(parents=True) + (path / "song.wav").touch() + + return paths + + +def build_manager( + root: Path, + open_directories: AbstractSet[Path], + monkeypatch: pytest.MonkeyPatch, +) -> ExplorerManager: + """An explorer reading one directory as its whole filesystem, so a test states every folder.""" + manager = ExplorerManager( + FakeConfigManager(root), # type: ignore[arg-type] + language_manager=FakeLanguageManager(), + open_directories=open_directories, + ) + monkeypatch.setattr(manager, "_get_filesystems", lambda: [root], raising=False) + return manager + + +def row_at(tree: Tree, path: Path) -> Optional[FileSystemNode]: + rows = tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) + return rows[0] if rows else None + + +def rows_below(tree: Tree, path: Path) -> List[str]: + row = row_at(tree, path) + assert row is not None + return sorted(str(child.name) for child in row.children) + + +class TestTheShapeASessionLeft: + """The folders standing open are handed back at startup, and read down to on the next refresh.""" + + def test_a_remembered_folder_comes_back_open( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(paths[MUSIC]) + assert rows_below(manager.tree, paths[MUSIC]) == [DRUMS, "song.wav"] + + def test_a_folder_nested_in_a_remembered_one_is_read_down_to( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A row stands for every folder above the remembered one, which is what shows it.""" + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(paths[MUSIC]) + assert rows_below(manager.tree, paths[DRUMS]) == ["song.wav"] + + def test_a_folder_no_session_left_open_stays_folded( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + + manager.refresh_tree() + + assert not manager.is_directory_open(paths[NOTES]) + assert rows_below(manager.tree, paths[NOTES]) == [] + + def test_a_folder_the_disk_has_lost_is_dropped_at_startup( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + gone = tmp_path / "gone" + + manager = build_manager(tmp_path, {paths[MUSIC], gone}, monkeypatch) + + assert manager.open_directories == {paths[MUSIC]} + + def test_a_file_standing_where_a_folder_was_is_dropped_at_startup( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + write_corpus(tmp_path) + replaced = tmp_path / "replaced" + replaced.touch() + + manager = build_manager(tmp_path, {replaced}, monkeypatch) + + assert manager.open_directories == set() + + +class TestReadingApartFromStandingOpen: + """A folder read once and then folded away is loaded and closed, and comes back closed.""" + + def test_a_folded_folder_keeps_the_children_it_read( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + manager.refresh_tree() + music = row_at(manager.tree, paths[MUSIC]) + assert music is not None + + manager.expand_directory(music) + manager.set_directory_open(paths[MUSIC], False) + + assert manager.has_loaded_children(paths[MUSIC]) + assert not manager.is_directory_open(paths[MUSIC]) + + def test_a_refresh_brings_a_folded_folder_back_folded( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + manager.refresh_tree() + music = row_at(manager.tree, paths[MUSIC]) + assert music is not None + manager.expand_directory(music) + manager.set_directory_open(paths[MUSIC], True) + manager.set_directory_open(paths[MUSIC], False) + + manager.refresh_tree() + + assert not manager.is_directory_open(paths[MUSIC]) + + def test_the_filesystem_the_tree_opens_at_stands_open( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(tmp_path) + + +class TestCollapseAll: + def test_every_folder_is_folded_and_what_was_read_is_dropped( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch) + manager.refresh_tree() + + manager.collapse_all() + + assert manager.open_directories == set() + assert not manager.has_loaded_children(paths[MUSIC]) + assert rows_below(manager.tree, tmp_path) == [] + + +class TestTheShapeASaveReads: + def test_the_folders_standing_open_are_answered_apart_from_the_explorer( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The explorer keeps writing its own shape, so what a save carries is a reading of it.""" + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + manager.refresh_tree() + written: Set[Path] = manager.open_directories + + manager.set_directory_open(paths[MUSIC], False) + + assert paths[MUSIC] in written + assert paths[MUSIC] not in manager.open_directories diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index 88c435e5..a18c1776 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import List, Tuple +from typing import List, Set, Tuple import pytest from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.panels.main import explorer as explorer_module from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode @@ -13,11 +14,24 @@ class FakeExplorerLogic: - """Answers what the panel asks of its model, recording the folders it is told to drop.""" + """Answers what the panel asks of its model, recording what it is told about each folder.""" def __init__(self, tree: Tree) -> None: self.tree = tree self.cleared: List[Tuple[str, ...]] = [] + self.loaded: Set[Path] = set() + self.read: List[Path] = [] + self.standing: List[Tuple[Path, bool]] = [] + + def has_loaded_children(self, filepath: Path) -> bool: + return filepath in self.loaded + + def expand_directory(self, node: FileSystemNode) -> None: + self.read.append(node.filepath) + self.loaded.add(node.filepath) + + def set_directory_open(self, filepath: Path, is_open: bool) -> None: + self.standing.append((filepath, is_open)) def collapse_all(self) -> None: root = self.tree.get_root() @@ -73,6 +87,85 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: return panel +@pytest.fixture +def toggled(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the rows the panel folds through the framework, in place of the widgets.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: False) + monkeypatch.setattr( + explorer_module.dpg, + "set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +class TestFollowingAFold: + """A click on a folder is how it opens, and the explorer is told what it now stands as.""" + + def test_opening_a_folder_reads_it_and_records_it_open( + self, + toggled: List[Tuple[str, bool]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.read == [MUSIC] + assert panel._explorer_logic.standing == [(MUSIC, True)] + assert toggled == [("row.music", True)] + + def test_a_folder_read_already_is_not_read_again( + self, + toggled: List[Tuple[str, bool]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + panel._explorer_logic.loaded.add(MUSIC) + monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.read == [] + assert panel._explorer_logic.standing == [(MUSIC, True)] + + def test_folding_a_folder_records_it_closed( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + panel._explorer_logic.loaded.add(MUSIC) + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "set_value", lambda tag, value: None) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.standing == [(MUSIC, False)] + + def test_a_row_that_left_the_tree_is_left_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.standing == [] + + class TestCollapseAll: def test_the_rows_fold_while_the_model_still_states_them( self, From 5a0396c32ed80d3eb888eb7a93ead94c9c73245b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:41:46 +0200 Subject: [PATCH 36/45] Added: application mark in the About dialog --- src/sampletones_application/application.py | 29 ++++++++----- .../categories/hierarchy.py | 1 + .../layout/general/dialogs/about.py | 15 +++++++ .../layout/general/dialogs/dialogs.py | 2 + src/sampletones_application/shell.py | 5 +++ src/sampletones_application/tags/general.py | 7 ++++ .../ui/elements/texture.py | 26 ++++++++++++ .../layout/general/dialogs.yaml | 5 +++ .../layout/test_about_dialog.py | 7 ++++ .../ui/elements/test_texture.py | 41 +++++++++++++++++++ 10 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 src/sampletones_application/layout/general/dialogs/about.py create mode 100644 src/sampletones_application/ui/elements/texture.py create mode 100644 tests/unit/sampletones_application/layout/test_about_dialog.py create mode 100644 tests/unit/sampletones_application/ui/elements/test_texture.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 87ca0cf3..868523b7 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -81,6 +81,7 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_ABOUT, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_TEXTURE_LOGO, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_MENU_FPS, TAG_GLOBAL_THEME_PLAYER_BUTTON, @@ -1182,7 +1183,8 @@ def _open_audio_settings(self) -> None: ) def _open_about_dialog(self) -> None: - """Presents the application name, version, description, and authorship in a modal notice.""" + """Presents the application's mark beside its name, version, description, and authorship.""" + about = self.layout.general.dialogs.about description = self.language_manager["global.dialog.message.about_description"] author_line = self.language_manager["global.dialog.template.about_author"].format( author=SAMPLETONES_AUTHOR, @@ -1190,21 +1192,26 @@ def _open_about_dialog(self) -> None: ) def content(parent: str) -> None: - name_text = dpg.add_text(SAMPLETONES_NAME_VERSION, parent=parent) - dpg.add_separator(parent=parent) - FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) - dpg.add_text( - description, - parent=parent, - wrap=self.dialogs.default_wrap, - ) - author_text = dpg.add_text(author_line, parent=parent) - FontRegistry.bind_to_item(author_text, Font.ITALIC) + with dpg.group(horizontal=True, parent=parent): + dpg.add_image( + TAG_GLOBAL_TEXTURE_LOGO, + width=about.logo, + height=about.logo, + ) + with dpg.group(): + name_text = dpg.add_text(SAMPLETONES_NAME_VERSION) + FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) + dpg.add_separator() + dpg.add_text(description, wrap=about.text_wrap) + author_text = dpg.add_text(author_line) + FontRegistry.bind_to_item(author_text, Font.ITALIC) self.dialogs.show_modal( get_dialog_tag(TAG_GLOBAL_DIALOG_ABOUT), self.language_manager["global.dialog.title.about"], content, + width=about.width, + height=about.height, ) def _refresh_audio_devices(self) -> None: diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 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/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/shell.py b/src/sampletones_application/shell.py index e3e21f06..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 @@ -169,6 +170,7 @@ def setup( ) -> None: dpg.create_context() self._set_fonts() + self._set_textures() self._register_shortcuts(bindings) self._set_default_theme() self._viewport_manager.create_viewport() @@ -198,6 +200,9 @@ def _setup_dearpygui(self) -> None: def _set_fonts(self) -> None: FontRegistry.register_fonts(self._layout.fonts.scale) + def _set_textures(self) -> None: + TextureRegistry.register_textures() + def _set_default_theme(self) -> None: self._theme.bind() diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 99533da8..fed74055 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -681,6 +681,13 @@ "sequencer", ) +TAG_GLOBAL_TEXTURE_LOGO = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.TEXTURE, + "logo", +) + SUF_BUTTON = "button" SUF_BUTTONS = "buttons" SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy") diff --git a/src/sampletones_application/ui/elements/texture.py b/src/sampletones_application/ui/elements/texture.py new file mode 100644 index 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_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index 7c57efcd..09eab64b 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -14,3 +14,8 @@ text_input: traceback: width: 0 height: 400 +about: + width: 480 + height: 210 + logo: 72 + padding: 40 diff --git a/tests/unit/sampletones_application/layout/test_about_dialog.py b/tests/unit/sampletones_application/layout/test_about_dialog.py new file mode 100644 index 00000000..99296cca --- /dev/null +++ b/tests/unit/sampletones_application/layout/test_about_dialog.py @@ -0,0 +1,7 @@ +from sampletones_application.layout.general.dialogs.about import AboutDialogLayout + + +class TestTheRoomTheTextTakes: + def test_the_text_wraps_in_what_the_mark_leaves(self) -> None: + layout = AboutDialogLayout(width=480, height=210, logo=72, padding=40) + assert layout.text_wrap == 368 diff --git a/tests/unit/sampletones_application/ui/elements/test_texture.py b/tests/unit/sampletones_application/ui/elements/test_texture.py new file mode 100644 index 00000000..6d8c9efa --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_texture.py @@ -0,0 +1,41 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO +from sampletones_application.ui.elements.texture import TextureRegistry + +MARK_SIZE = 256 + + +@pytest.fixture +def context() -> Iterator[None]: + """A DearPyGui context, textures being framework items rather than plain data.""" + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +class TestTheImagesTheInterfaceDraws: + def test_the_mark_is_read_into_a_texture_named_by_its_tag(self, context: None) -> None: + TextureRegistry.register_textures() + + assert dpg.does_item_exist(TAG_GLOBAL_TEXTURE_LOGO) + + def test_the_texture_carries_the_shipped_image_at_its_own_size(self, context: None) -> None: + """The image is read as it ships, and whatever draws it states the size it wants.""" + TextureRegistry.register_textures() + + configuration = dpg.get_item_configuration(TAG_GLOBAL_TEXTURE_LOGO) + assert (configuration["width"], configuration["height"]) == (MARK_SIZE, MARK_SIZE) + + def test_the_texture_is_there_to_be_drawn(self, context: None) -> None: + TextureRegistry.register_textures() + + with dpg.window(): + image = dpg.add_image(TAG_GLOBAL_TEXTURE_LOGO, width=72, height=72) + + assert dpg.get_item_type(image) == "mvAppItemType::mvImage" From ad6144c140b1781b51896e582c11e1a5d4064923 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:54:01 +0200 Subject: [PATCH 37/45] Documented: the browser's open rule, its remembered shape and the sweep --- docs/development/browser.md | 62 ++++++++++++++----- docs/guide/interface.md | 13 +++- src/sampletones_application/tags/general.py | 6 -- .../ui/elements/tree/tree.py | 10 --- .../utils/gui/dialogs.py | 5 -- src/sampletones_config/palettes/dark.yaml | 1 - src/sampletones_config/palettes/light.yaml | 1 - src/sampletones_config/palettes/studio.yaml | 1 - .../nodes/files/not_expanded_directory.yaml | 9 --- src/sampletones_core/structures/tree/tree.py | 8 +-- .../structures/tree/test_tree.py | 16 ----- 11 files changed, 59 insertions(+), 73 deletions(-) delete mode 100644 src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml diff --git a/docs/development/browser.md b/docs/development/browser.md index 67fd025d..a31c0669 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -34,9 +34,10 @@ complements `docs/development/architecture.md` (layering and ownership) and 7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, and each browser opens in the mode a session left it in. -8. **The reader's shape survives a rebuild.** Which rows stand open is what the reader made of the +8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave - the tree standing as it was, and a filter adds the way down to what it names. + the tree standing as it was, and so does the next run of the application. What a filter unfolds on + top of that shape is the reader's to ask for. --- @@ -111,9 +112,9 @@ The browsers form one line of inheritance, each level owning what it shares: fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the - refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree - locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states - what its card and refresh control read. + controls bringing the tree up to date and folding it away, the tree window, the folder-and-file + handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as + a `FileBrowserTags` class attribute and states what its card and refresh control read. * `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the rows the two branches hold, the colour a group and a sample read in, and the context menus. The Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what @@ -171,13 +172,25 @@ memory follows the size of what was found, and a row beneath a match is answered upwards. **What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows -and brings others along with them, and only the first kind is worth unfolding to: a search names the -rows whose label matched, and the favorites mode names its **anchors** — a row the star sits on, and, -where no row stands for the starred path, the shallowest rows that path reaches. So a starred folder -comes up open showing what it holds, a folder inside it stays as it was, and a star nested deeper -opens the way down to itself, since a starred row anchors wherever it sits. In the sample branch the -headings carry no path, which makes the variants the rows the star arrives at, and the way down to -them opens. +and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion +names are its **anchors**: for a search, the rows whose label matched; for the favorites mode, a row a +star sits on, and — where no row stands for the starred path — the shallowest rows that path reaches. +In the sample branch the headings carry no path, which is what makes the variants the rows a starred +folder arrives at. + +**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a +match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points +the reader at a star, so the rows above it open and the star's own row stands where the reader left it +(`TreeVisibility.leads_to`) — a starred folder is revealed rather than unfolded. A starred +reconstruction inside a starred folder anchors on its own, which is what opens the folder above it. + +**Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds +is a preference stated per kind of favorite, held in `ApplicationConfig.browser` and offered as +**View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred +folder, and everything it brings in where no row stands for it, reads the directories answer. Both are +off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The +panel reads the pair through `TreeLogicProtocol`, once per resolution, and a change of preference asks +each reconstruction browser for a redraw. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so @@ -187,12 +200,26 @@ declining a row declines its subtree, and one decision covers it while the trave the rows standing open, by the tag those rows are addressed under, and a later pass creates them open again: the filter adds the way down to what it names, and everything else comes back as it was left. A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click -is read a frame later, once the row has answered it, and the expansion items record what they set. A -pass over the whole tree states which rows exist, so the rows it left out leave the memory with them. +is read a frame later, once the row has answered it, and the expansion items record what they set. The +memory is held to the rows the model states, read afresh on every pass, so a row a moved +reconstructions directory left behind leaves the memory with it. + +The shape outlives the run as well. A browser is handed the rows it stands open as it is built +(`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to +`ApplicationState.expanded_rows` under the panel's tag. Reading it the once at exit keeps the session +free of a write per row per pass, a pass running on the tree worker. + +**The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level +at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its +children have been read, and whether its row stands open. They part company — a folder read and then +folded away is loaded and closed — and the open one is the shape a session writes to +`ApplicationState.expanded_directories`. A refresh reads down to each remembered folder through +`_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories +are dropped as the manager is built. **What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — -and the anchors are read out of that one answer. What it materialises is the starred rows and the rows +and the anchors the preference follows are read out of that one answer. What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite @@ -213,3 +240,8 @@ the shade states whether the control is live. Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, which is how a collapsed card is remembered too. + +**Folding the whole tree away** is the other control every card carries. It reaches the rows through the +model rather than the widget tree, so one pass covers a branch however deep it runs, and it records what +it set — leaving the memory empty, which is the shape a later pass then draws. The explorer folds first +and drops the folders it had read afterwards, so opening one lists it as it stands on disk. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 154147f8..7fe790ca 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -16,7 +16,8 @@ begin. Pick an audio file — or a whole folder — in the **Filesystem** browser on the left, set up how the reconstruction is done in the centre, and click **Convert -sample** (or **Convert directory** for a folder). The +sample** (or **Convert directory** for a folder). The browser opens the folders you +were last working in, and **Collapse all** folds them away again. The [instruction library](../concepts/instruction-library.md) for your settings is built automatically the first time it is needed, so you can convert straight away. While it runs, the panel names the file going in and where the result is going, and @@ -49,7 +50,15 @@ moved. To keep the reconstructions you return to within reach, right-click one — or a whole folder — and choose **Mark as favorite**, which highlights it in both views. Tick **Favorites only** under the search box to narrow the browser to your -favorites and everything inside them. +favorites and everything inside them. The browser keeps the folders you had open +while it narrows, so switching the tick on and off leaves the tree as you left it. +If you would rather it opened its way down to each favorite for you, turn that on +under **View ▸ Auto-expand favorites**, which answers for reconstructions and for +folders separately. + +**Collapse all**, beside the refresh button, folds the whole tree away in one +click. Whatever you leave open is remembered, so the tree comes back the way you +left it the next time you start the application. To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index fed74055..4fcef78c 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -302,12 +302,6 @@ Widget.THEME, "file_wave", ) -TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY = TagName( - Page.GLOBAL, - Panel.IMPLICIT, - Widget.THEME, - "file_not_expanded_directory", -) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 412b6da1..b1dc3405 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -36,7 +36,6 @@ TAG_GLOBAL_THEME_FAVORITE_CHILD, TAG_GLOBAL_THEME_FILE_LIBRARY, TAG_GLOBAL_THEME_FILE_NO_CONTENT, - TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY, TAG_GLOBAL_THEME_FILE_RECONSTRUCTION, TAG_GLOBAL_THEME_FILE_WAVE, TAG_GLOBAL_THEME_TREE_WINDOW, @@ -380,7 +379,6 @@ def _append_spec( open_on_double_click: bool = False, should_expand: bool = False, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> None: """Resolve a node into a :class:`NodeSpec` and record it for emission. @@ -401,7 +399,6 @@ def _append_spec( theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_node_expanded=is_node_expanded, ) stands_open = self._stands_open( node, @@ -1113,13 +1110,11 @@ def _apply_node_theme( node_tag: str, node: TreeNode, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> None: FontRegistry.bind_to_item(node_tag, self._resolve_node_name_font(node)) theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_node_expanded=is_node_expanded, ) ThemeRegistry.get(theme_tag).bind_to_item(node_tag) @@ -1128,7 +1123,6 @@ def _resolve_node_theme_tag( node: TreeNode, *, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> str: """Select the theme tag for a node from its type, favorite state, and content. @@ -1146,7 +1140,6 @@ def _resolve_node_theme_tag( return self._resolve_file_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_not_expanded=is_node_expanded, ) return self._resolve_other_theme_tag(node) @@ -1173,7 +1166,6 @@ def _resolve_file_theme_tag( node: FileSystemNode, *, has_favorite_ancestor: bool = False, - is_not_expanded: bool = False, ) -> str: if self._logic.is_node_favorite(node): return TAG_GLOBAL_THEME_FAVORITE @@ -1188,8 +1180,6 @@ def _resolve_file_theme_tag( case _: if has_favorite_ancestor: return TAG_GLOBAL_THEME_FAVORITE_CHILD - if is_not_expanded: - return TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY return TAG_GLOBAL_THEME_DEFAULT def _resolve_other_theme_tag(self, node: TreeNode) -> str: diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index 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_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/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 143b6e52..a21897bc 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar +from typing import Callable, Optional, Tuple, Type, TypeVar from anytree import PreOrderIter @@ -48,9 +48,3 @@ def find_nodes( ) and predicate(node) ) - - def collect_leaves(self) -> Sequence[TreeNode]: - if not self.root: - return [] - - return [node for node in PreOrderIter(self.root) if node.is_leaf] diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index 0ec7dad6..9ee14082 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -48,22 +48,6 @@ def test_set_root_replaces_the_shape(self, tree: Tree) -> None: assert tree.get_root() is replacement -class TestTreeCollectLeaves: - def test_returns_empty_for_empty_tree(self) -> None: - assert Tree().collect_leaves() == [] - - def test_singleton_root_is_its_own_leaf(self) -> None: - root = TreeNode("root", NodeType.ROOT) - t = Tree(root=root) - leaves = t.collect_leaves() - assert len(leaves) == 1 - assert leaves[0] is root - - def test_every_leaf_the_shape_holds_is_answered(self, tree: Tree) -> None: - leaf_names = {leaf.name for leaf in tree.collect_leaves()} - assert leaf_names == {"leaf_aa", "leaf_ab", "leaf_ba"} - - class TestTreeFindNodes: @staticmethod def _tree_with_twins() -> Tree: From 2e0c6fca7d814fd02b65b11d3bed95bc785b6c73 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 13:15:42 +0200 Subject: [PATCH 38/45] Changed: auto-expand favorites acting on the reader's switch alone --- docs/development/browser.md | 24 ++++-- docs/guide/interface.md | 3 +- src/sampletones_application/application.py | 12 +-- .../coordinators/tabs/reconstruction.py | 4 - .../coordinators/tabs/sequencer.py | 4 - .../ui/elements/tree/tree.py | 43 +++++++--- tests/suite/browser.py | 38 ++++++++- .../ui/elements/tree/test_expansion_memory.py | 69 +++++----------- .../ui/elements/tree/test_favorites_filter.py | 79 +++++++++++-------- 9 files changed, 153 insertions(+), 123 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index a31c0669..ada18699 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -37,7 +37,8 @@ complements `docs/development/architecture.md` (layering and ownership) and 8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave the tree standing as it was, and so does the next run of the application. What a filter unfolds on - top of that shape is the reader's to ask for. + top of that shape is the reader's to ask for, and it is drawn for as long as the filter names the + row rather than recorded. --- @@ -189,8 +190,15 @@ is a preference stated per kind of favorite, held in `ApplicationConfig.browser` **View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred folder, and everything it brings in where no row stands for it, reads the directories answer. Both are off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The -panel reads the pair through `TreeLogicProtocol`, once per resolution, and a change of preference asks -each reconstruction browser for a redraw. +panel reads the pair through `TreeLogicProtocol`, once per resolution. + +**The way down opens on the pass the reader asked for.** Switching the mode on is the reader asking to +be shown their favorites, so the pass that switch starts is the one that opens the way down to them: +`_state_favorites_only` records the request and `_resolve_filter` spends it. A pass after that — a +refresh, a query, a star gained or lost — draws the rows standing where the reader has them, and +switching the mode off asks for nothing to be opened. A change of preference asks for nothing either; +it is answered the next time the reader asks for the mode, which keeps a menu click from moving the +tree the reader is working in. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so @@ -199,10 +207,12 @@ declining a row declines its subtree, and one decision covers it while the trave **The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records the rows standing open, by the tag those rows are addressed under, and a later pass creates them open again: the filter adds the way down to what it names, and everything else comes back as it was left. -A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click -is read a frame later, once the row has answered it, and the expansion items record what they set. The -memory is held to the rows the model states, read afresh on every pass, so a row a moved -reconstructions directory left behind leaves the memory with it. +What the reader did is what is recorded — a click, read a frame later once the row has answered it, and +the expansion items and the collapse control, which record what they set. A row the filter opened is +drawn open on top of that shape and folds back once the filter stops naming it, so a narrowed browser +hands the tree back the way the reader had it. The memory is held to the rows the model states, read +afresh on every pass, so a row a moved reconstructions directory left behind leaves the memory with +it. The shape outlives the run as well. A browser is handed the rows it stands open as it is built (`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 7fe790ca..adc8ca92 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -54,7 +54,8 @@ favorites and everything inside them. The browser keeps the folders you had open while it narrows, so switching the tick on and off leaves the tree as you left it. If you would rather it opened its way down to each favorite for you, turn that on under **View ▸ Auto-expand favorites**, which answers for reconstructions and for -folders separately. +folders separately. It opens the way down each time you tick **Favorites only**, +and the rows it opened fold back as soon as you untick it. **Collapse all**, beside the refresh button, folds the whole tree away in one click. Whatever you leave open is remembered, so the tree comes back the way you diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 868523b7..4ea29c0f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -817,23 +817,13 @@ def _toggle_auto_expand_favorite_reconstructions(self) -> None: self.session_manager.set_auto_expand_favorite_reconstructions( not self.session_manager.auto_expand_favorite_reconstructions ) - self._redraw_browsers() + self._update_menu() def _toggle_auto_expand_favorite_directories(self) -> None: self.session_manager.set_auto_expand_favorite_directories( not self.session_manager.auto_expand_favorite_directories ) - self._redraw_browsers() - - def _redraw_browsers(self) -> None: - """Marks the choice in the menu and draws both browsers again from the model each holds. - - What the favorites mode opens is decided as a rebuild collects the rows, so a change of the - preference is answered by collecting them again rather than by reaching into the tree. - """ self._update_menu() - self._reconstructions_tab.redraw_browser() - self._sequencer_tab.redraw_browser() def _reconstruct_file_dialog(self) -> None: if self._is_operation_active(): diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9846cc55..53a77dbc 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -565,10 +565,6 @@ def save_browser_shape(self) -> None: self._browser_panel.expanded_rows, ) - def redraw_browser(self) -> None: - """Draws the browser again from the model it holds, which a change of filter asks for.""" - self._browser_panel.redraw_tree() - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index faa630e0..5d16f7c1 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -964,10 +964,6 @@ def save_browser_shape(self) -> None: self._sequencer_browser_panel.expanded_rows, ) - def redraw_browser(self) -> None: - """Draws the browser again from the model it holds, which a change of filter asks for.""" - self._sequencer_browser_panel.redraw_tree() - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._sequencer_browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b1dc3405..63765789 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -149,6 +149,7 @@ def __init__( self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None self._favorites_anchors: Optional[TreeVisibility] = None + self._auto_expand_pending: bool = False self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -332,7 +333,7 @@ def _on_favorites_only_changed( The rebuild resolves the filter against the model as it collects the rows, so the mode is stated here and answered there, and turning it on walks the model once. """ - self._filter = self._filter.with_favorites_only(favorites_only) + self._state_favorites_only(favorites_only) self._apply_favorites_glyph_color() self.call( self.on_favorites_filter_changed, @@ -362,8 +363,22 @@ def set_favorites_filter_enabled(self, enabled: bool) -> None: dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled) + def _state_favorites_only(self, favorites_only: bool) -> None: + """Takes the mode the reader switched to, asking the pass it starts to follow the stars. + + Switching the mode on is the reader asking to be shown their favorites, so that pass opens the + way down to them; a pass after it — a refresh, a query, a star gained or lost — draws the rows + standing where the reader has them. Switching the mode off asks for nothing to be opened. + """ + self._filter = self._filter.with_favorites_only(favorites_only) + self._auto_expand_pending = favorites_only + def _restore_favorites_only(self, favorites_only: bool) -> None: - """Takes the mode a session left the browser in, which its first rebuild then draws by.""" + """Takes the mode a session left the browser in, which its first rebuild then draws by. + + The rows a session left standing open come back with it, so the mode a browser opens in points + the reader at their favorites without opening a row. + """ self._filter = self._filter.with_favorites_only(favorites_only) def _get_node_handler_tag(self, node_type: NodeType) -> str: @@ -401,7 +416,6 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( - node, node_tag, should_expand=should_expand, ) @@ -423,23 +437,21 @@ def _append_spec( def _stands_open( self, - node: TreeNode, node_tag: str, *, should_expand: bool, ) -> bool: """Whether the row is created standing open: the filter points at it, or the memory holds it. - The shape the reader built is theirs to keep, so a row they opened comes back open and the - filter adds the way down to what it names. Recording the answer here is what carries that - shape into the pass after this one. + The shape the reader built is theirs to keep and theirs alone to change, so the memory answers + with the rows they opened and the filter draws the way down to what it names on top of that. A + row the filter opened therefore folds back once the filter stops naming it, and the tree the + reader comes back to is the one they left. """ if not self._REMEMBERS_EXPANSION: return should_expand - stands_open = should_expand or node_tag in self._expanded_rows - self._set_row_expanded(node_tag, stands_open and bool(node.children)) - return stands_open + return should_expand or node_tag in self._expanded_rows @property def expanded_rows(self) -> Set[str]: @@ -969,6 +981,7 @@ def _resolve_filter(self) -> None: self._favorites_visibility, self._favorites_anchors, ) = self._resolve_favorites() + self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -1008,10 +1021,14 @@ def _auto_expanded_anchors( ) -> List[TreeNode]: """The anchors whose star the reader asked the browser to open the way down to. - Which stars are followed is a preference stated per kind and read once per pass: a starred - reconstruction answers for itself, and a starred folder answers for itself together with the - rows it brings in where no row stands for the folder. + The pass the reader started by switching the mode on is the one that follows a star, so a pass + of its own accord points at nothing. Which stars are followed is a preference stated per kind + and read once per pass: a starred reconstruction answers for itself, and a starred folder + answers for itself together with the rows it brings in where no row stands for the folder. """ + if not self._auto_expand_pending: + return [] + reconstructions = self._logic.auto_expand_favorite_reconstructions directories = self._logic.auto_expand_favorite_directories return [ diff --git a/tests/suite/browser.py b/tests/suite/browser.py index e82be92a..89139cfe 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -299,7 +299,8 @@ def build_browser_panel( Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, and the control stands where a browser that has yet to build one leaves it. The pair of auto-expand answers states which stars the mode opens the way down to, as the reader's preference - does. + does, and the mode is stated the way a session restores it — so the way down opens once a test + asks for the mode through :func:`select_favorites`. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag @@ -318,6 +319,7 @@ def build_browser_panel( panel._favorites_glyph_tag = None panel.on_favorites_filter_changed = None panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._auto_expand_pending = False panel._resolve_filter() return panel @@ -427,6 +429,21 @@ def set_filter( panel._resolve_filter() +def select_favorites(panel: GUITreePanel) -> None: + """Switches the favorites mode on the way the reader's click does, and resolves the pass it starts. + + Asking to be shown the favorites is what asks the browser to follow a star, so a view showing an + opened row is read through this rather than through a mode stated any other way. + """ + panel._state_favorites_only(True) + panel._resolve_filter() + + +def resolve_pass(panel: GUITreePanel) -> None: + """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" + panel._resolve_filter() + + def view( corpus: BrowserCorpus, favorites: Set[Path], @@ -447,3 +464,22 @@ def view( auto_expand_directories=auto_expand_directories, ) ) + + +def view_on_selecting_favorites( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, +) -> str: + """The view a browser leaves once the reader switches the favorites mode on.""" + panel = build_browser_panel( + corpus, + favorites, + favorites_only=False, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, + ) + select_favorites(panel) + return render_view(panel) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 91665372..96002f86 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -13,6 +13,7 @@ nodes_at, render_view, row_named, + select_favorites, set_filter, set_row_expanded, ) @@ -43,53 +44,6 @@ - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) -WHOLE_TREE_AFTER_THE_MODE: Final[str] = as_view(""" - v By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - v 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - v FFT·γ0 - > PT - > takes - - alt - - beat - v PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - - stray - v By sample - v beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) - WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" > By configuration > 8 kHz·60 Hz·CQT·γ2·P @@ -155,19 +109,32 @@ def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> assert render_view(panel) == STARRED_CONFIGURATION - def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: - """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" + def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: BrowserCorpus) -> None: + """What the browser unfolded to show a favorite is the mode's, so the shape is left untouched.""" panel = build_browser_panel( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, + favorites_only=False, auto_expand_reconstructions=True, ) + select_favorites(panel) render_view(panel) set_filter(panel, favorites_only=False) - assert render_view(panel) == WHOLE_TREE_AFTER_THE_MODE + assert render_view(panel) == WHOLE_TREE + + def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + render_view(panel) + + assert panel.expanded_rows == set() def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 66c0a654..4e65f87e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -15,7 +15,11 @@ as_view, build_browser_panel, nodes_at, + render_view, + resolve_pass, + select_favorites, view, + view_on_selecting_favorites, ) CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" @@ -344,8 +348,8 @@ def rows_of(rendered: str) -> List[str]: class TestDrawnRows: """Which rows the mode draws: what the star reaches, and the rows leading down to it. - What is drawn is the star's to state and nothing else, so every row stands folded here — which is - what a browser opening with the preference off comes back as. + What is drawn is the star's to state and nothing else, so every row stands folded here: the mode + is stated the way a session restores it, and a mode nobody asked for opens no row. """ def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: @@ -385,10 +389,9 @@ def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: """Opening the way down to a star is a separate answer, so it moves no row in or out.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert rows_of( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_reconstructions=True, auto_expand_directories=True, ) @@ -399,14 +402,13 @@ class TestOpenRows: """Which rows stand open: the way down to a star the reader asked the browser to follow.""" def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + assert view_on_selecting_favorites(corpus, {corpus.paths["A/beat"]}) == STARRED_RECONSTRUCTION def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_RECONSTRUCTION_OPENED @@ -417,10 +419,9 @@ def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["D/solo"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_LONE_AUDIO_OPENED @@ -428,10 +429,9 @@ def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/drums/kick"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_IN_SUBFOLDER_OPENED @@ -442,10 +442,9 @@ def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_o corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["stray"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_STRAY_OPENED @@ -456,10 +455,9 @@ def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followe corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_OF_TWO_ALIKE @@ -470,10 +468,9 @@ def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_fol corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_RECONSTRUCTION @@ -481,10 +478,9 @@ def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_fol def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["C"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_CONFIGURATION_OPENED @@ -492,10 +488,9 @@ def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, c def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["archive"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_PLAIN_FOLDER_OPENED @@ -508,10 +503,9 @@ def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it( """The folder above stands on the way to the star below, which is what opens it.""" favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED @@ -522,10 +516,9 @@ def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed( corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["E"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDED_CONFIGURATION_OPENED @@ -537,10 +530,9 @@ def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( ) -> None: """No row stands for the folder there, so the variants are where the star arrives.""" assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["B"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_CONFIGURATION_B_OPENED @@ -550,10 +542,9 @@ def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserC """A reconstruction answers by its own preference, so following those opens the folder above.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_FOLDER_HOLDING_A_STAR_OPENED @@ -563,15 +554,41 @@ def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: B """Following folders alone opens the way to the folder, leaving the star inside it folded away.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER ) + def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> None: + """A browser opens with the rows its reader left standing, whichever stars it would follow.""" + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_RECONSTRUCTION + ) + + def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: BrowserCorpus) -> None: + """The way down is opened the once, so a refresh leaves the rows standing as they now are.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED + + resolve_pass(panel) + + assert render_view(panel) == STARRED_RECONSTRUCTION + class TestSearchInsideTheMode: """The mode states which rows are drawn, and the query states which of them are shown.""" From b37fe9271e5c4dd87ebdbc7dd39641ed5705fe25 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 13:19:26 +0200 Subject: [PATCH 39/45] Changed: the About dialog balancing its mark against the name --- src/sampletones_application/application.py | 2 +- src/sampletones_application/layout/fonts.py | 10 +++++++--- src/sampletones_application/tags/general.py | 6 ++++++ .../ui/elements/fonts/font.py | 1 + .../ui/elements/fonts/registry.py | 2 ++ src/sampletones_config/layout/fonts.yaml | 3 +++ .../layout/general/dialogs.yaml | 2 +- .../layout/test_fonts.py | 20 +++++++++++++++++++ 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sampletones_application/layout/test_fonts.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 4ea29c0f..ecb423e3 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1190,7 +1190,7 @@ def content(parent: str) -> None: ) with dpg.group(): name_text = dpg.add_text(SAMPLETONES_NAME_VERSION) - FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) + FontRegistry.bind_to_item(name_text, Font.BOLD_TITLE) dpg.add_separator() dpg.add_text(description, wrap=about.text_wrap) author_text = dpg.add_text(author_line) diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py index 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/tags/general.py b/src/sampletones_application/tags/general.py index 4fcef78c..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, 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_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 09eab64b..48e41eab 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -17,5 +17,5 @@ traceback: about: width: 480 height: 210 - logo: 72 + logo: 56 padding: 40 diff --git a/tests/unit/sampletones_application/layout/test_fonts.py b/tests/unit/sampletones_application/layout/test_fonts.py new file mode 100644 index 00000000..451768a9 --- /dev/null +++ b/tests/unit/sampletones_application/layout/test_fonts.py @@ -0,0 +1,20 @@ +from typing import Final + +from sampletones_application.layout.fonts import FontScale, FontsLayout, Step, Typeface + +SANS: Final[FontScale] = FontScale(small=11, medium=12, large=13, title=14) +MONO: Final[FontScale] = FontScale(small=21, medium=22, large=23, title=24) +ICON: Final[FontScale] = FontScale(small=31, medium=32, large=33, title=34) + + +class TestTheSizeLadder: + """Every rung a font asks for answers with a size, on the typeface asking for it.""" + + def test_every_rung_answers_with_the_size_the_scale_states(self) -> None: + assert [SANS.step(step) for step in Step] == [11, 12, 13, 14] + + def test_a_typeface_answers_from_a_ladder_of_its_own(self) -> None: + layout = FontsLayout(scale=1, sans=SANS, mono=MONO, icon=ICON) + + assert layout.size_for(Typeface.MONO, Step.TITLE) == 24 + assert layout.size_for(Typeface.SANS, Step.TITLE) == 14 From 7992a516c782ac73346989a922e8e75f4f36f2bd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 15:50:23 +0200 Subject: [PATCH 40/45] Fixed: the favorites mode folding the way down to rows the reader opened --- docs/development/browser.md | 49 +++++--- .../ui/elements/tree/tree.py | 98 ++++++++++++--- tests/suite/browser.py | 12 +- .../ui/elements/tree/test_expansion_memory.py | 118 +++++++++++++++++- .../ui/elements/tree/test_favorites.py | 2 +- .../ui/elements/tree/test_favorites_filter.py | 24 +++- .../ui/panels/main/test_explorer_controls.py | 2 +- .../shared/test_container_context_menu.py | 2 +- 8 files changed, 261 insertions(+), 46 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index ada18699..de65fdc1 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -37,8 +37,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave the tree standing as it was, and so does the next run of the application. What a filter unfolds on - top of that shape is the reader's to ask for, and it is drawn for as long as the filter names the - row rather than recorded. + top of that shape is remembered as the filter's own, held for as long as the filter is, and handed + back when it goes — apart from a row the reader's own has come to stand on, which stays open so the + view they built stays on the screen. --- @@ -192,27 +193,39 @@ folder, and everything it brings in where no row stands for it, reads the direct off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The panel reads the pair through `TreeLogicProtocol`, once per resolution. -**The way down opens on the pass the reader asked for.** Switching the mode on is the reader asking to -be shown their favorites, so the pass that switch starts is the one that opens the way down to them: -`_state_favorites_only` records the request and `_resolve_filter` spends it. A pass after that — a -refresh, a query, a star gained or lost — draws the rows standing where the reader has them, and -switching the mode off asks for nothing to be opened. A change of preference asks for nothing either; -it is answered the next time the reader asks for the mode, which keeps a menu click from moving the -tree the reader is working in. +**The way down opens on the pass the reader asked for, and stands for as long as the mode does.** +Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts +is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends +it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a +refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the +stars followed stay the ones the switch asked about. Switching the mode off lets the memory go and those +rows fold back. A change of preference asks for nothing; it is answered the next time the reader asks +for the mode, which keeps a menu click from moving the tree the reader is working in. + +**The way down becomes the reader's once their own rows stand on it.** A reader looking at their +favorites opens rows of their own below the way the mode opened, and folding that way would take theirs +off the screen with it. `_release_the_rows_the_mode_opened` therefore reads the model as the mode goes +off and hands the reader every row of the mode's that holds one of theirs somewhere below it, which +writes the way down into the shape a session keeps. What is left held the mode's opening alone, and +folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. -**The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records -the rows standing open, by the tag those rows are addressed under, and a later pass creates them open -again: the filter adds the way down to what it names, and everything else comes back as it was left. -What the reader did is what is recorded — a click, read a frame later once the row has answered it, and -the expansion items and the collapse control, which record what they set. A row the filter opened is -drawn open on top of that shape and folds back once the filter stops naming it, so a narrowed browser -hands the tree back the way the reader had it. The memory is held to the rows the model states, read -afresh on every pass, so a row a moved reconstructions directory left behind leaves the memory with -it. +**Two memories, each holding what one hand opened.** A browser holding `_REMEMBERS_EXPANSION` records +the rows standing open by the tag those rows are addressed under, and a later pass creates them open +again. The reader's memory holds what the reader did — a click, read a frame later once the row has +answered it, and the expansion items and the collapse control, which record what they set — and it is +what a session writes down. The mode's memory holds the way down it opened, and goes when the mode does, +so a narrowed browser hands the tree back the way the reader had it, keeping the rows theirs now stand +on. Folding a row is the reader's word +on it whichever hand opened it, so `_set_row_expanded` releases the mode's claim along with the reader's +and the row stays folded. Both memories are held to the rows the model states, read afresh on every +pass, so a row a moved reconstructions directory left behind leaves them with it. + +A search unfolds by the same rule from the other end: its matches and the rows above them open for as +long as the query stands, resolved afresh on each pass, and clearing the query folds them back. The shape outlives the run as well. A browser is handed the rows it stands open as it is built (`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 63765789..a6b1887a 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -142,7 +142,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._expanded_rows: Set[str] = set(initial_expanded_rows) + self._state_expansion_memory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -249,12 +249,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: self._forget_rows_the_model_dropped() return self._pending_specs + def _state_expansion_memory(self, initial_expanded_rows: AbstractSet[str]) -> None: + """Sets up the two memories of open rows: the reader's, and the favorites mode's. + + The reader's opens holding what a session left the browser standing as, and the mode's opening + empty, a mode being asked for afresh in each run. + """ + self._expanded_rows: Set[str] = set(initial_expanded_rows) + self._mode_expanded_rows: Set[str] = set() + def _forget_rows_the_model_dropped(self) -> None: - """Holds the memory of open rows to the rows the model states, read afresh on every pass. + """Holds both memories of open rows to the rows the model states, read afresh on every pass. - A row the memory holds that the model no longer states belongs to a folder the disk has lost, - so its place in the memory goes with it. Reading the model rather than the rows a pass drew is - what lets a browser opening in the favorites mode — or opening on a session written before the + A row a memory holds that the model no longer states belongs to a folder the disk has lost, so + its place goes with it. Reading the model rather than the rows a pass drew is what lets a + browser opening in the favorites mode — or opening on a session written before the reconstructions directory moved — drop what is gone. """ if not self._REMEMBERS_EXPANSION: @@ -264,7 +273,9 @@ def _forget_rows_the_model_dropped(self) -> None: if root is None: return - self._expanded_rows &= {self._generate_node_tag(node) for node in root.descendants if node.children} + rows_the_model_states = {self._generate_node_tag(node) for node in root.descendants if node.children} + self._expanded_rows &= rows_the_model_states + self._mode_expanded_rows &= rows_the_model_states def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -366,12 +377,47 @@ def set_favorites_filter_enabled(self, enabled: bool) -> None: def _state_favorites_only(self, favorites_only: bool) -> None: """Takes the mode the reader switched to, asking the pass it starts to follow the stars. - Switching the mode on is the reader asking to be shown their favorites, so that pass opens the - way down to them; a pass after it — a refresh, a query, a star gained or lost — draws the rows - standing where the reader has them. Switching the mode off asks for nothing to be opened. + Switching the mode on is the reader asking to be shown their favorites, so the pass it starts + opens the way down to them and notes which rows it opened. That way stands open for as long as + the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at + their favorites. Switching the mode off hands those rows back. """ self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only + self._release_the_rows_the_mode_opened() + + def _release_the_rows_the_mode_opened(self) -> None: + """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. + + A row the reader opened below one the mode opened stands on that row, so the reader is looking + at a tree they built on the way the mode opened. Handing that way back would take their own + rows off the screen with it, and it therefore becomes theirs to keep. What is left held the + mode's opening alone, and folds with it. + """ + if not self._mode_expanded_rows: + return + + self._expanded_rows |= self._rows_the_readers_own_stand_on() + self._mode_expanded_rows = set() + + def _rows_the_readers_own_stand_on(self) -> Set[str]: + """The rows the mode opened that hold a row the reader opened somewhere below them. + + Each row the reader stands open is read off the model together with the way up to it, so a row + between one of theirs and the top of the tree answers however deep theirs stands. + """ + root = self.tree.get_root() + if root is None: + return set() + + ways_down: Set[str] = set() + for node in root.descendants: + if self._generate_node_tag(node) not in self._expanded_rows: + continue + + ways_down |= {self._generate_node_tag(ancestor) for ancestor in node.ancestors} + + return ways_down & self._mode_expanded_rows def _restore_favorites_only(self, favorites_only: bool) -> None: """Takes the mode a session left the browser in, which its first rebuild then draws by. @@ -416,6 +462,7 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( + node, node_tag, should_expand=should_expand, ) @@ -437,34 +484,51 @@ def _append_spec( def _stands_open( self, + node: TreeNode, node_tag: str, *, should_expand: bool, ) -> bool: - """Whether the row is created standing open: the filter points at it, or the memory holds it. + """Whether the row is created standing open: a filter points at it, or a memory holds it. - The shape the reader built is theirs to keep and theirs alone to change, so the memory answers - with the rows they opened and the filter draws the way down to what it names on top of that. A - row the filter opened therefore folds back once the filter stops naming it, and the tree the - reader comes back to is the one they left. + Two memories answer, each holding what one hand opened. The reader's holds the rows they opened + themselves and is what a session writes down. The mode's holds the way down it opened on the + pass the reader asked for, which stands for as long as the mode does and folds once it goes, + apart from the rows the reader's own have come to stand on. """ if not self._REMEMBERS_EXPANSION: return should_expand - return should_expand or node_tag in self._expanded_rows + if self._opened_by_the_mode(node): + self._mode_expanded_rows.add(node_tag) + + return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows + + def _opened_by_the_mode(self, node: TreeNode) -> bool: + """Whether the favorites mode is opening the way down through this row on this pass. + + The anchors are resolved from the stars the reader asked to be pointed at, which is the pass + their switch started, so a pass of its own accord opens the way down through nothing. + """ + return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) @property def expanded_rows(self) -> Set[str]: - """The rows the browser stands open, which is the shape a session writes down.""" + """The rows the reader stands open, which is the shape a session writes down.""" return set(self._expanded_rows) def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: - """Holds whether a row stands open, which is what a later pass brings it back by.""" + """Holds whether a row stands open, which is what a later pass brings it back by. + + A row the reader opens is theirs from then on. A row they fold is theirs to fold whichever hand + opened it, so folding it lets go of the mode's claim on it too and it stays folded. + """ if expanded: self._expanded_rows.add(node_tag) return self._expanded_rows.discard(node_tag) + self._mode_expanded_rows.discard(node_tag) def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 89139cfe..6d52a436 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -304,7 +304,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._expanded_rows = set() if expanded_rows is None else set(expanded_rows) + panel._state_expansion_memory(set() if expanded_rows is None else expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] @@ -439,6 +439,16 @@ def select_favorites(panel: GUITreePanel) -> None: panel._resolve_filter() +def deselect_favorites(panel: GUITreePanel) -> None: + """Switches the favorites mode off the way the reader's click does, and resolves the pass it starts. + + Switching the mode off is what hands back the rows it opened, so a view showing them folded is read + through this. + """ + panel._state_favorites_only(False) + panel._resolve_filter() + + def resolve_pass(panel: GUITreePanel) -> None: """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" panel._resolve_filter() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 96002f86..9604996f 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -10,11 +10,12 @@ as_view, build_browser_panel, build_corpus, + deselect_favorites, nodes_at, render_view, + resolve_pass, row_named, select_favorites, - set_filter, set_row_expanded, ) @@ -44,6 +45,52 @@ - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) +THE_WAY_DOWN_TO_THE_READERS_ROW: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + v 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + v FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + v PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" > By configuration > 8 kHz·60 Hz·CQT·γ2·P @@ -120,10 +167,71 @@ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: Brows select_favorites(panel) render_view(panel) - set_filter(panel, favorites_only=False) + deselect_favorites(panel) assert render_view(panel) == WHOLE_TREE + def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off( + self, + corpus: BrowserCorpus, + ) -> None: + """A row of the reader's below one the mode opened makes that row part of the view they built. + + Handing back a row the reader's own stands on would take theirs off the screen with it, so the + way down to it stays open while the rows holding nothing of theirs fold. + """ + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + for label in ("By configuration", "44.1 kHz·30 Hz", "FFT·γ0", "PTN·#bbbbbbb"): + set_row_expanded(panel, row_named(corpus, label), expanded=True) + + set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + select_favorites(panel) + render_view(panel) + deselect_favorites(panel) + + assert render_view(panel) == THE_WAY_DOWN_TO_THE_READERS_ROW + + def test_the_way_down_the_mode_hands_over_is_written_down_with_the_readers_rows( + self, + corpus: BrowserCorpus, + ) -> None: + """A row the reader's own came to stand on is theirs from then on, so a session brings it back.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + heading = row_named(corpus, "FFT·γ0") + set_row_expanded(panel, row_named(corpus, "PTN·#bbbbbbb"), expanded=True) + select_favorites(panel) + render_view(panel) + + deselect_favorites(panel) + + assert panel._generate_node_tag(heading) in panel.expanded_rows + + def test_a_row_the_reader_folds_while_the_mode_is_on_stays_folded(self, corpus: BrowserCorpus) -> None: + """A row is the reader's to fold whichever hand opened it, so the mode lets go of its claim.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + render_view(panel) + + set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + resolve_pass(panel) + + assert "> FFT·γ0" in render_view(panel) + def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel( corpus, @@ -141,9 +249,9 @@ def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserC set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) render_view(panel) - set_filter(panel, favorites_only=True) + select_favorites(panel) render_view(panel) - set_filter(panel, favorites_only=False) + deselect_favorites(panel) assert "v archive" in render_view(panel) @@ -200,7 +308,7 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( render_view(panel) archive.parent = None - set_filter(panel, favorites_only=True) + select_favorites(panel) render_view(panel) assert panel._expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 47668357..cc6a108e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -82,7 +82,7 @@ def build_panel( panel._search_visibility = None panel._favorites_visibility = None panel._favorites_anchors = None - panel._expanded_rows = set() + panel._state_expansion_memory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 4e65f87e..7073cd2b 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -12,6 +12,7 @@ TREE_COLORS, WHOLE_TREE, BrowserCorpus, + FakeTreeLogic, as_view, build_browser_panel, nodes_at, @@ -574,8 +575,8 @@ def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> == STARRED_RECONSTRUCTION ) - def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: BrowserCorpus) -> None: - """The way down is opened the once, so a refresh leaves the rows standing as they now are.""" + def test_the_way_down_stands_open_for_as_long_as_the_mode_does(self, corpus: BrowserCorpus) -> None: + """A refresh while the mode is on leaves the reader looking at the way down to their stars.""" panel = build_browser_panel( corpus, {corpus.paths["A/beat"]}, @@ -587,6 +588,25 @@ def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: B resolve_pass(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED + + def test_a_star_gained_while_the_mode_is_on_opens_no_way_of_its_own(self, corpus: BrowserCorpus) -> None: + """The reader asked to be pointed at the stars they had, so a star gained since points nowhere.""" + panel = build_browser_panel( + corpus, + set(), + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + panel._logic = FakeTreeLogic( # type: ignore[assignment] + {corpus.paths["A/beat"]}, + auto_expand_reconstructions=True, + auto_expand_directories=False, + ) + + resolve_pass(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index a18c1776..fabf8586 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -82,7 +82,7 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: panel = GUIExplorerPanel.__new__(GUIExplorerPanel) panel.tag = PANEL_TAG panel.tree = tree - panel._expanded_rows = set() + panel._state_expansion_memory(set()) panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] return panel diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index 7807c7b6..c083003c 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -48,7 +48,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG - panel._expanded_rows = set() + panel._state_expansion_memory(set()) panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, From 25386b156b4c26b70fb5b8643883fd82608a6b4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 16:26:32 +0200 Subject: [PATCH 41/45] Refactored: the favorites mode's open rule into one answer per pass --- docs/development/browser.md | 4 +- .../ui/elements/tree/tree.py | 72 +++++++++---------- .../structures/tree/visibility.py | 8 --- .../ui/elements/tree/test_favorites.py | 1 - .../ui/elements/tree/test_filter.py | 2 +- .../structures/tree/test_visibility.py | 22 ------ 6 files changed, 39 insertions(+), 70 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index de65fdc1..d70197f9 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -182,8 +182,8 @@ folder arrives at. **A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points -the reader at a star, so the rows above it open and the star's own row stands where the reader left it -(`TreeVisibility.leads_to`) — a starred folder is revealed rather than unfolded. A starred +the reader at a star, so what opens is the rows above it (`_way_down_to`, over the anchors' ancestors) +while the star's own row stands where the reader left it — a starred folder is revealed. A starred reconstruction inside a starred folder anchors on its own, which is what opens the folder above it. **Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index a6b1887a..5281d749 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -148,7 +148,6 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None - self._favorites_anchors: Optional[TreeVisibility] = None self._auto_expand_pending: bool = False self._selected_node_tag: Optional[Union[str, int]] = None @@ -462,7 +461,6 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( - node, node_tag, should_expand=should_expand, ) @@ -484,7 +482,6 @@ def _append_spec( def _stands_open( self, - node: TreeNode, node_tag: str, *, should_expand: bool, @@ -493,25 +490,15 @@ def _stands_open( Two memories answer, each holding what one hand opened. The reader's holds the rows they opened themselves and is what a session writes down. The mode's holds the way down it opened on the - pass the reader asked for, which stands for as long as the mode does and folds once it goes, - apart from the rows the reader's own have come to stand on. + pass the reader asked for, noted as that pass resolved the filter, which stands for as long as + the mode does and folds once it goes, apart from the rows the reader's own have come to stand + on. """ if not self._REMEMBERS_EXPANSION: return should_expand - if self._opened_by_the_mode(node): - self._mode_expanded_rows.add(node_tag) - return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows - def _opened_by_the_mode(self, node: TreeNode) -> bool: - """Whether the favorites mode is opening the way down through this row on this pass. - - The anchors are resolved from the stars the reader asked to be pointed at, which is the pass - their switch started, so a pass of its own accord opens the way down through nothing. - """ - return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) - @property def expanded_rows(self) -> Set[str]: """The rows the reader stands open, which is the shape a session writes down.""" @@ -751,16 +738,13 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which a row leading to a named row is. + """Whether the search points at the row, which a match and every row above one is. - A search names the rows whose label matched and shows what each of them gathers, so a folder - it named opens. The favorites mode points the reader at a star and opens the way down to it - alone, which leaves the starred row standing as the reader left it. + A search names the rows whose label matched and shows what each of them gathers, so a folder it + named opens, for as long as the query stands. What the favorites mode opens is its memory to + answer, read as each row is created. """ - if self._search_visibility is not None and self._search_visibility.should_expand(node): - return True - - return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) + return self._search_visibility is not None and self._search_visibility.should_expand(node) def _create_status_bar_message_function( self, @@ -1037,14 +1021,17 @@ def _set_query(self, query: str) -> None: def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. - Reading the model rather than the rows lets the resolution run on the rebuild worker, and - keeps a filter typed before a refresh answering for the rows that refresh brings. + The model is what the whole answer is read from, so the resolution runs on the rebuild worker + and a filter stated before a refresh answers for the rows that refresh brings. The way down + the favorites mode opens is read out of the anchors here as well, which notes it the once for + the pass. """ self._search_visibility = self._resolve_search_visibility() ( self._favorites_visibility, - self._favorites_anchors, + way_down, ) = self._resolve_favorites() + self._mode_expanded_rows |= way_down self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: @@ -1060,25 +1047,38 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) - def _resolve_favorites( - self, - ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: - """The rows the favorites mode keeps, and the rows it opens the way down to. + def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]: + """The rows the favorites mode keeps, and the rows it opens the way down through. The two answer different questions — which rows the browser draws, and which of them stand - open — so each is resolved from a set of its own, the second being a part of the first. One - walk of the model finds the rows the star reaches, and the anchors are read out of that - answer, so a corpus of any size resolves into a walk and a pair of sets. + open — so each is read out of one walk of the model: the rows a star reaches state what is + drawn, and the rows above the anchors among them state the way down. A corpus of any size + therefore resolves into a walk and a pair of sets. """ if not self._filter.favorites_only: - return None, None + return None, set() reached = self.tree.find_nodes(TreeNode, self._is_node_starred) return ( resolve_visibility(reached), - resolve_visibility(self._auto_expanded_anchors(reached)), + self._way_down_to(self._auto_expanded_anchors(reached)), ) + def _way_down_to(self, anchors: Sequence[TreeNode]) -> Set[str]: + """The tags of the rows standing above the anchors, which is the way down the mode opens. + + An anchor is a row the reader asked to be pointed at, so what opens is the rows above it while + the anchor's own row stands as the reader left it. The container both branches hang from is a + row on no screen, and stays out of the answer. + """ + root = self.tree.get_root() + return { + self._generate_node_tag(ancestor) + for anchor in anchors + for ancestor in anchor.ancestors + if ancestor is not root + } + def _auto_expanded_anchors( self, reached: Sequence[TreeNode], diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 815de44c..7c9fbbc1 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -28,14 +28,6 @@ def should_expand(self, node: TreeNode) -> bool: """Whether the row stands open, which a named row does and so does every row above one.""" return node in self.matches or node in self.ancestors - def leads_to(self, node: TreeNode) -> bool: - """Whether the row stands on the way down to a named row, being none of the named rows itself. - - Answers the reader who is pointed at what was named rather than at what it holds, so opening - by this leaves a named row standing as it was while the rows above it show where it sits. - """ - return node in self.ancestors - def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility: """The visibility a set of named rows resolves to, read once per pass over the tree. diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index cc6a108e..7b5cd409 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -81,7 +81,6 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._favorites_anchors = None panel._state_expansion_memory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index d538bc63..2bc23730 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -51,7 +51,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._favorites_anchors = None + panel._state_expansion_memory(set()) return panel diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py index 4f3ec4d6..abd27830 100644 --- a/tests/unit/sampletones_core/structures/tree/test_visibility.py +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -107,28 +107,6 @@ def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) assert not any(visibility.should_expand(node) for node in nodes.values()) -class TestTheWayDownToARow: - """What ``leads_to`` answers: the rows above a named row, and none of the named rows.""" - - def test_every_row_above_a_match_leads_to_it(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, ["leaf_ba"]) - names = {name for name, node in nodes.items() if visibility.leads_to(node)} - assert names == {"root", "child_b"} - - def test_a_match_leads_to_nothing_of_its_own(self, nodes: Dict[str, TreeNode]) -> None: - """The reader is pointed at the match, so opening by this leaves it standing as it was.""" - visibility = visibility_of(nodes, ["child_a"]) - assert not visibility.leads_to(nodes["child_a"]) - - def test_a_match_above_another_leads_to_the_one_below_it(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) - assert visibility.leads_to(nodes["child_a"]) - - def test_nothing_named_leads_nowhere(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, []) - assert not any(visibility.leads_to(node) for node in nodes.values()) - - class TestResolvedSets: def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) From 4f78db466175d4110ba99bbee09828ef2213be29 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 16:52:04 +0200 Subject: [PATCH 42/45] Refactored: the favorites mode's release --- docs/development/browser.md | 17 ++++++------ .../ui/elements/tree/tree.py | 23 +++++++++------- tests/suite/browser.py | 13 +++++++-- .../ui/elements/tree/test_expansion_memory.py | 27 +++++++++++++++++++ 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index d70197f9..899c3f53 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -198,16 +198,17 @@ Switching the mode on is the reader asking to be shown their favorites, so the p is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the -stars followed stay the ones the switch asked about. Switching the mode off lets the memory go and those -rows fold back. A change of preference asks for nothing; it is answered the next time the reader asks -for the mode, which keeps a menu click from moving the tree the reader is working in. +stars followed stay the ones the switch asked about. Every turn of the mode is a pass's to answer: the +pass that reads the mode off lets the memory go and those rows fold back. A change of preference asks +for nothing; it is answered the next time the reader asks for the mode, which keeps a menu click from +moving the tree the reader is working in. **The way down becomes the reader's once their own rows stand on it.** A reader looking at their -favorites opens rows of their own below the way the mode opened, and folding that way would take theirs -off the screen with it. `_release_the_rows_the_mode_opened` therefore reads the model as the mode goes -off and hands the reader every row of the mode's that holds one of theirs somewhere below it, which -writes the way down into the shape a session keeps. What is left held the mode's opening alone, and -folds with it. +favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on +the screen. `_release_the_rows_the_mode_opened` therefore reads the model on the pass that finds the +mode off — on the tree worker, beside the other walks a pass makes — and hands the reader every row of +the mode's that holds one of theirs somewhere below it, which writes the way down into the shape a +session keeps. What is left held the mode's opening alone, and folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 5281d749..70c5e997 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -379,19 +379,19 @@ def _state_favorites_only(self, favorites_only: bool) -> None: Switching the mode on is the reader asking to be shown their favorites, so the pass it starts opens the way down to them and notes which rows it opened. That way stands open for as long as the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at - their favorites. Switching the mode off hands those rows back. + their favorites. Switching the mode off is answered by the pass that follows it as well, which + hands those rows back. """ self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only - self._release_the_rows_the_mode_opened() def _release_the_rows_the_mode_opened(self) -> None: """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. A row the reader opened below one the mode opened stands on that row, so the reader is looking - at a tree they built on the way the mode opened. Handing that way back would take their own - rows off the screen with it, and it therefore becomes theirs to keep. What is left held the - mode's opening alone, and folds with it. + at a tree they built on the way the mode opened. That row holds their own on the screen, and + it therefore becomes theirs to keep. What is left held the mode's opening alone, and folds with + it. The rows are read off the model, so a pass on the tree worker is what runs this. """ if not self._mode_expanded_rows: return @@ -1022,16 +1022,21 @@ def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. The model is what the whole answer is read from, so the resolution runs on the rebuild worker - and a filter stated before a refresh answers for the rows that refresh brings. The way down - the favorites mode opens is read out of the anchors here as well, which notes it the once for - the pass. + and a filter stated before a refresh answers for the rows that refresh brings. Both of the + favorites mode's turns are answered here, each by the pass that follows it: the pass the reader + asked for notes the way down it opens, and the pass that reads the mode off hands those rows + back. """ self._search_visibility = self._resolve_search_visibility() ( self._favorites_visibility, way_down, ) = self._resolve_favorites() - self._mode_expanded_rows |= way_down + if self._filter.favorites_only: + self._mode_expanded_rows |= way_down + else: + self._release_the_rows_the_mode_opened() + self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 6d52a436..afeaafdb 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -442,13 +442,22 @@ def select_favorites(panel: GUITreePanel) -> None: def deselect_favorites(panel: GUITreePanel) -> None: """Switches the favorites mode off the way the reader's click does, and resolves the pass it starts. - Switching the mode off is what hands back the rows it opened, so a view showing them folded is read - through this. + The pass that reads the mode off is what hands back the rows it opened, so a view showing them + folded is read through this. """ panel._state_favorites_only(False) panel._resolve_filter() +def click_favorites(panel: GUITreePanel, *, favorites_only: bool) -> None: + """States the mode the reader's click leaves the control reading, with no pass following it. + + A rebuild the tree is locked against starts nothing, so the click stands as a request and the pass + that runs next is what answers it. + """ + panel._state_favorites_only(favorites_only) + + def resolve_pass(panel: GUITreePanel) -> None: """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" panel._resolve_filter() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 9604996f..e026f93e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -10,6 +10,7 @@ as_view, build_browser_panel, build_corpus, + click_favorites, deselect_favorites, nodes_at, render_view, @@ -171,6 +172,32 @@ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: Brows assert render_view(panel) == WHOLE_TREE + def test_the_pass_that_follows_the_click_is_what_hands_the_modes_rows_back( + self, + corpus: BrowserCorpus, + ) -> None: + """The rows the mode opened are a pass's to hand back, which a click alone leaves standing. + + A click landing while the tree is locked starts no pass, so the rows stand as the mode left + them and whichever pass runs next folds them. + """ + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + view_the_mode_left = render_view(panel) + + click_favorites(panel, favorites_only=False) + + assert render_view(panel) == view_the_mode_left + + resolve_pass(panel) + + assert render_view(panel) == WHOLE_TREE + def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off( self, corpus: BrowserCorpus, From 6c8aa855c9348ff68f52d712aefc2ffd40ffb76b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 17:21:07 +0200 Subject: [PATCH 43/45] Refactored: the browser's open rows memory --- docs/development/browser.md | 36 ++--- .../ui/elements/tree/expansion.py | 75 ++++++++++ .../ui/elements/tree/tree.py | 135 +++++------------- tests/suite/browser.py | 5 +- .../ui/elements/tree/test_collapse_all.py | 2 +- .../ui/elements/tree/test_expansion.py | 116 +++++++++++++++ .../ui/elements/tree/test_expansion_memory.py | 8 +- .../ui/elements/tree/test_favorites.py | 3 +- .../ui/elements/tree/test_filter.py | 3 +- .../ui/panels/main/test_explorer_controls.py | 3 +- .../shared/test_container_context_menu.py | 17 ++- 11 files changed, 266 insertions(+), 137 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/expansion.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_expansion.py diff --git a/docs/development/browser.md b/docs/development/browser.md index 899c3f53..4c77c84c 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -110,9 +110,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: * `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the shape it holds across rebuilds, the rebuild handshake, spec collection, themes and - fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser - can offer. + they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` + (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row, + the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the controls bringing the tree up to date and folding it away, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as @@ -205,25 +205,27 @@ moving the tree the reader is working in. **The way down becomes the reader's once their own rows stand on it.** A reader looking at their favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on -the screen. `_release_the_rows_the_mode_opened` therefore reads the model on the pass that finds the -mode off — on the tree worker, beside the other walks a pass makes — and hands the reader every row of -the mode's that holds one of theirs somewhere below it, which writes the way down into the shape a -session keeps. What is left held the mode's opening alone, and folds with it. +the screen. `_release_mode_rows` therefore reads the model on the pass that finds the mode off — on the +tree worker, beside the other walks a pass makes — and hands the memory the ways down to the reader's +rows; `RowExpansionMemory.release` keeps the rows of the mode's among them, which writes the way down +into the shape a session keeps. What is left held the mode's opening alone, and folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. -**Two memories, each holding what one hand opened.** A browser holding `_REMEMBERS_EXPANSION` records -the rows standing open by the tag those rows are addressed under, and a later pass creates them open -again. The reader's memory holds what the reader did — a click, read a frame later once the row has -answered it, and the expansion items and the collapse control, which record what they set — and it is -what a session writes down. The mode's memory holds the way down it opened, and goes when the mode does, -so a narrowed browser hands the tree back the way the reader had it, keeping the rows theirs now stand -on. Folding a row is the reader's word -on it whichever hand opened it, so `_set_row_expanded` releases the mode's claim along with the reader's -and the row stays folded. Both memories are held to the rows the model states, read afresh on every -pass, so a row a moved reconstructions directory left behind leaves them with it. +**Two memories, each holding what one hand opened.** `RowExpansionMemory` owns both and the rules that +join them, holding each row by the tag it is addressed under so a later pass creates it open again. The +reader's rows hold what the reader did — a click, read a frame later once the row has answered it, and +the expansion items and the collapse control, which record what they set — and they are what a session +writes down. The mode's rows hold the way down it opened, and go when the mode does, so a narrowed +browser hands the tree back the way the reader had it, keeping the rows theirs now stand on. Folding a +row is the reader's word on it whichever hand opened it, so `remember` releases the mode's claim along +with the reader's and the row stays folded. A pass writes from the tree worker while a click writes from +the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the +model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves +them with it. Which browsers record a shape at all is `_REMEMBERS_EXPANSION`: it decides whether a click +is followed through to the memory, and a browser that keeps none leaves it empty. A search unfolds by the same rule from the other end: its matches and the rows above them open for as long as the query stands, resolved afresh on each pass, and clearing the query folds them back. diff --git a/src/sampletones_application/ui/elements/tree/expansion.py b/src/sampletones_application/ui/elements/tree/expansion.py new file mode 100644 index 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/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 70c5e997..f4c44f5a 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -57,6 +57,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.emitter import TreeEmitter +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol @@ -142,7 +143,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._state_expansion_memory(initial_expanded_rows) + self._expansion = RowExpansionMemory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -248,33 +249,19 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: self._forget_rows_the_model_dropped() return self._pending_specs - def _state_expansion_memory(self, initial_expanded_rows: AbstractSet[str]) -> None: - """Sets up the two memories of open rows: the reader's, and the favorites mode's. - - The reader's opens holding what a session left the browser standing as, and the mode's opening - empty, a mode being asked for afresh in each run. - """ - self._expanded_rows: Set[str] = set(initial_expanded_rows) - self._mode_expanded_rows: Set[str] = set() - def _forget_rows_the_model_dropped(self) -> None: - """Holds both memories of open rows to the rows the model states, read afresh on every pass. + """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A row a memory holds that the model no longer states belongs to a folder the disk has lost, so - its place goes with it. Reading the model rather than the rows a pass drew is what lets a - browser opening in the favorites mode — or opening on a session written before the - reconstructions directory moved — drop what is gone. + A row the memory holds that the model no longer states belongs to a folder the disk has lost, so + its place goes with it. The model is what the answer is read from, so a browser opening in the + favorites mode — or opening on a session written before the reconstructions directory moved — + drops what is gone. """ - if not self._REMEMBERS_EXPANSION: - return - root = self.tree.get_root() - if root is None: + if root is None or not self._expansion: return - rows_the_model_states = {self._generate_node_tag(node) for node in root.descendants if node.children} - self._expanded_rows &= rows_the_model_states - self._mode_expanded_rows &= rows_the_model_states + self._expansion.hold_to({self._generate_node_tag(node) for node in root.descendants if node.children}) def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -385,38 +372,21 @@ def _state_favorites_only(self, favorites_only: bool) -> None: self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only - def _release_the_rows_the_mode_opened(self) -> None: - """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. + def _release_mode_rows(self) -> None: + """Hands back the rows the favorites mode opened, which the memory holds the rule for. - A row the reader opened below one the mode opened stands on that row, so the reader is looking - at a tree they built on the way the mode opened. That row holds their own on the screen, and - it therefore becomes theirs to keep. What is left held the mode's opening alone, and folds with - it. The rows are read off the model, so a pass on the tree worker is what runs this. - """ - if not self._mode_expanded_rows: - return - - self._expanded_rows |= self._rows_the_readers_own_stand_on() - self._mode_expanded_rows = set() - - def _rows_the_readers_own_stand_on(self) -> Set[str]: - """The rows the mode opened that hold a row the reader opened somewhere below them. - - Each row the reader stands open is read off the model together with the way up to it, so a row - between one of theirs and the top of the tree answers however deep theirs stands. + The rows the reader stands open are read off the model, and the way down to each of them is what + the mode keeps standing however deep theirs stands. The model is read on the pass that finds the + mode off, on the tree worker. """ root = self.tree.get_root() - if root is None: - return set() - - ways_down: Set[str] = set() - for node in root.descendants: - if self._generate_node_tag(node) not in self._expanded_rows: - continue - - ways_down |= {self._generate_node_tag(ancestor) for ancestor in node.ancestors} + if root is None or not self._expansion.follows_the_mode: + return - return ways_down & self._mode_expanded_rows + reader_rows = self._expansion.rows + self._expansion.release( + self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]), + ) def _restore_favorites_only(self, favorites_only: bool) -> None: """Takes the mode a session left the browser in, which its first rebuild then draws by. @@ -460,10 +430,7 @@ def _append_spec( node, has_favorite_ancestor=has_favorite_ancestor, ) - stands_open = self._stands_open( - node_tag, - should_expand=should_expand, - ) + stands_open = should_expand or self._expansion.stands_open(node_tag) self._pending_specs.append( NodeSpec( node=node, @@ -480,42 +447,9 @@ def _append_spec( ) ) - def _stands_open( - self, - node_tag: str, - *, - should_expand: bool, - ) -> bool: - """Whether the row is created standing open: a filter points at it, or a memory holds it. - - Two memories answer, each holding what one hand opened. The reader's holds the rows they opened - themselves and is what a session writes down. The mode's holds the way down it opened on the - pass the reader asked for, noted as that pass resolved the filter, which stands for as long as - the mode does and folds once it goes, apart from the rows the reader's own have come to stand - on. - """ - if not self._REMEMBERS_EXPANSION: - return should_expand - - return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows - @property def expanded_rows(self) -> Set[str]: - """The rows the reader stands open, which is the shape a session writes down.""" - return set(self._expanded_rows) - - def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: - """Holds whether a row stands open, which is what a later pass brings it back by. - - A row the reader opens is theirs from then on. A row they fold is theirs to fold whichever hand - opened it, so folding it lets go of the mode's claim on it too and it stays folded. - """ - if expanded: - self._expanded_rows.add(node_tag) - return - - self._expanded_rows.discard(node_tag) - self._mode_expanded_rows.discard(node_tag) + return self._expansion.rows def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. @@ -527,7 +461,7 @@ def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: if container.children: node_tag = self._generate_node_tag(container) dpg_set_value(node_tag, expanded) - self._set_row_expanded(node_tag, expanded) + self._expansion.remember(node_tag, expanded=expanded) def _finish_emit( self, @@ -696,7 +630,7 @@ def _read_row_expansion(self, node_tag: str) -> None: if not dpg.does_item_exist(node_tag): return - self._set_row_expanded(node_tag, bool(dpg_get_value(node_tag))) + self._expansion.remember(node_tag, expanded=bool(dpg_get_value(node_tag))) def _setup_handlers(self) -> None: for handler in self._node_handlers.values(): @@ -1033,9 +967,9 @@ def _resolve_filter(self) -> None: way_down, ) = self._resolve_favorites() if self._filter.favorites_only: - self._mode_expanded_rows |= way_down + self._expansion.follow(way_down) else: - self._release_the_rows_the_mode_opened() + self._release_mode_rows() self._auto_expand_pending = False @@ -1069,20 +1003,15 @@ def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]: self._way_down_to(self._auto_expanded_anchors(reached)), ) - def _way_down_to(self, anchors: Sequence[TreeNode]) -> Set[str]: - """The tags of the rows standing above the anchors, which is the way down the mode opens. + def _way_down_to(self, rows: Sequence[TreeNode]) -> Set[str]: + """The tags of the rows standing above these rows, which is a way down to them. - An anchor is a row the reader asked to be pointed at, so what opens is the rows above it while - the anchor's own row stands as the reader left it. The container both branches hang from is a - row on no screen, and stays out of the answer. + A row given here is one the browser is pointed at, so a way down opens the rows above it while + its own row stands as the reader left it. The container both branches hang from is a row on no + screen, and stays out of the answer. """ root = self.tree.get_root() - return { - self._generate_node_tag(ancestor) - for anchor in anchors - for ancestor in anchor.ancestors - if ancestor is not root - } + return {self._generate_node_tag(ancestor) for row in rows for ancestor in row.ancestors if ancestor is not root} def _auto_expanded_anchors( self, diff --git a/tests/suite/browser.py b/tests/suite/browser.py index afeaafdb..d861995d 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -6,6 +6,7 @@ from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -304,7 +305,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._state_expansion_memory(set() if expanded_rows is None else expanded_rows) + panel._expansion = RowExpansionMemory(set() if expanded_rows is None else expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] @@ -415,7 +416,7 @@ def set_row_expanded( expanded: bool, ) -> None: """Leaves a row standing the way the reader would leave it, which the browser then remembers.""" - panel._set_row_expanded(panel._generate_node_tag(node), expanded) + panel._expansion.remember(panel._generate_node_tag(node), expanded=expanded) def set_filter( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py index 6dc8aca8..31d88e95 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py @@ -70,5 +70,5 @@ def test_the_shape_the_control_left_is_what_the_next_pass_draws( panel._on_collapse_all_clicked() - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() assert render_view(panel) == WHOLE_TREE diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py new file mode 100644 index 00000000..08eecd8a --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py @@ -0,0 +1,116 @@ +from typing import Set + +import pytest + +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory + +READER_ROW: str = "panel.node_reader" +MODE_ROW: str = "panel.node_mode" +WAY_DOWN: str = "panel.node_way_down" + + +@pytest.fixture +def memory() -> RowExpansionMemory: + return RowExpansionMemory(set()) + + +class TestTheRowsAMemoryHolds: + def test_a_memory_opens_holding_the_rows_a_session_left(self) -> None: + memory = RowExpansionMemory({READER_ROW}) + + assert memory.stands_open(READER_ROW) + assert memory.rows == {READER_ROW} + + def test_a_row_no_hand_opened_stands_closed(self, memory: RowExpansionMemory) -> None: + assert not memory.stands_open(READER_ROW) + assert not memory + + def test_the_rows_a_save_writes_are_the_readers_alone(self, memory: RowExpansionMemory) -> None: + """A save writes the shape the reader built, the mode's way down being its own to hold.""" + memory.remember(READER_ROW, expanded=True) + memory.follow({MODE_ROW}) + + assert memory.rows == {READER_ROW} + assert memory.stands_open(MODE_ROW) + + def test_the_rows_a_save_reads_are_taken_apart_from_the_memory(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + rows: Set[str] = memory.rows + + rows.add(MODE_ROW) + + assert memory.rows == {READER_ROW} + + +class TestFollowingTheReader: + def test_a_row_the_reader_opens_is_theirs(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + assert memory.rows == {READER_ROW} + + def test_a_row_the_reader_folds_lets_go_of_the_modes_claim(self, memory: RowExpansionMemory) -> None: + """A fold is the reader's word on a row whichever hand opened it, so the row stays folded.""" + memory.follow({MODE_ROW}) + + memory.remember(MODE_ROW, expanded=False) + + assert not memory.stands_open(MODE_ROW) + + def test_a_row_the_reader_folds_leaves_the_shape_a_save_writes(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + memory.remember(READER_ROW, expanded=False) + + assert memory.rows == set() + + +class TestTheWayDownTheModeOpens: + def test_the_way_down_stands_open_while_the_mode_does(self, memory: RowExpansionMemory) -> None: + memory.follow({WAY_DOWN, MODE_ROW}) + + assert memory.follows_the_mode + assert memory.stands_open(WAY_DOWN) + + def test_a_release_folds_the_rows_the_mode_opened(self, memory: RowExpansionMemory) -> None: + memory.follow({WAY_DOWN, MODE_ROW}) + + memory.release(set()) + + assert not memory.follows_the_mode + assert not memory.stands_open(WAY_DOWN) + + def test_a_release_keeps_the_rows_the_readers_own_stand_on(self, memory: RowExpansionMemory) -> None: + """A row of the mode's holding one of the reader's below it becomes theirs to keep.""" + memory.follow({WAY_DOWN, MODE_ROW}) + + memory.release({WAY_DOWN}) + + assert memory.rows == {WAY_DOWN} + assert not memory.stands_open(MODE_ROW) + + def test_a_release_answers_for_the_rows_the_mode_opened_alone(self, memory: RowExpansionMemory) -> None: + """The ways down a release is handed are read off the model, and a row no hand opened stays shut.""" + memory.follow({MODE_ROW}) + + memory.release({WAY_DOWN}) + + assert memory.rows == set() + assert not memory.stands_open(WAY_DOWN) + + +class TestTheRowsTheModelStates: + def test_a_row_the_model_dropped_leaves_both_memories(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + memory.follow({MODE_ROW}) + + memory.hold_to({READER_ROW}) + + assert memory.rows == {READER_ROW} + assert not memory.stands_open(MODE_ROW) + + def test_holding_to_nothing_empties_the_memory(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + memory.hold_to(set()) + + assert not memory diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index e026f93e..5b133df6 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -308,7 +308,7 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( archive.parent = None assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None: """The shape outlives the run it was made in, so a browser is handed it as it is built.""" @@ -338,7 +338,7 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( select_favorites(panel) render_view(panel) - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, set(), favorites_only=False) @@ -416,7 +416,7 @@ def test_the_reading_takes_the_state_the_row_stands_in( panel._read_row_expansion("row.tag") - assert panel._expanded_rows == {"row.tag"} + assert panel.expanded_rows == {"row.tag"} def test_a_row_that_left_the_tree_is_read_no_further( self, @@ -428,4 +428,4 @@ def test_a_row_that_left_the_tree_is_read_no_further( panel._read_row_expansion("row.tag") - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 7b5cd409..86342b4d 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -7,6 +7,7 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE_CHILD, ) +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -81,7 +82,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index 2bc23730..c88768b1 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -1,5 +1,6 @@ from typing import Dict, List, Set, Type +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.panels.reconstruction.browser import GUIReconstructionsBrowserPanel from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel @@ -51,7 +52,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) return panel diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index fabf8586..ddaf4648 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -4,6 +4,7 @@ import pytest from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.panels.main import explorer as explorer_module from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode @@ -82,7 +83,7 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: panel = GUIExplorerPanel.__new__(GUIExplorerPanel) panel.tag = PANEL_TAG panel.tree = tree - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] return panel diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index c083003c..1eaf6f26 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -6,6 +6,7 @@ from sampletones_application.ui.elements.tree import tree as tree_module from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.tag import compose_node_tag from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.ui.panels.shared import browser as shared_browser_module @@ -48,7 +49,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, @@ -303,7 +304,7 @@ def test_the_browser_remembers_the_shape_the_item_left( panel._add_context_menu_expansion_items(group) recorder.item(EXPAND_LABEL)["callback"]() - assert panel._expanded_rows == rows + assert panel.expanded_rows == rows def test_the_browser_forgets_the_shape_the_item_folded( self, @@ -312,15 +313,17 @@ def test_the_browser_forgets_the_shape_the_item_folded( ) -> None: panel = _panel() group, sample, _ = _sample_tree() - panel._expanded_rows = { - compose_node_tag(group, panel_tag=PANEL_TAG), - compose_node_tag(sample, panel_tag=PANEL_TAG), - } + panel._expansion = RowExpansionMemory( + { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + ) panel._add_context_menu_expansion_items(group) recorder.item(COLLAPSE_LABEL)["callback"]() - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_leaf_rows_are_left_alone( self, From da6e992b9d7a3b4b5f112a2d3570df37f474772e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 17:47:28 +0200 Subject: [PATCH 44/45] Refactored: the browser's opening mode and shape into constructor arguments --- .../ui/elements/tree/browser.py | 6 +++-- .../ui/elements/tree/tree.py | 25 ++++++------------- .../ui/panels/instruction/library.py | 1 + .../ui/panels/main/explorer.py | 3 +++ .../ui/panels/shared/browser.py | 3 +-- src/sampletones_core/configs/display.py | 8 +++++- .../ui/elements/tree/test_favorites_filter.py | 7 ------ .../sampletones_core/configs/test_display.py | 23 ++++++++++++++++- 8 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index eae1cdab..0a45e259 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -20,7 +20,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags -from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS, GUITreePanel +from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent @@ -55,7 +55,8 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, - initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, + initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] self._msg_collapse_all = language_manager["global.status.message.collapse_all"] @@ -70,6 +71,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, ) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index f4c44f5a..2bb3f397 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -83,6 +83,7 @@ SingleThreadExecutor, ) from sampletones_core.configs.display import ( + format_generators, format_nes_frequency, format_sample_rate, format_spectrum_method, @@ -125,15 +126,14 @@ def __init__( tag: str, tree_tag: str, tree_logic: TreeLogicProtocol, - width: int = -1, - height: int = -1, *, scheduling: SchedulingBehavior, search_label: str, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, + initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager self._logic = tree_logic @@ -146,7 +146,7 @@ def __init__( self._expansion = RowExpansionMemory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) - self._filter: TreeFilter = NO_FILTER + self._filter: TreeFilter = NO_FILTER.with_favorites_only(initial_favorites_only) self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None self._auto_expand_pending: bool = False @@ -184,8 +184,8 @@ def __init__( super().__init__( tag=tag, - width=width, - height=height, + width=-1, + height=-1, ) def _launch_rebuild( @@ -388,14 +388,6 @@ def _release_mode_rows(self) -> None: self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]), ) - def _restore_favorites_only(self, favorites_only: bool) -> None: - """Takes the mode a session left the browser in, which its first rebuild then draws by. - - The rows a session left standing open come back with it, so the mode a browser opens in points - the reader at their favorites without opening a row. - """ - self._filter = self._filter.with_favorites_only(favorites_only) - def _get_node_handler_tag(self, node_type: NodeType) -> str: return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE) @@ -810,15 +802,12 @@ def _reconstruction_detail_items( self, fields: ConfigDirectoryFields, ) -> List[Tuple[str, str]]: - generators = ", ".join( - generator.capitalized for generator in fields.generators - ) # TODO: operation deserves a helper function return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), (self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)), (self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)), (self._lbl_detail_transformation_gamma, str(fields.tg)), - (self._lbl_detail_generators, generators), + (self._lbl_detail_generators, format_generators(fields.generators)), (self._lbl_detail_configuration, short_hash(fields.ch)), ] diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 4196ef4c..5c98d092 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -127,6 +127,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=False, initial_expanded_rows=initial_expanded_rows, ) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 1cbe05c8..45da542d 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -23,6 +23,7 @@ from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags +from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import ( FileSystemNode, @@ -108,6 +109,8 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=False, + initial_expanded_rows=NO_EXPANDED_ROWS, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5e69b11d..8f5ca4a1 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -72,11 +72,10 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, ) - self._restore_favorites_only(initial_favorites_only) - @property def section_label(self) -> str: return self._language_manager["global.browser.label.browser"] diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 80b92f03..04a1750d 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,11 +1,12 @@ from collections import Counter from typing import Dict, Final, Sequence, Tuple -from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod from sampletones_shared.constants.symbols import HASH DISPLAY_SEPARATOR: Final[str] = "·" GAMMA_PREFIX: Final[str] = "γ" +GENERATOR_SEPARATOR: Final[str] = ", " DISPLAY_HASH_LENGTH: Final[int] = 7 HERTZ_UNIT: Final[str] = "Hz" @@ -44,6 +45,11 @@ def format_transformation_gamma(transformation_gamma: int) -> str: return f"{GAMMA_PREFIX}{transformation_gamma}" +def format_generators(generators: Sequence[GeneratorName]) -> str: + """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``).""" + return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators) + + def format_frequencies(sample_rate: int, nes_frequency: int) -> str: """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``).""" return DISPLAY_SEPARATOR.join( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 7073cd2b..c977916c 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -706,13 +706,6 @@ def test_a_change_draws_the_rows_the_new_mode_names( assert redraws == [True] - def test_the_mode_a_session_left_on_stands_before_the_first_rebuild(self, corpus: BrowserCorpus) -> None: - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) - - panel._restore_favorites_only(True) - - assert panel._filter.favorites_only - def test_a_query_typed_earlier_survives_a_change_of_mode( self, corpus: BrowserCorpus, diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index a5f3cee1..6d3d0617 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -7,6 +7,7 @@ DISPLAY_SEPARATOR, disambiguated_display_name, format_frequencies, + format_generators, format_nes_frequency, format_sample_rate, format_transformation, @@ -14,7 +15,7 @@ short_hash, unique_display_names, ) -from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod class TestFormatSampleRate: @@ -42,6 +43,26 @@ def test_marks_the_gamma(self) -> None: assert format_transformation_gamma(0) == "γ0" +class TestFormatGenerators: + def test_reads_the_generators_in_the_order_they_are_given(self) -> None: + assert ( + format_generators( + [ + GeneratorName.PULSE1, + GeneratorName.TRIANGLE, + GeneratorName.NOISE, + ], + ) + == "Pulse 1, Triangle, Noise" + ) + + def test_a_lone_generator_reads_as_its_own_name(self) -> None: + assert format_generators([GeneratorName.PULSE2]) == "Pulse 2" + + def test_no_generator_reads_as_nothing(self) -> None: + assert format_generators([]) == "" + + class TestFormatFrequencies: def test_reads_audio_rate_then_frame_rate(self) -> None: assert format_frequencies(44100, 30) == "44.1 kHz·30 Hz" From fd4b942f2eee44ebd151f534b707c00fca01ddbf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 18:04:06 +0200 Subject: [PATCH 45/45] Documented: the favorites modes --- docs/development/browser.md | 30 ++++++++++--------- docs/guide/interface.md | 2 +- .../logic/main/explorer_manager.py | 5 ++-- .../ui/elements/tree/browser.py | 4 +-- .../ui/elements/tree/tree.py | 8 ++--- .../ui/panels/shared/browser.py | 7 ++--- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index 4c77c84c..e764ab7b 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -109,8 +109,8 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: -* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the + filter they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the @@ -230,10 +230,12 @@ is followed through to the memory, and a browser that keeps none leaves it empty A search unfolds by the same rule from the other end: its matches and the rows above them open for as long as the query stands, resolved afresh on each pass, and clearing the query folds them back. -The shape outlives the run as well. A browser is handed the rows it stands open as it is built -(`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to -`ApplicationState.expanded_rows` under the panel's tag. Reading it the once at exit keeps the session -free of a write per row per pass, a pass running on the tree worker. +The shape outlives the run as well. A browser is handed the mode and the rows it opens with as it is +built (`initial_favorites_only`, `initial_expanded_rows`). A change of mode is written where it happens, +through `on_favorites_filter_changed`, and the shape is asked for the once, at exit: +`_persist_application_state` takes each tab's rows into `ApplicationState.expanded_rows` under the +panel's tag, so a pass holds what it opened in memory, on the tree worker, and the session file reads it +from there. **The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its @@ -243,14 +245,14 @@ folded away is loaded and closed — and the open one is the shape a session wri `_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories are dropped as the manager is built. -**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing -each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — -and the anchors the preference follows are read out of that one answer. What it materialises is the starred rows and the rows -above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of -thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their -headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite -toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring one -takes it out along with what it held. +**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each +row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — and the +anchors the preference follows are read out of that one answer. What it materialises is the starred rows +and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding +hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones +and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A +favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring +one takes it out along with what it held. A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the diff --git a/docs/guide/interface.md b/docs/guide/interface.md index adc8ca92..6ff99732 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -55,7 +55,7 @@ while it narrows, so switching the tick on and off leaves the tree as you left i If you would rather it opened its way down to each favorite for you, turn that on under **View ▸ Auto-expand favorites**, which answers for reconstructions and for folders separately. It opens the way down each time you tick **Favorites only**, -and the rows it opened fold back as soon as you untick it. +and unticking folds those rows back. **Collapse all**, beside the refresh button, folds the whole tree away in one click. Whatever you leave open is remembered, so the tree comes back the way you diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 5fdaf755..0458af98 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -46,8 +46,9 @@ def __init__( def refresh_tree(self) -> None: """Reads the filesystem afresh, down to every folder the tree has to show a row for. - A refresh builds the tree from nothing, so each folder it needs is read once into it: reading a - folder twice would replace the rows below it, and with them the folders already read under it. + A refresh builds the tree from nothing, so ``_loaded_directories`` starts empty and each folder + it needs is read into it once: the rows a read places under a folder stand as the walk carries + on deeper. """ self._loaded_directories.clear() container_root = TreeNode( diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 0a45e259..2428bd6e 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -174,8 +174,8 @@ def _on_refresh_clicked(self) -> None: def _on_collapse_all_clicked(self) -> None: """Folds every row of the tree away, leaving the reader the level the tree opens at. - The rows are reached through the model rather than the widget tree, so one pass covers a - branch however deep it runs, and the browser is told what each row now stands as. + The rows are reached through the model, so one pass covers a branch however deep it runs, + and the browser is told what each row now stands as. """ root = self.tree.get_root() if root is None: diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 2bb3f397..f63f5db6 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -252,8 +252,8 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: def _forget_rows_the_model_dropped(self) -> None: """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A row the memory holds that the model no longer states belongs to a folder the disk has lost, so - its place goes with it. The model is what the answer is read from, so a browser opening in the + A row the memory holds beyond the rows the model states belongs to a folder the disk has lost, + so its place goes with it. The model is what the answer is read from, so a browser opening in the favorites mode — or opening on a session written before the reconstructions directory moved — drops what is gone. """ @@ -1053,8 +1053,8 @@ def _is_node_anchored(self, node: TreeNode) -> bool: row above it is reached, which is how the sample branch answers: its headings carry no path, so the variants are where the star arrives. - Asked of the rows the star reaches, so a row it declines stands under a row it named, and - the reader is pointed at the folder rather than at everything inside it. + Asked of the rows the star reaches, so a row it declines stands under a row it named: the + reader is pointed at the folder, and the rows inside it stand as they were. """ if self._logic.is_node_favorite(node): return True diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 8f5ca4a1..35652a6b 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -232,10 +232,9 @@ def _on_reconstruction_node_double_clicked( def _show_container_context_menu(self, node: TreeNode) -> None: """Offers what a row the browser invents can answer: what it gathers, and how it folds. - A group or a sample stands for a facet of the reconstructions below it rather than for a path - on disk, so its menu reads the subtree — how many reconstructions it gathers, the rows folding - under it, the label the tree shows it by, and for a sample the audio its reconstructions were - made from. + A group or a sample stands for a facet of the reconstructions below it, so its menu reads the + subtree — how many reconstructions it gathers, the rows folding under it, the label the tree + shows it by, and for a sample the audio its reconstructions were made from. """ if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE): return