Compare commits

...

9 Commits

Author SHA1 Message Date
h
6060135f66 ci: add permissions
Some checks failed
Build and Package / Build and Package (app, macos-latest, macOS) (push) Has been cancelled
Build and Package / Upload Release (push) Has been cancelled
2026-02-02 16:40:06 +01:00
h
ac933a9fe4 ci: should build now
Some checks failed
Build and Package / Build and Package (app, macos-latest, macOS) (push) Has been cancelled
Build and Package / Upload Release (push) Has been cancelled
2026-02-02 16:30:06 +01:00
h
11d119bf6b docs: update README
Some checks failed
Build and Package / Build and Package (app, macos-latest, macOS) (push) Has been cancelled
Build and Package / Upload Release (push) Has been cancelled
2026-02-02 16:23:32 +01:00
h
5f25d2e46e feat: prepare for release 2026-02-02 16:08:33 +01:00
h
acc42abed9 feat: add keyboard shortcuts help popup (Ctrl+Shift+/) 2026-02-02 15:04:16 +01:00
h
c9e10e2b6c feat: add vim visual line mode with shift+v 2026-02-02 13:53:54 +01:00
h
a39797505e feat: add drag-drop, multi-selection, and context menu for plugins 2026-02-02 13:53:53 +01:00
h
08556cfc36 feat: add category creation and renaming 2026-01-31 21:59:28 +01:00
h
a2bce69cf0 feat: reordering categories, backup revert tracking for categories 2026-01-31 20:43:06 +01:00
16 changed files with 2269 additions and 66 deletions

