Compare commits

...

18 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
h
f7b8caf86c fix: preserve category/manufacturer selection when focusing and highlighting
- Add internal state tracking (_active_category, _active_manufacturer) to Sidebar
- Cmd+3/4 now restores previously selected category/manufacturer instead of always selecting first
- Manufacturer gray highlight remains when clicking a plugin (filter still active)
- Pressing Enter on plugin or sidebar now highlights plugin's categories
- All focus/selection uses proper selectionModel.setCurrentIndex with ClearAndSelect flag
2026-01-31 13:46:44 +01:00
h
404bde0a9c feat: display categories in user-defined order
Replace alphabetical sorting with user-defined order from logic-plugin-manager.
Categories now display according to their index property, preserving both
top-level and nested category ordering as specified in Logic Pro.
2026-01-31 13:46:33 +01:00
h
e7014f3777 feat: search is filtered by category 2026-01-28 23:35:59 +01:00
h
8cc70d9ef0 feat: replace _set_plugin_field workaround with updated logic-plugin-manager fix 2026-01-28 23:26:46 +01:00
h
82e1f7c311 fix: now can remove plugins name 2026-01-28 01:12:05 +01:00
h
080d39199e feat: better change list in backups 2026-01-28 00:55:44 +01:00
h
9ad0794872 feat: add plugin nickname/shortname editing 2026-01-28 00:39:49 +01:00
h
f6886aed04 feat: add backup system 2026-01-28 00:24:48 +01:00
h
335409d7d9 fix: use proper nickname and shortname fields from tagset to display in table 2026-01-28 00:06:30 +01:00
20 changed files with 3846 additions and 79 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 # 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] [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"
@@ -17,7 +18,7 @@ sources = [
requires = [ requires = [
"PySide6-Essentials~=6.8", "PySide6-Essentials~=6.8",
"logic-plugin-manager[search]>=1.0.0", "logic-plugin-manager[search]>=1.0.1",
"pyqt-liquidglass>=0.1.0", "pyqt-liquidglass>=0.1.0",
] ]
@@ -34,7 +35,7 @@ version = "0.0.0"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"briefcase>=0.3.25", "briefcase>=0.3.25",
"logic-plugin-manager[search]>=1.0.0", "logic-plugin-manager[search]>=1.0.1",
"pyqt-liquidglass>=0.1.0", "pyqt-liquidglass>=0.1.0",
"PySide6-Essentials~=6.8", "PySide6-Essentials~=6.8",
] ]

View File

@@ -0,0 +1,455 @@
from __future__ import annotations
import hashlib
import json
import plistlib
import shutil
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from logic_plugin_manager.defaults import tags_path
from illogical.modules.backup_models import (
BackupChanges,
BackupInfo,
BackupManifest,
BackupSettings,
BackupTrigger,
CategoryChange,
CategoryChangeType,
ChangeType,
DetailedBackupChanges,
FieldChange,
PluginChange,
)
if TYPE_CHECKING:
from pathlib import Path
from logic_plugin_manager import Logic
MANIFEST_VERSION = 1
MANIFEST_FILENAME = "manifest.json"
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:
sha256 = hashlib.sha256()
with file_path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
sha256.update(chunk)
return f"sha256:{sha256.hexdigest()}"
def _get_backup_files() -> list[Path]:
if not TAGS_PATH.exists():
return []
return [f for f in TAGS_PATH.iterdir() if f.is_file()]
def _generate_backup_name(trigger: BackupTrigger) -> str:
timestamp = datetime.now(UTC).strftime("%Y-%m-%d_%H-%M-%S")
return f"{timestamp}_{trigger.value}"
def _load_manifest(backup_path: Path) -> BackupManifest | None:
manifest_file = backup_path / MANIFEST_FILENAME
if not manifest_file.exists():
return None
try:
with manifest_file.open() as f:
return BackupManifest.from_dict(json.load(f))
except (json.JSONDecodeError, KeyError, ValueError):
return None
def _save_manifest(backup_path: Path, manifest: BackupManifest) -> None:
manifest_file = backup_path / MANIFEST_FILENAME
with manifest_file.open("w") as f:
json.dump(manifest.to_dict(), f, indent=2)
def _get_latest_backup() -> BackupInfo | None:
backups = list_backups()
return backups[0] if backups else None
def _compute_changes_between(
current_files: dict[str, str], previous_files: dict[str, str]
) -> BackupChanges:
added = [f for f in current_files if f not in previous_files]
deleted = [f for f in previous_files if f not in current_files]
modified = [
f
for f in current_files
if f in previous_files and current_files[f] != previous_files[f]
]
return BackupChanges(added=added, modified=modified, deleted=deleted)
def create_backup(
trigger: BackupTrigger = BackupTrigger.MANUAL, description: str = ""
) -> BackupInfo:
BACKUPS_PATH.mkdir(parents=True, exist_ok=True)
backup_name = _generate_backup_name(trigger)
backup_path = BACKUPS_PATH / backup_name
backup_path.mkdir()
source_files = _get_backup_files()
checksums: dict[str, str] = {}
total_size = 0
for src_file in source_files:
dst_file = backup_path / src_file.name
shutil.copy2(src_file, dst_file)
checksums[src_file.name] = _compute_file_checksum(src_file)
total_size += src_file.stat().st_size
latest = _get_latest_backup()
previous_backup = latest.name if latest else None
if latest:
previous_manifest = _load_manifest(latest.path)
if previous_manifest:
changes = _compute_changes_between(checksums, previous_manifest.checksums)
else:
changes = BackupChanges()
else:
changes = BackupChanges(added=list(checksums.keys()))
manifest = BackupManifest(
version=MANIFEST_VERSION,
timestamp=datetime.now(UTC),
trigger=trigger,
description=description,
file_count=len(source_files),
total_size_bytes=total_size,
previous_backup=previous_backup,
changes=changes,
checksums=checksums,
)
_save_manifest(backup_path, manifest)
return BackupInfo(
name=backup_name,
path=backup_path,
timestamp=manifest.timestamp,
trigger=trigger,
file_count=manifest.file_count,
total_size_bytes=manifest.total_size_bytes,
description=description,
)
def list_backups() -> list[BackupInfo]:
if not BACKUPS_PATH.exists():
return []
backups: list[BackupInfo] = []
for backup_dir in BACKUPS_PATH.iterdir():
if not backup_dir.is_dir() or backup_dir.name.startswith("."):
continue
manifest = _load_manifest(backup_dir)
if manifest:
backups.append(
BackupInfo(
name=backup_dir.name,
path=backup_dir,
timestamp=manifest.timestamp,
trigger=manifest.trigger,
file_count=manifest.file_count,
total_size_bytes=manifest.total_size_bytes,
description=manifest.description,
)
)
else:
try:
parts = backup_dir.name.rsplit("_", 1)
timestamp_str = parts[0]
trigger_str = parts[1] if len(parts) > 1 else "manual"
timestamp = datetime.strptime(
timestamp_str, "%Y-%m-%d_%H-%M-%S"
).replace(tzinfo=UTC)
trigger = BackupTrigger(trigger_str)
except (ValueError, IndexError):
continue
files = list(backup_dir.glob("*"))
total_size = sum(f.stat().st_size for f in files if f.is_file())
backups.append(
BackupInfo(
name=backup_dir.name,
path=backup_dir,
timestamp=timestamp,
trigger=trigger,
file_count=len(files),
total_size_bytes=total_size,
)
)
backups.sort(key=lambda b: b.timestamp, reverse=True)
return backups
def compute_changes(backup_name: str) -> BackupChanges:
backup_path = BACKUPS_PATH / backup_name
if not backup_path.exists():
return BackupChanges()
backup_manifest = _load_manifest(backup_path)
if not backup_manifest:
return BackupChanges()
current_files = _get_backup_files()
current_checksums = {f.name: _compute_file_checksum(f) for f in current_files}
backup_checksums = backup_manifest.checksums
added = [f for f in current_checksums if f not in backup_checksums]
deleted = [f for f in backup_checksums if f not in current_checksums]
modified = [
f
for f in current_checksums
if f in backup_checksums and current_checksums[f] != backup_checksums[f]
]
return BackupChanges(added=added, modified=modified, deleted=deleted)
def restore_backup(backup_name: str) -> bool:
backup_path = BACKUPS_PATH / backup_name
if not backup_path.exists():
return False
create_backup(BackupTrigger.AUTO, f"Auto-backup before restoring {backup_name}")
for existing_file in TAGS_PATH.iterdir():
if existing_file.is_file():
existing_file.unlink()
for backup_file in backup_path.iterdir():
if backup_file.is_file() and backup_file.name != MANIFEST_FILENAME:
shutil.copy2(backup_file, TAGS_PATH / backup_file.name)
return True
def purge_old_backups(settings: BackupSettings) -> int:
if not BACKUPS_PATH.exists():
return 0
backups = list_backups()
if not backups:
return 0
to_delete: list[BackupInfo] = []
cutoff_date = datetime.now(UTC) - timedelta(days=settings.retention_days)
for i, backup in enumerate(backups):
if i >= settings.max_backups or (
settings.retention_days > 0 and backup.timestamp < cutoff_date
):
to_delete.append(backup)
for backup in to_delete:
shutil.rmtree(backup.path)
return len(to_delete)
def get_storage_usage() -> tuple[int, int]:
if not BACKUPS_PATH.exists():
return (0, 0)
total_bytes = 0
count = 0
for backup_dir in BACKUPS_PATH.iterdir():
if not backup_dir.is_dir() or backup_dir.name.startswith("."):
continue
count += 1
for file in backup_dir.iterdir():
if file.is_file():
total_bytes += file.stat().st_size
return (total_bytes, count)
def get_last_auto_backup_time() -> datetime | None:
backups = list_backups()
for backup in backups:
if backup.trigger == BackupTrigger.AUTO:
return backup.timestamp
return None
def should_create_auto_backup(min_interval_seconds: int = 300) -> bool:
last_auto = get_last_auto_backup_time()
if last_auto is None:
return True
elapsed = (datetime.now(UTC) - last_auto).total_seconds()
return elapsed >= min_interval_seconds
def _parse_tagset_plist(path: Path) -> dict | None:
try:
with path.open("rb") as f:
return plistlib.load(f)
except Exception: # noqa: BLE001
return None
def _compute_field_changes(
old_data: dict | None, new_data: dict | None
) -> list[FieldChange]:
changes: list[FieldChange] = []
old_data = old_data or {}
new_data = new_data or {}
backup_nickname = old_data.get("nickname", "")
current_nickname = new_data.get("nickname", "")
if backup_nickname != current_nickname:
changes.append(
FieldChange("nickname", current_nickname or None, backup_nickname or None)
)
backup_shortname = old_data.get("shortname", "")
current_shortname = new_data.get("shortname", "")
if backup_shortname != current_shortname:
changes.append(
FieldChange(
"shortname", current_shortname or None, backup_shortname or None
)
)
backup_tags = set(old_data.get("tags", {}).keys())
current_tags = set(new_data.get("tags", {}).keys())
changes.extend(
FieldChange(f"category:{tag}", None, "removed")
for tag in current_tags - backup_tags
)
changes.extend(
FieldChange(f"category:{tag}", None, "added")
for tag in backup_tags - current_tags
)
return changes
def _resolve_plugin_name(tags_id: str, logic: Logic | None) -> str:
if logic is None:
return tags_id
plugin = logic.plugins.get_by_tags_id(tags_id)
if plugin:
return plugin.name
return tags_id
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:
backup_path = BACKUPS_PATH / backup_name
if not backup_path.exists():
return DetailedBackupChanges()
backup_manifest = _load_manifest(backup_path)
if not backup_manifest:
return DetailedBackupChanges()
current_files = _get_backup_files()
current_checksums = {f.name: _compute_file_checksum(f) for f in current_files}
backup_checksums = backup_manifest.checksums
added_files = [f for f in current_checksums if f not in backup_checksums]
deleted_files = [f for f in backup_checksums if f not in current_checksums]
modified_files = [
f
for f in current_checksums
if f in backup_checksums and current_checksums[f] != backup_checksums[f]
]
plugin_changes: list[PluginChange] = []
for filename in added_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
plugin_changes.append(PluginChange(tags_id, plugin_name, ChangeType.ADDED))
for filename in deleted_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
plugin_changes.append(PluginChange(tags_id, plugin_name, ChangeType.DELETED))
for filename in modified_files:
tags_id = _tags_id_from_filename(filename)
plugin_name = _resolve_plugin_name(tags_id, logic)
current_path = TAGS_PATH / filename
backup_file_path = backup_path / filename
current_data = _parse_tagset_plist(current_path)
backup_data = _parse_tagset_plist(backup_file_path)
field_changes = _compute_field_changes(backup_data, current_data)
plugin_changes.append(
PluginChange(tags_id, plugin_name, ChangeType.MODIFIED, field_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

@@ -0,0 +1,192 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import Enum
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pathlib import Path
class BackupTrigger(Enum):
MANUAL = "manual"
AUTO = "auto"
class ChangeType(Enum):
ADDED = "added"
MODIFIED = "modified"
DELETED = "deleted"
class CategoryChangeType(Enum):
MOVED = "moved"
DELETED = "deleted"
ADDED = "added"
@dataclass
class FieldChange:
field_name: str
old_value: str | None
new_value: str | None
@dataclass
class PluginChange:
tags_id: str
plugin_name: str
change_type: ChangeType
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]:
return [p for p in self.plugins if p.change_type == ChangeType.ADDED]
@property
def modified(self) -> list[PluginChange]:
return [p for p in self.plugins if p.change_type == ChangeType.MODIFIED]
@property
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 and len(self.categories) == 0
@dataclass
class BackupChanges:
added: list[str] = field(default_factory=list)
modified: list[str] = field(default_factory=list)
deleted: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, list[str]]:
return {"added": self.added, "modified": self.modified, "deleted": self.deleted}
@classmethod
def from_dict(cls, data: dict[str, list[str]]) -> BackupChanges:
return cls(
added=data.get("added", []),
modified=data.get("modified", []),
deleted=data.get("deleted", []),
)
@property
def is_empty(self) -> bool:
return not self.added and not self.modified and not self.deleted
@property
def total_count(self) -> int:
return len(self.added) + len(self.modified) + len(self.deleted)
@dataclass
class BackupManifest:
version: int
timestamp: datetime
trigger: BackupTrigger
description: str
file_count: int
total_size_bytes: int
previous_backup: str | None
changes: BackupChanges
checksums: dict[str, str]
def to_dict(self) -> dict:
return {
"version": self.version,
"timestamp": self.timestamp.isoformat(),
"trigger": self.trigger.value,
"description": self.description,
"file_count": self.file_count,
"total_size_bytes": self.total_size_bytes,
"previous_backup": self.previous_backup,
"changes": self.changes.to_dict(),
"checksums": self.checksums,
}
@classmethod
def from_dict(cls, data: dict) -> BackupManifest:
ts = datetime.fromisoformat(data["timestamp"])
if ts.tzinfo is None:
ts = ts.replace(tzinfo=UTC)
return cls(
version=data["version"],
timestamp=ts,
trigger=BackupTrigger(data["trigger"]),
description=data.get("description", ""),
file_count=data["file_count"],
total_size_bytes=data["total_size_bytes"],
previous_backup=data.get("previous_backup"),
changes=BackupChanges.from_dict(data.get("changes", {})),
checksums=data.get("checksums", {}),
)
@dataclass
class BackupInfo:
name: str
path: Path
timestamp: datetime
trigger: BackupTrigger
file_count: int
total_size_bytes: int
description: str = ""
@property
def display_name(self) -> str:
trigger_label = "Manual" if self.trigger == BackupTrigger.MANUAL else "Auto"
return f"{self.timestamp.strftime('%Y-%m-%d %H:%M:%S')} ({trigger_label})"
@property
def size_display(self) -> str:
return _format_size(self.total_size_bytes)
_BYTES_PER_KB = 1024
def _format_size(size_bytes: int) -> str:
size = float(size_bytes)
for unit in ("B", "KB", "MB", "GB"):
if size < _BYTES_PER_KB:
return f"{size:.1f} {unit}" if unit != "B" else f"{int(size)} {unit}"
size /= _BYTES_PER_KB
return f"{size:.1f} TB"
@dataclass
class BackupSettings:
retention_days: int = 30
max_backups: int = 100
auto_purge: bool = True

View File

