From a39797505e2d3e69b6420de3c701fe416ad0bb4b Mon Sep 17 00:00:00 2001 From: h Date: Mon, 2 Feb 2026 13:30:39 +0100 Subject: [PATCH] feat: add drag-drop, multi-selection, and context menu for plugins --- src/illogical/modules/models.py | 63 ++++++- src/illogical/ui/main_window.py | 133 +++++++++++++- src/illogical/ui/plugin_table.py | 290 ++++++++++++++++++++++++++++++- src/illogical/ui/sidebar.py | 53 +++++- 4 files changed, 527 insertions(+), 12 deletions(-) diff --git a/src/illogical/modules/models.py b/src/illogical/modules/models.py index 7ca824f..a1c8781 100644 --- a/src/illogical/modules/models.py +++ b/src/illogical/modules/models.py @@ -136,10 +136,39 @@ class PluginTableModel(QAbstractTableModel): def flags(self, index: QModelIndex) -> Qt.ItemFlag: base_flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable + if index.isValid(): + base_flags |= Qt.ItemFlag.ItemIsDragEnabled if index.column() in (COL_CUSTOM_NAME, COL_SHORT_NAME): return base_flags | Qt.ItemFlag.ItemIsEditable return base_flags + def mimeTypes(self) -> list[str]: # noqa: N802 + return [PLUGIN_MIME_TYPE] + + def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802 + mime_data = QMimeData() + if not indexes: + return mime_data + + rows = set() + for index in indexes: + if index.isValid(): + rows.add(index.row()) + + plugin_ids = [] + for row in sorted(rows): + plugin = self.get_plugin(row) + if plugin: + plugin_ids.append( + f"{plugin.name}|{plugin.manufacturer}|{plugin.type_code}" + ) + + if plugin_ids: + data = "\n".join(plugin_ids) + mime_data.setData(PLUGIN_MIME_TYPE, QByteArray(data.encode("utf-8"))) + + return mime_data + def setData( # noqa: N802 self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole ) -> bool: @@ -298,12 +327,14 @@ class CategoryTreeItem: CATEGORY_MIME_TYPE = "application/x-illogical-category" +PLUGIN_MIME_TYPE = "application/x-illogical-plugin" class CategoryTreeModel(QAbstractItemModel): category_changed = Signal() error_occurred = Signal(str, str) backup_requested = Signal(bool) + plugins_dropped = Signal(list, str, bool) def __init__(self, parent: QObject | None = None) -> None: super().__init__(parent) @@ -443,10 +474,10 @@ class CategoryTreeModel(QAbstractItemModel): ) def supportedDropActions(self) -> Qt.DropAction: # noqa: N802 - return Qt.DropAction.MoveAction + return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction def mimeTypes(self) -> list[str]: # noqa: N802 - return [CATEGORY_MIME_TYPE] + return [CATEGORY_MIME_TYPE, PLUGIN_MIME_TYPE] def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802 mime_data = QMimeData() @@ -473,6 +504,9 @@ class CategoryTreeModel(QAbstractItemModel): column: int, # noqa: ARG002 parent: QModelIndex, ) -> bool: + if data.hasFormat(PLUGIN_MIME_TYPE): + return self._handle_plugin_drop(data, parent) + if action != Qt.DropAction.MoveAction: return False if not data.hasFormat(CATEGORY_MIME_TYPE): @@ -538,6 +572,27 @@ class CategoryTreeModel(QAbstractItemModel): self._rebuild_from_virtual() return True + def _handle_plugin_drop(self, data: QMimeData, parent: QModelIndex) -> bool: + from PySide6.QtWidgets import QApplication # noqa: PLC0415 + + if not parent.isValid(): + return False + + item: CategoryTreeItem = parent.internalPointer() + target_category = "" if item.full_path == "Top Level" else item.full_path + + modifiers = QApplication.keyboardModifiers() + is_add = bool(modifiers & Qt.KeyboardModifier.ShiftModifier) + is_move = not is_add + + raw_data = data.data(PLUGIN_MIME_TYPE).data() + plugin_ids = bytes(raw_data).decode("utf-8").split("\n") if raw_data else [] + + if plugin_ids: + self.plugins_dropped.emit(plugin_ids, target_category, is_move) + + 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 @@ -644,6 +699,10 @@ class CategoryTreeModel(QAbstractItemModel): return None return index.internalPointer() + @property + def root_item(self) -> CategoryTreeItem: + return self._root + def setData( # noqa: N802 self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole ) -> bool: diff --git a/src/illogical/ui/main_window.py b/src/illogical/ui/main_window.py index 542e590..b551c8d 100644 --- a/src/illogical/ui/main_window.py +++ b/src/illogical/ui/main_window.py @@ -20,7 +20,7 @@ from illogical.ui.restore_backup_window import RestoreBackupWindow from illogical.ui.sidebar import Sidebar if TYPE_CHECKING: - from logic_plugin_manager import AudioComponent, Logic, SearchResult + from logic_plugin_manager import AudioComponent, Category, Logic, SearchResult from PySide6.QtGui import QCloseEvent, QKeyEvent, QShowEvent from illogical.modules.backup_models import ( @@ -138,6 +138,12 @@ class MainWindow(QMainWindow): self._backup_service.set_logic(logic) self._sidebar.populate(logic) self._plugin_table.set_plugins(logic) + self._plugin_table.set_category_tree(self._sidebar.category_model) + self._sidebar.category_model.plugins_dropped.connect(self._on_plugins_dropped) + self._plugin_table.category_assignment_requested.connect( + self._on_category_assignment + ) + self._plugin_table.category_removal_requested.connect(self._on_category_removal) self._loading_overlay.hide() self._plugin_table.focus_table() @@ -201,6 +207,131 @@ class MainWindow(QMainWindow): BackupTrigger.AUTO, "Before category modification" ) + def _on_plugins_dropped( + self, + plugin_ids: list[str], + category_path: str, + is_move: bool, # noqa: FBT001 + ) -> None: + if not self._logic: + return + + plugins = [] + for plugin_id in plugin_ids: + parts = plugin_id.split("|") + if len(parts) == 3: # noqa: PLR2004 + name, manufacturer, type_code = parts + for plugin in self._logic.plugins.all(): + if ( + plugin.name == name + and plugin.manufacturer == manufacturer + and plugin.type_code == type_code + ): + plugins.append(plugin) + break + + if plugins: + self._assign_plugins_to_category(plugins, category_path, is_move) + + def _on_category_assignment( + self, + plugins: list[AudioComponent], + category_path: str, + is_move: bool, # noqa: FBT001 + ) -> None: + self._assign_plugins_to_category(plugins, category_path, is_move) + + def _on_category_removal(self, plugins: list[AudioComponent]) -> None: + if not self._logic or not self._current_category: + return + + try: + if backup_manager.should_create_auto_backup(): + backup_manager.create_backup( + BackupTrigger.AUTO, + f"Before removing {len(plugins)} plugin(s) from category", + ) + + category_path = self._current_category + if category_path == "Top Level": + category_path = "" + + category = self._logic.categories.get(category_path) + if category: + for plugin in plugins: + plugin.remove_from_category(category) + self._logic.sync_category_plugin_amount(category) + + self._sidebar.populate(self._logic) + self._plugin_table.filter_by_category(self._current_category) + + except Exception as e: # noqa: BLE001 + QMessageBox.warning( + self, "Category Removal Failed", f"Failed to remove plugins: {e}" + ) + + def _assign_plugins_to_category( + self, + plugins: list[AudioComponent], + category_path: str, + is_move: bool, # noqa: FBT001 + ) -> None: + if not self._logic: + return + + try: + if backup_manager.should_create_auto_backup(): + action = "Moving" if is_move else "Assigning" + backup_manager.create_backup( + BackupTrigger.AUTO, + f"Before {action.lower()} {len(plugins)} plugin(s) to category", + ) + + if category_path: + category = self._logic.categories.get(category_path) + if not category: + category = self._logic.introduce_category(category_path) + else: + category = self._logic.categories.get("") + if not category: + category = self._logic.introduce_category("") + + for plugin in plugins: + self._add_plugin_to_category(plugin, category, is_move) + + self._logic.sync_category_plugin_amount(category) + self._sidebar.populate(self._logic) + + if self._current_manufacturer: + self._plugin_table.filter_by_manufacturer(self._current_manufacturer) + else: + self._plugin_table.filter_by_category(self._current_category) + + except Exception as e: # noqa: BLE001 + QMessageBox.warning( + self, "Category Assignment Failed", f"Failed to assign plugins: {e}" + ) + + def _add_plugin_to_category( + self, + plugin: AudioComponent, + category: Category, + is_move: bool, # noqa: FBT001 + ) -> None: + current_tags = getattr(plugin.tagset, "tags", None) + if current_tags is None: + plugin.tagset.load() + current_tags = plugin.tagset.tags + + if is_move: + new_tags = {category.name: "user"} + else: + new_tags = dict(current_tags) + new_tags[category.name] = "user" + + plugin.tagset.set_tags(new_tags) + plugin.load() + 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/plugin_table.py b/src/illogical/ui/plugin_table.py index 003162a..d1a57e0 100644 --- a/src/illogical/ui/plugin_table.py +++ b/src/illogical/ui/plugin_table.py @@ -2,11 +2,13 @@ from __future__ import annotations from typing import TYPE_CHECKING -from PySide6.QtCore import Qt, Signal +from PySide6.QtCore import QItemSelection, QPoint, Qt, QTimer, Signal +from PySide6.QtGui import QCursor from PySide6.QtWidgets import ( QAbstractItemView, QFrame, QHeaderView, + QMenu, QTableView, QVBoxLayout, QWidget, @@ -19,14 +21,16 @@ from illogical.modules.models import ( COL_SHORT_NAME, COL_TYPE, COL_VERSION, + CategoryTreeItem, + CategoryTreeModel, PluginTableModel, ) from illogical.ui.search_bar import SearchBar if TYPE_CHECKING: from logic_plugin_manager import AudioComponent, Logic - from PySide6.QtCore import QModelIndex - from PySide6.QtGui import QKeyEvent, QResizeEvent + from PySide6.QtCore import QEvent, QModelIndex + from PySide6.QtGui import QKeyEvent, QMouseEvent, QResizeEvent KVK_J = 0x26 @@ -35,6 +39,49 @@ KVK_K = 0x28 class _VimTableView(QTableView): enter_pressed = Signal() + context_menu_requested = Signal(QPoint) + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._anchor_row: int | None = None + self.setDragEnabled(True) + self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly) + self.setDefaultDropAction(Qt.DropAction.CopyAction) + self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.customContextMenuRequested.connect(self._on_context_menu) + + def _on_context_menu(self, pos: QPoint) -> None: + if self.selectionModel().hasSelection(): + self.context_menu_requested.emit(pos) + + def event(self, e: QEvent) -> bool: + from PySide6.QtCore import QEvent as QEventType # noqa: PLC0415 + from PySide6.QtGui import QKeyEvent as QKeyEventType # noqa: PLC0415 + + if ( + e.type() == QEventType.Type.ShortcutOverride + and isinstance(e, QKeyEventType) + and e.key() == Qt.Key.Key_A + and e.modifiers() == Qt.KeyboardModifier.ControlModifier + ): + e.accept() + return True + return super().event(e) + + def _select_all_rows(self) -> None: + row_count = self.model().rowCount() + if row_count > 0: + top_left = self.model().index(0, 0) + bottom_right = self.model().index( + row_count - 1, self.model().columnCount() - 1 + ) + selection = QItemSelection(top_left, bottom_right) + self.selectionModel().select( + selection, + self.selectionModel().SelectionFlag.ClearAndSelect + | self.selectionModel().SelectionFlag.Rows, + ) + self._anchor_row = 0 def _select_row(self, row: int) -> None: index = self.model().index(row, 0) @@ -43,21 +90,106 @@ class _VimTableView(QTableView): self.selectionModel().SelectionFlag.ClearAndSelect | self.selectionModel().SelectionFlag.Rows, ) + self._anchor_row = row + + def _extend_selection_to(self, row: int) -> None: + if self._anchor_row is None: + self._anchor_row = self.currentIndex().row() + + start = min(self._anchor_row, row) + end = max(self._anchor_row, row) + + self.selectionModel().clear() + for r in range(start, end + 1): + idx = self.model().index(r, 0) + self.selectionModel().select( + idx, + self.selectionModel().SelectionFlag.Select + | self.selectionModel().SelectionFlag.Rows, + ) + + index = self.model().index(row, 0) + self.selectionModel().setCurrentIndex( + index, self.selectionModel().SelectionFlag.NoUpdate + ) + + def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802 + index = self.indexAt(event.position().toPoint()) + if not index.isValid(): + super().mousePressEvent(event) + return + + mods = event.modifiers() + has_cmd = bool(mods & Qt.KeyboardModifier.ControlModifier) + has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier) + + if has_cmd and not has_shift: + self.selectionModel().setCurrentIndex( + index, + self.selectionModel().SelectionFlag.Toggle + | self.selectionModel().SelectionFlag.Rows, + ) + if self._anchor_row is None: + self._anchor_row = index.row() + event.accept() + return + + if has_shift and self._anchor_row is not None: + self.selectionModel().clear() + start = min(self._anchor_row, index.row()) + end = max(self._anchor_row, index.row()) + for r in range(start, end + 1): + idx = self.model().index(r, 0) + self.selectionModel().select( + idx, + self.selectionModel().SelectionFlag.Select + | self.selectionModel().SelectionFlag.Rows, + ) + self.selectionModel().setCurrentIndex( + index, self.selectionModel().SelectionFlag.NoUpdate + ) + event.accept() + return + + self._anchor_row = index.row() + super().mousePressEvent(event) def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 vk = event.nativeVirtualKey() key = event.key() + mods = event.modifiers() + has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier) + has_alt = bool(mods & Qt.KeyboardModifier.AltModifier) + has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier) + + if has_ctrl and key == Qt.Key.Key_A: + self._select_all_rows() + event.accept() + return + + if has_alt and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + self.context_menu_requested.emit(QPoint(0, 0)) + event.accept() + return if vk == KVK_J or key == Qt.Key.Key_Down: current = self.currentIndex() if current.row() < self.model().rowCount() - 1: - self._select_row(current.row() + 1) + new_row = current.row() + 1 + if has_shift: + self._extend_selection_to(new_row) + else: + self._select_row(new_row) event.accept() return if vk == KVK_K or key == Qt.Key.Key_Up: current = self.currentIndex() if current.row() > 0: - self._select_row(current.row() - 1) + new_row = current.row() - 1 + if has_shift: + self._extend_selection_to(new_row) + else: + self._select_row(new_row) event.accept() return if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): @@ -66,14 +198,28 @@ class _VimTableView(QTableView): return super().keyPressEvent(event) + def get_selected_plugins(self) -> list[AudioComponent]: + model = self.model() + if not isinstance(model, PluginTableModel): + return [] + plugins = [] + for index in self.selectionModel().selectedRows(): + plugin = model.get_plugin(index.row()) + if plugin: + plugins.append(plugin) + return plugins + class PluginTableView(QWidget): search_changed = Signal(str) plugin_selected = Signal(object) edit_requested = Signal(object, int, str) + category_assignment_requested = Signal(list, str, bool) + category_removal_requested = Signal(list) def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) + self._category_tree: CategoryTreeModel | None = None layout = QVBoxLayout(self) layout.setContentsMargins(16, 0, 16, 16) @@ -90,7 +236,7 @@ class PluginTableView(QWidget): self._table.setModel(self._model) self._table.setAlternatingRowColors(True) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) - self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self._table.setShowGrid(False) self._table.verticalHeader().setVisible(False) self._table.horizontalHeader().setStretchLastSection(True) @@ -138,6 +284,7 @@ class PluginTableView(QWidget): self._table.selectionModel().currentChanged.connect(self._on_current_changed) self._table.enter_pressed.connect(self._on_enter_pressed) + self._table.context_menu_requested.connect(self._on_context_menu_requested) def _on_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None: if current.isValid(): @@ -152,6 +299,137 @@ class PluginTableView(QWidget): if plugin: self.plugin_selected.emit(plugin) + def set_category_tree(self, tree: CategoryTreeModel) -> None: + self._category_tree = tree + + def _on_context_menu_requested(self, pos: QPoint) -> None: # noqa: ARG002 + plugins = self._table.get_selected_plugins() + if not plugins: + return + self._show_context_menu(plugins) + + def _show_context_menu(self, plugins: list[AudioComponent]) -> None: + import pyqt_liquidglass as glass # noqa: PLC0415 + + opts = glass.GlassOptions(corner_radius=10.0) + + def apply_glass_on_show(m: QMenu) -> None: + glass.prepare_window_for_glass(m) + glass.apply_glass_to_window(m, opts) + + menu = QMenu(self) + menu.setStyleSheet(self._get_glass_menu_stylesheet()) + glass.prepare_window_for_glass(menu) + + assign_menu = menu.addMenu("Assign to") + assign_menu.setStyleSheet(self._get_glass_menu_stylesheet()) + assign_menu.aboutToShow.connect(lambda: apply_glass_on_show(assign_menu)) + self._build_category_submenu(assign_menu, plugins, is_move=False) + + has_categories = any(p.categories for p in plugins) + if has_categories: + move_menu = menu.addMenu("Move to") + move_menu.setStyleSheet(self._get_glass_menu_stylesheet()) + move_menu.aboutToShow.connect(lambda: apply_glass_on_show(move_menu)) + self._build_category_submenu(move_menu, plugins, is_move=True) + + menu.addSeparator() + remove_action = menu.addAction("Remove from current category") + remove_action.triggered.connect( + lambda: self._on_remove_from_category(plugins) + ) + + menu.popup(QCursor.pos()) + QTimer.singleShot(0, lambda: glass.apply_glass_to_window(menu, opts)) + + def _build_category_submenu( + self, menu: QMenu, plugins: list[AudioComponent], *, is_move: bool + ) -> None: + import pyqt_liquidglass as glass # noqa: PLC0415 + + if self._category_tree is None: + return + + def apply_glass_on_show(submenu: QMenu) -> None: + glass.prepare_window_for_glass(submenu) + opts = glass.GlassOptions(corner_radius=8.0) + glass.apply_glass_to_window(submenu, opts) + + def build_from_item(parent_menu: QMenu, item: CategoryTreeItem) -> None: + for child in item.children: + if child.full_path == "Top Level": + action = parent_menu.addAction("Top Level") + action.triggered.connect( + lambda _=None, p=plugins, m=is_move: self._on_category_action( + p, "", m + ) + ) + elif child.children: + submenu = parent_menu.addMenu(child.name) + submenu.setStyleSheet(self._get_glass_menu_stylesheet()) + submenu.aboutToShow.connect( + lambda s=submenu: apply_glass_on_show(s) + ) + + self_action = submenu.addAction(f"{child.name}") + self_action.triggered.connect( + lambda _=None, p=plugins, c=child.full_path, m=is_move: ( + self._on_category_action(p, c, m) + ) + ) + submenu.addSeparator() + build_from_item(submenu, child) + else: + action = parent_menu.addAction(child.name) + action.triggered.connect( + lambda _=None, p=plugins, c=child.full_path, m=is_move: ( + self._on_category_action(p, c, m) + ) + ) + + build_from_item(menu, self._category_tree.root_item) + + def _on_category_action( + self, + plugins: list[AudioComponent], + category_path: str, + is_move: bool, # noqa: FBT001 + ) -> None: + self.category_assignment_requested.emit(plugins, category_path, is_move) + + def _on_remove_from_category(self, plugins: list[AudioComponent]) -> None: + self.category_removal_requested.emit(plugins) + + def _get_glass_menu_stylesheet(self) -> str: + return """ + QMenu { + background: transparent; + border: none; + border-radius: 10px; + padding: 4px 2px; + } + QMenu::item { + padding: 4px 12px 4px 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::separator { + height: 1px; + background: rgba(255, 255, 255, 0.12); + margin: 4px 10px; + } + QMenu::right-arrow { + width: 8px; + height: 8px; + right: 9px; + top: -1px; + } + """ + def set_plugins(self, logic: Logic) -> None: self._model.set_plugins(logic) self._resize_columns() diff --git a/src/illogical/ui/sidebar.py b/src/illogical/ui/sidebar.py index 6cfb078..83f9afd 100644 --- a/src/illogical/ui/sidebar.py +++ b/src/illogical/ui/sidebar.py @@ -4,7 +4,14 @@ from typing import TYPE_CHECKING from AppKit import NSColor # type: ignore[attr-defined] from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal -from PySide6.QtGui import QCursor, QFont +from PySide6.QtGui import ( + QCursor, + QDragEnterEvent, + QDragLeaveEvent, + QDragMoveEvent, + QDropEvent, + QFont, +) from PySide6.QtWidgets import ( QAbstractItemView, QFrame, @@ -23,6 +30,7 @@ from PySide6.QtWidgets import ( ) from illogical.modules.models import ( + PLUGIN_MIME_TYPE, CategoryTreeItem, CategoryTreeModel, ManufacturerFilterProxy, @@ -50,12 +58,13 @@ class _VimTreeView(QTreeView): self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) - self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) + self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop) 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 + self.drop_target_path: str | None = None def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802 old_model = self.model() @@ -124,6 +133,36 @@ class _VimTreeView(QTreeView): return super().mouseDoubleClickEvent(event) + def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802 + if event.mimeData().hasFormat(PLUGIN_MIME_TYPE): + event.acceptProposedAction() + else: + super().dragEnterEvent(event) + + def dragMoveEvent(self, event: QDragMoveEvent) -> None: # noqa: N802 + index = self.indexAt(event.position().toPoint()) + if event.mimeData().hasFormat(PLUGIN_MIME_TYPE): + if index.isValid(): + item: CategoryTreeItem = index.internalPointer() + self.drop_target_path = item.full_path + else: + self.drop_target_path = None + self.viewport().update() + event.acceptProposedAction() + else: + self.drop_target_path = None + super().dragMoveEvent(event) + + def dragLeaveEvent(self, event: QDragLeaveEvent) -> None: # noqa: N802 + self.drop_target_path = None + self.viewport().update() + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent) -> None: # noqa: N802 + self.drop_target_path = None + self.viewport().update() + super().dropEvent(event) + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912 vk = event.nativeVirtualKey() key = event.key() @@ -365,7 +404,11 @@ class _CategoryDelegate(QStyledItemDelegate): isinstance(tree_view, _VimTreeView) and tree_view.context_menu_path == full_path ) - if is_context_target: + is_drop_target = ( + isinstance(tree_view, _VimTreeView) + and tree_view.drop_target_path == full_path + ) + if is_context_target or is_drop_target: painter.save() path = QPainterPath() path.addRoundedRect(option.rect.toRectF(), 4, 4) @@ -695,6 +738,10 @@ class Sidebar(QWidget): self._category_model.build_from_plugins(logic) self._manufacturer_model.build_from_plugins(logic) + @property + def category_model(self) -> CategoryTreeModel: + return self._category_model + def _clear_selections(self) -> None: self._category_tree.clearSelection() self._manufacturer_list.clearSelection()