From 08556cfc366e66e502d758c32f0f1a623f13cf25 Mon Sep 17 00:00:00 2001 From: h Date: Sat, 31 Jan 2026 21:59:28 +0100 Subject: [PATCH] feat: add category creation and renaming --- src/illogical/modules/models.py | 79 ++++++++++++++++- src/illogical/modules/sf_symbols.py | 14 ++- src/illogical/modules/virtual_category.py | 57 ++++++++++++ src/illogical/ui/sidebar.py | 102 +++++++++++++++++++++- 4 files changed, 246 insertions(+), 6 deletions(-) diff --git a/src/illogical/modules/models.py b/src/illogical/modules/models.py index 78e0d39..7ca824f 100644 --- a/src/illogical/modules/models.py +++ b/src/illogical/modules/models.py @@ -18,6 +18,7 @@ from PySide6.QtCore import ( QObject, QSortFilterProxyModel, Qt, + QTimer, Signal, ) @@ -400,7 +401,7 @@ class CategoryTreeModel(QAbstractItemModel): item: CategoryTreeItem = index.internalPointer() - if role == Qt.ItemDataRole.DisplayRole: + if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole): return item.name if role == Qt.ItemDataRole.UserRole: @@ -438,6 +439,7 @@ class CategoryTreeModel(QAbstractItemModel): default_flags | Qt.ItemFlag.ItemIsDragEnabled | Qt.ItemFlag.ItemIsDropEnabled + | Qt.ItemFlag.ItemIsEditable ) def supportedDropActions(self) -> Qt.DropAction: # noqa: N802 @@ -642,6 +644,81 @@ class CategoryTreeModel(QAbstractItemModel): return None return index.internalPointer() + def setData( # noqa: N802 + self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole + ) -> bool: + if role != Qt.ItemDataRole.EditRole: + return False + if not index.isValid(): + return False + + item: CategoryTreeItem = index.internalPointer() + if item.full_path == "Top Level": + return False + + new_name = str(value).strip() if value else "" + if not new_name or new_name == item.name: + return False + + if not self._virtual_tree or not self._logic: + return False + + return self.rename_category(index, new_name) + + def create_category(self, name: str, parent_path: str | None = None) -> QModelIndex: + if not self._virtual_tree or not self._logic: + return QModelIndex() + + new_path = f"{parent_path}:{name}" if parent_path else name + + self.backup_requested.emit(True) # noqa: FBT003 + + try: + if not self._virtual_tree.create_category(new_path, self._logic): + return QModelIndex() + except CategoryError as e: + self.error_occurred.emit("Category Creation Failed", str(e)) + return QModelIndex() + + self._rebuild_from_virtual() + return self.index_for_path(new_path) + + def rename_category(self, index: QModelIndex, new_name: str) -> bool: + if not index.isValid() or not self._virtual_tree or not self._logic: + return False + + item: CategoryTreeItem = index.internalPointer() + if item.full_path == "Top Level": + return False + + 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.rename_category(node, new_name): + 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 Rename Failed", str(e)) + return False + + QTimer.singleShot(0, self._rebuild_from_virtual) + return True + class ManufacturerListModel(QAbstractListModel): def __init__(self, parent: QObject | None = None) -> None: diff --git a/src/illogical/modules/sf_symbols.py b/src/illogical/modules/sf_symbols.py index a97606b..870fa08 100644 --- a/src/illogical/modules/sf_symbols.py +++ b/src/illogical/modules/sf_symbols.py @@ -2,6 +2,7 @@ from __future__ import annotations from AppKit import ( NSColor, # type: ignore[attr-defined] + NSFontWeightBold, # type: ignore[attr-defined] NSFontWeightRegular, # type: ignore[attr-defined] NSGraphicsContext, # type: ignore[attr-defined] NSImage, # type: ignore[attr-defined] @@ -20,7 +21,7 @@ from Quartz import ( kCGImageAlphaPremultipliedLast, # type: ignore[attr-defined] ) -_icon_cache: dict[tuple[str, int, tuple[float, ...] | None], QIcon] = {} +_icon_cache: dict[tuple[str, int, tuple[float, ...] | None, bool], QIcon] = {} SCALE_FACTOR = 2 @@ -28,10 +29,14 @@ DEFAULT_COLOR = (155.0 / 255.0, 153.0 / 255.0, 158.0 / 255.0, 1.0) def sf_symbol( - name: str, size: int = 16, color: tuple[float, float, float, float] | None = None + name: str, + size: int = 16, + color: tuple[float, float, float, float] | None = None, + *, + bold: bool = False, ) -> QIcon: color_key = color if color else None - cache_key = (name, size, color_key) + cache_key = (name, size, color_key, bold) if cache_key in _icon_cache: return _icon_cache[cache_key] @@ -39,8 +44,9 @@ def sf_symbol( if ns_image is None: return QIcon() + weight = NSFontWeightBold if bold else NSFontWeightRegular size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_( - float(size), NSFontWeightRegular, NSImageSymbolScaleMedium + float(size), weight, NSImageSymbolScaleMedium ) r, g, b, a = color if color else DEFAULT_COLOR icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a) diff --git a/src/illogical/modules/virtual_category.py b/src/illogical/modules/virtual_category.py index 78ae7b9..a0235bb 100644 --- a/src/illogical/modules/virtual_category.py +++ b/src/illogical/modules/virtual_category.py @@ -472,3 +472,60 @@ class VirtualCategoryTree: if n.full_path != old_path: result[n.full_path.replace(old_path, node.full_path, 1)] = n.full_path return result + + def create_category(self, path: str, logic: Logic) -> bool: + if path in self._nodes: + return False + if path == "Top Level": + return False + + parts = path.split(":") + parent_path = ":".join(parts[:-1]) if len(parts) > 1 else "" + name = parts[-1] + + if parent_path: + parent_node = self.get_node(parent_path) + if parent_node is None: + return False + else: + parent_node = self._root + + node = VirtualCategoryNode( + name=name, full_path=path, parent=parent_node, plugin_count=0 + ) + parent_node.children.append(node) + self._nodes[path] = node + + with contextlib.suppress(CategoryExistsError): + logic.introduce_category(path) + + return True + + def rename_category(self, node: VirtualCategoryNode, new_name: str) -> bool: + if node.full_path == "Top Level": + return False + if not new_name or ":" in new_name: + return False + + old_path = node.full_path + + if node.parent and node.parent.full_path: + new_path = f"{node.parent.full_path}:{new_name}" + else: + new_path = new_name + + if new_path in self._nodes and self._nodes[new_path] != node: + return False + + del self._nodes[old_path] + node.name = new_name + node.full_path = new_path + self._nodes[new_path] = node + + for desc in node.descendants_flat(): + old_desc_path = desc.full_path + del self._nodes[old_desc_path] + desc.full_path = desc.full_path.replace(old_path, new_path, 1) + self._nodes[desc.full_path] = desc + + return True diff --git a/src/illogical/ui/sidebar.py b/src/illogical/ui/sidebar.py index f0c2308..6cfb078 100644 --- a/src/illogical/ui/sidebar.py +++ b/src/illogical/ui/sidebar.py @@ -12,6 +12,7 @@ from PySide6.QtWidgets import ( QLabel, QListView, QMenu, + QPushButton, QSplitter, QStyle, QStyledItemDelegate, @@ -110,6 +111,19 @@ class _VimTreeView(QTreeView): return super().mousePressEvent(event) + def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802 + index = self.indexAt(event.position().toPoint()) + if index.isValid(): + item: CategoryTreeItem = index.internalPointer() + if item.children: + if self.isExpanded(index): + self.collapse(index) + else: + self.expand(index) + event.accept() + return + super().mouseDoubleClickEvent(event) + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912 vk = event.nativeVirtualKey() key = event.key() @@ -237,6 +251,18 @@ class _VimTreeView(QTreeView): """) glass.prepare_window_for_glass(menu) + rename_action = menu.addAction(sf_symbol("pencil", 14), "Rename") + rename_action.triggered.connect(lambda: self._do_rename(index)) + + create_sub_action = menu.addAction( + sf_symbol("folder.badge.plus", 14), "Create Subcategory" + ) + create_sub_action.triggered.connect( + lambda: self._do_create_subcategory(model, index) + ) + + menu.addSeparator() + 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)) @@ -277,6 +303,19 @@ class _VimTreeView(QTreeView): model.extract_category(index) self._restore_selection_after_move(index) + def _do_rename(self, index: QModelIndex) -> None: + self.edit(index) + + def _do_create_subcategory( + self, model: CategoryTreeModel, index: QModelIndex + ) -> None: + item: CategoryTreeItem = index.internalPointer() + new_index = model.create_category("Untitled", item.full_path) + if new_index.isValid(): + self.expand(index) + self.setCurrentIndex(new_index) + self.edit(new_index) + class _VimListView(QListView): enter_pressed = Signal() @@ -362,6 +401,19 @@ class _CategoryDelegate(QStyledItemDelegate): super().paint(painter, option, index) + def setEditorData( # noqa: N802 + self, editor: QWidget, index: QModelIndex + ) -> None: + from PySide6.QtWidgets import QLineEdit # noqa: PLC0415 + + if isinstance(editor, QLineEdit): + text = index.data(Qt.ItemDataRole.EditRole) + if text: + editor.setText(str(text)) + editor.selectAll() + else: + super().setEditorData(editor, index) + class StickyItem(QWidget): clicked = Signal(str) @@ -437,6 +489,43 @@ class _SectionHeader(QWidget): layout.addStretch() +class _CategorySectionHeader(_SectionHeader): + add_clicked = Signal() + + def __init__( + self, title: str, icon_name: str, parent: QWidget | None = None + ) -> None: + super().__init__(title, icon_name, parent) + + from PySide6.QtCore import QSize # noqa: PLC0415 + + self._add_button = QPushButton() + self._add_button.setFixedSize(16, 16) + self._add_button.setIconSize(QSize(10, 10)) + self._add_button.setCursor(Qt.CursorShape.PointingHandCursor) + icon = sf_symbol("plus", 10, (0.25, 0.25, 0.27, 1.0), bold=True) + if not icon.isNull(): + self._add_button.setIcon(icon) + self._add_button.setStyleSheet(""" + QPushButton { + background-color: #9B999E; + border: none; + border-radius: 8px; + } + QPushButton:hover { + background-color: #ADABAF; + } + QPushButton:pressed { + background-color: #8A888D; + } + """) + self._add_button.clicked.connect(self.add_clicked) + + layout = self.layout() + if layout is not None: + layout.addWidget(self._add_button) + + class _DraggableHeader(_SectionHeader): dragged = Signal(int) @@ -525,7 +614,8 @@ class Sidebar(QWidget): layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - header = _SectionHeader("Category", "folder") + header = _CategorySectionHeader("Category", "folder") + header.add_clicked.connect(self._on_add_category_clicked) layout.addWidget(header) tree_container = QWidget() @@ -649,6 +739,16 @@ class Sidebar(QWidget): self._manufacturer_list.clearSelection() self.category_selected.emit(full_path) + def _on_add_category_clicked(self) -> None: + index = self._category_model.create_category("Untitled") + if index.isValid(): + parent = index.parent() + while parent.isValid(): + self._category_tree.expand(parent) + parent = parent.parent() + self._category_tree.setCurrentIndex(index) + self._category_tree.edit(index) + def _on_manufacturer_clicked(self, index: QModelIndex) -> None: manufacturer = index.data(Qt.ItemDataRole.DisplayRole) if manufacturer: