From a2bce69cf05df405f8cfb3660175203829547fc7 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 31 Jan 2026 20:41:29 +0100 Subject: [PATCH] feat: reordering categories, backup revert tracking for categories --- src/illogical/modules/backup_manager.py | 50 ++- src/illogical/modules/backup_models.py | 30 +- src/illogical/modules/models.py | 328 +++++++++++++-- src/illogical/modules/virtual_category.py | 474 ++++++++++++++++++++++ src/illogical/ui/main_window.py | 7 + src/illogical/ui/restore_backup_window.py | 37 ++ src/illogical/ui/sidebar.py | 243 ++++++++++- 7 files changed, 1122 insertions(+), 47 deletions(-) create mode 100644 src/illogical/modules/virtual_category.py diff --git a/src/illogical/modules/backup_manager.py b/src/illogical/modules/backup_manager.py index 6838003..f992d4d 100644 --- a/src/illogical/modules/backup_manager.py +++ b/src/illogical/modules/backup_manager.py @@ -15,6 +15,8 @@ from illogical.modules.backup_models import ( BackupManifest, BackupSettings, BackupTrigger, + CategoryChange, + CategoryChangeType, ChangeType, DetailedBackupChanges, FieldChange, @@ -32,6 +34,7 @@ BACKUP_INDEX_FILENAME = ".backup_index.json" TAGS_PATH = tags_path BACKUPS_PATH = tags_path.parent / ".nothing_to_see_here_just_illogical_saving_ur_ass" +MUSICAPPS_PROPERTIES = "MusicApps.properties" def _compute_file_checksum(file_path: Path) -> str: @@ -353,6 +356,47 @@ def _tags_id_from_filename(filename: str) -> str: return filename.removesuffix(".tagset") +def _parse_musicapps_sorting(path: Path) -> list[str]: + props_file = path / MUSICAPPS_PROPERTIES + if not props_file.exists(): + return [] + try: + with props_file.open("rb") as f: + data = plistlib.load(f) + return data.get("sorting", []) + except Exception: # noqa: BLE001 + return [] + + +def _compute_category_changes( + backup_sorting: list[str], current_sorting: list[str] +) -> list[CategoryChange]: + changes: list[CategoryChange] = [] + backup_set = set(backup_sorting) + current_set = set(current_sorting) + + moved_new_paths: set[str] = set() + + for old_path in backup_set - current_set: + base_name = old_path.split(":")[-1] + moved_to = next( + (c for c in current_set - backup_set if c.split(":")[-1] == base_name), None + ) + if moved_to: + changes.append(CategoryChange(old_path, moved_to, CategoryChangeType.MOVED)) + moved_new_paths.add(moved_to) + else: + changes.append(CategoryChange(old_path, None, CategoryChangeType.DELETED)) + + changes.extend( + CategoryChange(None, new_path, CategoryChangeType.ADDED) + for new_path in current_set - backup_set + if new_path not in moved_new_paths + ) + + return changes + + def compute_detailed_changes( backup_name: str, logic: Logic | None = None ) -> DetailedBackupChanges: @@ -404,4 +448,8 @@ def compute_detailed_changes( PluginChange(tags_id, plugin_name, ChangeType.MODIFIED, field_changes) ) - return DetailedBackupChanges(plugins=plugin_changes) + backup_sorting = _parse_musicapps_sorting(backup_path) + current_sorting = _parse_musicapps_sorting(TAGS_PATH) + category_changes = _compute_category_changes(backup_sorting, current_sorting) + + return DetailedBackupChanges(plugins=plugin_changes, categories=category_changes) diff --git a/src/illogical/modules/backup_models.py b/src/illogical/modules/backup_models.py index 90b43ab..94369c5 100644 --- a/src/illogical/modules/backup_models.py +++ b/src/illogical/modules/backup_models.py @@ -20,6 +20,12 @@ class ChangeType(Enum): DELETED = "deleted" +class CategoryChangeType(Enum): + MOVED = "moved" + DELETED = "deleted" + ADDED = "added" + + @dataclass class FieldChange: field_name: str @@ -35,9 +41,17 @@ class PluginChange: field_changes: list[FieldChange] = field(default_factory=list) +@dataclass +class CategoryChange: + old_path: str | None + new_path: str | None + change_type: CategoryChangeType + + @dataclass class DetailedBackupChanges: plugins: list[PluginChange] = field(default_factory=list) + categories: list[CategoryChange] = field(default_factory=list) @property def added(self) -> list[PluginChange]: @@ -51,9 +65,23 @@ class DetailedBackupChanges: def deleted(self) -> list[PluginChange]: return [p for p in self.plugins if p.change_type == ChangeType.DELETED] + @property + def categories_moved(self) -> list[CategoryChange]: + return [c for c in self.categories if c.change_type == CategoryChangeType.MOVED] + + @property + def categories_deleted(self) -> list[CategoryChange]: + return [ + c for c in self.categories if c.change_type == CategoryChangeType.DELETED + ] + + @property + def categories_added(self) -> list[CategoryChange]: + return [c for c in self.categories if c.change_type == CategoryChangeType.ADDED] + @property def is_empty(self) -> bool: - return len(self.plugins) == 0 + return len(self.plugins) == 0 and len(self.categories) == 0 @dataclass diff --git a/src/illogical/modules/models.py b/src/illogical/modules/models.py index cd14e4d..78e0d39 100644 --- a/src/illogical/modules/models.py +++ b/src/illogical/modules/models.py @@ -2,10 +2,18 @@ from __future__ import annotations from typing import TYPE_CHECKING, ClassVar +from logic_plugin_manager.exceptions import ( + CategoryExistsError, + CategoryValidationError, + MusicAppsLoadError, + MusicAppsWriteError, +) from PySide6.QtCore import ( QAbstractItemModel, QAbstractListModel, QAbstractTableModel, + QByteArray, + QMimeData, QModelIndex, QObject, QSortFilterProxyModel, @@ -14,10 +22,19 @@ from PySide6.QtCore import ( ) from illogical.modules.sf_symbols import sf_symbol +from illogical.modules.virtual_category import VirtualCategoryTree if TYPE_CHECKING: from logic_plugin_manager import AudioComponent, Logic +CategoryError = ( + MusicAppsLoadError, + MusicAppsWriteError, + CategoryExistsError, + CategoryValidationError, + OSError, + ValueError, +) COL_NAME = 0 COL_CUSTOM_NAME = 1 @@ -246,12 +263,21 @@ class PluginTableModel(QAbstractTableModel): class CategoryTreeItem: def __init__( - self, name: str, full_path: str, parent: CategoryTreeItem | None = None + self, + name: str, + full_path: str, + parent: CategoryTreeItem | None = None, + plugin_count: int = 0, ) -> None: self.name = name self.full_path = full_path self.parent_item = parent self.children: list[CategoryTreeItem] = [] + self.plugin_count = plugin_count + + @property + def is_empty(self) -> bool: + return self.plugin_count == 0 def append_child(self, child: CategoryTreeItem) -> None: self.children.append(child) @@ -270,56 +296,59 @@ class CategoryTreeItem: return 0 +CATEGORY_MIME_TYPE = "application/x-illogical-category" + + class CategoryTreeModel(QAbstractItemModel): + category_changed = Signal() + error_occurred = Signal(str, str) + backup_requested = Signal(bool) + def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) self._root = CategoryTreeItem("", "") + self._virtual_tree: VirtualCategoryTree | None = None + self._logic: Logic | None = None def build_from_plugins(self, logic: Logic) -> None: self.beginResetModel() - self._root = CategoryTreeItem("", "") - - categories: set[str] = set() - for plugin in logic.plugins.all(): - for cat in plugin.categories: - if cat.name != "": - categories.add(cat.name) - - category_items: dict[str, CategoryTreeItem] = {} - - top_level_item = CategoryTreeItem("Top Level", "Top Level", self._root) - self._root.append_child(top_level_item) - - for cat_path in categories: - parts = cat_path.split(":") - current_path = "" - parent_item = self._root - - for part in parts: - current_path = f"{current_path}:{part}" if current_path else part - - if current_path not in category_items: - item = CategoryTreeItem(part, current_path, parent_item) - parent_item.append_child(item) - category_items[current_path] = item - - parent_item = category_items[current_path] - - self._sort_category_tree(self._root, logic) + self._logic = logic + self._virtual_tree = VirtualCategoryTree() + self._virtual_tree.build_from_logic(logic) + self._root = self._build_qt_tree_from_virtual() self.endResetModel() - def _sort_category_tree(self, item: CategoryTreeItem, logic: Logic) -> None: - def get_sort_key(path: str) -> tuple[int, str]: - if path in logic.categories: - return (logic.categories[path].index, path.lower()) - return (2**31 - 1, path.lower()) + def _build_qt_tree_from_virtual(self) -> CategoryTreeItem: + from illogical.modules.virtual_category import ( # noqa: PLC0415 + VirtualCategoryNode, + ) - top_level = [c for c in item.children if c.full_path == "Top Level"] - others = [c for c in item.children if c.full_path != "Top Level"] - others.sort(key=lambda c: get_sort_key(c.full_path)) - item.children = top_level + others - for child in item.children: - self._sort_category_tree(child, logic) + if self._virtual_tree is None: + return CategoryTreeItem("", "") + + root = CategoryTreeItem("", "") + + def build_item( + virtual_node: VirtualCategoryNode, parent_item: CategoryTreeItem + ) -> None: + for child_node in virtual_node.children: + item = CategoryTreeItem( + child_node.name, + child_node.full_path, + parent_item, + child_node.plugin_count, + ) + parent_item.append_child(item) + build_item(child_node, item) + + build_item(self._virtual_tree.root, root) + return root + + def _rebuild_from_virtual(self) -> None: + self.beginResetModel() + self._root = self._build_qt_tree_from_virtual() + self.endResetModel() + self.category_changed.emit() def index( self, row: int, column: int, parent: QModelIndex | None = None @@ -396,6 +425,223 @@ class CategoryTreeModel(QAbstractItemModel): return find_in_item(self._root, QModelIndex()) + def flags(self, index: QModelIndex) -> Qt.ItemFlag: + default_flags = super().flags(index) + if not index.isValid(): + return default_flags | Qt.ItemFlag.ItemIsDropEnabled + + item: CategoryTreeItem = index.internalPointer() + if item.full_path == "Top Level": + return default_flags + + return ( + default_flags + | Qt.ItemFlag.ItemIsDragEnabled + | Qt.ItemFlag.ItemIsDropEnabled + ) + + def supportedDropActions(self) -> Qt.DropAction: # noqa: N802 + return Qt.DropAction.MoveAction + + def mimeTypes(self) -> list[str]: # noqa: N802 + return [CATEGORY_MIME_TYPE] + + def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802 + mime_data = QMimeData() + if not indexes: + return mime_data + + paths = [] + for index in indexes: + if index.isValid(): + item: CategoryTreeItem = index.internalPointer() + if item.full_path and item.full_path != "Top Level": + paths.append(item.full_path) + + if paths: + mime_data.setData(CATEGORY_MIME_TYPE, QByteArray(paths[0].encode("utf-8"))) + + return mime_data + + def dropMimeData( # noqa: N802, C901, PLR0911, PLR0912 + self, + data: QMimeData, + action: Qt.DropAction, + row: int, + column: int, # noqa: ARG002 + parent: QModelIndex, + ) -> bool: + if action != Qt.DropAction.MoveAction: + return False + if not data.hasFormat(CATEGORY_MIME_TYPE): + return False + if self._virtual_tree is None or self._logic is None: + return False + + raw_data = data.data(CATEGORY_MIME_TYPE).data() + source_path = bytes(raw_data).decode("utf-8") if raw_data else "" + source_node = self._virtual_tree.get_node(source_path) + if source_node is None: + return False + + all_nodes = source_node.all_nodes_flat() + old_path_to_node = [(n.full_path, n) for n in all_nodes] + + if parent.isValid(): + target_item: CategoryTreeItem = parent.internalPointer() + target_path = target_item.full_path + else: + target_path = "" + + if row == -1: + target_node = self._virtual_tree.get_node(target_path) + if target_node is None: + return False + if target_path == "Top Level": + return False + if not self._virtual_tree.insert_into_parent(source_node, target_node): + return False + else: + if target_path: + target_parent_node = self._virtual_tree.get_node(target_path) + else: + target_parent_node = self._virtual_tree.root + + if target_parent_node is None: + return False + + if row < len(target_parent_node.children): + sibling_node = target_parent_node.children[row] + if not self._virtual_tree.move_before(source_node, sibling_node): + return False + elif target_parent_node.children: + last_sibling = target_parent_node.children[-1] + if not self._virtual_tree.move_after(source_node, last_sibling): + return False + + changed = { + old_path: n.full_path + for old_path, n in old_path_to_node + if old_path != n.full_path + } + + self.backup_requested.emit(bool(changed)) + + try: + self._virtual_tree.sync_to_logic(self._logic, changed if changed else None) + self._virtual_tree.update_plugin_counts(self._logic) + except CategoryError as e: + self.error_occurred.emit("Category Move Failed", str(e)) + return False + self._rebuild_from_virtual() + return True + + def move_category_up(self, index: QModelIndex) -> bool: + if not index.isValid() or self._virtual_tree is None or self._logic is None: + return False + + item: CategoryTreeItem = index.internalPointer() + node = self._virtual_tree.get_node(item.full_path) + if node is None: + return False + + if not self._virtual_tree.move_within_level(node, -1): + return False + + self.backup_requested.emit(False) # noqa: FBT003 + + try: + self._virtual_tree.sync_to_logic(self._logic) + except CategoryError as e: + self.error_occurred.emit("Category Move Failed", str(e)) + return False + self._rebuild_from_virtual() + return True + + def move_category_down(self, index: QModelIndex) -> bool: + if not index.isValid() or self._virtual_tree is None or self._logic is None: + return False + + item: CategoryTreeItem = index.internalPointer() + node = self._virtual_tree.get_node(item.full_path) + if node is None: + return False + + if not self._virtual_tree.move_within_level(node, 1): + return False + + self.backup_requested.emit(False) # noqa: FBT003 + + try: + self._virtual_tree.sync_to_logic(self._logic) + except CategoryError as e: + self.error_occurred.emit("Category Move Failed", str(e)) + return False + self._rebuild_from_virtual() + return True + + def extract_category(self, index: QModelIndex) -> bool: + if not index.isValid() or self._virtual_tree is None or self._logic is None: + return False + + item: CategoryTreeItem = index.internalPointer() + node = self._virtual_tree.get_node(item.full_path) + if node is None: + return False + + all_nodes = node.all_nodes_flat() + old_path_to_node = [(n.full_path, n) for n in all_nodes] + + if not self._virtual_tree.extract_from_parent(node): + return False + + changed = { + old_path: n.full_path + for old_path, n in old_path_to_node + if old_path != n.full_path + } + + self.backup_requested.emit(True) # noqa: FBT003 + + try: + self._virtual_tree.sync_to_logic(self._logic, changed if changed else None) + self._virtual_tree.update_plugin_counts(self._logic) + except CategoryError as e: + self.error_occurred.emit("Category Move Failed", str(e)) + return False + self._rebuild_from_virtual() + return True + + def can_delete_category(self, index: QModelIndex) -> bool: + if not index.isValid(): + return False + + item: CategoryTreeItem = index.internalPointer() + if item.full_path == "Top Level": + return False + + return item.is_empty and not item.children + + def delete_category(self, index: QModelIndex) -> bool: + if not index.isValid() or self._virtual_tree is None or self._logic is None: + return False + if not self.can_delete_category(index): + return False + + self.backup_requested.emit(True) # noqa: FBT003 + + item: CategoryTreeItem = index.internalPointer() + if not self._virtual_tree.delete_category(item.full_path, self._logic): + return False + + self._rebuild_from_virtual() + return True + + def get_item_at_index(self, index: QModelIndex) -> CategoryTreeItem | None: + if not index.isValid(): + return None + return index.internalPointer() + class ManufacturerListModel(QAbstractListModel): def __init__(self, parent: QObject | None = None) -> None: diff --git a/src/illogical/modules/virtual_category.py b/src/illogical/modules/virtual_category.py new file mode 100644 index 0000000..78ae7b9 --- /dev/null +++ b/src/illogical/modules/virtual_category.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import contextlib +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from logic_plugin_manager.exceptions import CategoryExistsError + +if TYPE_CHECKING: + from logic_plugin_manager import Logic + + +@dataclass +class VirtualCategoryNode: + name: str + full_path: str + parent: VirtualCategoryNode | None = None + children: list[VirtualCategoryNode] = field(default_factory=list) + plugin_count: int = 0 + + @property + def depth(self) -> int: + level = 0 + node = self.parent + while node is not None and node.full_path: + level += 1 + node = node.parent + return level + + @property + def siblings(self) -> list[VirtualCategoryNode]: + if self.parent is None: + return [self] + return self.parent.children + + @property + def sibling_index(self) -> int: + return self.siblings.index(self) + + @property + def is_first(self) -> bool: + return self.sibling_index == 0 + + @property + def is_last(self) -> bool: + return self.sibling_index == len(self.siblings) - 1 + + @property + def is_empty(self) -> bool: + return self.plugin_count == 0 + + def descendants_flat(self) -> list[VirtualCategoryNode]: + result: list[VirtualCategoryNode] = [] + for child in self.children: + result.append(child) + result.extend(child.descendants_flat()) + return result + + def all_nodes_flat(self) -> list[VirtualCategoryNode]: + result = [self] + for child in self.children: + result.extend(child.all_nodes_flat()) + return result + + +class VirtualCategoryTree: + def __init__(self) -> None: + self._root = VirtualCategoryNode(name="", full_path="") + self._nodes: dict[str, VirtualCategoryNode] = {} + self._top_level: VirtualCategoryNode | None = None + + @property + def root(self) -> VirtualCategoryNode: + return self._root + + def get_node(self, path: str) -> VirtualCategoryNode | None: + return self._nodes.get(path) + + def build_from_logic(self, logic: Logic) -> None: + self._root = VirtualCategoryNode(name="", full_path="") + self._nodes = {} + + self._top_level = VirtualCategoryNode( + name="Top Level", full_path="Top Level", parent=self._root + ) + self._root.children.append(self._top_level) + self._nodes["Top Level"] = self._top_level + + plugin_categories: dict[str, int] = {} + for plugin in logic.plugins.all(): + for cat in plugin.categories: + if cat.name: + plugin_categories[cat.name] = plugin_categories.get(cat.name, 0) + 1 + + tagpool_categories = set(logic.musicapps.tagpool.categories.keys()) + sorting_categories = set(logic.musicapps.properties.sorting) + all_category_paths = ( + set(plugin_categories.keys()) | tagpool_categories | sorting_categories + ) + + for cat_path in all_category_paths: + if not cat_path: + continue + self._ensure_category_exists(cat_path, plugin_categories.get(cat_path, 0)) + + self._sort_by_logic_indexes(logic) + + def _ensure_category_exists( + self, cat_path: str, plugin_count: int = 0 + ) -> VirtualCategoryNode: + if cat_path in self._nodes: + if plugin_count > 0: + self._nodes[cat_path].plugin_count = plugin_count + return self._nodes[cat_path] + + parts = cat_path.split(":") + current_path = "" + parent_node = self._root + + for i, part in enumerate(parts): + current_path = f"{current_path}:{part}" if current_path else part + if current_path in self._nodes: + parent_node = self._nodes[current_path] + else: + is_final = i == len(parts) - 1 + node = VirtualCategoryNode( + name=part, + full_path=current_path, + parent=parent_node, + plugin_count=plugin_count if is_final else 0, + ) + parent_node.children.append(node) + self._nodes[current_path] = node + parent_node = node + + return self._nodes[cat_path] + + def _sort_by_logic_indexes(self, logic: Logic) -> None: + def get_sort_key(node: VirtualCategoryNode) -> tuple[int, str]: + if node.full_path in logic.categories: + return (logic.categories[node.full_path].index, node.full_path.lower()) + return (2**31 - 1, node.full_path.lower()) + + def sort_children(node: VirtualCategoryNode) -> None: + top_level = [c for c in node.children if c.full_path == "Top Level"] + others = [c for c in node.children if c.full_path != "Top Level"] + others.sort(key=get_sort_key) + node.children = top_level + others + for child in node.children: + sort_children(child) + + sort_children(self._root) + + def move_within_level(self, node: VirtualCategoryNode, delta: int) -> bool: + if node.parent is None: + return False + + siblings = node.parent.children + current_idx = siblings.index(node) + new_idx = current_idx + delta + + if node.full_path == "Top Level": + return False + if new_idx < 0 or new_idx >= len(siblings): + return False + + target = siblings[new_idx] + if target.full_path == "Top Level": + return False + + siblings[current_idx], siblings[new_idx] = ( + siblings[new_idx], + siblings[current_idx], + ) + return True + + def extract_from_parent(self, node: VirtualCategoryNode) -> bool: + if node.parent is None or not node.parent.full_path: + return False + if node.full_path == "Top Level": + return False + + old_parent = node.parent + grandparent = old_parent.parent + if grandparent is None: + return False + + old_node_path = node.full_path + if grandparent.full_path: + new_node_path = f"{grandparent.full_path}:{node.name}" + else: + new_node_path = node.name + + del self._nodes[node.full_path] + node.full_path = new_node_path + self._nodes[node.full_path] = node + + for desc in node.descendants_flat(): + del self._nodes[desc.full_path] + desc.full_path = desc.full_path.replace(old_node_path, new_node_path, 1) + self._nodes[desc.full_path] = desc + + old_parent.children.remove(node) + parent_idx = grandparent.children.index(old_parent) + grandparent.children.insert(parent_idx + 1, node) + node.parent = grandparent + + return True + + def insert_into_parent( # noqa: C901 + self, node: VirtualCategoryNode, new_parent: VirtualCategoryNode + ) -> bool: + if node.full_path == "Top Level": + return False + if new_parent.full_path == "Top Level": + return False + if node.parent is None: + return False + + old_path = node.full_path + new_path = ( + f"{new_parent.full_path}:{node.name}" if new_parent.full_path else node.name + ) + + if new_path == old_path: + return False + + if new_path in self._nodes and self._nodes[new_path] != node: + return False + + old_paths = {node.full_path: node} + for desc in node.descendants_flat(): + old_paths[desc.full_path] = desc + + if node.full_path in self._nodes: + del self._nodes[node.full_path] + node.full_path = new_path + self._nodes[new_path] = node + + for desc in node.descendants_flat(): + old_desc_path = next(k for k, v in old_paths.items() if v == desc) + if old_desc_path in self._nodes: + del self._nodes[old_desc_path] + desc.full_path = desc.full_path.replace(old_path, new_path, 1) + self._nodes[desc.full_path] = desc + + if node in node.parent.children: + node.parent.children.remove(node) + new_parent.children.append(node) + node.parent = new_parent + + return True + + def move_before( # noqa: C901, PLR0911 + self, node: VirtualCategoryNode, target: VirtualCategoryNode + ) -> bool: + if node.full_path == "Top Level" or target.full_path == "Top Level": + return False + if node == target: + return False + if target.parent is None: + return False + + target_parent = target.parent + if target not in target_parent.children: + return False + target_idx = target_parent.children.index(target) + + if node.parent == target_parent: + if node not in target_parent.children: + return False + node_idx = target_parent.children.index(node) + target_parent.children.remove(node) + if node_idx < target_idx: + target_idx -= 1 + target_parent.children.insert(target_idx, node) + return True + + old_path = node.full_path + new_prefix = target_parent.full_path + new_path = f"{new_prefix}:{node.name}" if new_prefix else node.name + + if new_path in self._nodes and self._nodes[new_path] != node: + return False + + if new_path != old_path: + old_paths = list(self._nodes.keys()) + for p in old_paths: + if p == old_path or p.startswith(old_path + ":"): + n = self._nodes.pop(p) + n.full_path = p.replace(old_path, new_path, 1) + self._nodes[n.full_path] = n + + node.full_path = new_path + + if node.parent and node in node.parent.children: + node.parent.children.remove(node) + target_parent.children.insert(target_idx, node) + node.parent = target_parent + + return True + + def move_after( # noqa: C901, PLR0911 + self, node: VirtualCategoryNode, target: VirtualCategoryNode + ) -> bool: + if node.full_path == "Top Level" or target.full_path == "Top Level": + return False + if node == target: + return False + if target.parent is None: + return False + + target_parent = target.parent + if target not in target_parent.children: + return False + target_idx = target_parent.children.index(target) + 1 + + if node.parent == target_parent: + if node not in target_parent.children: + return False + node_idx = target_parent.children.index(node) + target_parent.children.remove(node) + if node_idx < target_idx: + target_idx -= 1 + target_parent.children.insert(target_idx, node) + return True + + old_path = node.full_path + new_prefix = target_parent.full_path + new_path = f"{new_prefix}:{node.name}" if new_prefix else node.name + + if new_path in self._nodes and self._nodes[new_path] != node: + return False + + if new_path != old_path: + old_paths = list(self._nodes.keys()) + for p in old_paths: + if p == old_path or p.startswith(old_path + ":"): + n = self._nodes.pop(p) + n.full_path = p.replace(old_path, new_path, 1) + self._nodes[n.full_path] = n + + node.full_path = new_path + + if node.parent and node in node.parent.children: + node.parent.children.remove(node) + target_parent.children.insert(target_idx, node) + node.parent = target_parent + + return True + + def calculate_flat_indexes(self) -> dict[str, int]: + indexes: dict[str, int] = {} + current_index = 0 + + def traverse(node: VirtualCategoryNode) -> None: + nonlocal current_index + if node.full_path and node.full_path != "Top Level": + indexes[node.full_path] = current_index + current_index += 1 + for child in node.children: + traverse(child) + + traverse(self._root) + return indexes + + def sync_to_logic( # noqa: C901, PLR0912 + self, logic: Logic, changed_paths: dict[str, str] | None = None + ) -> None: + changed_new_paths: set[str] = set() + old_paths_to_remove: set[str] = set() + + if changed_paths: + sorted_changes = sorted( + changed_paths.items(), key=lambda x: len(x[0]), reverse=True + ) + + for old_path, new_path in sorted_changes: + if old_path == new_path: + continue + + changed_new_paths.add(new_path) + old_paths_to_remove.add(old_path) + + plugins_to_move = list(logic.plugins.get_by_category(old_path)) + + try: + new_cat = logic.introduce_category(new_path) + except CategoryExistsError: + new_cat = logic.categories.get(new_path) + if new_cat is None: + logic.discover_categories() + new_cat = logic.categories[new_path] + + logic.discover_categories() + + for plugin in plugins_to_move: + plugin.add_to_category(new_cat) + old_cat = logic.categories.get(old_path) + if old_cat: + with contextlib.suppress(Exception): + plugin.remove_from_category(old_cat) + + logic.plugins.reindex_all() + + for new_path in changed_new_paths: + if new_path in logic.categories: + logic.sync_category_plugin_amount(logic.categories[new_path]) + + node = self.get_node(new_path) + if node: + plugins_in_cat = list(logic.plugins.get_by_category(new_path)) + node.plugin_count = len(plugins_in_cat) + + logic.discover_categories() + + sorted_old_paths = sorted( + old_paths_to_remove, key=lambda p: len(p), reverse=True + ) + for old_path in sorted_old_paths: + if old_path in logic.categories: + remaining = list(logic.plugins.get_by_category(old_path)) + if not remaining: + with contextlib.suppress(Exception): + logic.musicapps.remove_category(old_path) + + flat_indexes = self.calculate_flat_indexes() + sorted_paths = sorted(flat_indexes.keys(), key=lambda p: flat_indexes[p]) + + logic.discover_categories() + + for path in sorted_paths: + if path not in logic.categories: + continue + cat = logic.categories[path] + target_index = flat_indexes[path] + if cat.index != target_index: + with contextlib.suppress(ValueError): + cat.move_to(target_index) + + def update_plugin_counts(self, logic: Logic) -> None: + logic.plugins.reindex_all() + for path, node in self._nodes.items(): + if path == "Top Level": + continue + node.plugin_count = len(list(logic.plugins.get_by_category(path))) + + def delete_category(self, path: str, logic: Logic) -> bool: + node = self.get_node(path) + if node is None: + return False + if node.full_path == "Top Level": + return False + if node.plugin_count > 0: + return False + if node.children: + return False + + if node.parent: + node.parent.children.remove(node) + del self._nodes[path] + + if path in logic.categories: + logic.musicapps.remove_category(path) + + return True + + def get_category_paths_for_move(self, node: VirtualCategoryNode) -> dict[str, str]: + result: dict[str, str] = {} + old_path = node.full_path + for n in node.all_nodes_flat(): + if n.full_path != old_path: + result[n.full_path.replace(old_path, node.full_path, 1)] = n.full_path + return result diff --git a/src/illogical/ui/main_window.py b/src/illogical/ui/main_window.py index c8200fa..542e590 100644 --- a/src/illogical/ui/main_window.py +++ b/src/illogical/ui/main_window.py @@ -87,6 +87,7 @@ class MainWindow(QMainWindow): self._sidebar.category_selected.connect(self._on_category_selected) self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected) self._sidebar.enter_pressed.connect(self._plugin_table.focus_table) + self._sidebar.backup_requested.connect(self._on_category_backup_requested) self._plugin_table.search_changed.connect(self._on_search_changed) self._plugin_table.plugin_selected.connect(self._on_plugin_selected) self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested) @@ -194,6 +195,12 @@ class MainWindow(QMainWindow): except OSError as e: QMessageBox.warning(self, "Edit Failed", f"Failed to save changes: {e}") + def _on_category_backup_requested(self, force: bool) -> None: # noqa: FBT001 + if force or backup_manager.should_create_auto_backup(): + backup_manager.create_backup( + BackupTrigger.AUTO, "Before category modification" + ) + def _on_search_results(self, results: list[SearchResult]) -> None: plugins = [r.plugin for r in results] self._plugin_table.filter_by_search_results( diff --git a/src/illogical/ui/restore_backup_window.py b/src/illogical/ui/restore_backup_window.py index 02940ed..8b87612 100644 --- a/src/illogical/ui/restore_backup_window.py +++ b/src/illogical/ui/restore_backup_window.py @@ -19,6 +19,8 @@ from PySide6.QtWidgets import ( from illogical.modules.backup_models import ( BackupInfo, BackupTrigger, + CategoryChange, + CategoryChangeType, ChangeType, DetailedBackupChanges, FieldChange, @@ -167,6 +169,16 @@ class RestoreBackupWindow(QDialog): self._changes_tree.addTopLevelItem(item) return + self._add_category_group( + changes.categories_added, "Categories to remove", "folder.badge.minus" + ) + self._add_category_group( + changes.categories_moved, "Categories to revert", "folder.badge.gearshape" + ) + self._add_category_group( + changes.categories_deleted, "Categories to restore", "folder.badge.plus" + ) + self._add_plugin_group(changes.added, "Plugins to remove", "minus.circle") self._add_plugin_group( changes.modified, "Plugins to revert", "arrow.uturn.backward.circle" @@ -195,6 +207,31 @@ class RestoreBackupWindow(QDialog): self._changes_tree.addTopLevelItem(group_item) group_item.setExpanded(True) + def _add_category_group( + self, categories: list[CategoryChange], label: str, icon_name: str + ) -> None: + if not categories: + return + + group_item = QTreeWidgetItem([f"{label} ({len(categories)})"]) + icon = sf_symbol(icon_name, 14) + if not icon.isNull(): + group_item.setIcon(0, icon) + + for cat in sorted( + categories, key=lambda c: (c.old_path or c.new_path or "").lower() + ): + if cat.change_type == CategoryChangeType.MOVED: + text = f"{cat.new_path} → {cat.old_path}" + elif cat.change_type == CategoryChangeType.DELETED: + text = cat.old_path or "" + else: + text = cat.new_path or "" + QTreeWidgetItem(group_item, [text]) + + self._changes_tree.addTopLevelItem(group_item) + group_item.setExpanded(True) + def _format_field_change(self, change: FieldChange) -> str: if change.field_name.startswith("category:"): category = change.field_name.split(":", 1)[1] diff --git a/src/illogical/ui/sidebar.py b/src/illogical/ui/sidebar.py index 2c75f66..f0c2308 100644 --- a/src/illogical/ui/sidebar.py +++ b/src/illogical/ui/sidebar.py @@ -3,14 +3,15 @@ from __future__ import annotations from typing import TYPE_CHECKING from AppKit import NSColor # type: ignore[attr-defined] -from PySide6.QtCore import QModelIndex, QRect, Qt, Signal -from PySide6.QtGui import QFont +from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal +from PySide6.QtGui import QCursor, QFont from PySide6.QtWidgets import ( QAbstractItemView, QFrame, QHBoxLayout, QLabel, QListView, + QMenu, QSplitter, QStyle, QStyledItemDelegate, @@ -21,6 +22,7 @@ from PySide6.QtWidgets import ( ) from illogical.modules.models import ( + CategoryTreeItem, CategoryTreeModel, ManufacturerFilterProxy, ManufacturerListModel, @@ -42,15 +44,109 @@ KVK_L = 0x25 class _VimTreeView(QTreeView): enter_pressed = Signal() + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDefaultDropAction(Qt.DropAction.MoveAction) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu_requested) + self._expanded_paths: set[str] = set() + self.context_menu_path: str | None = None + + def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802 + old_model = self.model() + if old_model is not None: + old_model.modelAboutToBeReset.disconnect(self._save_expanded_state) + old_model.modelReset.disconnect(self._restore_expanded_state) + super().setModel(model) + if model is not None: + model.modelAboutToBeReset.connect(self._save_expanded_state) + model.modelReset.connect(self._restore_expanded_state) + + def _save_expanded_state(self) -> None: + self._expanded_paths.clear() + model = self.model() + if not isinstance(model, CategoryTreeModel): + return + + def collect_expanded(parent: QModelIndex) -> None: + for row in range(model.rowCount(parent)): + index = model.index(row, 0, parent) + if self.isExpanded(index): + path = index.data(Qt.ItemDataRole.UserRole) + if path: + self._expanded_paths.add(path) + collect_expanded(index) + + collect_expanded(QModelIndex()) + + def _restore_expanded_state(self) -> None: + model = self.model() + if not isinstance(model, CategoryTreeModel): + return + + for path in self._expanded_paths: + index = model.index_for_path(path) + if index.isValid(): + self.expand(index) + def _select_and_activate(self, index: QModelIndex) -> None: self.selectionModel().setCurrentIndex( index, self.selectionModel().SelectionFlag.ClearAndSelect ) self.clicked.emit(index) - def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 + def _on_context_menu_requested(self, pos: QPoint) -> None: + index = self.indexAt(pos) + if index.isValid(): + self._show_context_menu(index) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + if event.button() == Qt.MouseButton.RightButton: + event.accept() + return + super().mousePressEvent(event) + + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912 vk = event.nativeVirtualKey() key = event.key() + mods = event.modifiers() + + has_alt = bool(mods & Qt.KeyboardModifier.AltModifier) + has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier) + has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier) + has_meta = bool(mods & Qt.KeyboardModifier.MetaModifier) + + if has_alt and has_shift and not has_ctrl and not has_meta: + model = self.model() + current = self.currentIndex() + if isinstance(model, CategoryTreeModel): + if key == Qt.Key.Key_Up: + if not model.move_category_up(current): + model.extract_category(current) + self._restore_selection_after_move(current) + event.accept() + return + if key == Qt.Key.Key_Down: + if not model.move_category_down(current): + model.extract_category(current) + self._restore_selection_after_move(current) + event.accept() + return + + if ( + has_alt + and not has_shift + and not has_ctrl + and not has_meta + and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter) + ): + self._show_context_menu(self.currentIndex()) + event.accept() + return if vk == KVK_J or key == Qt.Key.Key_Down: next_idx = self.indexBelow(self.currentIndex()) @@ -78,6 +174,109 @@ class _VimTreeView(QTreeView): return super().keyPressEvent(event) + def _restore_selection_after_move(self, old_index: QModelIndex) -> None: + if not old_index.isValid(): + return + item: CategoryTreeItem = old_index.internalPointer() + path = item.full_path + model = self.model() + if isinstance(model, CategoryTreeModel): + new_index = model.index_for_path(path) + if new_index.isValid(): + parent = new_index.parent() + while parent.isValid(): + self.expand(parent) + parent = parent.parent() + self.selectionModel().setCurrentIndex( + new_index, self.selectionModel().SelectionFlag.ClearAndSelect + ) + + def _show_context_menu(self, index: QModelIndex) -> None: + if not index.isValid(): + return + + model = self.model() + if not isinstance(model, CategoryTreeModel): + return + + item: CategoryTreeItem = index.internalPointer() + if item.full_path == "Top Level": + return + + self.context_menu_path = item.full_path + self.viewport().update() + + import pyqt_liquidglass as glass # noqa: PLC0415 + + menu = QMenu(self) + menu.aboutToHide.connect(self._on_context_menu_hidden) + menu.setStyleSheet(""" + QMenu { + background: transparent; + border: none; + border-radius: 10px; + padding: 4px 2px; + } + QMenu::item { + padding: 6px 14px 6px 6px; + margin: 0px 2px; + border-radius: 6px; + color: rgba(255, 255, 255, 0.9); + } + QMenu::item:selected { + background-color: rgba(255, 255, 255, 0.15); + } + QMenu::icon { + padding-left: 6px; + } + QMenu::separator { + height: 1px; + background: rgba(255, 255, 255, 0.12); + margin: 6px 10px; + } + """) + glass.prepare_window_for_glass(menu) + + move_up_action = menu.addAction(sf_symbol("arrow.up", 14), "Move Up") + move_up_action.setShortcut("Alt+Shift+Up") + move_up_action.triggered.connect(lambda: self._do_move_up(model, index)) + + move_down_action = menu.addAction(sf_symbol("arrow.down", 14), "Move Down") + move_down_action.setShortcut("Alt+Shift+Down") + move_down_action.triggered.connect(lambda: self._do_move_down(model, index)) + + if item.parent_item and item.parent_item.full_path: + menu.addSeparator() + extract_action = menu.addAction( + sf_symbol("arrow.turn.left.up", 14), "Move Out of Parent" + ) + extract_action.triggered.connect(lambda: self._do_extract(model, index)) + + if model.can_delete_category(index): + menu.addSeparator() + delete_action = menu.addAction(sf_symbol("trash", 14), "Delete Category") + delete_action.triggered.connect(lambda: model.delete_category(index)) + + menu.popup(QCursor.pos()) + opts = glass.GlassOptions(corner_radius=10.0) + QTimer.singleShot(0, lambda: glass.apply_glass_to_window(menu, opts)) + + def _on_context_menu_hidden(self) -> None: + self.context_menu_path = None + self.viewport().update() + + def _do_move_up(self, model: CategoryTreeModel, index: QModelIndex) -> None: + model.move_category_up(index) + self._restore_selection_after_move(index) + + def _do_move_down(self, model: CategoryTreeModel, index: QModelIndex) -> None: + model.move_category_down(index) + self._restore_selection_after_move(index) + + def _do_extract(self, model: CategoryTreeModel, index: QModelIndex) -> None: + model.extract_category(index) + self._restore_selection_after_move(index) + class _VimListView(QListView): enter_pressed = Signal() @@ -117,9 +316,23 @@ class _CategoryDelegate(QStyledItemDelegate): def paint( self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex ) -> None: + from PySide6.QtGui import QBrush, QColor, QPainterPath # noqa: PLC0415 + full_path = index.data(Qt.ItemDataRole.UserRole) icon = index.data(Qt.ItemDataRole.DecorationRole) + tree_view = option.widget + is_context_target = ( + isinstance(tree_view, _VimTreeView) + and tree_view.context_menu_path == full_path + ) + if is_context_target: + painter.save() + path = QPainterPath() + path.addRoundedRect(option.rect.toRectF(), 4, 4) + painter.fillPath(path, QBrush(QColor(128, 128, 128, 60))) + painter.restore() + if full_path == "Top Level" and icon and not icon.isNull(): opt = QStyleOptionViewItem(option) self.initStyleOption(opt, index) @@ -252,6 +465,7 @@ class Sidebar(QWidget): category_selected = Signal(object) manufacturer_selected = Signal(str) enter_pressed = Signal() + backup_requested = Signal(bool) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) @@ -320,7 +534,9 @@ class Sidebar(QWidget): tree_layout.setSpacing(0) self._category_model = CategoryTreeModel() - self._category_tree = _VimTreeView() + self._category_model.error_occurred.connect(self._show_category_error) + self._category_model.backup_requested.connect(self.backup_requested) + self._category_tree = _VimTreeView(self) self._category_tree.setItemDelegate(_CategoryDelegate(self._category_tree)) self._category_tree.setModel(self._category_model) self._category_tree.setHeaderHidden(True) @@ -394,6 +610,25 @@ class Sidebar(QWidget): self._manufacturer_list.clearSelection() self._uncategorized.set_selected(False) + def _show_category_error(self, title: str, message: str) -> None: + from AppKit import NSAlert, NSAlertStyleWarning, NSApp # noqa: PLC0415 + + alert = NSAlert.alloc().init() + alert.setMessageText_(title) + alert.setInformativeText_(message) + alert.setAlertStyle_(NSAlertStyleWarning) + alert.addButtonWithTitle_("OK") + + window = None + if self.window(): + window = self.window().winId().__int__() + ns_window = NSApp.windowWithWindowNumber_(window) + if ns_window: + alert.beginSheetModalForWindow_completionHandler_(ns_window, None) + return + + alert.runModal() + def _on_show_all_clicked(self) -> None: self._active_category = "Show All" self._active_manufacturer = None