@@ -0,0 +1,162 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from PySide6.QtCore import QObject, QThread, Signal
from illogical.modules import backup_manager
from illogical.modules.backup_models import BackupSettings, BackupTrigger
if TYPE_CHECKING:
from logic_plugin_manager import Logic
logger = logging.getLogger(__name__)
class BackupWorker(QObject):
backup_created = Signal(object)
backup_list_ready = Signal(list)
restore_completed = Signal(bool, str)
changes_computed = Signal(str, object)
detailed_changes_computed = Signal(str, object)
storage_usage_ready = Signal(int, int)
purge_completed = Signal(int)
error_occurred = Signal(str)
def __init__(self) -> None:
super().__init__()
self._logic: Logic | None = None
def set_logic(self, logic: Logic) -> None:
self._logic = logic
def create_backup(
self, trigger: BackupTrigger = BackupTrigger.MANUAL, description: str = ""
) -> None:
try:
backup_info = backup_manager.create_backup(trigger, description)
self.backup_created.emit(backup_info)
except OSError as e:
logger.exception("Backup creation failed")
self.error_occurred.emit(str(e))
def list_backups(self) -> None:
try:
backups = backup_manager.list_backups()
self.backup_list_ready.emit(backups)
except OSError as e:
logger.exception("Listing backups failed")
self.error_occurred.emit(str(e))
def restore_backup(self, backup_name: str) -> None:
try:
success = backup_manager.restore_backup(backup_name)
self.restore_completed.emit(success, backup_name)
except OSError as e:
logger.exception("Restore failed")
self.error_occurred.emit(str(e))
def compute_changes(self, backup_name: str) -> None:
try:
changes = backup_manager.compute_changes(backup_name)
self.changes_computed.emit(backup_name, changes)
except OSError as e:
logger.exception("Computing changes failed")
self.error_occurred.emit(str(e))
def compute_detailed_changes(self, backup_name: str) -> None:
try:
changes = backup_manager.compute_detailed_changes(backup_name, self._logic)
self.detailed_changes_computed.emit(backup_name, changes)
except OSError as e:
logger.exception("Computing detailed changes failed")
self.error_occurred.emit(str(e))
def get_storage_usage(self) -> None:
try:
total_bytes, count = backup_manager.get_storage_usage()
self.storage_usage_ready.emit(total_bytes, count)
except OSError as e:
logger.exception("Getting storage usage failed")
self.error_occurred.emit(str(e))
def purge_old_backups(self, settings: BackupSettings) -> None:
try:
deleted_count = backup_manager.purge_old_backups(settings)
self.purge_completed.emit(deleted_count)
except OSError as e:
logger.exception("Purging backups failed")
self.error_occurred.emit(str(e))
def ensure_backup_before_change(self) -> None:
try:
if backup_manager.should_create_auto_backup():
backup_info = backup_manager.create_backup(
BackupTrigger.AUTO, "Auto-backup before plugin modification"
)
self.backup_created.emit(backup_info)
except OSError as e:
logger.exception("Auto-backup before change failed")
self.error_occurred.emit(str(e))
class BackupService(QObject):
backup_created = Signal(object)
backup_list_ready = Signal(list)
restore_completed = Signal(bool, str)
changes_computed = Signal(str, object)
detailed_changes_computed = Signal(str, object)
storage_usage_ready = Signal(int, int)
purge_completed = Signal(int)
error_occurred = Signal(str)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._thread = QThread()
self._worker = BackupWorker()
self._worker.moveToThread(self._thread)
self._worker.backup_created.connect(self.backup_created)
self._worker.backup_list_ready.connect(self.backup_list_ready)
self._worker.restore_completed.connect(self.restore_completed)
self._worker.changes_computed.connect(self.changes_computed)
self._worker.detailed_changes_computed.connect(self.detailed_changes_computed)
self._worker.storage_usage_ready.connect(self.storage_usage_ready)
self._worker.purge_completed.connect(self.purge_completed)
self._worker.error_occurred.connect(self.error_occurred)
self._thread.start()
def create_backup(
self, trigger: BackupTrigger = BackupTrigger.MANUAL, description: str = ""
) -> None:
self._worker.create_backup(trigger, description)
def list_backups(self) -> None:
self._worker.list_backups()
def restore_backup(self, backup_name: str) -> None:
self._worker.restore_backup(backup_name)
def compute_changes(self, backup_name: str) -> None:
self._worker.compute_changes(backup_name)
def compute_detailed_changes(self, backup_name: str) -> None:
self._worker.compute_detailed_changes(backup_name)
def set_logic(self, logic: Logic) -> None:
self._worker.set_logic(logic)
def get_storage_usage(self) -> None:
self._worker.get_storage_usage()
def purge_old_backups(self, settings: BackupSettings) -> None:
self._worker.purge_old_backups(settings)
def ensure_backup_before_change(self) -> None:
self._worker.ensure_backup_before_change()
def shutdown(self) -> None:
self._thread.quit()
self._thread.wait()

View File

@@ -2,21 +2,40 @@ from __future__ import annotations
from typing import TYPE_CHECKING, ClassVar from typing import TYPE_CHECKING, ClassVar
from logic_plugin_manager.exceptions import (
CategoryExistsError,
CategoryValidationError,
MusicAppsLoadError,
MusicAppsWriteError,
)
from PySide6.QtCore import ( from PySide6.QtCore import (
QAbstractItemModel, QAbstractItemModel,
QAbstractListModel, QAbstractListModel,
QAbstractTableModel, QAbstractTableModel,
QByteArray,
QMimeData,
QModelIndex, QModelIndex,
QObject, QObject,
QSortFilterProxyModel, QSortFilterProxyModel,
Qt, Qt,
QTimer,
Signal,
) )
from illogical.modules.sf_symbols import sf_symbol from illogical.modules.sf_symbols import sf_symbol
from illogical.modules.virtual_category import VirtualCategoryTree
if TYPE_CHECKING: if TYPE_CHECKING:
from logic_plugin_manager import AudioComponent, Logic from logic_plugin_manager import AudioComponent, Logic
CategoryError = (
MusicAppsLoadError,
MusicAppsWriteError,
CategoryExistsError,
CategoryValidationError,
OSError,
ValueError,
)
COL_NAME = 0 COL_NAME = 0
COL_CUSTOM_NAME = 1 COL_CUSTOM_NAME = 1
@@ -49,6 +68,8 @@ class PluginTableModel(QAbstractTableModel):
"Version", "Version",
] ]
edit_requested = Signal(object, int, str)
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._all_plugins: list[AudioComponent] = [] self._all_plugins: list[AudioComponent] = []
@@ -96,13 +117,13 @@ class PluginTableModel(QAbstractTableModel):
plugin = self._plugins[index.row()] plugin = self._plugins[index.row()]
col = index.column() col = index.column()
if role == Qt.ItemDataRole.DisplayRole: if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
if col == COL_NAME: if col == COL_NAME:
return plugin.name return plugin.name
if col == COL_CUSTOM_NAME: if col == COL_CUSTOM_NAME:
return getattr(plugin, "custom_name", "") or "" return plugin.tagset.nickname
if col == COL_SHORT_NAME: if col == COL_SHORT_NAME:
return getattr(plugin, "short_name", "") or "" return plugin.tagset.shortname
if col == COL_TYPE: if col == COL_TYPE:
return plugin.type_name.display_name return plugin.type_name.display_name
if col == COL_MANUFACTURER: if col == COL_MANUFACTURER:
@@ -113,6 +134,73 @@ class PluginTableModel(QAbstractTableModel):
return None return None
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:
if role != Qt.ItemDataRole.EditRole:
return False
if not index.isValid() or not (0 <= index.row() < len(self._plugins)):
return False
col = index.column()
if col not in (COL_CUSTOM_NAME, COL_SHORT_NAME):
return False
plugin = self._plugins[index.row()]
new_value = str(value) if value else ""
if col == COL_CUSTOM_NAME:
current_value = plugin.tagset.nickname
else:
current_value = plugin.tagset.shortname
if new_value == (current_value or ""):
return False
self.edit_requested.emit(plugin, col, new_value)
return False
def update_plugin_display(self, plugin: AudioComponent, column: int) -> None:
try:
row = self._plugins.index(plugin)
except ValueError:
return
index = self.index(row, column)
self.dataChanged.emit(index, index, [Qt.ItemDataRole.DisplayRole])
def filter_by_category(self, category: str | None) -> None: def filter_by_category(self, category: str | None) -> None:
self.beginResetModel() self.beginResetModel()
if category == "Show All": if category == "Show All":
@@ -142,9 +230,32 @@ class PluginTableModel(QAbstractTableModel):
self._apply_sort() self._apply_sort()
self.endResetModel() self.endResetModel()
def filter_by_search_results(self, plugins: list[AudioComponent]) -> None: def filter_by_search_results(
self,
plugins: list[AudioComponent],
category: str | None = None,
manufacturer: str | None = None,
) -> None:
self.beginResetModel() self.beginResetModel()
self._plugins = plugins if manufacturer is not None:
self._plugins = [
p for p in plugins if p.manufacturer.lower() == manufacturer.lower()
]
elif category == "Show All" or category is ...:
self._plugins = plugins
elif category is None:
self._plugins = [p for p in plugins if not p.categories]
elif category == "Top Level":
self._plugins = [
p for p in plugins if any(c.name == "" for c in p.categories)
]
elif category is not None:
self._plugins = [
p for p in plugins if any(c.name == category for c in p.categories)
]
else:
self._plugins = plugins
self._apply_sort()
self.endResetModel() self.endResetModel()
def get_plugin(self, row: int) -> AudioComponent | None: def get_plugin(self, row: int) -> AudioComponent | None:
@@ -170,8 +281,8 @@ class PluginTableModel(QAbstractTableModel):
return getattr(plugin, "version", 0) or 0 return getattr(plugin, "version", 0) or 0
values = { values = {
COL_NAME: plugin.name, COL_NAME: plugin.name,
COL_CUSTOM_NAME: getattr(plugin, "custom_name", "") or "", COL_CUSTOM_NAME: plugin.tagset.nickname or "",
COL_SHORT_NAME: getattr(plugin, "short_name", "") or "", COL_SHORT_NAME: plugin.tagset.shortname or "",
COL_TYPE: plugin.type_code, COL_TYPE: plugin.type_code,
COL_MANUFACTURER: plugin.manufacturer, COL_MANUFACTURER: plugin.manufacturer,
} }
@@ -182,12 +293,21 @@ class PluginTableModel(QAbstractTableModel):
class CategoryTreeItem: class CategoryTreeItem:
def __init__( 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: ) -> None:
self.name = name self.name = name
self.full_path = full_path self.full_path = full_path
self.parent_item = parent self.parent_item = parent
self.children: list[CategoryTreeItem] = [] 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: def append_child(self, child: CategoryTreeItem) -> None:
self.children.append(child) self.children.append(child)
@@ -206,43 +326,62 @@ class CategoryTreeItem:
return 0 return 0
CATEGORY_MIME_TYPE = "application/x-illogical-category"
PLUGIN_MIME_TYPE = "application/x-illogical-plugin"
class CategoryTreeModel(QAbstractItemModel): 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: def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent) super().__init__(parent)
self._root = CategoryTreeItem("", "") self._root = CategoryTreeItem("", "")
self._virtual_tree: VirtualCategoryTree | None = None
self._logic: Logic | None = None
def build_from_plugins(self, logic: Logic) -> None: def build_from_plugins(self, logic: Logic) -> None:
self.beginResetModel() self.beginResetModel()
self._root = CategoryTreeItem("", "") self._logic = logic
self._virtual_tree = VirtualCategoryTree()
categories: set[str] = set() self._virtual_tree.build_from_logic(logic)
for plugin in logic.plugins.all(): self._root = self._build_qt_tree_from_virtual()
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 sorted(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.endResetModel() self.endResetModel()
def _build_qt_tree_from_virtual(self) -> CategoryTreeItem:
from illogical.modules.virtual_category import ( # noqa: PLC0415
VirtualCategoryNode,
)
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( def index(
self, row: int, column: int, parent: QModelIndex | None = None self, row: int, column: int, parent: QModelIndex | None = None
) -> QModelIndex: ) -> QModelIndex:
@@ -293,7 +432,7 @@ class CategoryTreeModel(QAbstractItemModel):
item: CategoryTreeItem = index.internalPointer() item: CategoryTreeItem = index.internalPointer()
if role == Qt.ItemDataRole.DisplayRole: if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
return item.name return item.name
if role == Qt.ItemDataRole.UserRole: if role == Qt.ItemDataRole.UserRole:
@@ -318,6 +457,327 @@ class CategoryTreeModel(QAbstractItemModel):
return find_in_item(self._root, QModelIndex()) 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): class ManufacturerListModel(QAbstractListModel):
def __init__(self, parent: QObject | None = None) -> None: def __init__(self, parent: QObject | None = None) -> None:

