Compare commits
18 Commits
5696b056cf
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 6060135f66 | |||
| ac933a9fe4 | |||
| 11d119bf6b | |||
| 5f25d2e46e | |||
| acc42abed9 | |||
| c9e10e2b6c | |||
| a39797505e | |||
| 08556cfc36 | |||
| a2bce69cf0 | |||
| f7b8caf86c | |||
| 404bde0a9c | |||
| e7014f3777 | |||
| 8cc70d9ef0 | |||
| 82e1f7c311 | |||
| 080d39199e | |||
| 9ad0794872 | |||
| f6886aed04 | |||
| 335409d7d9 |
102
.github/workflows/build.yml
vendored
Normal file
102
.github/workflows/build.yml
vendored
Normal file
@@ -0,0 +1,102 @@
|
||||
name: "Build and Package"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
env:
|
||||
FORCE_COLOR: "1"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build and Package
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
target: [ "macOS" ]
|
||||
include:
|
||||
- target: "macOS"
|
||||
output-format: "app"
|
||||
runs-on: "macos-latest"
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
- name: Install Python 3.13
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Setup Environment
|
||||
run: uv sync
|
||||
|
||||
- name: Build App
|
||||
run: |
|
||||
${{ matrix.briefcase-build-prefix }} \
|
||||
uv run briefcase build \
|
||||
${{ matrix.platform || matrix.target }} \
|
||||
${{ matrix.output-format }} \
|
||||
--test --no-input --log \
|
||||
${{ matrix.briefcase-args }} \
|
||||
${{ matrix.briefcase-build-args }}
|
||||
|
||||
- name: Package App
|
||||
run: |
|
||||
${{ matrix.briefcase-package-prefix }} \
|
||||
uv run briefcase package \
|
||||
${{ matrix.platform || matrix.target }} \
|
||||
${{ matrix.output-format }} \
|
||||
--update --adhoc-sign --no-input --log \
|
||||
${{ matrix.briefcase-args }} \
|
||||
${{ matrix.briefcase-package-args }}
|
||||
|
||||
- name: Upload App
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: App-${{ matrix.target }}
|
||||
path: dist
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload Log
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: Log-Failure-${{ matrix.target }}
|
||||
path: logs/*
|
||||
|
||||
|
||||
release:
|
||||
name: "Upload Release"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
needs:
|
||||
- package
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download macOS build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: App-macOS
|
||||
path: dist/
|
||||
|
||||
- name: Display all files
|
||||
run: ls -R
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/*
|
||||
fail_on_unmatched_files: true
|
||||
41
README.md
41
README.md
@@ -1,3 +1,42 @@
|
||||
# illogical
|
||||
|
||||
The sane Logic Pro plugin manager Apple forgot to build
|
||||

|
||||
|
||||
The sane Logic Pro plugin manager Apple forgot to build.
|
||||
|
||||
## Features
|
||||
|
||||
- Browse and organize Audio Units plugins
|
||||
- "Uncategorized" folder
|
||||
- Tree-like view for categories
|
||||
- Vim-style keyboard navigation (j/k/h/l)
|
||||
- Backup and restore plugin configurations
|
||||
- Native Plug-in manager feel with liquid glass UI
|
||||
|
||||
## Install
|
||||
|
||||
**Homebrew** (coming soon)
|
||||
|
||||
**Manual**
|
||||
|
||||
1. Download the latest `.dmg` from [Releases](https://github.com/kotikotprojects/illogical/releases).
|
||||
2. Drag the app to the **Applications** folder.
|
||||
3. **Right-click** the app and select **Open**. (You only need to do this once).
|
||||
|
||||
⚠️ **If you see "App is damaged" error:**
|
||||
Run this command in Terminal:
|
||||
```bash
|
||||
xattr -cr /Applications/illogical.app
|
||||
```
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `J/K` | Navigate up/down |
|
||||
| `H/L` | Collapse/expand |
|
||||
| `Shift+V` | Visual line mode |
|
||||
| `Cmd+F` | Search |
|
||||
| `Cmd+B` | Backup |
|
||||
| `Cmd+Shift+R` | Restore |
|
||||
| `Cmd+Shift+/` | Show all shortcuts |
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
[tool.briefcase]
|
||||
project_name = "illogical"
|
||||
bundle = "com.kotikot.illogical"
|
||||
version = "0.0.1"
|
||||
version = "1.0.0"
|
||||
url = "https://dsp.kotikot.com/illogical"
|
||||
license.file = "LICENSE"
|
||||
author = "h"
|
||||
author_email = "h@kotikot.com"
|
||||
icon = "src/illogical/resources/icon"
|
||||
|
||||
[tool.briefcase.app.illogical]
|
||||
formal_name = "illogical"
|
||||
@@ -17,7 +18,7 @@ sources = [
|
||||
|
||||
requires = [
|
||||
"PySide6-Essentials~=6.8",
|
||||
"logic-plugin-manager[search]>=1.0.0",
|
||||
"logic-plugin-manager[search]>=1.0.1",
|
||||
"pyqt-liquidglass>=0.1.0",
|
||||
]
|
||||
|
||||
@@ -34,7 +35,7 @@ version = "0.0.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"briefcase>=0.3.25",
|
||||
"logic-plugin-manager[search]>=1.0.0",
|
||||
"logic-plugin-manager[search]>=1.0.1",
|
||||
"pyqt-liquidglass>=0.1.0",
|
||||
"PySide6-Essentials~=6.8",
|
||||
]
|
||||
|
||||
455
src/illogical/modules/backup_manager.py
Normal file
455
src/illogical/modules/backup_manager.py
Normal 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)
|
||||
192
src/illogical/modules/backup_models.py
Normal file
192
src/illogical/modules/backup_models.py
Normal 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
|
||||
162
src/illogical/modules/backup_service.py
Normal file
162
src/illogical/modules/backup_service.py
Normal 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()
|
||||
@@ -2,21 +2,40 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from logic_plugin_manager.exceptions import (
|
||||
CategoryExistsError,
|
||||
CategoryValidationError,
|
||||
MusicAppsLoadError,
|
||||
MusicAppsWriteError,
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
QAbstractItemModel,
|
||||
QAbstractListModel,
|
||||
QAbstractTableModel,
|
||||
QByteArray,
|
||||
QMimeData,
|
||||
QModelIndex,
|
||||
QObject,
|
||||
QSortFilterProxyModel,
|
||||
Qt,
|
||||
QTimer,
|
||||
Signal,
|
||||
)
|
||||
|
||||
from illogical.modules.sf_symbols import sf_symbol
|
||||
from illogical.modules.virtual_category import VirtualCategoryTree
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logic_plugin_manager import AudioComponent, Logic
|
||||
|
||||
CategoryError = (
|
||||
MusicAppsLoadError,
|
||||
MusicAppsWriteError,
|
||||
CategoryExistsError,
|
||||
CategoryValidationError,
|
||||
OSError,
|
||||
ValueError,
|
||||
)
|
||||
|
||||
COL_NAME = 0
|
||||
COL_CUSTOM_NAME = 1
|
||||
@@ -49,6 +68,8 @@ class PluginTableModel(QAbstractTableModel):
|
||||
"Version",
|
||||
]
|
||||
|
||||
edit_requested = Signal(object, int, str)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._all_plugins: list[AudioComponent] = []
|
||||
@@ -96,13 +117,13 @@ class PluginTableModel(QAbstractTableModel):
|
||||
plugin = self._plugins[index.row()]
|
||||
col = index.column()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||
if col == COL_NAME:
|
||||
return plugin.name
|
||||
if col == COL_CUSTOM_NAME:
|
||||
return getattr(plugin, "custom_name", "") or ""
|
||||
return plugin.tagset.nickname
|
||||
if col == COL_SHORT_NAME:
|
||||
return getattr(plugin, "short_name", "") or ""
|
||||
return plugin.tagset.shortname
|
||||
if col == COL_TYPE:
|
||||
return plugin.type_name.display_name
|
||||
if col == COL_MANUFACTURER:
|
||||
@@ -113,6 +134,73 @@ class PluginTableModel(QAbstractTableModel):
|
||||
|
||||
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:
|
||||
self.beginResetModel()
|
||||
if category == "Show All":
|
||||
@@ -142,9 +230,32 @@ class PluginTableModel(QAbstractTableModel):
|
||||
self._apply_sort()
|
||||
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()
|
||||
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()
|
||||
|
||||
def get_plugin(self, row: int) -> AudioComponent | None:
|
||||
@@ -170,8 +281,8 @@ class PluginTableModel(QAbstractTableModel):
|
||||
return getattr(plugin, "version", 0) or 0
|
||||
values = {
|
||||
COL_NAME: plugin.name,
|
||||
COL_CUSTOM_NAME: getattr(plugin, "custom_name", "") or "",
|
||||
COL_SHORT_NAME: getattr(plugin, "short_name", "") or "",
|
||||
COL_CUSTOM_NAME: plugin.tagset.nickname or "",
|
||||
COL_SHORT_NAME: plugin.tagset.shortname or "",
|
||||
COL_TYPE: plugin.type_code,
|
||||
COL_MANUFACTURER: plugin.manufacturer,
|
||||
}
|
||||
@@ -182,12 +293,21 @@ class PluginTableModel(QAbstractTableModel):
|
||||
|
||||
class CategoryTreeItem:
|
||||
def __init__(
|
||||
self, name: str, full_path: str, parent: CategoryTreeItem | None = None
|
||||
self,
|
||||
name: str,
|
||||
full_path: str,
|
||||
parent: CategoryTreeItem | None = None,
|
||||
plugin_count: int = 0,
|
||||
) -> None:
|
||||
self.name = name
|
||||
self.full_path = full_path
|
||||
self.parent_item = parent
|
||||
self.children: list[CategoryTreeItem] = []
|
||||
self.plugin_count = plugin_count
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return self.plugin_count == 0
|
||||
|
||||
def append_child(self, child: CategoryTreeItem) -> None:
|
||||
self.children.append(child)
|
||||
@@ -206,43 +326,62 @@ class CategoryTreeItem:
|
||||
return 0
|
||||
|
||||
|
||||
CATEGORY_MIME_TYPE = "application/x-illogical-category"
|
||||
PLUGIN_MIME_TYPE = "application/x-illogical-plugin"
|
||||
|
||||
|
||||
class CategoryTreeModel(QAbstractItemModel):
|
||||
category_changed = Signal()
|
||||
error_occurred = Signal(str, str)
|
||||
backup_requested = Signal(bool)
|
||||
plugins_dropped = Signal(list, str, bool)
|
||||
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._root = CategoryTreeItem("", "")
|
||||
self._virtual_tree: VirtualCategoryTree | None = None
|
||||
self._logic: Logic | None = None
|
||||
|
||||
def build_from_plugins(self, logic: Logic) -> None:
|
||||
self.beginResetModel()
|
||||
self._root = CategoryTreeItem("", "")
|
||||
|
||||
categories: set[str] = set()
|
||||
for plugin in logic.plugins.all():
|
||||
for cat in plugin.categories:
|
||||
if cat.name != "":
|
||||
categories.add(cat.name)
|
||||
|
||||
category_items: dict[str, CategoryTreeItem] = {}
|
||||
|
||||
top_level_item = CategoryTreeItem("Top Level", "Top Level", self._root)
|
||||
self._root.append_child(top_level_item)
|
||||
|
||||
for cat_path in 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._logic = logic
|
||||
self._virtual_tree = VirtualCategoryTree()
|
||||
self._virtual_tree.build_from_logic(logic)
|
||||
self._root = self._build_qt_tree_from_virtual()
|
||||
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(
|
||||
self, row: int, column: int, parent: QModelIndex | None = None
|
||||
) -> QModelIndex:
|
||||
@@ -293,7 +432,7 @@ class CategoryTreeModel(QAbstractItemModel):
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
|
||||
if role == Qt.ItemDataRole.DisplayRole:
|
||||
if role in (Qt.ItemDataRole.DisplayRole, Qt.ItemDataRole.EditRole):
|
||||
return item.name
|
||||
|
||||
if role == Qt.ItemDataRole.UserRole:
|
||||
@@ -318,6 +457,327 @@ class CategoryTreeModel(QAbstractItemModel):
|
||||
|
||||
return find_in_item(self._root, QModelIndex())
|
||||
|
||||
def flags(self, index: QModelIndex) -> Qt.ItemFlag:
|
||||
default_flags = super().flags(index)
|
||||
if not index.isValid():
|
||||
return default_flags | Qt.ItemFlag.ItemIsDropEnabled
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path == "Top Level":
|
||||
return default_flags
|
||||
|
||||
return (
|
||||
default_flags
|
||||
| Qt.ItemFlag.ItemIsDragEnabled
|
||||
| Qt.ItemFlag.ItemIsDropEnabled
|
||||
| Qt.ItemFlag.ItemIsEditable
|
||||
)
|
||||
|
||||
def supportedDropActions(self) -> Qt.DropAction: # noqa: N802
|
||||
return Qt.DropAction.MoveAction | Qt.DropAction.CopyAction
|
||||
|
||||
def mimeTypes(self) -> list[str]: # noqa: N802
|
||||
return [CATEGORY_MIME_TYPE, PLUGIN_MIME_TYPE]
|
||||
|
||||
def mimeData(self, indexes: list[QModelIndex]) -> QMimeData: # noqa: N802
|
||||
mime_data = QMimeData()
|
||||
if not indexes:
|
||||
return mime_data
|
||||
|
||||
paths = []
|
||||
for index in indexes:
|
||||
if index.isValid():
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path and item.full_path != "Top Level":
|
||||
paths.append(item.full_path)
|
||||
|
||||
if paths:
|
||||
mime_data.setData(CATEGORY_MIME_TYPE, QByteArray(paths[0].encode("utf-8")))
|
||||
|
||||
return mime_data
|
||||
|
||||
def dropMimeData( # noqa: N802, C901, PLR0911, PLR0912
|
||||
self,
|
||||
data: QMimeData,
|
||||
action: Qt.DropAction,
|
||||
row: int,
|
||||
column: int, # noqa: ARG002
|
||||
parent: QModelIndex,
|
||||
) -> bool:
|
||||
if data.hasFormat(PLUGIN_MIME_TYPE):
|
||||
return self._handle_plugin_drop(data, parent)
|
||||
|
||||
if action != Qt.DropAction.MoveAction:
|
||||
return False
|
||||
if not data.hasFormat(CATEGORY_MIME_TYPE):
|
||||
return False
|
||||
if self._virtual_tree is None or self._logic is None:
|
||||
return False
|
||||
|
||||
raw_data = data.data(CATEGORY_MIME_TYPE).data()
|
||||
source_path = bytes(raw_data).decode("utf-8") if raw_data else ""
|
||||
source_node = self._virtual_tree.get_node(source_path)
|
||||
if source_node is None:
|
||||
return False
|
||||
|
||||
all_nodes = source_node.all_nodes_flat()
|
||||
old_path_to_node = [(n.full_path, n) for n in all_nodes]
|
||||
|
||||
if parent.isValid():
|
||||
target_item: CategoryTreeItem = parent.internalPointer()
|
||||
target_path = target_item.full_path
|
||||
else:
|
||||
target_path = ""
|
||||
|
||||
if row == -1:
|
||||
target_node = self._virtual_tree.get_node(target_path)
|
||||
if target_node is None:
|
||||
return False
|
||||
if target_path == "Top Level":
|
||||
return False
|
||||
if not self._virtual_tree.insert_into_parent(source_node, target_node):
|
||||
return False
|
||||
else:
|
||||
if target_path:
|
||||
target_parent_node = self._virtual_tree.get_node(target_path)
|
||||
else:
|
||||
target_parent_node = self._virtual_tree.root
|
||||
|
||||
if target_parent_node is None:
|
||||
return False
|
||||
|
||||
if row < len(target_parent_node.children):
|
||||
sibling_node = target_parent_node.children[row]
|
||||
if not self._virtual_tree.move_before(source_node, sibling_node):
|
||||
return False
|
||||
elif target_parent_node.children:
|
||||
last_sibling = target_parent_node.children[-1]
|
||||
if not self._virtual_tree.move_after(source_node, last_sibling):
|
||||
return False
|
||||
|
||||
changed = {
|
||||
old_path: n.full_path
|
||||
for old_path, n in old_path_to_node
|
||||
if old_path != n.full_path
|
||||
}
|
||||
|
||||
self.backup_requested.emit(bool(changed))
|
||||
|
||||
try:
|
||||
self._virtual_tree.sync_to_logic(self._logic, changed if changed else None)
|
||||
self._virtual_tree.update_plugin_counts(self._logic)
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Move Failed", str(e))
|
||||
return False
|
||||
self._rebuild_from_virtual()
|
||||
return True
|
||||
|
||||
def _handle_plugin_drop(self, data: QMimeData, parent: QModelIndex) -> bool:
|
||||
from PySide6.QtWidgets import QApplication # noqa: PLC0415
|
||||
|
||||
if not parent.isValid():
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = parent.internalPointer()
|
||||
target_category = "" if item.full_path == "Top Level" else item.full_path
|
||||
|
||||
modifiers = QApplication.keyboardModifiers()
|
||||
is_add = bool(modifiers & Qt.KeyboardModifier.ShiftModifier)
|
||||
is_move = not is_add
|
||||
|
||||
raw_data = data.data(PLUGIN_MIME_TYPE).data()
|
||||
plugin_ids = bytes(raw_data).decode("utf-8").split("\n") if raw_data else []
|
||||
|
||||
if plugin_ids:
|
||||
self.plugins_dropped.emit(plugin_ids, target_category, is_move)
|
||||
|
||||
return True
|
||||
|
||||
def move_category_up(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
node = self._virtual_tree.get_node(item.full_path)
|
||||
if node is None:
|
||||
return False
|
||||
|
||||
if not self._virtual_tree.move_within_level(node, -1):
|
||||
return False
|
||||
|
||||
self.backup_requested.emit(False) # noqa: FBT003
|
||||
|
||||
try:
|
||||
self._virtual_tree.sync_to_logic(self._logic)
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Move Failed", str(e))
|
||||
return False
|
||||
self._rebuild_from_virtual()
|
||||
return True
|
||||
|
||||
def move_category_down(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
node = self._virtual_tree.get_node(item.full_path)
|
||||
if node is None:
|
||||
return False
|
||||
|
||||
if not self._virtual_tree.move_within_level(node, 1):
|
||||
return False
|
||||
|
||||
self.backup_requested.emit(False) # noqa: FBT003
|
||||
|
||||
try:
|
||||
self._virtual_tree.sync_to_logic(self._logic)
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Move Failed", str(e))
|
||||
return False
|
||||
self._rebuild_from_virtual()
|
||||
return True
|
||||
|
||||
def extract_category(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
node = self._virtual_tree.get_node(item.full_path)
|
||||
if node is None:
|
||||
return False
|
||||
|
||||
all_nodes = node.all_nodes_flat()
|
||||
old_path_to_node = [(n.full_path, n) for n in all_nodes]
|
||||
|
||||
if not self._virtual_tree.extract_from_parent(node):
|
||||
return False
|
||||
|
||||
changed = {
|
||||
old_path: n.full_path
|
||||
for old_path, n in old_path_to_node
|
||||
if old_path != n.full_path
|
||||
}
|
||||
|
||||
self.backup_requested.emit(True) # noqa: FBT003
|
||||
|
||||
try:
|
||||
self._virtual_tree.sync_to_logic(self._logic, changed if changed else None)
|
||||
self._virtual_tree.update_plugin_counts(self._logic)
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Move Failed", str(e))
|
||||
return False
|
||||
self._rebuild_from_virtual()
|
||||
return True
|
||||
|
||||
def can_delete_category(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid():
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path == "Top Level":
|
||||
return False
|
||||
|
||||
return item.is_empty and not item.children
|
||||
|
||||
def delete_category(self, index: QModelIndex) -> bool:
|
||||
if not index.isValid() or self._virtual_tree is None or self._logic is None:
|
||||
return False
|
||||
if not self.can_delete_category(index):
|
||||
return False
|
||||
|
||||
self.backup_requested.emit(True) # noqa: FBT003
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if not self._virtual_tree.delete_category(item.full_path, self._logic):
|
||||
return False
|
||||
|
||||
self._rebuild_from_virtual()
|
||||
return True
|
||||
|
||||
def get_item_at_index(self, index: QModelIndex) -> CategoryTreeItem | None:
|
||||
if not index.isValid():
|
||||
return None
|
||||
return index.internalPointer()
|
||||
|
||||
@property
|
||||
def root_item(self) -> CategoryTreeItem:
|
||||
return self._root
|
||||
|
||||
def setData( # noqa: N802
|
||||
self, index: QModelIndex, value: object, role: int = Qt.ItemDataRole.EditRole
|
||||
) -> bool:
|
||||
if role != Qt.ItemDataRole.EditRole:
|
||||
return False
|
||||
if not index.isValid():
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path == "Top Level":
|
||||
return False
|
||||
|
||||
new_name = str(value).strip() if value else ""
|
||||
if not new_name or new_name == item.name:
|
||||
return False
|
||||
|
||||
if not self._virtual_tree or not self._logic:
|
||||
return False
|
||||
|
||||
return self.rename_category(index, new_name)
|
||||
|
||||
def create_category(self, name: str, parent_path: str | None = None) -> QModelIndex:
|
||||
if not self._virtual_tree or not self._logic:
|
||||
return QModelIndex()
|
||||
|
||||
new_path = f"{parent_path}:{name}" if parent_path else name
|
||||
|
||||
self.backup_requested.emit(True) # noqa: FBT003
|
||||
|
||||
try:
|
||||
if not self._virtual_tree.create_category(new_path, self._logic):
|
||||
return QModelIndex()
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Creation Failed", str(e))
|
||||
return QModelIndex()
|
||||
|
||||
self._rebuild_from_virtual()
|
||||
return self.index_for_path(new_path)
|
||||
|
||||
def rename_category(self, index: QModelIndex, new_name: str) -> bool:
|
||||
if not index.isValid() or not self._virtual_tree or not self._logic:
|
||||
return False
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path == "Top Level":
|
||||
return False
|
||||
|
||||
node = self._virtual_tree.get_node(item.full_path)
|
||||
if node is None:
|
||||
return False
|
||||
|
||||
all_nodes = node.all_nodes_flat()
|
||||
old_path_to_node = [(n.full_path, n) for n in all_nodes]
|
||||
|
||||
if not self._virtual_tree.rename_category(node, new_name):
|
||||
return False
|
||||
|
||||
changed = {
|
||||
old_path: n.full_path
|
||||
for old_path, n in old_path_to_node
|
||||
if old_path != n.full_path
|
||||
}
|
||||
|
||||
self.backup_requested.emit(True) # noqa: FBT003
|
||||
|
||||
try:
|
||||
self._virtual_tree.sync_to_logic(self._logic, changed if changed else None)
|
||||
self._virtual_tree.update_plugin_counts(self._logic)
|
||||
except CategoryError as e:
|
||||
self.error_occurred.emit("Category Rename Failed", str(e))
|
||||
return False
|
||||
|
||||
QTimer.singleShot(0, self._rebuild_from_virtual)
|
||||
return True
|
||||
|
||||
|
||||
class ManufacturerListModel(QAbstractListModel):
|
||||
def __init__(self, parent: QObject | None = None) -> None:
|
||||
|
||||
54
src/illogical/modules/settings.py
Normal file
54
src/illogical/modules/settings.py
Normal 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
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from AppKit import (
|
||||
NSColor, # type: ignore[attr-defined]
|
||||
NSFontWeightBold, # type: ignore[attr-defined]
|
||||
NSFontWeightRegular, # type: ignore[attr-defined]
|
||||
NSGraphicsContext, # type: ignore[attr-defined]
|
||||
NSImage, # type: ignore[attr-defined]
|
||||
@@ -20,7 +21,7 @@ from Quartz import (
|
||||
kCGImageAlphaPremultipliedLast, # type: ignore[attr-defined]
|
||||
)
|
||||
|
||||
_icon_cache: dict[tuple[str, int, tuple[float, ...] | None], QIcon] = {}
|
||||
_icon_cache: dict[tuple[str, int, tuple[float, ...] | None, bool], QIcon] = {}
|
||||
|
||||
SCALE_FACTOR = 2
|
||||
|
||||
@@ -28,10 +29,14 @@ DEFAULT_COLOR = (155.0 / 255.0, 153.0 / 255.0, 158.0 / 255.0, 1.0)
|
||||
|
||||
|
||||
def sf_symbol(
|
||||
name: str, size: int = 16, color: tuple[float, float, float, float] | None = None
|
||||
name: str,
|
||||
size: int = 16,
|
||||
color: tuple[float, float, float, float] | None = None,
|
||||
*,
|
||||
bold: bool = False,
|
||||
) -> QIcon:
|
||||
color_key = color if color else None
|
||||
cache_key = (name, size, color_key)
|
||||
cache_key = (name, size, color_key, bold)
|
||||
if cache_key in _icon_cache:
|
||||
return _icon_cache[cache_key]
|
||||
|
||||
@@ -39,8 +44,9 @@ def sf_symbol(
|
||||
if ns_image is None:
|
||||
return QIcon()
|
||||
|
||||
weight = NSFontWeightBold if bold else NSFontWeightRegular
|
||||
size_config = NSImageSymbolConfiguration.configurationWithPointSize_weight_scale_(
|
||||
float(size), NSFontWeightRegular, NSImageSymbolScaleMedium
|
||||
float(size), weight, NSImageSymbolScaleMedium
|
||||
)
|
||||
r, g, b, a = color if color else DEFAULT_COLOR
|
||||
icon_color = NSColor.colorWithSRGBRed_green_blue_alpha_(r, g, b, a)
|
||||
|
||||
531
src/illogical/modules/virtual_category.py
Normal file
531
src/illogical/modules/virtual_category.py
Normal 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
|
||||
@@ -1,2 +0,0 @@
|
||||
Put any application resources (e.g., icons and resources) here;
|
||||
they can be referenced in code as "resources/filename".
|
||||
BIN
src/illogical/resources/icon.icns
Normal file
BIN
src/illogical/resources/icon.icns
Normal file
Binary file not shown.
133
src/illogical/ui/backup_settings_window.py
Normal file
133
src/illogical/ui/backup_settings_window.py
Normal 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}")
|
||||
@@ -4,17 +4,32 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pyqt_liquidglass as glass
|
||||
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.settings import Settings
|
||||
from illogical.ui.backup_settings_window import BackupSettingsWindow
|
||||
from illogical.ui.loading_overlay import LoadingOverlay
|
||||
from illogical.ui.menu_bar import MenuBar
|
||||
from illogical.ui.plugin_table import PluginTableView
|
||||
from illogical.ui.restore_backup_window import RestoreBackupWindow
|
||||
from illogical.ui.shortcuts_help_window import ShortcutsHelpWindow
|
||||
from illogical.ui.sidebar import Sidebar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logic_plugin_manager import Logic, SearchResult
|
||||
from logic_plugin_manager import AudioComponent, Category, Logic, SearchResult
|
||||
from PySide6.QtGui import QCloseEvent, QKeyEvent, QShowEvent
|
||||
|
||||
from illogical.modules.backup_models import (
|
||||
BackupInfo,
|
||||
BackupSettings,
|
||||
DetailedBackupChanges,
|
||||
)
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self) -> None:
|
||||
@@ -25,12 +40,25 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._logic: Logic | None = None
|
||||
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_service()
|
||||
self._setup_backup_service()
|
||||
self._setup_menu_bar()
|
||||
|
||||
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:
|
||||
self._central = QWidget()
|
||||
layout = QHBoxLayout(self._central)
|
||||
@@ -61,8 +89,10 @@ class MainWindow(QMainWindow):
|
||||
self._sidebar.category_selected.connect(self._on_category_selected)
|
||||
self._sidebar.manufacturer_selected.connect(self._on_manufacturer_selected)
|
||||
self._sidebar.enter_pressed.connect(self._plugin_table.focus_table)
|
||||
self._sidebar.backup_requested.connect(self._on_category_backup_requested)
|
||||
self._plugin_table.search_changed.connect(self._on_search_changed)
|
||||
self._plugin_table.plugin_selected.connect(self._on_plugin_selected)
|
||||
self._plugin_table.edit_requested.connect(self._on_plugin_edit_requested)
|
||||
|
||||
def _setup_service(self) -> None:
|
||||
self._service = PluginService(self)
|
||||
@@ -71,6 +101,22 @@ class MainWindow(QMainWindow):
|
||||
self._service.search_results.connect(self._on_search_results)
|
||||
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
|
||||
super().showEvent(event)
|
||||
if not self._glass_applied:
|
||||
@@ -92,22 +138,44 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _on_plugins_loaded(self, logic: Logic) -> None:
|
||||
self._logic = logic
|
||||
self._backup_service.set_logic(logic)
|
||||
self._sidebar.populate(logic)
|
||||
self._plugin_table.set_plugins(logic)
|
||||
self._plugin_table.set_category_tree(self._sidebar.category_model)
|
||||
self._sidebar.category_model.plugins_dropped.connect(self._on_plugins_dropped)
|
||||
self._plugin_table.category_assignment_requested.connect(
|
||||
self._on_category_assignment
|
||||
)
|
||||
self._plugin_table.category_removal_requested.connect(self._on_category_removal)
|
||||
self._loading_overlay.hide()
|
||||
self._plugin_table.focus_table()
|
||||
|
||||
def _on_category_selected(self, category: str | None) -> None:
|
||||
self._plugin_table.clear_search()
|
||||
self._current_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:
|
||||
self._plugin_table.clear_search()
|
||||
self._current_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:
|
||||
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
|
||||
self._service.search(query)
|
||||
|
||||
@@ -118,13 +186,245 @@ class MainWindow(QMainWindow):
|
||||
paths.append("Top Level")
|
||||
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:
|
||||
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:
|
||||
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
|
||||
mods = event.modifiers()
|
||||
key = event.key()
|
||||
@@ -173,6 +473,17 @@ class MainWindow(QMainWindow):
|
||||
self._sidebar.clear_manufacturer_search()
|
||||
self._sidebar.select_show_all()
|
||||
|
||||
def _show_shortcuts_help(self) -> None:
|
||||
if self._shortcuts_window is not None:
|
||||
self._shortcuts_window.close()
|
||||
self._shortcuts_window = ShortcutsHelpWindow()
|
||||
self._shortcuts_window.destroyed.connect(self._on_shortcuts_window_destroyed)
|
||||
self._shortcuts_window.show_centered(self)
|
||||
|
||||
def _on_shortcuts_window_destroyed(self) -> None:
|
||||
self._shortcuts_window = None
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self._service.shutdown()
|
||||
self._backup_service.shutdown()
|
||||
super().closeEvent(event)
|
||||
|
||||
99
src/illogical/ui/menu_bar.py
Normal file
99
src/illogical/ui/menu_bar.py
Normal 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)
|
||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtCore import QItemSelection, QPoint, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import QCursor
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QHeaderView,
|
||||
QMenu,
|
||||
QTableView,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -19,21 +21,71 @@ from illogical.modules.models import (
|
||||
COL_SHORT_NAME,
|
||||
COL_TYPE,
|
||||
COL_VERSION,
|
||||
CategoryTreeItem,
|
||||
CategoryTreeModel,
|
||||
PluginTableModel,
|
||||
)
|
||||
from illogical.ui.search_bar import SearchBar
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from logic_plugin_manager import AudioComponent, Logic
|
||||
from PySide6.QtCore import QModelIndex
|
||||
from PySide6.QtGui import QKeyEvent, QResizeEvent
|
||||
from PySide6.QtCore import QEvent, QModelIndex
|
||||
from PySide6.QtGui import QKeyEvent, QMouseEvent, QResizeEvent
|
||||
|
||||
|
||||
KVK_J = 0x26
|
||||
KVK_K = 0x28
|
||||
KVK_V = 0x09
|
||||
|
||||
|
||||
class _VimTableView(QTableView):
|
||||
enter_pressed = Signal()
|
||||
context_menu_requested = Signal(QPoint)
|
||||
visual_mode_changed = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._anchor_row: int | None = None
|
||||
self._visual_line_mode: bool = False
|
||||
self.setDragEnabled(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragOnly)
|
||||
self.setDefaultDropAction(Qt.DropAction.CopyAction)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._on_context_menu)
|
||||
|
||||
def _on_context_menu(self, pos: QPoint) -> None:
|
||||
if self.selectionModel().hasSelection():
|
||||
self.context_menu_requested.emit(pos)
|
||||
|
||||
def event(self, e: QEvent) -> bool:
|
||||
from PySide6.QtCore import QEvent as QEventType # noqa: PLC0415
|
||||
from PySide6.QtGui import QKeyEvent as QKeyEventType # noqa: PLC0415
|
||||
|
||||
if (
|
||||
e.type() == QEventType.Type.ShortcutOverride
|
||||
and isinstance(e, QKeyEventType)
|
||||
and e.key() == Qt.Key.Key_A
|
||||
and e.modifiers() == Qt.KeyboardModifier.ControlModifier
|
||||
):
|
||||
e.accept()
|
||||
return True
|
||||
return super().event(e)
|
||||
|
||||
def _select_all_rows(self) -> None:
|
||||
row_count = self.model().rowCount()
|
||||
if row_count > 0:
|
||||
top_left = self.model().index(0, 0)
|
||||
bottom_right = self.model().index(
|
||||
row_count - 1, self.model().columnCount() - 1
|
||||
)
|
||||
selection = QItemSelection(top_left, bottom_right)
|
||||
self.selectionModel().select(
|
||||
selection,
|
||||
self.selectionModel().SelectionFlag.ClearAndSelect
|
||||
| self.selectionModel().SelectionFlag.Rows,
|
||||
)
|
||||
self._anchor_row = 0
|
||||
|
||||
def _select_row(self, row: int) -> None:
|
||||
index = self.model().index(row, 0)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
@@ -41,32 +93,174 @@ class _VimTableView(QTableView):
|
||||
self.selectionModel().SelectionFlag.ClearAndSelect
|
||||
| self.selectionModel().SelectionFlag.Rows,
|
||||
)
|
||||
self._anchor_row = row
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
def _extend_selection_to(self, row: int) -> None:
|
||||
if self._anchor_row is None:
|
||||
self._anchor_row = self.currentIndex().row()
|
||||
|
||||
start = min(self._anchor_row, row)
|
||||
end = max(self._anchor_row, row)
|
||||
|
||||
self.selectionModel().clear()
|
||||
for r in range(start, end + 1):
|
||||
idx = self.model().index(r, 0)
|
||||
self.selectionModel().select(
|
||||
idx,
|
||||
self.selectionModel().SelectionFlag.Select
|
||||
| self.selectionModel().SelectionFlag.Rows,
|
||||
)
|
||||
|
||||
index = self.model().index(row, 0)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
index, self.selectionModel().SelectionFlag.NoUpdate
|
||||
)
|
||||
|
||||
def enter_visual_line_mode(self) -> None:
|
||||
if self._visual_line_mode:
|
||||
return
|
||||
self._visual_line_mode = True
|
||||
current = self.currentIndex()
|
||||
if current.isValid():
|
||||
self._anchor_row = current.row()
|
||||
self._select_row(current.row())
|
||||
self.visual_mode_changed.emit(True) # noqa: FBT003
|
||||
|
||||
def exit_visual_line_mode(self) -> None:
|
||||
if not self._visual_line_mode:
|
||||
return
|
||||
self._visual_line_mode = False
|
||||
self.visual_mode_changed.emit(False) # noqa: FBT003
|
||||
|
||||
def is_visual_line_mode(self) -> bool:
|
||||
return self._visual_line_mode
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
if self._visual_line_mode:
|
||||
self.exit_visual_line_mode()
|
||||
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if not index.isValid():
|
||||
super().mousePressEvent(event)
|
||||
return
|
||||
|
||||
mods = event.modifiers()
|
||||
has_cmd = bool(mods & Qt.KeyboardModifier.ControlModifier)
|
||||
has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
|
||||
|
||||
if has_cmd and not has_shift:
|
||||
self.selectionModel().setCurrentIndex(
|
||||
index,
|
||||
self.selectionModel().SelectionFlag.Toggle
|
||||
| self.selectionModel().SelectionFlag.Rows,
|
||||
)
|
||||
if self._anchor_row is None:
|
||||
self._anchor_row = index.row()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if has_shift and self._anchor_row is not None:
|
||||
self.selectionModel().clear()
|
||||
start = min(self._anchor_row, index.row())
|
||||
end = max(self._anchor_row, index.row())
|
||||
for r in range(start, end + 1):
|
||||
idx = self.model().index(r, 0)
|
||||
self.selectionModel().select(
|
||||
idx,
|
||||
self.selectionModel().SelectionFlag.Select
|
||||
| self.selectionModel().SelectionFlag.Rows,
|
||||
)
|
||||
self.selectionModel().setCurrentIndex(
|
||||
index, self.selectionModel().SelectionFlag.NoUpdate
|
||||
)
|
||||
event.accept()
|
||||
return
|
||||
|
||||
self._anchor_row = index.row()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912
|
||||
vk = event.nativeVirtualKey()
|
||||
key = event.key()
|
||||
mods = event.modifiers()
|
||||
has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
|
||||
has_alt = bool(mods & Qt.KeyboardModifier.AltModifier)
|
||||
has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
|
||||
|
||||
if key == Qt.Key.Key_Escape and self._visual_line_mode:
|
||||
self.exit_visual_line_mode()
|
||||
current = self.currentIndex()
|
||||
if current.isValid():
|
||||
self._select_row(current.row())
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if has_shift and vk == KVK_V:
|
||||
self.enter_visual_line_mode()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if has_ctrl and key == Qt.Key.Key_A:
|
||||
self._select_all_rows()
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if has_alt and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.context_menu_requested.emit(QPoint(0, 0))
|
||||
event.accept()
|
||||
return
|
||||
|
||||
extend_selection = has_shift or self._visual_line_mode
|
||||
|
||||
if vk == KVK_J or key == Qt.Key.Key_Down:
|
||||
current = self.currentIndex()
|
||||
if current.row() < self.model().rowCount() - 1:
|
||||
self._select_row(current.row() + 1)
|
||||
new_row = current.row() + 1
|
||||
if extend_selection:
|
||||
self._extend_selection_to(new_row)
|
||||
else:
|
||||
self._select_row(new_row)
|
||||
event.accept()
|
||||
return
|
||||
if vk == KVK_K or key == Qt.Key.Key_Up:
|
||||
current = self.currentIndex()
|
||||
if current.row() > 0:
|
||||
self._select_row(current.row() - 1)
|
||||
new_row = current.row() - 1
|
||||
if extend_selection:
|
||||
self._extend_selection_to(new_row)
|
||||
else:
|
||||
self._select_row(new_row)
|
||||
event.accept()
|
||||
return
|
||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.enter_pressed.emit()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def get_selected_plugins(self) -> list[AudioComponent]:
|
||||
model = self.model()
|
||||
if not isinstance(model, PluginTableModel):
|
||||
return []
|
||||
plugins = []
|
||||
for index in self.selectionModel().selectedRows():
|
||||
plugin = model.get_plugin(index.row())
|
||||
if plugin:
|
||||
plugins.append(plugin)
|
||||
return plugins
|
||||
|
||||
|
||||
class PluginTableView(QWidget):
|
||||
search_changed = Signal(str)
|
||||
plugin_selected = Signal(object)
|
||||
edit_requested = Signal(object, int, str)
|
||||
category_assignment_requested = Signal(list, str, bool)
|
||||
category_removal_requested = Signal(list)
|
||||
visual_mode_changed = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._category_tree: CategoryTreeModel | None = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(16, 0, 16, 16)
|
||||
@@ -78,11 +272,12 @@ class PluginTableView(QWidget):
|
||||
layout.addWidget(self._search_bar)
|
||||
|
||||
self._model = PluginTableModel()
|
||||
self._model.edit_requested.connect(self.edit_requested)
|
||||
self._table = _VimTableView()
|
||||
self._table.setModel(self._model)
|
||||
self._table.setAlternatingRowColors(True)
|
||||
self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self._table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self._table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||||
self._table.setShowGrid(False)
|
||||
self._table.verticalHeader().setVisible(False)
|
||||
self._table.horizontalHeader().setStretchLastSection(True)
|
||||
@@ -129,6 +324,9 @@ class PluginTableView(QWidget):
|
||||
layout.addWidget(self._table, 1)
|
||||
|
||||
self._table.selectionModel().currentChanged.connect(self._on_current_changed)
|
||||
self._table.enter_pressed.connect(self._on_enter_pressed)
|
||||
self._table.context_menu_requested.connect(self._on_context_menu_requested)
|
||||
self._table.visual_mode_changed.connect(self.visual_mode_changed)
|
||||
|
||||
def _on_current_changed(self, current: QModelIndex, _previous: QModelIndex) -> None:
|
||||
if current.isValid():
|
||||
@@ -136,6 +334,144 @@ class PluginTableView(QWidget):
|
||||
if 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:
|
||||
self._model.set_plugins(logic)
|
||||
self._resize_columns()
|
||||
@@ -146,12 +482,23 @@ class PluginTableView(QWidget):
|
||||
def filter_by_manufacturer(self, manufacturer: str) -> None:
|
||||
self._model.filter_by_manufacturer(manufacturer)
|
||||
|
||||
def filter_by_search_results(self, plugins: list[AudioComponent]) -> None:
|
||||
self._model.filter_by_search_results(plugins)
|
||||
def filter_by_search_results(
|
||||
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:
|
||||
self._search_bar.clear()
|
||||
|
||||
def get_search_text(self) -> str:
|
||||
return self._search_bar.text()
|
||||
|
||||
def focus_search(self) -> None:
|
||||
self._search_bar.setFocus()
|
||||
self._search_bar.selectAll()
|
||||
@@ -162,6 +509,15 @@ class PluginTableView(QWidget):
|
||||
if not has_selection and self._model.rowCount() > 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:
|
||||
self._table.setFocus()
|
||||
|
||||
|
||||
271
src/illogical/ui/restore_backup_window.py
Normal file
271
src/illogical/ui/restore_backup_window.py
Normal 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()
|
||||
169
src/illogical/ui/shortcuts_help_window.py
Normal file
169
src/illogical/ui/shortcuts_help_window.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pyqt_liquidglass as glass
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QGridLayout, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
|
||||
SHORTCUTS: dict[str, list[tuple[str, str]]] = {
|
||||
"Navigation": [
|
||||
("J / ↓", "Move down"),
|
||||
("K / ↑", "Move up"),
|
||||
("H / ←", "Collapse (categories)"),
|
||||
("L / →", "Expand (categories)"),
|
||||
("↩", "Enter category"),
|
||||
],
|
||||
"Visual Mode": [("⇧V", "Select lines"), ("Esc", "Exit mode")],
|
||||
"Quick Access": [
|
||||
("⌘1", "Show All"),
|
||||
("⌘2", "Uncategorized"),
|
||||
("⌘3", "Focus categories"),
|
||||
("⌘4", "Focus manufacturers"),
|
||||
("⌘F", "Search"),
|
||||
],
|
||||
"Actions": [
|
||||
("⌥↩", "Context menu"),
|
||||
("⌥⇧↑", "Move category up"),
|
||||
("⌥⇧↓", "Move category down"),
|
||||
("⌘A", "Select all"),
|
||||
],
|
||||
"Drag & Drop": [("⇧+Drop", "Add (don't move)")],
|
||||
"Backup": [("⌘B", "Create backup"), ("⌘⇧R", "Restore")],
|
||||
}
|
||||
|
||||
|
||||
class ShortcutsHelpWindow(QWidget):
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._setup_window()
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_window(self) -> None:
|
||||
self.setWindowTitle("Keyboard Shortcuts")
|
||||
self.setWindowFlags(Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
|
||||
def _setup_ui(self) -> None:
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(40, 36, 40, 32)
|
||||
layout.setSpacing(24)
|
||||
|
||||
title = QLabel("Keyboard Shortcuts")
|
||||
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
title.setStyleSheet("""
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
background: transparent;
|
||||
""")
|
||||
layout.addWidget(title)
|
||||
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(32)
|
||||
grid.setColumnStretch(0, 1)
|
||||
grid.setColumnStretch(1, 1)
|
||||
|
||||
sections = list(SHORTCUTS.items())
|
||||
for i, (section_name, shortcuts) in enumerate(sections):
|
||||
col = i % 2
|
||||
row = i // 2
|
||||
section_widget = self._create_section(section_name, shortcuts)
|
||||
grid.addWidget(section_widget, row, col, Qt.AlignmentFlag.AlignTop)
|
||||
|
||||
layout.addLayout(grid)
|
||||
layout.addStretch()
|
||||
|
||||
hint = QLabel("Press Esc to close")
|
||||
hint.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
hint.setStyleSheet("""
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
background: transparent;
|
||||
""")
|
||||
layout.addWidget(hint)
|
||||
|
||||
def _create_section(self, title: str, shortcuts: list[tuple[str, str]]) -> QWidget:
|
||||
section = QWidget()
|
||||
section.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout(section)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(10)
|
||||
|
||||
header = QLabel(title)
|
||||
header.setStyleSheet("""
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
background: transparent;
|
||||
padding-bottom: 4px;
|
||||
""")
|
||||
layout.addWidget(header)
|
||||
|
||||
for key, description in shortcuts:
|
||||
row = self._create_shortcut_row(key, description)
|
||||
layout.addWidget(row)
|
||||
|
||||
return section
|
||||
|
||||
def _create_shortcut_row(self, key: str, description: str) -> QWidget:
|
||||
row = QWidget()
|
||||
row.setStyleSheet("background: transparent;")
|
||||
layout = QHBoxLayout(row)
|
||||
layout.setContentsMargins(0, 4, 0, 4)
|
||||
layout.setSpacing(14)
|
||||
|
||||
key_label = QLabel(key)
|
||||
key_label.setStyleSheet("""
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', 'Menlo', monospace;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
""")
|
||||
|
||||
desc_label = QLabel(description)
|
||||
desc_label.setStyleSheet("""
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
background: transparent;
|
||||
""")
|
||||
|
||||
layout.addWidget(key_label)
|
||||
layout.addWidget(desc_label, 1)
|
||||
|
||||
return row
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
if event.key() == Qt.Key.Key_Escape:
|
||||
self.close()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def focusOutEvent(self, _event: object) -> None: # noqa: N802
|
||||
self.close()
|
||||
|
||||
def show_centered(self, parent: QWidget | None = None) -> None:
|
||||
glass.prepare_window_for_glass(self, frameless=True)
|
||||
self.adjustSize()
|
||||
self.show()
|
||||
self.activateWindow()
|
||||
self.setFocus()
|
||||
|
||||
if parent:
|
||||
parent_geo = parent.geometry()
|
||||
x = parent_geo.center().x() - self.width() // 2
|
||||
y = parent_geo.center().y() - self.height() // 2
|
||||
self.move(x, y)
|
||||
|
||||
QTimer.singleShot(0, self._apply_glass)
|
||||
|
||||
def _apply_glass(self) -> None:
|
||||
glass.apply_glass_to_window(
|
||||
self, options=glass.GlassOptions(corner_radius=16.0)
|
||||
)
|
||||
@@ -3,14 +3,23 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from AppKit import NSColor # type: ignore[attr-defined]
|
||||
from PySide6.QtCore import QModelIndex, QRect, Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtCore import QModelIndex, QPoint, QRect, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QCursor,
|
||||
QDragEnterEvent,
|
||||
QDragLeaveEvent,
|
||||
QDragMoveEvent,
|
||||
QDropEvent,
|
||||
QFont,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QListView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStyle,
|
||||
QStyledItemDelegate,
|
||||
@@ -21,6 +30,8 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from illogical.modules.models import (
|
||||
PLUGIN_MIME_TYPE,
|
||||
CategoryTreeItem,
|
||||
CategoryTreeModel,
|
||||
ManufacturerFilterProxy,
|
||||
ManufacturerListModel,
|
||||
@@ -42,15 +53,153 @@ KVK_L = 0x25
|
||||
class _VimTreeView(QTreeView):
|
||||
enter_pressed = Signal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setDragEnabled(True)
|
||||
self.setAcceptDrops(True)
|
||||
self.setDropIndicatorShown(True)
|
||||
self.setDragDropMode(QAbstractItemView.DragDropMode.DragDrop)
|
||||
self.setDefaultDropAction(Qt.DropAction.MoveAction)
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._on_context_menu_requested)
|
||||
self._expanded_paths: set[str] = set()
|
||||
self.context_menu_path: str | None = None
|
||||
self.drop_target_path: str | None = None
|
||||
|
||||
def setModel(self, model: CategoryTreeModel | None) -> None: # noqa: N802
|
||||
old_model = self.model()
|
||||
if old_model is not None:
|
||||
old_model.modelAboutToBeReset.disconnect(self._save_expanded_state)
|
||||
old_model.modelReset.disconnect(self._restore_expanded_state)
|
||||
super().setModel(model)
|
||||
if model is not None:
|
||||
model.modelAboutToBeReset.connect(self._save_expanded_state)
|
||||
model.modelReset.connect(self._restore_expanded_state)
|
||||
|
||||
def _save_expanded_state(self) -> None:
|
||||
self._expanded_paths.clear()
|
||||
model = self.model()
|
||||
if not isinstance(model, CategoryTreeModel):
|
||||
return
|
||||
|
||||
def collect_expanded(parent: QModelIndex) -> None:
|
||||
for row in range(model.rowCount(parent)):
|
||||
index = model.index(row, 0, parent)
|
||||
if self.isExpanded(index):
|
||||
path = index.data(Qt.ItemDataRole.UserRole)
|
||||
if path:
|
||||
self._expanded_paths.add(path)
|
||||
collect_expanded(index)
|
||||
|
||||
collect_expanded(QModelIndex())
|
||||
|
||||
def _restore_expanded_state(self) -> None:
|
||||
model = self.model()
|
||||
if not isinstance(model, CategoryTreeModel):
|
||||
return
|
||||
|
||||
for path in self._expanded_paths:
|
||||
index = model.index_for_path(path)
|
||||
if index.isValid():
|
||||
self.expand(index)
|
||||
|
||||
def _select_and_activate(self, index: QModelIndex) -> None:
|
||||
self.selectionModel().setCurrentIndex(
|
||||
index, self.selectionModel().SelectionFlag.ClearAndSelect
|
||||
)
|
||||
self.clicked.emit(index)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802
|
||||
def _on_context_menu_requested(self, pos: QPoint) -> None:
|
||||
index = self.indexAt(pos)
|
||||
if index.isValid():
|
||||
self._show_context_menu(index)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
if event.button() == Qt.MouseButton.RightButton:
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None: # noqa: N802
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if index.isValid():
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.children:
|
||||
if self.isExpanded(index):
|
||||
self.collapse(index)
|
||||
else:
|
||||
self.expand(index)
|
||||
event.accept()
|
||||
return
|
||||
super().mouseDoubleClickEvent(event)
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None: # noqa: N802
|
||||
if event.mimeData().hasFormat(PLUGIN_MIME_TYPE):
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
super().dragEnterEvent(event)
|
||||
|
||||
def dragMoveEvent(self, event: QDragMoveEvent) -> None: # noqa: N802
|
||||
index = self.indexAt(event.position().toPoint())
|
||||
if event.mimeData().hasFormat(PLUGIN_MIME_TYPE):
|
||||
if index.isValid():
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
self.drop_target_path = item.full_path
|
||||
else:
|
||||
self.drop_target_path = None
|
||||
self.viewport().update()
|
||||
event.acceptProposedAction()
|
||||
else:
|
||||
self.drop_target_path = None
|
||||
super().dragMoveEvent(event)
|
||||
|
||||
def dragLeaveEvent(self, event: QDragLeaveEvent) -> None: # noqa: N802
|
||||
self.drop_target_path = None
|
||||
self.viewport().update()
|
||||
super().dragLeaveEvent(event)
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None: # noqa: N802
|
||||
self.drop_target_path = None
|
||||
self.viewport().update()
|
||||
super().dropEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802, C901, PLR0911, PLR0912
|
||||
vk = event.nativeVirtualKey()
|
||||
key = event.key()
|
||||
mods = event.modifiers()
|
||||
|
||||
has_alt = bool(mods & Qt.KeyboardModifier.AltModifier)
|
||||
has_shift = bool(mods & Qt.KeyboardModifier.ShiftModifier)
|
||||
has_ctrl = bool(mods & Qt.KeyboardModifier.ControlModifier)
|
||||
has_meta = bool(mods & Qt.KeyboardModifier.MetaModifier)
|
||||
|
||||
if has_alt and has_shift and not has_ctrl and not has_meta:
|
||||
model = self.model()
|
||||
current = self.currentIndex()
|
||||
if isinstance(model, CategoryTreeModel):
|
||||
if key == Qt.Key.Key_Up:
|
||||
if not model.move_category_up(current):
|
||||
model.extract_category(current)
|
||||
self._restore_selection_after_move(current)
|
||||
event.accept()
|
||||
return
|
||||
if key == Qt.Key.Key_Down:
|
||||
if not model.move_category_down(current):
|
||||
model.extract_category(current)
|
||||
self._restore_selection_after_move(current)
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if (
|
||||
has_alt
|
||||
and not has_shift
|
||||
and not has_ctrl
|
||||
and not has_meta
|
||||
and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter)
|
||||
):
|
||||
self._show_context_menu(self.currentIndex())
|
||||
event.accept()
|
||||
return
|
||||
|
||||
if vk == KVK_J or key == Qt.Key.Key_Down:
|
||||
next_idx = self.indexBelow(self.currentIndex())
|
||||
@@ -78,6 +227,134 @@ class _VimTreeView(QTreeView):
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def _restore_selection_after_move(self, old_index: QModelIndex) -> None:
|
||||
if not old_index.isValid():
|
||||
return
|
||||
item: CategoryTreeItem = old_index.internalPointer()
|
||||
path = item.full_path
|
||||
model = self.model()
|
||||
if isinstance(model, CategoryTreeModel):
|
||||
new_index = model.index_for_path(path)
|
||||
if new_index.isValid():
|
||||
parent = new_index.parent()
|
||||
while parent.isValid():
|
||||
self.expand(parent)
|
||||
parent = parent.parent()
|
||||
self.selectionModel().setCurrentIndex(
|
||||
new_index, self.selectionModel().SelectionFlag.ClearAndSelect
|
||||
)
|
||||
|
||||
def _show_context_menu(self, index: QModelIndex) -> None:
|
||||
if not index.isValid():
|
||||
return
|
||||
|
||||
model = self.model()
|
||||
if not isinstance(model, CategoryTreeModel):
|
||||
return
|
||||
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
if item.full_path == "Top Level":
|
||||
return
|
||||
|
||||
self.context_menu_path = item.full_path
|
||||
self.viewport().update()
|
||||
|
||||
import pyqt_liquidglass as glass # noqa: PLC0415
|
||||
|
||||
menu = QMenu(self)
|
||||
menu.aboutToHide.connect(self._on_context_menu_hidden)
|
||||
menu.setStyleSheet("""
|
||||
QMenu {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 6px 14px 6px 6px;
|
||||
margin: 0px 2px;
|
||||
border-radius: 6px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
QMenu::icon {
|
||||
padding-left: 6px;
|
||||
}
|
||||
QMenu::separator {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin: 6px 10px;
|
||||
}
|
||||
""")
|
||||
glass.prepare_window_for_glass(menu)
|
||||
|
||||
rename_action = menu.addAction(sf_symbol("pencil", 14), "Rename")
|
||||
rename_action.triggered.connect(lambda: self._do_rename(index))
|
||||
|
||||
create_sub_action = menu.addAction(
|
||||
sf_symbol("folder.badge.plus", 14), "Create Subcategory"
|
||||
)
|
||||
create_sub_action.triggered.connect(
|
||||
lambda: self._do_create_subcategory(model, index)
|
||||
)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
move_up_action = menu.addAction(sf_symbol("arrow.up", 14), "Move Up")
|
||||
move_up_action.setShortcut("Alt+Shift+Up")
|
||||
move_up_action.triggered.connect(lambda: self._do_move_up(model, index))
|
||||
|
||||
move_down_action = menu.addAction(sf_symbol("arrow.down", 14), "Move Down")
|
||||
move_down_action.setShortcut("Alt+Shift+Down")
|
||||
move_down_action.triggered.connect(lambda: self._do_move_down(model, index))
|
||||
|
||||
if item.parent_item and item.parent_item.full_path:
|
||||
menu.addSeparator()
|
||||
extract_action = menu.addAction(
|
||||
sf_symbol("arrow.turn.left.up", 14), "Move Out of Parent"
|
||||
)
|
||||
extract_action.triggered.connect(lambda: self._do_extract(model, index))
|
||||
|
||||
if model.can_delete_category(index):
|
||||
menu.addSeparator()
|
||||
delete_action = menu.addAction(sf_symbol("trash", 14), "Delete Category")
|
||||
delete_action.triggered.connect(lambda: model.delete_category(index))
|
||||
|
||||
menu.popup(QCursor.pos())
|
||||
opts = glass.GlassOptions(corner_radius=10.0)
|
||||
QTimer.singleShot(0, lambda: glass.apply_glass_to_window(menu, opts))
|
||||
|
||||
def _on_context_menu_hidden(self) -> None:
|
||||
self.context_menu_path = None
|
||||
self.viewport().update()
|
||||
|
||||
def _do_move_up(self, model: CategoryTreeModel, index: QModelIndex) -> None:
|
||||
model.move_category_up(index)
|
||||
self._restore_selection_after_move(index)
|
||||
|
||||
def _do_move_down(self, model: CategoryTreeModel, index: QModelIndex) -> None:
|
||||
model.move_category_down(index)
|
||||
self._restore_selection_after_move(index)
|
||||
|
||||
def _do_extract(self, model: CategoryTreeModel, index: QModelIndex) -> None:
|
||||
model.extract_category(index)
|
||||
self._restore_selection_after_move(index)
|
||||
|
||||
def _do_rename(self, index: QModelIndex) -> None:
|
||||
self.edit(index)
|
||||
|
||||
def _do_create_subcategory(
|
||||
self, model: CategoryTreeModel, index: QModelIndex
|
||||
) -> None:
|
||||
item: CategoryTreeItem = index.internalPointer()
|
||||
new_index = model.create_category("Untitled", item.full_path)
|
||||
if new_index.isValid():
|
||||
self.expand(index)
|
||||
self.setCurrentIndex(new_index)
|
||||
self.edit(new_index)
|
||||
|
||||
|
||||
class _VimListView(QListView):
|
||||
enter_pressed = Signal()
|
||||
@@ -117,9 +394,27 @@ class _CategoryDelegate(QStyledItemDelegate):
|
||||
def paint(
|
||||
self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex
|
||||
) -> None:
|
||||
from PySide6.QtGui import QBrush, QColor, QPainterPath # noqa: PLC0415
|
||||
|
||||
full_path = index.data(Qt.ItemDataRole.UserRole)
|
||||
icon = index.data(Qt.ItemDataRole.DecorationRole)
|
||||
|
||||
tree_view = option.widget
|
||||
is_context_target = (
|
||||
isinstance(tree_view, _VimTreeView)
|
||||
and tree_view.context_menu_path == full_path
|
||||
)
|
||||
is_drop_target = (
|
||||
isinstance(tree_view, _VimTreeView)
|
||||
and tree_view.drop_target_path == full_path
|
||||
)
|
||||
if is_context_target or is_drop_target:
|
||||
painter.save()
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(option.rect.toRectF(), 4, 4)
|
||||
painter.fillPath(path, QBrush(QColor(128, 128, 128, 60)))
|
||||
painter.restore()
|
||||
|
||||
if full_path == "Top Level" and icon and not icon.isNull():
|
||||
opt = QStyleOptionViewItem(option)
|
||||
self.initStyleOption(opt, index)
|
||||
@@ -149,6 +444,19 @@ class _CategoryDelegate(QStyledItemDelegate):
|
||||
|
||||
super().paint(painter, option, index)
|
||||
|
||||
def setEditorData( # noqa: N802
|
||||
self, editor: QWidget, index: QModelIndex
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QLineEdit # noqa: PLC0415
|
||||
|
||||
if isinstance(editor, QLineEdit):
|
||||
text = index.data(Qt.ItemDataRole.EditRole)
|
||||
if text:
|
||||
editor.setText(str(text))
|
||||
editor.selectAll()
|
||||
else:
|
||||
super().setEditorData(editor, index)
|
||||
|
||||
|
||||
class StickyItem(QWidget):
|
||||
clicked = Signal(str)
|
||||
@@ -224,6 +532,43 @@ class _SectionHeader(QWidget):
|
||||
layout.addStretch()
|
||||
|
||||
|
||||
class _CategorySectionHeader(_SectionHeader):
|
||||
add_clicked = Signal()
|
||||
|
||||
def __init__(
|
||||
self, title: str, icon_name: str, parent: QWidget | None = None
|
||||
) -> None:
|
||||
super().__init__(title, icon_name, parent)
|
||||
|
||||
from PySide6.QtCore import QSize # noqa: PLC0415
|
||||
|
||||
self._add_button = QPushButton()
|
||||
self._add_button.setFixedSize(16, 16)
|
||||
self._add_button.setIconSize(QSize(10, 10))
|
||||
self._add_button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
icon = sf_symbol("plus", 10, (0.25, 0.25, 0.27, 1.0), bold=True)
|
||||
if not icon.isNull():
|
||||
self._add_button.setIcon(icon)
|
||||
self._add_button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background-color: #9B999E;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #ADABAF;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #8A888D;
|
||||
}
|
||||
""")
|
||||
self._add_button.clicked.connect(self.add_clicked)
|
||||
|
||||
layout = self.layout()
|
||||
if layout is not None:
|
||||
layout.addWidget(self._add_button)
|
||||
|
||||
|
||||
class _DraggableHeader(_SectionHeader):
|
||||
dragged = Signal(int)
|
||||
|
||||
@@ -252,11 +597,15 @@ class Sidebar(QWidget):
|
||||
category_selected = Signal(object)
|
||||
manufacturer_selected = Signal(str)
|
||||
enter_pressed = Signal()
|
||||
backup_requested = Signal(bool)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setMinimumWidth(200)
|
||||
|
||||
self._active_category: str | None = None
|
||||
self._active_manufacturer: str | None = None
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
@@ -308,7 +657,8 @@ class Sidebar(QWidget):
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
header = _SectionHeader("Category", "folder")
|
||||
header = _CategorySectionHeader("Category", "folder")
|
||||
header.add_clicked.connect(self._on_add_category_clicked)
|
||||
layout.addWidget(header)
|
||||
|
||||
tree_container = QWidget()
|
||||
@@ -317,7 +667,9 @@ class Sidebar(QWidget):
|
||||
tree_layout.setSpacing(0)
|
||||
|
||||
self._category_model = CategoryTreeModel()
|
||||
self._category_tree = _VimTreeView()
|
||||
self._category_model.error_occurred.connect(self._show_category_error)
|
||||
self._category_model.backup_requested.connect(self.backup_requested)
|
||||
self._category_tree = _VimTreeView(self)
|
||||
self._category_tree.setItemDelegate(_CategoryDelegate(self._category_tree))
|
||||
self._category_tree.setModel(self._category_model)
|
||||
self._category_tree.setHeaderHidden(True)
|
||||
@@ -386,28 +738,69 @@ class Sidebar(QWidget):
|
||||
self._category_model.build_from_plugins(logic)
|
||||
self._manufacturer_model.build_from_plugins(logic)
|
||||
|
||||
@property
|
||||
def category_model(self) -> CategoryTreeModel:
|
||||
return self._category_model
|
||||
|
||||
def _clear_selections(self) -> None:
|
||||
self._category_tree.clearSelection()
|
||||
self._manufacturer_list.clearSelection()
|
||||
self._uncategorized.set_selected(False)
|
||||
|
||||
def _show_category_error(self, title: str, message: str) -> None:
|
||||
from AppKit import NSAlert, NSAlertStyleWarning, NSApp # noqa: PLC0415
|
||||
|
||||
alert = NSAlert.alloc().init()
|
||||
alert.setMessageText_(title)
|
||||
alert.setInformativeText_(message)
|
||||
alert.setAlertStyle_(NSAlertStyleWarning)
|
||||
alert.addButtonWithTitle_("OK")
|
||||
|
||||
window = None
|
||||
if self.window():
|
||||
window = self.window().winId().__int__()
|
||||
ns_window = NSApp.windowWithWindowNumber_(window)
|
||||
if ns_window:
|
||||
alert.beginSheetModalForWindow_completionHandler_(ns_window, None)
|
||||
return
|
||||
|
||||
alert.runModal()
|
||||
|
||||
def _on_show_all_clicked(self) -> None:
|
||||
self._active_category = "Show All"
|
||||
self._active_manufacturer = None
|
||||
self._clear_selections()
|
||||
self.category_selected.emit("Show All")
|
||||
|
||||
def _on_uncategorized_clicked(self) -> None:
|
||||
self._active_category = None
|
||||
self._active_manufacturer = None
|
||||
self._clear_selections()
|
||||
self.category_selected.emit(None)
|
||||
|
||||
def _on_category_clicked(self, index: QModelIndex) -> None:
|
||||
full_path = index.data(Qt.ItemDataRole.UserRole)
|
||||
if full_path:
|
||||
self._active_category = full_path
|
||||
self._active_manufacturer = None
|
||||
self._manufacturer_list.clearSelection()
|
||||
self.category_selected.emit(full_path)
|
||||
|
||||
def _on_add_category_clicked(self) -> None:
|
||||
index = self._category_model.create_category("Untitled")
|
||||
if index.isValid():
|
||||
parent = index.parent()
|
||||
while parent.isValid():
|
||||
self._category_tree.expand(parent)
|
||||
parent = parent.parent()
|
||||
self._category_tree.setCurrentIndex(index)
|
||||
self._category_tree.edit(index)
|
||||
|
||||
def _on_manufacturer_clicked(self, index: QModelIndex) -> None:
|
||||
manufacturer = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
if manufacturer:
|
||||
self._active_manufacturer = manufacturer
|
||||
self._active_category = None
|
||||
self._category_tree.clearSelection()
|
||||
self.manufacturer_selected.emit(manufacturer)
|
||||
|
||||
@@ -431,26 +824,58 @@ class Sidebar(QWidget):
|
||||
self._manufacturer_search.clear()
|
||||
|
||||
def select_show_all(self) -> None:
|
||||
self._active_category = "Show All"
|
||||
self._active_manufacturer = None
|
||||
self._clear_selections()
|
||||
self.category_selected.emit("Show All")
|
||||
|
||||
def select_uncategorized(self) -> None:
|
||||
self._active_category = None
|
||||
self._active_manufacturer = None
|
||||
self._clear_selections()
|
||||
self._uncategorized.set_selected(True)
|
||||
self.category_selected.emit(None)
|
||||
|
||||
def focus_category_tree(self) -> None:
|
||||
self._category_tree.setFocus()
|
||||
top_level_index = self._category_model.index_for_path("Top Level")
|
||||
if top_level_index.isValid():
|
||||
self._category_tree.setCurrentIndex(top_level_index)
|
||||
|
||||
if self._active_category and self._active_category not in ("Show All", None):
|
||||
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:
|
||||
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)
|
||||
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:
|
||||
sizes = self._splitter.sizes()
|
||||
@@ -461,7 +886,10 @@ class Sidebar(QWidget):
|
||||
|
||||
def highlight_categories(self, category_paths: list[str]) -> None:
|
||||
self._category_tree.clearSelection()
|
||||
|
||||
if self._active_manufacturer is None:
|
||||
self._manufacturer_list.clearSelection()
|
||||
|
||||
self._uncategorized.set_selected(False)
|
||||
|
||||
if not category_paths:
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -287,7 +287,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ 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 = "pyside6-essentials", specifier = "~=6.8" },
|
||||
]
|
||||
@@ -306,11 +306,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "logic-plugin-manager"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
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 = [
|
||||
{ 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]
|
||||
|
||||
Reference in New Issue
Block a user