Compare commits
7 Commits
d8c2a6604e
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6060135f66 | |||
| ac933a9fe4 | |||
| 11d119bf6b | |||
| 5f25d2e46e | |||
| acc42abed9 | |||
| c9e10e2b6c | |||
| a39797505e |
102
.github/workflows/build.yml
vendored
Normal file
102
.github/workflows/build.yml
vendored
Normal 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
|
||||||
41
README.md
41
README.md
@@ -1,3 +1,42 @@
|
|||||||
# illogical
|
# illogical
|
||||||
|
|
||||||
The sane Logic Pro plugin manager Apple forgot to build
|

|
||||||
|
|
||||||
|
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 |
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
[tool.briefcase]
|
[tool.briefcase]
|
||||||
project_name = "illogical"
|
project_name = "illogical"
|
||||||
bundle = "com.kotikot.illogical"
|
bundle = "com.kotikot.illogical"
|
||||||
version = "0.0.1"
|
version = "1.0.0"
|
||||||
url = "https://dsp.kotikot.com/illogical"
|
url = "https://dsp.kotikot.com/illogical"
|
||||||
license.file = "LICENSE"
|
license.file = "LICENSE"
|
||||||
author = "h"
|
author = "h"
|
||||||
author_email = "h@kotikot.com"
|
author_email = "h@kotikot.com"
|
||||||
|
icon = "src/illogical/resources/icon"
|
||||||
|
|
||||||
[tool.briefcase.app.illogical]
|
[tool.briefcase.app.illogical]
|
||||||
formal_name = "illogical"
|
formal_name = "illogical"
|
||||||
|
|||||||
@@ -136,10 +136,39 @@ class PluginTableModel(QAbstractTableModel):
|
|||||||
|
|
||||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||||
base_flags = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
|
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):
|
if index.column() in (COL_CUSTOM_NAME, COL_SHORT_NAME):
|
||||||
return base_flags | Qt.ItemFlag.ItemIsEditable
|
return base_flags | Qt.ItemFlag.ItemIsEditable
|
||||||
return base_flags
|
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
|
def setData( # noqa: N802
|
||||||
self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole
|
self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@@ -298,12 +327,14 @@ class CategoryTreeItem:
|
|||||||
|
|
||||||
|
|
||||||
CATEGORY_MIME_TYPE = "application/x-illogical-category"
|
CATEGORY_MIME_TYPE = "application/x-illogical-category"
|
||||||
|
PLUGIN_MIME_TYPE = "application/x-illogical-plugin"
|
||||||
|
|
||||||
|
|
||||||
class CategoryTreeModel(QAbstractItemModel):
|
class CategoryTreeModel(QAbstractItemModel):
|
||||||
category_changed = Signal()
|
category_changed = Signal()
|
||||||
error_occurred = Signal(str, str)
|
error_occurred = Signal(str, str)
|
||||||
backup_requested = Signal(bool)
|
backup_requested = Signal(bool)
|
||||||
|
plugins_dropped = Signal(list, str, bool)
|
||||||
|
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -443,10 +474,10 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
|
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
|
||||||
return Qt.DropAction.MoveAction
|
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
|
||||||
|
|
||||||
def mimeTypes(self) -> list[str]: # noqa: N802
|
def mimeTypes(self) -> list[str]: # noqa: N802
|
||||||
return [CATEGORY_MIME_TYPE]
|
return [CATEGORY_MIME_TYPE, PLUGIN_MIME_TYPE]
|
||||||
|
|
||||||
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
|
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
|
||||||
mime_data = QMimeData()
|
mime_data = QMimeData()
|
||||||
@@ -473,6 +504,9 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
column: int, # noqa: ARG002
|
column: int, # noqa: ARG002
|
||||||
parent: QModelIndex,
|
parent: QModelIndex,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
if data.hasFormat(PLUGIN_MIME_TYPE):
|
||||||
|
return self._handle_plugin_drop(data, parent)
|
||||||
|
|
||||||
if action != Qt.DropAction.MoveAction:
|
if action != Qt.DropAction.MoveAction:
|
||||||
return False
|
return False
|
||||||
if not data.hasFormat(CATEGORY_MIME_TYPE):
|
if not data.hasFormat(CATEGORY_MIME_TYPE):
|
||||||
@@ -538,6 +572,27 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
self._rebuild_from_virtual()
|
self._rebuild_from_virtual()
|
||||||
return True
|
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:
|
def move_category_up(self, index: QModelIndex) -> bool:
|
||||||
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
||||||
return False
|
return False
|
||||||
@@ -644,6 +699,10 @@ class CategoryTreeModel(QAbstractItemModel):
|
|||||||
return None
|
return None
|
||||||
return index.internalPointer()
|
return index.internalPointer()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def root_item(self) -> CategoryTreeItem:
|
||||||
|
return self._root
|
||||||
|
|
||||||
def setData( # noqa: N802
|
def setData( # noqa: N802
|
||||||
self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole
|
self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole
|
||||||
) -> bool:
|
) -> bool:
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
Put any application resources (e.g., icons and resources) here;
|
|
||||||
they can be referenced in code as "resources/filename".
|
|
||||||
BIN
src/illogical/resources/icon.icns
Normal file
BIN
src/illogical/resources/icon.icns
Normal file
Binary file not shown.
@@ -17,10 +17,11 @@ from illogical.ui.loading_overlay import LoadingOverlay
|
|||||||
from illogical.ui.menu_bar import MenuBar
|
from illogical.ui.menu_bar import MenuBar
|
||||||
from illogical.ui.plugin_table import PluginTableView
|
from illogical.ui.plugin_table import PluginTableView
|
||||||
from illogical.ui.restore_backup_window import RestoreBackupWindow
|
from illogical.ui.restore_backup_window import RestoreBackupWindow
|
||||||
|
from illogical.ui.shortcuts_help_window import ShortcutsHelpWindow
|
||||||
from illogical.ui.sidebar import Sidebar
|
from illogical.ui.sidebar import Sidebar
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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 PySide6.QtGui import QCloseEvent, QKeyEvent, QShowEvent
|
||||||
|
|
||||||
from illogical.modules.backup_models import (
|
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.backup_now_triggered.connect(self._on_backup_now)
|
||||||
self._menu_bar.restore_backup_triggered.connect(self._on_restore_backup)
|
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.backup_settings_triggered.connect(self._on_backup_settings)
|
||||||
|
self._menu_bar.shortcuts_help_triggered.connect(self._show_shortcuts_help)
|
||||||
|
|
||||||
def _setup_ui(self) -> None:
|
def _setup_ui(self) -> None:
|
||||||
self._central = QWidget()
|
self._central = QWidget()
|
||||||
@@ -113,6 +115,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._restore_window: RestoreBackupWindow | None = None
|
self._restore_window: RestoreBackupWindow | None = None
|
||||||
self._settings_window: BackupSettingsWindow | None = None
|
self._settings_window: BackupSettingsWindow | None = None
|
||||||
|
self._shortcuts_window: ShortcutsHelpWindow | None = None
|
||||||
|
|
||||||
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
def showEvent(self, event: QShowEvent) -> None: # noqa: N802
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
@@ -138,6 +141,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._backup_service.set_logic(logic)
|
self._backup_service.set_logic(logic)
|
||||||
self._sidebar.populate(logic)
|
self._sidebar.populate(logic)
|
||||||
self._plugin_table.set_plugins(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._loading_overlay.hide()
|
||||||
self._plugin_table.focus_table()
|
self._plugin_table.focus_table()
|
||||||
|
|
||||||
@@ -201,6 +210,131 @@ class MainWindow(QMainWindow):
|
|||||||
BackupTrigger.AUTO, "Before category modification"
|
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:
|
def _on_search_results(self, results: list[SearchResult]) -> None:
|
||||||
plugins = [r.plugin for r in results]
|
plugins = [r.plugin for r in results]
|
||||||
self._plugin_table.filter_by_search_results(
|
self._plugin_table.filter_by_search_results(
|
||||||
@@ -339,6 +473,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._sidebar.clear_manufacturer_search()
|
self._sidebar.clear_manufacturer_search()
|
||||||
self._sidebar.select_show_all()
|
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
|
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||||
self._service.shutdown()
|
self._service.shutdown()
|
||||||
self._backup_service.shutdown()
|
self._backup_service.shutdown()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ class MenuBar(QMenuBar):
|
|||||||
backup_now_triggered = Signal()
|
backup_now_triggered = Signal()
|
||||||
restore_backup_triggered = Signal()
|
restore_backup_triggered = Signal()
|
||||||
backup_settings_triggered = Signal()
|
backup_settings_triggered = Signal()
|
||||||
|
shortcuts_help_triggered = Signal()
|
||||||
|
|
||||||
def __init__(self, main_window: QMainWindow | None = None) -> None:
|
def __init__(self, main_window: QMainWindow | None = None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -90,4 +91,9 @@ class MenuBar(QMenuBar):
|
|||||||
backup_menu.addAction(settings_action)
|
backup_menu.addAction(settings_action)
|
||||||
|
|
||||||
def _setup_help_menu(self) -> None:
|
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)
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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 (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
|
QMenu,
|
||||||
QTableView,
|
QTableView,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
@@ -19,22 +21,70 @@ from illogical.modules.models import (
|
|||||||
COL_SHORT_NAME,
|
COL_SHORT_NAME,
|
||||||
COL_TYPE,
|
COL_TYPE,
|
||||||
COL_VERSION,
|
COL_VERSION,
|
||||||
|
CategoryTreeItem,
|
||||||
|
CategoryTreeModel,
|
||||||
PluginTableModel,
|
PluginTableModel,
|
||||||
)
|
)
|
||||||
from illogical.ui.search_bar import SearchBar
|
from illogical.ui.search_bar import SearchBar
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from logic_plugin_manager import AudioComponent, Logic
|
from logic_plugin_manager import AudioComponent, Logic
|
||||||
from PySide6.QtCore import QModelIndex
|
from PySide6.QtCore import QEvent, QModelIndex
|
||||||
from PySide6.QtGui import QKeyEvent, QResizeEvent
|
from PySide6.QtGui import QKeyEvent, QMouseEvent, QResizeEvent
|
||||||
|
|
||||||
|
|
||||||
KVK_J = 0x26
|
KVK_J = 0x26
|
||||||
KVK_K = 0x28
|
KVK_K = 0x28
|
||||||
|
KVK_V = 0x09
|
||||||
|
|
||||||
|
|
||||||
class _VimTableView(QTableView):
|
class _VimTableView(QTableView):
|
||||||
enter_pressed = Signal()
|
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:
|
def _select_row(self, row: int) -> None:
|
||||||
index = self.model().index(row, 0)
|
index = self.model().index(row, 0)
|
||||||
@@ -43,21 +93,143 @@ class _VimTableView(QTableView):
|
|||||||
self.selectionModel().SelectionFlag.ClearAndSelect
|
self.selectionModel().SelectionFlag.ClearAndSelect
|
||||||
| self.selectionModel().SelectionFlag.Rows,
|
| 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()
|
vk = event.nativeVirtualKey()
|
||||||
key = event.key()
|
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:
|
if vk == KVK_J or key == Qt.Key.Key_Down:
|
||||||
current = self.currentIndex()
|
current = self.currentIndex()
|
||||||
if current.row() < self.model().rowCount() - 1:
|
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()
|
event.accept()
|
||||||
return
|
return
|
||||||
if vk == KVK_K or key == Qt.Key.Key_Up:
|
if vk == KVK_K or key == Qt.Key.Key_Up:
|
||||||
current = self.currentIndex()
|
current = self.currentIndex()
|
||||||
if current.row() > 0:
|
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()
|
event.accept()
|
||||||
return
|
return
|
||||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
@@ -66,14 +238,29 @@ class _VimTableView(QTableView):
|
|||||||
return
|
return
|
||||||
super().keyPressEvent(event)
|
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):
|
class PluginTableView(QWidget):
|
||||||
search_changed = Signal(str)
|
search_changed = Signal(str)
|
||||||
plugin_selected = Signal(object)
|
plugin_selected = Signal(object)
|
||||||
edit_requested = Signal(object, int, str)
|
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:
|
def __init__(self, parent: QWidget | None = None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
self._category_tree: CategoryTreeModel | None = None
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(16, 0, 16, 16)
|
layout.setContentsMargins(16, 0, 16, 16)
|
||||||
@@ -90,7 +277,7 @@ class PluginTableView(QWidget):
|
|||||||
self._table.setModel(self._model)
|
self._table.setModel(self._model)
|
||||||
self._table.setAlternatingRowColors(True)
|
self._table.setAlternatingRowColors(True)
|
||||||
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||||
self._table.setShowGrid(False)
|
self._table.setShowGrid(False)
|
||||||
self._table.verticalHeader().setVisible(False)
|
self._table.verticalHeader().setVisible(False)
|
||||||
self._table.horizontalHeader().setStretchLastSection(True)
|
self._table.horizontalHeader().setStretchLastSection(True)
|
||||||
@@ -138,6 +325,8 @@ class PluginTableView(QWidget):
|
|||||||
|
|
||||||
self._table.selectionModel().currentChanged.connect(self._on_current_changed)
|
self._table.selectionModel().currentChanged.connect(self._on_current_changed)
|
||||||
self._table.enter_pressed.connect(self._on_enter_pressed)
|
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:
|
def _on_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None:
|
||||||
if current.isValid():
|
if current.isValid():
|
||||||
@@ -152,6 +341,137 @@ class PluginTableView(QWidget):
|
|||||||
if plugin:
|
if plugin:
|
||||||
self.plugin_selected.emit(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:
|
def set_plugins(self, logic: Logic) -> None:
|
||||||
self._model.set_plugins(logic)
|
self._model.set_plugins(logic)
|
||||||
self._resize_columns()
|
self._resize_columns()
|
||||||
@@ -195,6 +515,9 @@ class PluginTableView(QWidget):
|
|||||||
if plugin:
|
if plugin:
|
||||||
self.plugin_selected.emit(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:
|
def _on_search_escape(self) -> None:
|
||||||
self._table.setFocus()
|
self._table.setFocus()
|
||||||
|
|
||||||
|
|||||||
169
src/illogical/ui/shortcuts_help_window.py
Normal file
169
src/illogical/ui/shortcuts_help_window.py
Normal 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)
|
||||||
|
)
|
||||||
@@ -4,7 +4,14 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from AppKit import NSColor # type: ignore[attr-defined]
|
from AppKit import NSColor # type: ignore[attr-defined]
|
||||||
from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
|
from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
|
||||||
from PySide6.QtGui import QCursor, QFont
|
from PySide6.QtGui import (
|
||||||
|
QCursor,
|
||||||
|
QDragEnterEvent,
|
||||||
|
QDragLeaveEvent,
|
||||||
|
QDragMoveEvent,
|
||||||
|
QDropEvent,
|
||||||
|
QFont,
|
||||||
|
)
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -23,6 +30,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from illogical.modules.models import (
|
from illogical.modules.models import (
|
||||||
|
PLUGIN_MIME_TYPE,
|
||||||
CategoryTreeItem,
|
CategoryTreeItem,
|
||||||
CategoryTreeModel,
|
CategoryTreeModel,
|
||||||
ManufacturerFilterProxy,
|
ManufacturerFilterProxy,
|
||||||
@@ -50,12 +58,13 @@ class _VimTreeView(QTreeView):
|
|||||||
self.setDragEnabled(True)
|
self.setDragEnabled(True)
|
||||||
self.setAcceptDrops(True)
|
self.setAcceptDrops(True)
|
||||||
self.setDropIndicatorShown(True)
|
self.setDropIndicatorShown(True)
|
||||||
self.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove)
|
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
||||||
self.setDefaultDropAction(Qt.DropAction.MoveAction)
|
self.setDefaultDropAction(Qt.DropAction.MoveAction)
|
||||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
self.customContextMenuRequested.connect(self._on_context_menu_requested)
|
self.customContextMenuRequested.connect(self._on_context_menu_requested)
|
||||||
self._expanded_paths: set[str] = set()
|
self._expanded_paths: set[str] = set()
|
||||||
self.context_menu_path: str | None = None
|
self.context_menu_path: str | None = None
|
||||||
|
self.drop_target_path: str | None = None
|
||||||
|
|
||||||
def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802
|
def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802
|
||||||
old_model = self.model()
|
old_model = self.model()
|
||||||
@@ -124,6 +133,36 @@ class _VimTreeView(QTreeView):
|
|||||||
return
|
return
|
||||||
super().mouseDoubleClickEvent(event)
|
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
|
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912
|
||||||
vk = event.nativeVirtualKey()
|
vk = event.nativeVirtualKey()
|
||||||
key = event.key()
|
key = event.key()
|
||||||
@@ -365,7 +404,11 @@ class _CategoryDelegate(QStyledItemDelegate):
|
|||||||
isinstance(tree_view, _VimTreeView)
|
isinstance(tree_view, _VimTreeView)
|
||||||
and tree_view.context_menu_path == full_path
|
and tree_view.context_menu_path == full_path
|
||||||
)
|
)
|
||||||
if is_context_target:
|
is_drop_target = (
|
||||||
|
isinstance(tree_view, _VimTreeView)
|
||||||
|
and tree_view.drop_target_path == full_path
|
||||||
|
)
|
||||||
|
if is_context_target or is_drop_target:
|
||||||
painter.save()
|
painter.save()
|
||||||
path = QPainterPath()
|
path = QPainterPath()
|
||||||
path.addRoundedRect(option.rect.toRectF(), 4, 4)
|
path.addRoundedRect(option.rect.toRectF(), 4, 4)
|
||||||
@@ -695,6 +738,10 @@ class Sidebar(QWidget):
|
|||||||
self._category_model.build_from_plugins(logic)
|
self._category_model.build_from_plugins(logic)
|
||||||
self._manufacturer_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:
|
def _clear_selections(self) -> None:
|
||||||
self._category_tree.clearSelection()
|
self._category_tree.clearSelection()
|
||||||
self._manufacturer_list.clearSelection()
|
self._manufacturer_list.clearSelection()
|
||||||
|
|||||||
Reference in New Issue
Block a user