feat: add category creation and renaming

This commit is contained in:
h
2026-01-31 21:59:28 +01:00
parent a2bce69cf0
commit 08556cfc36
4 changed files with 246 additions and 6 deletions

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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: