feat: add category creation and renaming
This commit is contained in:
@@ -18,6 +18,7 @@ from PySide6.QtCore import (
|
|||||||
QObject,
|
QObject,
|
||||||
QSortFilterProxyModel,
|
QSortFilterProxyModel,
|
||||||
Qt,
|
Qt,
|
||||||
|
QTimer,
|
||||||
Signal,
|
Signal,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -400,7 +401,7 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
|
|
||||||
item: CategoryTreeItem = index.internalPointer()
|
item: CategoryTreeItem = index.internalPointer()
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.DisplayRole:
|
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||||
return item.name
|
return item.name
|
||||||
|
|
||||||
if role == Qt.ItemDataRole.UserRole:
|
if role == Qt.ItemDataRole.UserRole:
|
||||||
@@ -438,6 +439,7 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
default_flags
|
default_flags
|
||||||
| Qt.ItemFlag.ItemIsDragEnabled
|
| Qt.ItemFlag.ItemIsDragEnabled
|
||||||
| Qt.ItemFlag.ItemIsDropEnabled
|
| Qt.ItemFlag.ItemIsDropEnabled
|
||||||
|
| Qt.ItemFlag.ItemIsEditable
|
||||||
)
|
)
|
||||||
|
|
||||||
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
|
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
|
||||||
@@ -642,6 +644,81 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
return None
|
return None
|
||||||
return index.internalPointer()
|
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):
|
class ManufacturerListModel(QAbstractListModel):
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from AppKit import (
|
from AppKit import (
|
||||||
NSColor, # type: ignore[attr-defined]
|
NSColor, # type: ignore[attr-defined]
|
||||||
|
NSFontWeightBold, # type: ignore[attr-defined]
|
||||||
NSFontWeightRegular, # type: ignore[attr-defined]
|
NSFontWeightRegular, # type: ignore[attr-defined]
|
||||||
NSGraphicsContext, # type: ignore[attr-defined]
|
NSGraphicsContext, # type: ignore[attr-defined]
|
||||||
NSImage, # type: ignore[attr-defined]
|
NSImage, # type: ignore[attr-defined]
|
||||||
@@ -20,7 +21,7 @@ from Quartz import (
|
|||||||
kCGImageAlphaPremultipliedLast, # type: ignore[attr-defined]
|
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
|
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(
|
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:
|
) -> QIcon:
|
||||||
color_key = color if color else None
|
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:
|
if cache_key in _icon_cache:
|
||||||
return _icon_cache[cache_key]
|
return _icon_cache[cache_key]
|
||||||
|
|
||||||
@@ -39,8 +44,9 @@ def sf_symbol(
|
|||||||
if ns_image is None:
|
if ns_image is None:
|
||||||
return QIcon()
|
return QIcon()
|
||||||
|
|
||||||
|
weight = NSFontWeightBold if bold else NSFontWeightRegular
|
||||||
size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_(
|
size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_(
|
||||||
float(size), NSFontWeightRegular, NSImageSymbolScaleMedium
|
float(size), weight, NSImageSymbolScaleMedium
|
||||||
)
|
)
|
||||||
r, g, b, a = color if color else DEFAULT_COLOR
|
r, g, b, a = color if color else DEFAULT_COLOR
|
||||||
icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a)
|
icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a)
|
||||||
|
|||||||
@@ -472,3 +472,60 @@ class VirtualCategoryTree:
|
|||||||
if n.full_path != old_path:
|
if n.full_path != old_path:
|
||||||
result[n.full_path.replace(old_path, node.full_path, 1)] = n.full_path
|
result[n.full_path.replace(old_path, node.full_path, 1)] = n.full_path
|
||||||
return result
|
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
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from PySide6.QtWidgets import (
|
|||||||
QLabel,
|
QLabel,
|
||||||
QListView,
|
QListView,
|
||||||
QMenu,
|
QMenu,
|
||||||
|
QPushButton,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QStyle,
|
QStyle,
|
||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
@@ -110,6 +111,19 @@ class _VimTreeView(QTreeView):
|
|||||||
return
|
return
|
||||||
super().mousePressEvent(event)
|
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
|
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912
|
||||||
vk = event.nativeVirtualKey()
|
vk = event.nativeVirtualKey()
|
||||||
key = event.key()
|
key = event.key()
|
||||||
@@ -237,6 +251,18 @@ class _VimTreeView(QTreeView):
|
|||||||
""")
|
""")
|
||||||
glass.prepare_window_for_glass(menu)
|
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 = menu.addAction(sf_symbol("arrow.up", 14), "Move Up")
|
||||||
move_up_action.setShortcut("Alt+Shift+Up")
|
move_up_action.setShortcut("Alt+Shift+Up")
|
||||||
move_up_action.triggered.connect(lambda: self._do_move_up(model, index))
|
move_up_action.triggered.connect(lambda: self._do_move_up(model, index))
|
||||||
@@ -277,6 +303,19 @@ class _VimTreeView(QTreeView):
|
|||||||
model.extract_category(index)
|
model.extract_category(index)
|
||||||
self._restore_selection_after_move(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):
|
class _VimListView(QListView):
|
||||||
enter_pressed = Signal()
|
enter_pressed = Signal()
|
||||||
@@ -362,6 +401,19 @@ class _CategoryDelegate(QStyledItemDelegate):
|
|||||||
|
|
||||||
super().paint(painter, option, index)
|
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):
|
class StickyItem(QWidget):
|
||||||
clicked = Signal(str)
|
clicked = Signal(str)
|
||||||
@@ -437,6 +489,43 @@ class _SectionHeader(QWidget):
|
|||||||
layout.addStretch()
|
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):
|
class _DraggableHeader(_SectionHeader):
|
||||||
dragged = Signal(int)
|
dragged = Signal(int)
|
||||||
|
|
||||||
@@ -525,7 +614,8 @@ class Sidebar(QWidget):
|
|||||||
layout.setContentsMargins(0, 0, 0, 0)
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
layout.setSpacing(0)
|
layout.setSpacing(0)
|
||||||
|
|
||||||
header = _SectionHeader("Category", "folder")
|
header = _CategorySectionHeader("Category", "folder")
|
||||||
|
header.add_clicked.connect(self._on_add_category_clicked)
|
||||||
layout.addWidget(header)
|
layout.addWidget(header)
|
||||||
|
|
||||||
tree_container = QWidget()
|
tree_container = QWidget()
|
||||||
@@ -649,6 +739,16 @@ class Sidebar(QWidget):
|
|||||||
self._manufacturer_list.clearSelection()
|
self._manufacturer_list.clearSelection()
|
||||||
self.category_selected.emit(full_path)
|
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:
|
def _on_manufacturer_clicked(self, index: QModelIndex) -> None:
|
||||||
manufacturer = index.data(Qt.ItemDataRole.DisplayRole)
|
manufacturer = index.data(Qt.ItemDataRole.DisplayRole)
|
||||||
if manufacturer:
|
if manufacturer:
|
||||||
|
|||||||
Reference in New Issue
Block a user