View File

@@ -0,0 +1,54 @@
from __future__ import annotations
from PySide6.QtCore import QSettings
from illogical.modules.backup_models import BackupSettings
_BACKUP_RETENTION_DAYS = "backup/retention_days"
_BACKUP_MAX_COUNT = "backup/max_count"
_BACKUP_AUTO_PURGE = "backup/auto_purge"
class Settings:
def __init__(self) -> None:
self._settings = QSettings("com.kotikot.illogical", "illogical")
@property
def backup_retention_days(self) -> int:
value = self._settings.value(_BACKUP_RETENTION_DAYS, 30)
return int(str(value)) if value is not None else 30
@backup_retention_days.setter
def backup_retention_days(self, value: int) -> None:
self._settings.setValue(_BACKUP_RETENTION_DAYS, value)
@property
def backup_max_count(self) -> int:
value = self._settings.value(_BACKUP_MAX_COUNT, 100)
return int(str(value)) if value is not None else 100
@backup_max_count.setter
def backup_max_count(self, value: int) -> None:
self._settings.setValue(_BACKUP_MAX_COUNT, value)
@property
def backup_auto_purge(self) -> bool:
default_value = True
value = self._settings.value(_BACKUP_AUTO_PURGE, default_value, type=bool) # type: ignore[call-overload]
return bool(value)
@backup_auto_purge.setter
def backup_auto_purge(self, value: bool) -> None:
self._settings.setValue(_BACKUP_AUTO_PURGE, value)
def get_backup_settings(self) -> BackupSettings:
return BackupSettings(
retention_days=self.backup_retention_days,
max_backups=self.backup_max_count,
auto_purge=self.backup_auto_purge,
)
def save_backup_settings(self, settings: BackupSettings) -> None:
self.backup_retention_days = settings.retention_days
self.backup_max_count = settings.max_backups
self.backup_auto_purge = settings.auto_purge

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from AppKit import ( from AppKit import (
NSColor, # type: ignore[attr-defined] NSColor, # type: ignore[attr-defined]
NSFontWeightBold, # type: ignore[attr-defined]
NSFontWeightRegular, # type: ignore[attr-defined] NSFontWeightRegular, # type: ignore[attr-defined]
NSGraphicsContext, # type: ignore[attr-defined] NSGraphicsContext, # type: ignore[attr-defined]
NSImage, # type: ignore[attr-defined] NSImage, # type: ignore[attr-defined]
@@ -20,7 +21,7 @@ from Quartz import (
kCGImageAlphaPremultipliedLast, # type: ignore[attr-defined] kCGImageAlphaPremultipliedLast, # type: ignore[attr-defined]
) )
_icon_cache: dict[tuple[str, int, tuple[float, ...] | None], QIcon] = {} _icon_cache: dict[tuple[str, int, tuple[float, ...] | None, bool], QIcon] = {}
SCALE_FACTOR = 2 SCALE_FACTOR = 2
@@ -28,10 +29,14 @@ DEFAULT_COLOR = (155.0 / 255.0, 153.0 / 255.0, 158.0 / 255.0, 1.0)
def sf_symbol( def sf_symbol(
name: str, size: int = 16, color: tuple[float, float, float, float] | None = None name: str,
size: int = 16,
color: tuple[float, float, float, float] | None = None,
*,
bold: bool = False,
) -> QIcon: ) -> QIcon:
color_key = color if color else None color_key = color if color else None
cache_key = (name, size, color_key) cache_key = (name, size, color_key, bold)
if cache_key in _icon_cache: if cache_key in _icon_cache:
return _icon_cache[cache_key] return _icon_cache[cache_key]
@@ -39,8 +44,9 @@ def sf_symbol(
if ns_image is None: if ns_image is None:
return QIcon() return QIcon()
weight = NSFontWeightBold if bold else NSFontWeightRegular
size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_( size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_(
float(size), NSFontWeightRegular, NSImageSymbolScaleMedium float(size), weight, NSImageSymbolScaleMedium
) )
r, g, b, a = color if color else DEFAULT_COLOR r, g, b, a = color if color else DEFAULT_COLOR
icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a) icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a)

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

@@ -0,0 +1,133 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
QWidget,
)
from illogical.modules.backup_models import BackupSettings, _format_size
RETENTION_OPTIONS = [(7, "7 days"), (30, "30 days"), (90, "90 days"), (0, "Forever")]
class BackupSettingsWindow(QDialog):
settings_saved = Signal(object)
purge_requested = Signal()
def __init__(self, settings: BackupSettings, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._settings = settings
self._setup_ui()
self._load_settings()
def _setup_ui(self) -> None:
self.setWindowTitle("Backup Settings")
self.setFixedSize(400, 300)
self.setWindowModality(Qt.WindowModality.WindowModal)
layout = QVBoxLayout(self)
layout.setSpacing(16)
layout.setContentsMargins(20, 20, 20, 20)
layout.addWidget(self._create_retention_group())
layout.addWidget(self._create_storage_group())
layout.addStretch()
layout.addWidget(self._create_buttons())
def _create_retention_group(self) -> QGroupBox:
group = QGroupBox("Retention Policy")
layout = QVBoxLayout(group)
layout.setSpacing(12)
retention_row = QHBoxLayout()
retention_row.addWidget(QLabel("Keep backups for:"))
self._retention_combo = QComboBox()
for _, label in RETENTION_OPTIONS:
self._retention_combo.addItem(label)
retention_row.addWidget(self._retention_combo)
retention_row.addStretch()
layout.addLayout(retention_row)
max_row = QHBoxLayout()
max_row.addWidget(QLabel("Maximum backups:"))
self._max_spin = QSpinBox()
self._max_spin.setRange(5, 500)
self._max_spin.setValue(100)
max_row.addWidget(self._max_spin)
max_row.addStretch()
layout.addLayout(max_row)
self._auto_purge_check = QCheckBox("Automatically purge old backups")
layout.addWidget(self._auto_purge_check)
return group
def _create_storage_group(self) -> QGroupBox:
group = QGroupBox("Storage")
layout = QVBoxLayout(group)
layout.setSpacing(12)
self._storage_label = QLabel("Loading...")
layout.addWidget(self._storage_label)
self._purge_button = QPushButton("Purge Old Backups Now")
self._purge_button.clicked.connect(self._on_purge_clicked)
layout.addWidget(self._purge_button)
return group
def _create_buttons(self) -> QWidget:
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
layout.addWidget(cancel_btn)
save_btn = QPushButton("Save")
save_btn.setDefault(True)
save_btn.clicked.connect(self._on_save)
layout.addWidget(save_btn)
return container
def _load_settings(self) -> None:
retention_days = self._settings.retention_days
for i, (days, _) in enumerate(RETENTION_OPTIONS):
if days == retention_days:
self._retention_combo.setCurrentIndex(i)
break
self._max_spin.setValue(self._settings.max_backups)
self._auto_purge_check.setChecked(self._settings.auto_purge)
def _on_save(self) -> None:
idx = self._retention_combo.currentIndex()
retention_days = RETENTION_OPTIONS[idx][0]
settings = BackupSettings(
retention_days=retention_days,
max_backups=self._max_spin.value(),
auto_purge=self._auto_purge_check.isChecked(),
)
self.settings_saved.emit(settings)
self.accept()
def _on_purge_clicked(self) -> None:
self.purge_requested.emit()
def update_storage_info(self, total_bytes: int, count: int) -> None:
size_display = _format_size(total_bytes)
self._storage_label.setText(f"{count} backups using {size_display}")

View File

@@ -4,17 +4,32 @@ from typing import TYPE_CHECKING
import pyqt_liquidglass as glass import pyqt_liquidglass as glass
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import QHBoxLayout, QMainWindow, QSplitter, QWidget from PySide6.QtWidgets import QHBoxLayout, QMainWindow, QMessageBox, QSplitter, QWidget
from illogical.modules import backup_manager
from illogical.modules.backup_models import BackupTrigger
from illogical.modules.backup_service import BackupService
from illogical.modules.models import COL_CUSTOM_NAME, COL_SHORT_NAME
from illogical.modules.plugin_service import PluginService from illogical.modules.plugin_service import PluginService
from illogical.modules.settings import Settings
from illogical.ui.backup_settings_window import BackupSettingsWindow
from illogical.ui.loading_overlay import LoadingOverlay from illogical.ui.loading_overlay import LoadingOverlay
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.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 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 (
BackupInfo,
BackupSettings,
DetailedBackupChanges,
)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
def __init__(self) -> None: def __init__(self) -> None:
@@ -25,12 +40,25 @@ class MainWindow(QMainWindow):
self._logic: Logic | None = None self._logic: Logic | None = None
self._glass_applied = False self._glass_applied = False
self._settings = Settings()
self._current_category: str | None = "Show All"
self._current_manufacturer: str | None = None
self._setup_ui() self._setup_ui()
self._setup_service() self._setup_service()
self._setup_backup_service()
self._setup_menu_bar()
glass.prepare_window_for_glass(self) glass.prepare_window_for_glass(self)
def _setup_menu_bar(self) -> None:
self._menu_bar = MenuBar(self)
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: def _setup_ui(self) -> None:
self._central = QWidget() self._central = QWidget()
layout = QHBoxLayout(self._central) layout = QHBoxLayout(self._central)
@@ -61,8 +89,10 @@ class MainWindow(QMainWindow):
self._sidebar.category_selected.connect(self._on_category_selected) self._sidebar.category_selected.connect(self._on_category_selected)
self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected) self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected)
self._sidebar.enter_pressed.connect(self._plugin_table.focus_table) 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.search_changed.connect(self._on_search_changed)
self._plugin_table.plugin_selected.connect(self._on_plugin_selected) self._plugin_table.plugin_selected.connect(self._on_plugin_selected)
self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested)
def _setup_service(self) -> None: def _setup_service(self) -> None:
self._service = PluginService(self) self._service = PluginService(self)
@@ -71,6 +101,22 @@ class MainWindow(QMainWindow):
self._service.search_results.connect(self._on_search_results) self._service.search_results.connect(self._on_search_results)
self._service.error_occurred.connect(self._on_error) self._service.error_occurred.connect(self._on_error)
def _setup_backup_service(self) -> None:
self._backup_service = BackupService(self)
self._backup_service.backup_created.connect(self._on_backup_created)
self._backup_service.backup_list_ready.connect(self._on_backup_list_ready)
self._backup_service.restore_completed.connect(self._on_restore_completed)
self._backup_service.detailed_changes_computed.connect(
self._on_detailed_changes_computed
)
self._backup_service.storage_usage_ready.connect(self._on_storage_usage_ready)
self._backup_service.purge_completed.connect(self._on_purge_completed)
self._backup_service.error_occurred.connect(self._on_backup_error)
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 def showEvent(self, event: QShowEvent) -> None: # noqa: N802
super().showEvent(event) super().showEvent(event)
if not self._glass_applied: if not self._glass_applied:
@@ -92,22 +138,44 @@ class MainWindow(QMainWindow):
def _on_plugins_loaded(self, logic: Logic) -> None: def _on_plugins_loaded(self, logic: Logic) -> None:
self._logic = logic self._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()
def _on_category_selected(self, category: str | None) -> None: def _on_category_selected(self, category: str | None) -> None:
self._plugin_table.clear_search() self._current_category = category
self._plugin_table.filter_by_category(category) self._current_manufacturer = None
query = self._plugin_table.get_search_text()
if query:
self._service.search(query)
else:
self._plugin_table.filter_by_category(category)
def _on_manufacturer_selected(self, manufacturer: str) -> None: def _on_manufacturer_selected(self, manufacturer: str) -> None:
self._plugin_table.clear_search() self._current_manufacturer = manufacturer
self._plugin_table.filter_by_manufacturer(manufacturer) self._current_category = None
query = self._plugin_table.get_search_text()
if query:
self._service.search(query)
else:
self._plugin_table.filter_by_manufacturer(manufacturer)
def _on_search_changed(self, query: str) -> None: def _on_search_changed(self, query: str) -> None:
if not query: if not query:
self._plugin_table.filter_by_category("Show All") if self._current_manufacturer:
self._plugin_table.filter_by_manufacturer(self._current_manufacturer)
else:
self._plugin_table.filter_by_category(
self._current_category if self._current_category else "Show All"
)
return return
self._service.search(query) self._service.search(query)
@@ -118,13 +186,245 @@ class MainWindow(QMainWindow):
paths.append("Top Level") paths.append("Top Level")
self._sidebar.highlight_categories(paths) self._sidebar.highlight_categories(paths)
def _on_plugin_edit_requested(
self, plugin: AudioComponent, column: int, new_value: str
) -> None:
try:
if backup_manager.should_create_auto_backup():
field = "nickname" if column == COL_CUSTOM_NAME else "shortname"
description = f"Before setting {field} of {plugin.name}"
backup_manager.create_backup(BackupTrigger.AUTO, description)
if column == COL_CUSTOM_NAME:
plugin.set_nickname(new_value or None)
elif column == COL_SHORT_NAME:
plugin.set_shortname(new_value or None)
self._plugin_table.update_plugin_display(plugin, column)
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: 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(plugins) self._plugin_table.filter_by_search_results(
plugins,
category=self._current_category,
manufacturer=self._current_manufacturer,
)
def _on_error(self, message: str) -> None: def _on_error(self, message: str) -> None:
self._loading_overlay.set_message(f"Error: {message}") self._loading_overlay.set_message(f"Error: {message}")
def _on_backup_now(self) -> None:
self._backup_service.create_backup()
def _on_restore_backup(self) -> None:
self._restore_window = RestoreBackupWindow(self)
self._restore_window.backup_selected.connect(self._on_restore_backup_selected)
self._restore_window.restore_requested.connect(self._on_restore_requested)
self._backup_service.list_backups()
self._restore_window.show()
def _on_backup_settings(self) -> None:
settings = self._settings.get_backup_settings()
self._settings_window = BackupSettingsWindow(settings, self)
self._settings_window.settings_saved.connect(self._on_settings_saved)
self._settings_window.purge_requested.connect(self._on_purge_requested)
self._backup_service.get_storage_usage()
self._settings_window.show()
def _on_backup_created(self, backup_info: BackupInfo) -> None:
QMessageBox.information(
self,
"Backup Created",
f"Backup created successfully.\n\n"
f"Files: {backup_info.file_count}\n"
f"Size: {backup_info.size_display}",
)
def _on_backup_list_ready(self, backups: list[BackupInfo]) -> None:
if self._restore_window:
self._restore_window.set_backups(backups)
def _on_restore_backup_selected(self, backup_name: str) -> None:
self._backup_service.compute_detailed_changes(backup_name)
def _on_detailed_changes_computed(
self, backup_name: str, changes: DetailedBackupChanges
) -> None:
if self._restore_window:
self._restore_window.set_detailed_changes(backup_name, changes)
def _on_restore_requested(self, backup_name: str) -> None:
self._backup_service.restore_backup(backup_name)
def _on_restore_completed(self, success: bool, backup_name: str) -> None: # noqa: FBT001
if success:
QMessageBox.information(
self,
"Restore Complete",
f"Backup '{backup_name}' has been restored.\n\n"
"Please restart the application to see the changes.",
)
else:
QMessageBox.warning(
self, "Restore Failed", f"Failed to restore backup '{backup_name}'."
)
def _on_storage_usage_ready(self, total_bytes: int, count: int) -> None:
if self._settings_window:
self._settings_window.update_storage_info(total_bytes, count)
def _on_settings_saved(self, settings: BackupSettings) -> None:
self._settings.save_backup_settings(settings)
if settings.auto_purge:
self._backup_service.purge_old_backups(settings)
def _on_purge_requested(self) -> None:
settings = self._settings.get_backup_settings()
self._backup_service.purge_old_backups(settings)
def _on_purge_completed(self, deleted_count: int) -> None:
self._backup_service.get_storage_usage()
if deleted_count > 0:
QMessageBox.information(
self, "Purge Complete", f"Deleted {deleted_count} old backup(s)."
)
def _on_backup_error(self, message: str) -> None:
QMessageBox.warning(self, "Backup Error", message)
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
mods = event.modifiers() mods = event.modifiers()
key = event.key() key = event.key()
@@ -173,6 +473,17 @@ 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()
super().closeEvent(event) super().closeEvent(event)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from PySide6.QtCore import Signal
from PySide6.QtGui import QAction, QKeySequence
from PySide6.QtWidgets import QMenuBar
if TYPE_CHECKING:
from PySide6.QtWidgets import QMainWindow
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__()
self._main_window = main_window
self.setNativeMenuBar(True)
self._setup_menus()
def _setup_menus(self) -> None:
self._setup_file_menu()
self._setup_edit_menu()
self._setup_backup_menu()
self._setup_help_menu()
def _setup_file_menu(self) -> None:
file_menu = self.addMenu("File")
close_action = QAction("Close Window", self)
close_action.setShortcut(QKeySequence.StandardKey.Close)
if self._main_window is not None:
close_action.triggered.connect(self._main_window.close)
file_menu.addAction(close_action)
def _setup_edit_menu(self) -> None:
edit_menu = self.addMenu("Edit")
undo_action = QAction("Undo", self)
undo_action.setShortcut(QKeySequence.StandardKey.Undo)
undo_action.setEnabled(False)
edit_menu.addAction(undo_action)
redo_action = QAction("Redo", self)
redo_action.setShortcut(QKeySequence.StandardKey.Redo)
redo_action.setEnabled(False)
edit_menu.addAction(redo_action)
edit_menu.addSeparator()
cut_action = QAction("Cut", self)
cut_action.setShortcut(QKeySequence.StandardKey.Cut)
edit_menu.addAction(cut_action)
copy_action = QAction("Copy", self)
copy_action.setShortcut(QKeySequence.StandardKey.Copy)
edit_menu.addAction(copy_action)
paste_action = QAction("Paste", self)
paste_action.setShortcut(QKeySequence.StandardKey.Paste)
edit_menu.addAction(paste_action)
select_all_action = QAction("Select All", self)
select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
edit_menu.addAction(select_all_action)
def _setup_backup_menu(self) -> None:
backup_menu = self.addMenu("Backup")
backup_now_action = QAction("Backup Now", self)
backup_now_action.setShortcut(QKeySequence("Ctrl+B"))
backup_now_action.triggered.connect(self.backup_now_triggered)
backup_menu.addAction(backup_now_action)
backup_menu.addSeparator()
restore_action = QAction("Restore Backup...", self)
restore_action.setShortcut(QKeySequence("Ctrl+Shift+R"))
restore_action.triggered.connect(self.restore_backup_triggered)
backup_menu.addAction(restore_action)
backup_menu.addSeparator()
settings_action = QAction("Backup Settings...", self)
settings_action.setShortcut(QKeySequence.StandardKey.Preferences)
settings_action.triggered.connect(self.backup_settings_triggered)
backup_menu.addAction(settings_action)
def _setup_help_menu(self) -> None:
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 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,21 +21,71 @@ 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()
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)
self.selectionModel().setCurrentIndex( self.selectionModel().setCurrentIndex(
@@ -41,32 +93,174 @@ 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()
return
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.enter_pressed.emit()
event.accept() event.accept()
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)
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)
@@ -78,11 +272,12 @@ class PluginTableView(QWidget):
layout.addWidget(self._search_bar) layout.addWidget(self._search_bar)
self._model = PluginTableModel() self._model = PluginTableModel()
self._model.edit_requested.connect(self.edit_requested)
self._table = _VimTableView() self._table = _VimTableView()
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)
@@ -129,6 +324,9 @@ class PluginTableView(QWidget):
layout.addWidget(self._table, 1) layout.addWidget(self._table, 1)
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.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():
@@ -136,6 +334,144 @@ class PluginTableView(QWidget):
if plugin: if plugin:
self.plugin_selected.emit(plugin) self.plugin_selected.emit(plugin)
def _on_enter_pressed(self) -> None:
current = self._table.currentIndex()
if current.isValid():
plugin = self._model.get_plugin(current.row())
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: def set_plugins(self, logic: Logic) -> None:
self._model.set_plugins(logic) self._model.set_plugins(logic)
self._resize_columns() self._resize_columns()
@@ -146,12 +482,23 @@ class PluginTableView(QWidget):
def filter_by_manufacturer(self, manufacturer: str) -> None: def filter_by_manufacturer(self, manufacturer: str) -> None:
self._model.filter_by_manufacturer(manufacturer) self._model.filter_by_manufacturer(manufacturer)
def filter_by_search_results(self, plugins: list[AudioComponent]) -> None: def filter_by_search_results(
self._model.filter_by_search_results(plugins) self,
plugins: list[AudioComponent],
category: str | None = None,
manufacturer: str | None = None,
) -> None:
self._model.filter_by_search_results(plugins, category, manufacturer)
def update_plugin_display(self, plugin: AudioComponent, column: int) -> None:
self._model.update_plugin_display(plugin, column)
def clear_search(self) -> None: def clear_search(self) -> None:
self._search_bar.clear() self._search_bar.clear()
def get_search_text(self) -> str:
return self._search_bar.text()
def focus_search(self) -> None: def focus_search(self) -> None:
self._search_bar.setFocus() self._search_bar.setFocus()
self._search_bar.selectAll() self._search_bar.selectAll()
@@ -162,6 +509,15 @@ class PluginTableView(QWidget):
if not has_selection and self._model.rowCount() > 0: if not has_selection and self._model.rowCount() > 0:
self._table.selectRow(0) self._table.selectRow(0)
current = self._table.currentIndex()
if current.isValid():
plugin = self._model.get_plugin(current.row())
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: def _on_search_escape(self) -> None:
self._table.setFocus() self._table.setFocus()

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QDialog,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSplitter,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from illogical.modules.backup_models import (
BackupInfo,
BackupTrigger,
CategoryChange,
CategoryChangeType,
ChangeType,
DetailedBackupChanges,
FieldChange,
PluginChange,
)
from illogical.modules.sf_symbols import sf_symbol
class RestoreBackupWindow(QDialog):
restore_requested = Signal(str)
backup_selected = Signal(str)
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._backups: list[BackupInfo] = []
self._changes_cache: dict[str, DetailedBackupChanges] = {}
self._setup_ui()
def _setup_ui(self) -> None:
self.setWindowTitle("Restore Backup")
self.resize(700, 500)
self.setWindowModality(Qt.WindowModality.WindowModal)
layout = QVBoxLayout(self)
layout.setSpacing(12)
layout.setContentsMargins(16, 16, 16, 16)
splitter = QSplitter(Qt.Orientation.Horizontal)
splitter.addWidget(self._create_backup_list())
splitter.addWidget(self._create_details_panel())
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 2)
layout.addWidget(splitter, 1)
layout.addWidget(self._create_buttons())
def _create_backup_list(self) -> QWidget:
container = QWidget()
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(QLabel("Available Backups"))
self._backup_list = QListWidget()
self._backup_list.currentItemChanged.connect(self._on_backup_selected)
layout.addWidget(self._backup_list)
return container
def _create_details_panel(self) -> QWidget:
container = QWidget()
layout = QVBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
layout.addWidget(QLabel("Details"))
self._details_label = QLabel("Select a backup to view details")
self._details_label.setWordWrap(True)
layout.addWidget(self._details_label)
layout.addWidget(QLabel("Changes if restored:"))
self._changes_tree = QTreeWidget()
self._changes_tree.setHeaderHidden(True)
self._changes_tree.setIndentation(16)
layout.addWidget(self._changes_tree, 1)
return container
def _create_buttons(self) -> QWidget:
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.addStretch()
cancel_btn = QPushButton("Cancel")
cancel_btn.clicked.connect(self.reject)
layout.addWidget(cancel_btn)
self._restore_btn = QPushButton("Restore")
self._restore_btn.setEnabled(False)
self._restore_btn.clicked.connect(self._on_restore_clicked)
layout.addWidget(self._restore_btn)
return container
def set_backups(self, backups: list[BackupInfo]) -> None:
self._backups = backups
self._backup_list.clear()
for backup in backups:
item = QListWidgetItem()
item.setText(backup.display_name)
item.setData(Qt.ItemDataRole.UserRole, backup.name)
if backup.trigger == BackupTrigger.MANUAL:
icon_name = "hand.tap"
else:
icon_name = "clock.arrow.circlepath"
icon = sf_symbol(icon_name, 16)
if not icon.isNull():
item.setIcon(icon)
self._backup_list.addItem(item)
def _on_backup_selected(self, current: QListWidgetItem | None) -> None:
if current is None:
self._restore_btn.setEnabled(False)
self._details_label.setText("Select a backup to view details")
self._changes_tree.clear()
return
backup_name = current.data(Qt.ItemDataRole.UserRole)
self._restore_btn.setEnabled(True)
backup = next((b for b in self._backups if b.name == backup_name), None)
if backup:
details = (
f"Created: {backup.timestamp.strftime('%Y-%m-%d %H:%M:%S')}\n"
f"Files: {backup.file_count}\n"
f"Size: {backup.size_display}"
)
if backup.description:
details += f"\nNote: {backup.description}"
self._details_label.setText(details)
self.backup_selected.emit(backup_name)
def set_detailed_changes(
self, backup_name: str, changes: DetailedBackupChanges
) -> None:
self._changes_cache[backup_name] = changes
current = self._backup_list.currentItem()
if current and current.data(Qt.ItemDataRole.UserRole) == backup_name:
self._display_changes(changes)
def _display_changes(self, changes: DetailedBackupChanges) -> None:
self._changes_tree.clear()
if changes.is_empty:
item = QTreeWidgetItem(["No changes (identical to current)"])
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"
)
self._add_plugin_group(changes.deleted, "Plugins to restore", "plus.circle")
def _add_plugin_group(
self, plugins: list[PluginChange], label: str, icon_name: str
) -> None:
if not plugins:
return
group_item = QTreeWidgetItem([f"{label} ({len(plugins)})"])
icon = sf_symbol(icon_name, 14)
if not icon.isNull():
group_item.setIcon(0, icon)
for plugin in sorted(plugins, key=lambda p: p.plugin_name.lower()):
plugin_item = QTreeWidgetItem(group_item, [plugin.plugin_name])
if plugin.change_type == ChangeType.MODIFIED and plugin.field_changes:
for field_change in plugin.field_changes:
change_text = self._format_field_change(field_change)
QTreeWidgetItem(plugin_item, [change_text])
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]
if change.new_value == "added":
return f"+ {category}"
return f" {category}"
current = change.old_value or "(empty)"
restored = change.new_value or "(empty)"
return f"{change.field_name}: '{current}''{restored}'"
def _on_restore_clicked(self) -> None:
current = self._backup_list.currentItem()
if not current:
return
backup_name = current.data(Qt.ItemDataRole.UserRole)
backup = next((b for b in self._backups if b.name == backup_name), None)
if not backup:
return
ts = backup.timestamp.strftime("%Y-%m-%d %H:%M:%S")
msg = (
f"Are you sure you want to restore the backup from {ts}?\n\n"
"An automatic backup will be created before restoring."
)
result = QMessageBox.question(
self,
"Restore Backup",
msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result == QMessageBox.StandardButton.Yes:
self.restore_requested.emit(backup_name)
self.accept()

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 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, QRect, Qt, Signal from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
from PySide6.QtGui import QFont from PySide6.QtGui import (
QCursor,
QDragEnterEvent,
QDragLeaveEvent,
QDragMoveEvent,
QDropEvent,
QFont,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QListView, QListView,
QMenu,
QPushButton,
QSplitter, QSplitter,
QStyle, QStyle,
QStyledItemDelegate, QStyledItemDelegate,
@@ -21,6 +30,8 @@ from PySide6.QtWidgets import (
) )
from illogical.modules.models import ( from illogical.modules.models import (
PLUGIN_MIME_TYPE,
CategoryTreeItem,
CategoryTreeModel, CategoryTreeModel,
ManufacturerFilterProxy, ManufacturerFilterProxy,
ManufacturerListModel, ManufacturerListModel,
@@ -42,15 +53,153 @@ KVK_L = 0x25
class _VimTreeView(QTreeView): class _VimTreeView(QTreeView):
enter_pressed = Signal() 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: def _select_and_activate(self, index: QModelIndex) -> None:
self.selectionModel().setCurrentIndex( self.selectionModel().setCurrentIndex(
index, self.selectionModel().SelectionFlag.ClearAndSelect index, self.selectionModel().SelectionFlag.ClearAndSelect
) )
self.clicked.emit(index) 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() vk = event.nativeVirtualKey()
key = event.key() 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: if vk == KVK_J or key == Qt.Key.Key_Down:
next_idx = self.indexBelow(self.currentIndex()) next_idx = self.indexBelow(self.currentIndex())
@@ -78,6 +227,134 @@ class _VimTreeView(QTreeView):
return return
super().keyPressEvent(event) 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): class _VimListView(QListView):
enter_pressed = Signal() enter_pressed = Signal()
@@ -117,9 +394,27 @@ class _CategoryDelegate(QStyledItemDelegate):
def paint( def paint(
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
) -> None: ) -> None:
from PySide6.QtGui import QBrush, QColor, QPainterPath # noqa: PLC0415
full_path = index.data(Qt.ItemDataRole.UserRole) full_path = index.data(Qt.ItemDataRole.UserRole)
icon = index.data(Qt.ItemDataRole.DecorationRole) 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(): if full_path == "Top Level" and icon and not icon.isNull():
opt = QStyleOptionViewItem(option) opt = QStyleOptionViewItem(option)
self.initStyleOption(opt, index) self.initStyleOption(opt, index)
@@ -149,6 +444,19 @@ class _CategoryDelegate(QStyledItemDelegate):
super().paint(painter, option, index) super().paint(painter, option, index)
def setEditorData( # noqa: N802
self, editor: QWidget, index: QModelIndex
) -> None:
from PySide6.QtWidgets import QLineEdit # noqa: PLC0415
if isinstance(editor, QLineEdit):
text = index.data(Qt.ItemDataRole.EditRole)
if text:
editor.setText(str(text))
editor.selectAll()
else:
super().setEditorData(editor, index)
class StickyItem(QWidget): class StickyItem(QWidget):
clicked = Signal(str) clicked = Signal(str)
@@ -224,6 +532,43 @@ class _SectionHeader(QWidget):
layout.addStretch() layout.addStretch()
class _CategorySectionHeader(_SectionHeader):
add_clicked = Signal()
def __init__(
self, title: str, icon_name: str, parent: QWidget | None = None
) -> None:
super().__init__(title, icon_name, parent)
from PySide6.QtCore import QSize # noqa: PLC0415
self._add_button = QPushButton()
self._add_button.setFixedSize(16, 16)
self._add_button.setIconSize(QSize(10, 10))
self._add_button.setCursor(Qt.CursorShape.PointingHandCursor)
icon = sf_symbol("plus", 10, (0.25, 0.25, 0.27, 1.0), bold=True)
if not icon.isNull():
self._add_button.setIcon(icon)
self._add_button.setStyleSheet("""
QPushButton {
background-color: #9B999E;
border: none;
border-radius: 8px;
}
QPushButton:hover {
background-color: #ADABAF;
}
QPushButton:pressed {
background-color: #8A888D;
}
""")
self._add_button.clicked.connect(self.add_clicked)
layout = self.layout()
if layout is not None:
layout.addWidget(self._add_button)
class _DraggableHeader(_SectionHeader): class _DraggableHeader(_SectionHeader):
dragged = Signal(int) dragged = Signal(int)
@@ -252,11 +597,15 @@ class Sidebar(QWidget):
category_selected = Signal(object) category_selected = Signal(object)
manufacturer_selected = Signal(str) manufacturer_selected = Signal(str)
enter_pressed = Signal() enter_pressed = Signal()
backup_requested = 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.setMinimumWidth(200) self.setMinimumWidth(200)
self._active_category: str | None = None
self._active_manufacturer: str | None = None
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
@@ -308,7 +657,8 @@ class Sidebar(QWidget):
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0) layout.setSpacing(0)
header = _SectionHeader("Category", "folder") header = _CategorySectionHeader("Category", "folder")
header.add_clicked.connect(self._on_add_category_clicked)
layout.addWidget(header) layout.addWidget(header)
tree_container = QWidget() tree_container = QWidget()
@@ -317,7 +667,9 @@ class Sidebar(QWidget):
tree_layout.setSpacing(0) tree_layout.setSpacing(0)
self._category_model = CategoryTreeModel() 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.setItemDelegate(_CategoryDelegate(self._category_tree))
self._category_tree.setModel(self._category_model) self._category_tree.setModel(self._category_model)
self._category_tree.setHeaderHidden(True) self._category_tree.setHeaderHidden(True)
@@ -386,28 +738,69 @@ 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()
self._uncategorized.set_selected(False) 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: def _on_show_all_clicked(self) -> None:
self._active_category = "Show All"
self._active_manufacturer = None
self._clear_selections() self._clear_selections()
self.category_selected.emit("Show All") self.category_selected.emit("Show All")
def _on_uncategorized_clicked(self) -> None: def _on_uncategorized_clicked(self) -> None:
self._active_category = None
self._active_manufacturer = None
self._clear_selections() self._clear_selections()
self.category_selected.emit(None) self.category_selected.emit(None)
def _on_category_clicked(self, index: QModelIndex) -> None: def _on_category_clicked(self, index: QModelIndex) -> None:
full_path = index.data(Qt.ItemDataRole.UserRole) full_path = index.data(Qt.ItemDataRole.UserRole)
if full_path: if full_path:
self._active_category = full_path
self._active_manufacturer = None
self._manufacturer_list.clearSelection() self._manufacturer_list.clearSelection()
self.category_selected.emit(full_path) self.category_selected.emit(full_path)
def _on_add_category_clicked(self) -> None:
index = self._category_model.create_category("Untitled")
if index.isValid():
parent = index.parent()
while parent.isValid():
self._category_tree.expand(parent)
parent = parent.parent()
self._category_tree.setCurrentIndex(index)
self._category_tree.edit(index)
def _on_manufacturer_clicked(self, index: QModelIndex) -> None: def _on_manufacturer_clicked(self, index: QModelIndex) -> None:
manufacturer = index.data(Qt.ItemDataRole.DisplayRole) manufacturer = index.data(Qt.ItemDataRole.DisplayRole)
if manufacturer: if manufacturer:
self._active_manufacturer = manufacturer
self._active_category = None
self._category_tree.clearSelection() self._category_tree.clearSelection()
self.manufacturer_selected.emit(manufacturer) self.manufacturer_selected.emit(manufacturer)
@@ -431,26 +824,58 @@ class Sidebar(QWidget):
self._manufacturer_search.clear() self._manufacturer_search.clear()
def select_show_all(self) -> None: def select_show_all(self) -> None:
self._active_category = "Show All"
self._active_manufacturer = None
self._clear_selections() self._clear_selections()
self.category_selected.emit("Show All") self.category_selected.emit("Show All")
def select_uncategorized(self) -> None: def select_uncategorized(self) -> None:
self._active_category = None
self._active_manufacturer = None
self._clear_selections() self._clear_selections()
self._uncategorized.set_selected(True) self._uncategorized.set_selected(True)
self.category_selected.emit(None) self.category_selected.emit(None)
def focus_category_tree(self) -> None: def focus_category_tree(self) -> None:
self._category_tree.setFocus() self._category_tree.setFocus()
top_level_index = self._category_model.index_for_path("Top Level")
if top_level_index.isValid(): if self._active_category and self._active_category not in ("Show All", None):
self._category_tree.setCurrentIndex(top_level_index) target_path = self._active_category
else:
target_path = "Top Level"
target_index = self._category_model.index_for_path(target_path)
if target_index.isValid():
parent = target_index.parent()
while parent.isValid():
self._category_tree.expand(parent)
parent = parent.parent()
self._category_tree.selectionModel().setCurrentIndex(
target_index,
self._category_tree.selectionModel().SelectionFlag.ClearAndSelect,
)
def focus_manufacturer_list(self) -> None: def focus_manufacturer_list(self) -> None:
self._manufacturer_list.setFocus() self._manufacturer_list.setFocus()
if not self._manufacturer_list.selectionModel().hasSelection():
target_index = None
if self._active_manufacturer:
for row in range(self._manufacturer_proxy.rowCount()):
index = self._manufacturer_proxy.index(row, 0)
if index.data(Qt.ItemDataRole.DisplayRole) == self._active_manufacturer:
target_index = index
break
if target_index is None:
first_index = self._manufacturer_proxy.index(0, 0) first_index = self._manufacturer_proxy.index(0, 0)
if first_index.isValid(): if first_index.isValid():
self._manufacturer_list.setCurrentIndex(first_index) target_index = first_index
if target_index is not None:
self._manufacturer_list.selectionModel().setCurrentIndex(
target_index,
self._manufacturer_list.selectionModel().SelectionFlag.ClearAndSelect,
)
def _on_header_dragged(self, delta: int) -> None: def _on_header_dragged(self, delta: int) -> None:
sizes = self._splitter.sizes() sizes = self._splitter.sizes()
@@ -461,7 +886,10 @@ class Sidebar(QWidget):
def highlight_categories(self, category_paths: list[str]) -> None: def highlight_categories(self, category_paths: list[str]) -> None:
self._category_tree.clearSelection() self._category_tree.clearSelection()
self._manufacturer_list.clearSelection()
if self._active_manufacturer is None:
self._manufacturer_list.clearSelection()
self._uncategorized.set_selected(False) self._uncategorized.set_selected(False)
if not category_paths: if not category_paths:

8
uv.lock generated
View File

@@ -287,7 +287,7 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "briefcase", specifier = ">=0.3.25" }, { name = "briefcase", specifier = ">=0.3.25" },
{ name = "logic-plugin-manager", extras = ["search"], specifier = ">=1.0.0" }, { name = "logic-plugin-manager", extras = ["search"], specifier = ">=1.0.1" },
{ name = "pyqt-liquidglass", specifier = ">=0.1.0" }, { name = "pyqt-liquidglass", specifier = ">=0.1.0" },
{ name = "pyside6-essentials", specifier = "~=6.8" }, { name = "pyside6-essentials", specifier = "~=6.8" },
] ]
@@ -306,11 +306,11 @@ wheels = [
[[package]] [[package]]
name = "logic-plugin-manager" name = "logic-plugin-manager"
version = "1.0.0" version = "1.0.1"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/36/47/0cdfe1b1e35fb30d9b0b7d1c7b8c133254364da8613131d750af1c83b885/logic_plugin_manager-1.0.0.tar.gz", hash = "sha256:b8364838897c4c3319d3ef69ddbdbaa3fc2d795815d2385dea8f2ca7f25f481f", size = 16174, upload-time = "2025-11-07T16:16:34.245Z" } sdist = { url = "https://files.pythonhosted.org/packages/bc/08/53bb70675e896c217f6bb1d608ed66908ff6a24a910bae0d35e3be13a539/logic_plugin_manager-1.0.1.tar.gz", hash = "sha256:4ffb5c40d5c21b90d89cc6e23bfdbae5578d5a9ed2fd5b21984a3c621d4e9b67", size = 16234, upload-time = "2026-01-28T20:48:53.687Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/0f/3e/df9410cfa94cf75f1196fce9797a64482823b009bdab417fb11de8ef9c70/logic_plugin_manager-1.0.0-py3-none-any.whl", hash = "sha256:b23e0e1469687d97a33de44b7feaea1e470808dff0968e5289b66e07b7dd6607", size = 23149, upload-time = "2025-11-07T16:16:33.411Z" }, { url = "https://files.pythonhosted.org/packages/41/85/01a9431a2036739af6275be0f7d295040354f5771201b2f39b33db067be7/logic_plugin_manager-1.0.1-py3-none-any.whl", hash = "sha256:50d2321caf851a7a189ea179cf0da7b5dea50bb47c7136ef87aac530f369f673", size = 23226, upload-time = "2026-01-28T20:48:52.355Z" },
] ]
[package.optional-dependencies] [package.optional-dependencies]