102
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,102 @@
name: "Build and Package"
on:
workflow_dispatch:
push:
tags:
- v*
env:
FORCE_COLOR: "1"
defaults:
run:
shell: bash
jobs:
package:
name: Build and Package
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
target: [ "macOS" ]
include:
- target: "macOS"
output-format: "app"
runs-on: "macos-latest"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v6
- name: Install Python 3.13
run: uv python install 3.13
- name: Setup Environment
run: uv sync
- name: Build App
run: |
${{ matrix.briefcase-build-prefix }} \
uv run briefcase build \
${{ matrix.platform || matrix.target }} \
${{ matrix.output-format }} \
--test --no-input --log \
${{ matrix.briefcase-args }} \
${{ matrix.briefcase-build-args }}
- name: Package App
run: |
${{ matrix.briefcase-package-prefix }} \
uv run briefcase package \
${{ matrix.platform || matrix.target }} \
${{ matrix.output-format }} \
--update --adhoc-sign --no-input --log \
${{ matrix.briefcase-args }} \
${{ matrix.briefcase-package-args }}
- name: Upload App
uses: actions/upload-artifact@v4
with:
name: App-${{ matrix.target }}
path: dist
if-no-files-found: error
- name: Upload Log
if: failure()
uses: actions/upload-artifact@v4
with:
name: Log-Failure-${{ matrix.target }}
path: logs/*
release:
name: "Upload Release"
runs-on: ubuntu-latest
permissions:
contents: write
needs:
- package
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download macOS build
uses: actions/download-artifact@v4
with:
name: App-macOS
path: dist/
- name: Display all files
run: ls -R
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: dist/*
fail_on_unmatched_files: true

View File

@@ -1,3 +1,42 @@
# illogical
The sane Logic Pro plugin manager Apple forgot to build
![screenshot](https://i.imgur.com/JN15VhH.png)
The sane Logic Pro plugin manager Apple forgot to build.
## Features
- Browse and organize Audio Units plugins
- "Uncategorized" folder
- Tree-like view for categories
- Vim-style keyboard navigation (j/k/h/l)
- Backup and restore plugin configurations
- Native Plug-in manager feel with liquid glass UI
## Install
**Homebrew** (coming soon)
**Manual**
1. Download the latest `.dmg` from [Releases](https://github.com/kotikotprojects/illogical/releases).
2. Drag the app to the **Applications** folder.
3. **Right-click** the app and select **Open**. (You only need to do this once).
⚠️ **If you see "App is damaged" error:**
Run this command in Terminal:
```bash
xattr -cr /Applications/illogical.app
```
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `J/K` | Navigate up/down |
| `H/L` | Collapse/expand |
| `Shift+V` | Visual line mode |
| `Cmd+F` | Search |
| `Cmd+B` | Backup |
| `Cmd+Shift+R` | Restore |
| `Cmd+Shift+/` | Show all shortcuts |

View File

@@ -1,11 +1,12 @@
[tool.briefcase]
project_name = "illogical"
bundle = "com.kotikot.illogical"
version = "0.0.1"
version = "1.0.0"
url = "https://dsp.kotikot.com/illogical"
license.file = "LICENSE"
author = "h"
author_email = "h@kotikot.com"
icon = "src/illogical/resources/icon"
[tool.briefcase.app.illogical]
formal_name = "illogical"

View File

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

View File

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

View File

@@ -2,22 +2,40 @@ 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,
Qt,
QTimer,
Signal,
)
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
@@ -118,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:
@@ -246,12 +293,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 +326,61 @@ class CategoryTreeItem:
return 0
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)
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
@@ -371,7 +432,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:
@@ -396,6 +457,327 @@ 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
| Qt.ItemFlag.ItemIsEditable
)
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
def mimeTypes(self) -> list[str]: # noqa: N802
return [CATEGORY_MIME_TYPE, PLUGIN_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 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):
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 _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
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()
@property
def root_item(self) -> CategoryTreeItem:
return self._root
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

@@ -0,0 +1,531 @@
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
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

@@ -1,2 +0,0 @@
Put any application resources (e.g., icons and resources) here;
they can be referenced in code as "resources/filename".

Binary file not shown.

View File

@@ -17,10 +17,11 @@ from illogical.ui.loading_overlay import LoadingOverlay
from illogical.ui.menu_bar import MenuBar
from illogical.ui.plugin_table import PluginTableView
from illogical.ui.restore_backup_window import RestoreBackupWindow
from illogical.ui.shortcuts_help_window import ShortcutsHelpWindow
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 (
@@ -56,6 +57,7 @@ class MainWindow(QMainWindow):
self._menu_bar.backup_now_triggered.connect(self._on_backup_now)
self._menu_bar.restore_backup_triggered.connect(self._on_restore_backup)
self._menu_bar.backup_settings_triggered.connect(self._on_backup_settings)
self._menu_bar.shortcuts_help_triggered.connect(self._show_shortcuts_help)
def _setup_ui(self) -> None:
self._central = QWidget()
@@ -87,6 +89,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)
@@ -112,6 +115,7 @@ class MainWindow(QMainWindow):
self._restore_window: RestoreBackupWindow | None = None
self._settings_window: BackupSettingsWindow | None = None
self._shortcuts_window: ShortcutsHelpWindow | None = None
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
super().showEvent(event)
@@ -137,6 +141,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()
@@ -194,6 +204,137 @@ 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_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(
@@ -332,6 +473,16 @@ class MainWindow(QMainWindow):
self._sidebar.clear_manufacturer_search()
self._sidebar.select_show_all()
def _show_shortcuts_help(self) -> None:
if self._shortcuts_window is not None:
self._shortcuts_window.close()
self._shortcuts_window = ShortcutsHelpWindow()
self._shortcuts_window.destroyed.connect(self._on_shortcuts_window_destroyed)
self._shortcuts_window.show_centered(self)
def _on_shortcuts_window_destroyed(self) -> None:
self._shortcuts_window = None
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
self._service.shutdown()
self._backup_service.shutdown()

View File

@@ -14,6 +14,7 @@ class MenuBar(QMenuBar):
backup_now_triggered = Signal()
restore_backup_triggered = Signal()
backup_settings_triggered = Signal()
shortcuts_help_triggered = Signal()
def __init__(self, main_window: QMainWindow | None = None) -> None:
super().__init__()
@@ -90,4 +91,9 @@ class MenuBar(QMenuBar):
backup_menu.addAction(settings_action)
def _setup_help_menu(self) -> None:
self.addMenu("Help")
help_menu = self.addMenu("Help")
shortcuts_action = QAction("Keyboard Shortcuts", self)
shortcuts_action.setShortcut(QKeySequence("Ctrl+Shift+/"))
shortcuts_action.triggered.connect(self.shortcuts_help_triggered)
help_menu.addAction(shortcuts_action)

View File

@@ -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,22 +21,70 @@ 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
KVK_K = 0x28
KVK_V = 0x09
class _VimTableView(QTableView):
enter_pressed = Signal()
context_menu_requested = Signal(QPoint)
visual_mode_changed = Signal(bool)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._anchor_row: int | None = None
self._visual_line_mode: bool = False
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 +93,143 @@ class _VimTableView(QTableView):
self.selectionModel().SelectionFlag.ClearAndSelect
| self.selectionModel().SelectionFlag.Rows,
)
self._anchor_row = row
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
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 enter_visual_line_mode(self) -> None:
if self._visual_line_mode:
return
self._visual_line_mode = True
current = self.currentIndex()
if current.isValid():
self._anchor_row = current.row()
self._select_row(current.row())
self.visual_mode_changed.emit(True) # noqa: FBT003
def exit_visual_line_mode(self) -> None:
if not self._visual_line_mode:
return
self._visual_line_mode = False
self.visual_mode_changed.emit(False) # noqa: FBT003
def is_visual_line_mode(self) -> bool:
return self._visual_line_mode
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
if self._visual_line_mode:
self.exit_visual_line_mode()
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, C901, PLR0911, PLR0912
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 key == Qt.Key.Key_Escape and self._visual_line_mode:
self.exit_visual_line_mode()
current = self.currentIndex()
if current.isValid():
self._select_row(current.row())
event.accept()
return
if has_shift and vk == KVK_V:
self.enter_visual_line_mode()
event.accept()
return
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
extend_selection = has_shift or self._visual_line_mode
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 extend_selection:
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 extend_selection:
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 +238,29 @@ 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)
visual_mode_changed = Signal(bool)
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 +277,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 +325,8 @@ 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)
self._table.visual_mode_changed.connect(self.visual_mode_changed)
def _on_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None:
if current.isValid():
@@ -152,6 +341,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()
@@ -195,6 +515,9 @@ class PluginTableView(QWidget):
if plugin:
self.plugin_selected.emit(plugin)
def is_visual_line_mode(self) -> bool:
return self._table.is_visual_line_mode()
def _on_search_escape(self) -> None:
self._table.setFocus()

View File

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

View File

@@ -0,0 +1,169 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import pyqt_liquidglass as glass
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QVBoxLayout, QWidget
if TYPE_CHECKING:
from PySide6.QtGui import QKeyEvent
SHORTCUTS: dict[str, list[tuple[str, str]]] = {
"Navigation": [
("J / ↓", "Move down"),
("K / ↑", "Move up"),
("H / ←", "Collapse (categories)"),
("L / →", "Expand (categories)"),
("", "Enter category"),
],
"Visual Mode": [("⇧V", "Select lines"), ("Esc", "Exit mode")],
"Quick Access": [
("⌘1", "Show All"),
("⌘2", "Uncategorized"),
("⌘3", "Focus categories"),
("⌘4", "Focus manufacturers"),
("⌘F", "Search"),
],
"Actions": [
("⌥↩", "Context menu"),
("⌥⇧↑", "Move category up"),
("⌥⇧↓", "Move category down"),
("⌘A", "Select all"),
],
"Drag & Drop": [("⇧+Drop", "Add (don't move)")],
"Backup": [("⌘B", "Create backup"), ("⌘⇧R", "Restore")],
}
class ShortcutsHelpWindow(QWidget):
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._setup_window()
self._setup_ui()
def _setup_window(self) -> None:
self.setWindowTitle("Keyboard Shortcuts")
self.setWindowFlags(Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
def _setup_ui(self) -> None:
layout = QVBoxLayout(self)
layout.setContentsMargins(40, 36, 40, 32)
layout.setSpacing(24)
title = QLabel("Keyboard Shortcuts")
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("""
font-size: 17px;
font-weight: 600;
color: white;
background: transparent;
""")
layout.addWidget(title)
grid = QGridLayout()
grid.setSpacing(32)
grid.setColumnStretch(0, 1)
grid.setColumnStretch(1, 1)
sections = list(SHORTCUTS.items())
for i, (section_name, shortcuts) in enumerate(sections):
col = i % 2
row = i // 2
section_widget = self._create_section(section_name, shortcuts)
grid.addWidget(section_widget, row, col, Qt.AlignmentFlag.AlignTop)
layout.addLayout(grid)
layout.addStretch()
hint = QLabel("Press Esc to close")
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
hint.setStyleSheet("""
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
background: transparent;
""")
layout.addWidget(hint)
def _create_section(self, title: str, shortcuts: list[tuple[str, str]]) -> QWidget:
section = QWidget()
section.setStyleSheet("background: transparent;")
layout = QVBoxLayout(section)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(10)
header = QLabel(title)
header.setStyleSheet("""
font-size: 14px;
font-weight: 600;
color: rgba(255, 255, 255, 0.9);
background: transparent;
padding-bottom: 4px;
""")
layout.addWidget(header)
for key, description in shortcuts:
row = self._create_shortcut_row(key, description)
layout.addWidget(row)
return section
def _create_shortcut_row(self, key: str, description: str) -> QWidget:
row = QWidget()
row.setStyleSheet("background: transparent;")
layout = QHBoxLayout(row)
layout.setContentsMargins(0, 4, 0, 4)
layout.setSpacing(14)
key_label = QLabel(key)
key_label.setStyleSheet("""
font-size: 13px;
font-family: 'SF Mono', 'Menlo', monospace;
color: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.1);
border-radius: 5px;
padding: 5px 10px;
""")
desc_label = QLabel(description)
desc_label.setStyleSheet("""
font-size: 13px;
color: rgba(255, 255, 255, 0.7);
background: transparent;
""")
layout.addWidget(key_label)
layout.addWidget(desc_label, 1)
return row
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
if event.key() == Qt.Key.Key_Escape:
self.close()
event.accept()
return
super().keyPressEvent(event)
def focusOutEvent(self, _event: object) -> None: # noqa: N802
self.close()
def show_centered(self, parent: QWidget | None = None) -> None:
glass.prepare_window_for_glass(self, frameless=True)
self.adjustSize()
self.show()
self.activateWindow()
self.setFocus()
if parent:
parent_geo = parent.geometry()
x = parent_geo.center().x() - self.width() // 2
y = parent_geo.center().y() - self.height() // 2
self.move(x, y)
QTimer.singleShot(0, self._apply_glass)
def _apply_glass(self) -> None:
glass.apply_glass_to_window(
self, options=glass.GlassOptions(corner_radius=16.0)
)

View File

@@ -3,14 +3,23 @@ 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,
QDragEnterEvent,
QDragLeaveEvent,
QDragMoveEvent,
QDropEvent,
QFont,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QFrame,
QHBoxLayout,
QLabel,
QListView,
QMenu,
QPushButton,
QSplitter,
QStyle,
QStyledItemDelegate,
@@ -21,6 +30,8 @@ from PySide6.QtWidgets import (
)
from illogical.modules.models import (
PLUGIN_MIME_TYPE,
CategoryTreeItem,
CategoryTreeModel,
ManufacturerFilterProxy,
ManufacturerListModel,
@@ -42,15 +53,153 @@ 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.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()
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 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 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()
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 +227,134 @@ 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)
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))
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)
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()
@@ -117,9 +394,27 @@ 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
)
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)
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)
@@ -149,6 +444,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)
@@ -224,6 +532,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)
@@ -252,6 +597,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)
@@ -311,7 +657,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()
@@ -320,7 +667,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)
@@ -389,11 +738,34 @@ 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()
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
@@ -414,6 +786,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: