From 87ab7ee73c22fba75b4e870019c206b25a691371 Mon Sep 17 00:00:00 2001 From: Hirnwunde Date: Thu, 26 Feb 2026 08:42:12 +0100 Subject: [PATCH] first --- README.md | 30 ++ cursor_pyqt6_image_sorting_application.md | 510 ++++++++++++++++++ photo_sorter/__init__.py | 5 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 261 bytes photo_sorter/config.json | 20 + .../config_manager.cpython-312.pyc | Bin 0 -> 4147 bytes photo_sorter/core/config_manager.py | 73 +++ photo_sorter/core/file_operations.py | 82 +++ photo_sorter/core/image_loader.py | 78 +++ photo_sorter/main.py | 26 + .../__pycache__/main_window.cpython-312.pyc | Bin 0 -> 12762 bytes photo_sorter/ui/image_viewer.py | 106 ++++ photo_sorter/ui/main_window.py | 248 +++++++++ photo_sorter/ui/settings_dialog.py | 124 +++++ photo_sorter/ui/target_bar.py | 64 +++ requirements.txt | 2 + 16 files changed, 1368 insertions(+) create mode 100644 README.md create mode 100644 cursor_pyqt6_image_sorting_application.md create mode 100644 photo_sorter/__init__.py create mode 100644 photo_sorter/__pycache__/__init__.cpython-312.pyc create mode 100644 photo_sorter/config.json create mode 100644 photo_sorter/core/__pycache__/config_manager.cpython-312.pyc create mode 100644 photo_sorter/core/config_manager.py create mode 100644 photo_sorter/core/file_operations.py create mode 100644 photo_sorter/core/image_loader.py create mode 100644 photo_sorter/main.py create mode 100644 photo_sorter/ui/__pycache__/main_window.cpython-312.pyc create mode 100644 photo_sorter/ui/image_viewer.py create mode 100644 photo_sorter/ui/main_window.py create mode 100644 photo_sorter/ui/settings_dialog.py create mode 100644 photo_sorter/ui/target_bar.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..41470c0 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# Photo Sorter (PyQt6) + +Einfache Desktop-Anwendung zum schnellen Sortieren von Fotos per Tastatur. + +## Features +- Quellverzeichnis auswählen und alle Bilder nacheinander anzeigen +- Bis zu 10 Zielverzeichnisse (Tasten 1–0) mit optionalen Labels +- Verschieben oder Kopieren (global konfigurierbar, Standard: Verschieben) +- Undo-Funktion für die letzte Aktion + +## Installation + +```bash +cd pyPhotoSorter +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +## Start + +```bash +python -m photo_sorter.main +``` + +Oder direkt: + +```bash +python photo_sorter/main.py +``` diff --git a/cursor_pyqt6_image_sorting_application.md b/cursor_pyqt6_image_sorting_application.md new file mode 100644 index 0000000..74bbc6f --- /dev/null +++ b/cursor_pyqt6_image_sorting_application.md @@ -0,0 +1,510 @@ +# PyQt6 image sorting application +_Exported on 26.2.2026 at 8:40:33 MEZ from Cursor (2.5.25)_ + +--- + +**User** + +🎯 Ziel +Entwickle eine PyQt6-Desktop-Anwendung, mit der Bilder aus einem Quellverzeichnis per Tastendruck in konfigurierbare Zielverzeichnisse verschoben oder kopiert werden können. + +⚙️ Funktionale Anforderungen +1. Quellverzeichnis + • Beim Start kann der Benutzer ein Quellverzeichnis auswählen (via QFileDialog) + • Die App lädt alle Bilddateien (.jpg, .jpeg, .png, .webp, .bmp, .gif) aus diesem Verzeichnis + • Die Bilder werden der Reihe nach angezeigt +2. Bildanzeige + • Das aktuelle Bild wird zentriert und skaliert (aspect-ratio-erhaltend) im Hauptbereich angezeigt (QLabel mit QPixmap) + • Dateiname und Index (z. B. Bild 42 / 1337) werden oberhalb des Bildes angezeigt +3. Zielverzeichnisse konfigurieren + • Es gibt 1 bis 10 Zielverzeichnisse, die den Tasten 1 bis 0 (auf der Haupttastatur) zugeordnet sind + • Jedes Ziel besteht aus: + ◦ Einer Taste (1–0) + ◦ Einem Verzeichnispfad (auswählbar via QFileDialog) + ◦ Einem optionalen Kurznamen/Label (z. B. "Urlaub", "Löschen") + • Die Konfiguration kann über einen separaten Dialog oder ein Panel bearbeitet werden + • Ziele werden in einer JSON-Konfigurationsdatei gespeichert und beim nächsten Start geladen +4. Tastatursteuerung + • Taste 1–0: Bild in das entsprechende Zielverzeichnis verschieben oder kopieren (konfigurierbar, Standard: verschieben) → nächstes Bild wird automatisch geladen + • Taste → oder Leertaste: Bild überspringen (nächstes Bild, ohne Aktion) + • Taste ←: Vorheriges Bild anzeigen + • Taste Z oder Ctrl+Z: Letzte Aktion rückgängig machen (Bild zurückverschieben) +5. Zielanzeige (Statusleiste) + • Unterhalb des Bildes wird eine Leiste angezeigt mit allen konfigurierten Zielen, z. B.: + Urlaub Familie Löschen Landschaft + • Nicht belegte Tasten werden nicht angezeigt + • Beim Drücken einer Taste wird das entsprechende Ziel kurz visuell hervorgehoben + +🖥️ UI-Struktur +┌─────────────────────────────────────────┐ +│ [Menü: Datei | Einstellungen] │ +├─────────────────────────────────────────┤ +│ Bild 42 / 1337 – IMG_4567.jpg │ +├─────────────────────────────────────────┤ +│ │ +│ [ Bildanzeige ] │ +│ │ +├─────────────────────────────────────────┤ +│ Urlaub Familie Löschen │ +└─────────────────────────────────────────┘ + +🗂️ Projektstruktur +photo_sorter/ +├── main.py # Einstiegspunkt +├── config.json # Gespeicherte Zielverzeichnisse +├── ui/ +│ ├── main_window.py # Hauptfenster +│ ├── image_viewer.py # Widget für Bildanzeige +│ ├── target_bar.py # Widget für Zielanzeige +│ └── settings_dialog.py # Dialog zum Konfigurieren der Ziele +└── core/ + ├── image_loader.py # Laden & Iterieren der Bilder + ├── file_operations.py # Verschieben/Kopieren/Undo-Logik + └── config_manager.py # Lesen/Schreiben der JSON-Konfiguration + +📦 Abhängigkeiten +text +Kopieren +PyQt6 +Pillow (optional, für bessere Bildunterstützung / EXIF-Rotation) + +🔲 Nicht im Scope (bewusst ausgelassen) + • Keine Datenbankanbindung + • Kein Cloud-Upload + • Kein automatisches Tagging / KI-Klassifizierung + + +--- + +**Cursor** + +Ich erstelle dir ein neues PyQt6-Projekt gemäß deiner Struktur, inkl. `requirements.txt` und aller Module, dann implementiere ich schrittweise Bildnavigation, Dateioperationen, Konfiguration und Keyboard-Steuerung. Anschließend fasse ich kurz zusammen, wie du die App startest. + +Ich lege jetzt Projektstruktur, `requirements.txt` und Basisdateien an und implementiere danach die Kernlogik und UI. + +Grundstruktur, `requirements.txt`, `README` und Paketordner sind angelegt; als Nächstes kommen Kern-Module in `core/` und dann die UI in `ui/`. + +Core-Module für Konfiguration, Bildliste und Dateioperationen sind implementiert; jetzt baue ich die PyQt6-UI (MainWindow, ImageViewer, TargetBar, Settings-Dialog) und verdrahte die Tastatursteuerung. + +### Übersicht + +Ich habe dir die komplette Anwendung gemäß deiner Spezifikation implementiert – inkl. Projektstruktur, Kernlogik, UI, Konfiguration und Undo. + +### Projektstruktur + +In deinem Workspace liegt jetzt: + +- **Top-Level** + - `requirements.txt` + - `README.md` + - `photo_sorter/` + - `__init__.py` + - `main.py` + - `config.json` (wird bei Bedarf erstellt) + - `core/` + - `config_manager.py` + - `image_loader.py` + - `file_operations.py` + - `ui/` + - `main_window.py` + - `image_viewer.py` + - `target_bar.py` + - `settings_dialog.py` + +--- + +### Kernmodule (`core/`) + +#### `config_manager.py` + +- **Datenklassen** + +```python +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Dict, List, Literal +import json + +ActionType = Literal["move", "copy"] + + +@dataclass +class TargetConfig: + key: str # "1" .. "0" + path: str + label: str + + +@dataclass +class AppConfig: + targets: List[TargetConfig] + default_action: ActionType = "move" + + @classmethod + def empty(cls) -> "AppConfig": + return cls(targets=[], default_action="move") +``` + +- **ConfigManager** + - `load()`: + - Liest `config.json` (falls vorhanden) + - Baut `AppConfig` mit `targets` und `default_action` (`"move"` oder `"copy"`) + - Fällt bei Fehlern auf `AppConfig.empty()` zurück. + - `save(config: AppConfig)`: + - Schreibt JSON mit: + - `targets`: Liste von `{key, path, label}` + - `default_action`: `"move"`/`"copy"` + +Damit werden die Zielverzeichnisse und die globale Move/Copy-Einstellung persistiert. + +#### `image_loader.py` + +- Kümmert sich um: + - Laden der Bildliste aus einem Quellverzeichnis + - Navigation (`next`, `previous`, `set_index`) + - Zugriff auf aktuelles Bild und Gesamtanzahl + +Wichtige Punkte: + +```python +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} + +class ImageLoader: + def __init__(self) -> None: + self._images: List[Path] = [] + self._current_index: int = -1 + self._source_dir: Optional[Path] = None + + def load_from_directory(self, directory: Path) -> None: + directory = Path(directory) + self._source_dir = directory + # Filter und sortiere Bilddateien + files = sorted( + p for p in directory.iterdir() + if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS + ) + self._images = files + self._current_index = 0 if self._images else -1 + + def get_current(self) -> Optional[Path]: ... + def next(self) -> Optional[Path]: ... + def previous(self) -> Optional[Path]: ... + def set_index(self, index: int) -> Optional[Path]: ... + def update_current_path(self, new_path: Path) -> None: + # Wird nach Move/Undo genutzt, damit Zurückblättern funktioniert +``` + +#### `file_operations.py` + +- Implementiert Move/Copy mit Undo-Stack. + +```python +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional +import shutil + +from .config_manager import ActionType + + +@dataclass +class FileAction: + original_path: Path + new_path: Path + action: ActionType # "move" oder "copy" + index: int # Bildindex zum Zeitpunkt der Aktion +``` + +- **FileOperations**: + +```python +class FileOperations: + def __init__(self) -> None: + self._history: List[FileAction] = [] + + @staticmethod + def _unique_destination(dest_dir: Path, filename: str) -> Path: + # Erzeugt bei Namenskollisionen foo_1.jpg, foo_2.jpg, ... +``` + +- `move_or_copy(path, dest_dir, action, index) -> Path`: + - Ermittelt freien Zielpfad (keine Überschreibung) + - `move`: `shutil.move` + - `copy`: `shutil.copy2` + - Schiebt `FileAction` auf den History-Stack + - Gibt den finalen Zielpfad zurück (wichtig, um den Pfad in der Bildliste zu aktualisieren) + +- `undo_last() -> Optional[FileAction]`: + - Holt letzte Aktion vom Stack + - Bei `"move"`: Datei wieder an `original_path` zurückverschieben + - Bei `"copy"`: nur Kopie (`new_path`) löschen, Original bleibt + - Liefert die `FileAction` zurück, damit das UI den Index und Pfad zurücksetzen kann + +--- + +### UI-Module (`ui/`) + +#### `image_viewer.py` + +- `ImageViewer` ist ein `QWidget`, das das aktuelle Bild zentriert und mit erhaltenem Seitenverhältnis skaliert anzeigt. +- Nutzt optional **Pillow**, um EXIF-Orientation zu respektieren: + +```python +try: + from PIL import Image, ExifTags +except Exception: + Image = None + ExifTags = None +``` + +- `set_image(path: Optional[Path])`: + - Wenn Pillow verfügbar: + - Bild öffnen, EXIF-Orientation (3, 6, 8) berücksichtigen + - In RGBA konvertieren und in `QPixmap` überführen + - Fallback: `QPixmap(str(path))` +- `resizeEvent`: + - Skaliert `_original_pixmap` auf die Größe des Labels: + +```python +scaled = self._original_pixmap.scaled( + target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, +) +self._label.setPixmap(scaled) +``` + +#### `target_bar.py` + +- `TargetDisplay` (Dataclass): `key`, `label` +- `TargetBar(QWidget)`: + - `set_targets(List[TargetDisplay])`: + - Baut eine horizontale Leiste mit `QLabel`s für alle belegten Ziele. + - Nicht belegte Tasten werden nicht angezeigt (da keine `TargetDisplay` erzeugt wird). + - `highlight_target(key, duration_ms=200)`: + - Findet das Label zur Taste `key` + - Setzt kurz einen hervorgehobenen Style (blauer Rand, hellblauer Hintergrund) + - Setzt via `QTimer.singleShot` danach wieder den ursprünglichen Style + +#### `settings_dialog.py` + +- `SettingsDialog(AppConfig)`: + - Stellt eine Tabelle für Tasten 1–0 dar: + - Spalten: Taste, Label, Verzeichnis, Button „Wählen…“ + - Für jede Taste gibt es `QLineEdit` für Label und Pfad. + - „Wählen…“ öffnet `QFileDialog.getExistingDirectory`. + - Lädt bestehende Konfiguration pro Taste vor (Label + Pfad). + - Unten: + - Radiobuttons „Verschieben“ / „Kopieren“ als **globale** Standardaktion (`default_action`). + - `get_config() -> AppConfig`: + - Sammelt alle Tasten, bei denen ein Pfad gesetzt ist. + - Erzeugt `TargetConfig(key, path, label)`-Liste. + - Liest Aktion aus Radio-Buttons → `"move"` oder `"copy"`. + +--- + +### Hauptfenster & Steuerung (`main_window.py`) + +#### Aufbau + +- `MainWindow(QMainWindow)`: + - Hält: + - `ConfigManager` + `AppConfig` + - `ImageLoader` + - `FileOperations` + - Mapping `key -> TargetConfig` + - UI: + - Oben: `QLabel` mit `Bild X / Y – Dateiname` + - Mitte: `ImageViewer` + - Unten: `TargetBar` mit allen konfigurierten Zielen + - Statusbar (optional, aktuell ohne speziellen Text) + +- Menü: + - **Datei** + - „Quellverzeichnis öffnen…“ (`Ctrl+O`): ruft `_select_source_directory()` + - „Beenden“ (`Ctrl+Q`): `self.close()` + - **Einstellungen** + - „Ziele konfigurieren…“: öffnet `SettingsDialog` + +#### Konfiguration & Zielleiste + +- Beim Start: + +```python +self._config: AppConfig = self._config_manager.load() +self._targets_by_key = {t.key: t for t in self._config.targets} +self._apply_config_to_ui() +self._select_source_directory(initial=True) +``` + +- `_apply_config_to_ui()`: + - Aktualisiert Mapping `key -> TargetConfig` + - Baut `TargetDisplay`-Liste und übergibt sie an `TargetBar.set_targets`. + +- `_open_settings()`: + - Erstellt `SettingsDialog` mit aktueller Config + - Wenn `Accepted`: + - `new_config = dialog.get_config()` + - Speichert via `ConfigManager.save(new_config)` + - Wendet neue Zielleiste an (`_apply_config_to_ui()`) + +#### Quellverzeichnis & Bildanzeige + +- `_select_source_directory(initial: bool = False)`: + - Öffnet `QFileDialog.getExistingDirectory`. + - Wenn beim Initial-Start kein Verzeichnis gewählt wird, passiert nichts weiter (leeres Fenster). + - Lädt Bilder aus dem Ordner via `ImageLoader.load_from_directory`. + - Wenn keine Bilder gefunden: + - `QMessageBox.information` mit Hinweis + - `ImageViewer.clear()` und Info-Label „Keine Bilder gefunden“ + - Sonst: `_show_current_image()`. + +- `_show_current_image()`: + - Holt aktuelles `Path` und Index/Count aus `ImageLoader`. + - Aktualisiert `ImageViewer` und Info-Label: + - „Bild {index+1} / {count} – {filename}“. + +#### Tastatursteuerung + +`keyPressEvent` implementiert genau deine Vorgaben: + +- **Skip**: + - Rechts-Pfeil (`Qt.Key_Right`) oder Leertaste (`Qt.Key_Space`): + - `_skip_forward()`: `ImageLoader.next()` und `_show_current_image()` +- **Zurück**: + - Links-Pfeil (`Qt.Key_Left`): + - `_go_back()`: `ImageLoader.previous()` und `_show_current_image()` +- **Undo**: + - Taste `Z` **oder** `Ctrl+Z`: + - `_undo_action()` +- **Ziele 1–0**: + - `Qt.Key_1` … `Qt.Key_0`: + - Mappen auf `"1"` … `"0"` + - `_apply_target_action(key_char)` + +#### Zielaktion (Move/Copy) + Undo + +- `_apply_target_action(key: str)`: + + - Prüft: + - Gibt es Bilder? + - Ist für diese Taste ein Ziel konfiguriert? + - Holt aktuelles Bild via `ImageLoader.get_current()`. + - Ruft: + + ```python + new_path = self._file_ops.move_or_copy( + current, + Path(target.path), + self._config.default_action, # "move" oder "copy" + self._image_loader.current_index, + ) + ``` + + - Aktualisiert den Pfad in der Bildliste: + - `self._image_loader.update_current_path(new_path)` + - Wichtig, damit das Bild bei „Zurück“ vom neuen Ort geladen wird. + - Hebt das Ziel kurz hervor: + - `self._target_bar.highlight_target(key)` + - Lädt automatisch das nächste Bild: + - `self._image_loader.next()` + - `_show_current_image()` + +- `_undo_action()`: + + - Ruft `action = self._file_ops.undo_last()`. + - Wenn `None`: nichts zu tun. + - Sonst: + - Setzt den Bildindex auf den ursprünglichen Index der Aktion: + - `self._image_loader.set_index(action.index)` + - Wenn die Aktion ein **Move** war: + - Pfad in der Bildliste wieder auf den Originalpfad aktualisieren: + - `self._image_loader.update_current_path(action.original_path)` + - Zeigt das Bild wieder an: `_show_current_image()`. + + - Verhalten: + - **Move**: + - Datei wird im FS an Ursprungsort zurückgeschoben + - Bildliste zeigt wieder auf Ursprungsdatei + - **Copy**: + - Nur die Kopie wird gelöscht, Original bleibt + - Bildliste zeigt weiterhin auf das Original (Pfad ändert sich nicht) + +--- + +### Einstiegspunkt (`main.py`) + +- `photo_sorter/main.py`: + +```python +from PyQt6.QtWidgets import QApplication +from photo_sorter.core.config_manager import ConfigManager +from photo_sorter.ui.main_window import MainWindow + +def main() -> int: + app = QApplication(sys.argv) + + base_dir = Path(__file__).resolve().parent + config_path = base_dir / "config.json" + config_manager = ConfigManager(config_path) + + window = MainWindow(config_manager) + window.show() + + return app.exec() + +if __name__ == "__main__": + raise SystemExit(main()) +``` + +--- + +### Installation & Start + +1. **Abhängigkeiten installieren** + +```bash +cd /home/obk/src/cursor_ai_kram/pyPhotoSorter +python -m venv .venv +source .venv/bin/activate # unter Windows: .venv\Scripts\activate +pip install -r requirements.txt +``` + +2. **App starten** + +```bash +python -m photo_sorter.main +# oder +python photo_sorter/main.py +``` + +--- + +### Was ist umgesetzt im Verhältnis zu deinen Anforderungen? + +- **Quellverzeichnis**: + - Wird beim Start via Dialog abgefragt; weitere Auswahl jederzeit über Menü „Datei → Quellverzeichnis öffnen…“ möglich. +- **Bildanzeige**: + - `ImageViewer` zentriert & skaliert das Bild (Seitenverhältnis bleibt erhalten). + - Oben steht „Bild X / Y – Dateiname“. +- **Zielverzeichnisse**: + - Bis zu 10 Ziele (Tasten 1–0) konfigurierbar im `SettingsDialog`. + - Jedes Ziel hat Taste, Pfad, optional Label. + - Konfiguration wird in `config.json` gespeichert und beim Start geladen. +- **Tastatursteuerung**: + - 1–0: Move/Copy in Ziel, dann automatisches Weiterblättern. + - `→` / Leertaste: Skip (nächstes Bild). + - `←`: Vorheriges Bild. + - `Z` oder `Ctrl+Z`: Undo der letzten Aktion (inkl. Zurückverschieben). +- **Zielleiste**: + - Zeigt nur konfigurierte Ziele (ohne leere Tasten). + - Ziel wird beim Drücken kurz visuell hervorgehoben. + +Wenn du möchtest, kann ich als nächsten Schritt noch kleine UX-Details anpassen (z. B. Theme, größere Buttons/Labels) oder dir gezielt Codeausschnitte erläutern, die du anpassen willst (z. B. Verhalten bei Ende der Bildliste, zusätzliche Shortcuts). + +--- + +**User** + +könntest du bitte daraus ein git-repo machen, eine .gitignore erstellen, die alle gängigen IDE-Dateien, .venv's und so weiter exkludiert und eine cursor.md erstellen, die deinen promt abspeichert? + diff --git a/photo_sorter/__init__.py b/photo_sorter/__init__.py new file mode 100644 index 0000000..6ea7032 --- /dev/null +++ b/photo_sorter/__init__.py @@ -0,0 +1,5 @@ +""" +Photo Sorter Paket. + +Siehe `main.py` für den Einstiegspunkt der Anwendung. +""" diff --git a/photo_sorter/__pycache__/__init__.cpython-312.pyc b/photo_sorter/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b4d9a29f5a2a1783a787f2513a506853a6afda11 GIT binary patch literal 261 zcmXv}yG{c!5VXUIM3(XoZquB9AVNYBB`8v;Xg0^$$yt}PPitGzeMvfcJ^?{T$sf@2 z1Bi5~So4@>XLhBXnYZawFv9cGgT1Bsk;OmwA308l9GGTzY~Zs|x+W=>jgMZgJVt|Z zS+ypXLaeN5j9hn>b!9)S<>vJTr8Z7pSr?);+t4T1Mp~h~bbI4;a@$he%=r5lK>Hft zksY(`X`ve58RhR=6|h!y!r&1q3oTZi>W3fyUwhT(EI@b4q5!P35r6`mCiBkgv@;iY VPCs*>kn4V)an8R+1uw85`3>o^On?9Y literal 0 HcmV?d00001 diff --git a/photo_sorter/config.json b/photo_sorter/config.json new file mode 100644 index 0000000..39718c9 --- /dev/null +++ b/photo_sorter/config.json @@ -0,0 +1,20 @@ +{ + "targets": [ + { + "key": "1", + "path": "/home/obk/Bilder/sorter_versuch/fam", + "label": "Family" + }, + { + "key": "2", + "path": "/home/obk/Bilder/sorter_versuch/priv", + "label": "Privat" + }, + { + "key": "3", + "path": "/home/obk/Bilder/sorter_versuch/work", + "label": "Work" + } + ], + "default_action": "move" +} \ No newline at end of file diff --git a/photo_sorter/core/__pycache__/config_manager.cpython-312.pyc b/photo_sorter/core/__pycache__/config_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f63e7bfd4f14b0cc9cc51f5ec63458d301812810 GIT binary patch literal 4147 zcmb6cTWk~A_0H4dR~#oH2@A;@5)#ZKA+S(Hd5A|^Ud^sHv=Xl9Xz)xDJAQFzykO+0 zNK2(4A(5!6l9gyT{i%XLDqj`-TPmS`^e4tpXQx?}s;gG}Q)NjdRQl6%?s!aMK-)c0 z&YU^--0O4Cd;Sy*`Ut=ufB&n*8HJF)w0KH0 zO?WY}Unn=8bt z3bKdbyl^W!fS4f$iE%oK@jnDp8G_Cq1TLe58YLnHipqB2oJL0t>v%RZk{GoG)1tO8 zZWL@W2U^)uO1o&lZ;Q5~su?Y9sH*K#)pSZQDvs;-1ob=7@U^$uA<&0Y@m zk7d(FfA->dze(f$@jNxNRMisdIMveqxxxoySt~o7rEul`9MUSR<4ZhC4M3=xn$|Mf zs6qR31=;|IG86#93vMYMR|WKk_8BcR|_9EQV1gQ^U&B%vFDHZD()P zLz`g>Z9%jS5$ec(-uAPAP8-%(R;QbhjS4vqKt<>V7vT|RHz06V@ZGZb?&7B2YeQ3~ z%VO^{sqs0_N&8j=B8R_5$~ge{zS)BZVC9tubuK389-Y4qg@E}?SnV_2tBbJW(S5oE zPo*G7{dUlC;18U*y1MOb!Z58!KBGs>_!u=37p;h%Fe0ai-yhm}hQ(l>vP|xaSE~eQ zz&z%74KFrBteQuQ+}$c&Bx;RYAa^*hGL=CZ(x)2Sm~5EfrjZ%}gE|q9OcQ@9QuQe0 zU?P*SRP}1>8r}O`eguT8zFpcFncGm_xN}l`s;pL1QA}b5sRIjZ34@|s^c0nQ-n-SfaBaYmo4*#J zO;31>Z??JLmDB^{5ZIL00~21WI$#h|6pP+_L08vd(nSU!+As4QIZrtFyZMhR!g+F; z>mnA$HiK@cb{zTtVld91C&T12aZZUg4A~rgDdnw^o%`=_wqj)BSv`>%bt-!!9B&hr zVYCV8s6dhRioyDT(*Q6cX#|CWEK_aSxB^YdbSf{FWLwPUjEpTN%`D(lR?}_o;FY++ zh6`JO0f1sluqEjKi5v_kIS|)bkB#~$roGLL*dmTEcA(naRD5H~+MHFd95yBshq_1& zSXZLo0bYH{Kz({~!$~Mh*T|wO1QTa?wuFzk1m9J%i+VzgbQ|mwrQzt!%ShiFczfQL_H3Q>EQULt6WM#3n>@PIv~Eh9F{X{#!MWY#)}C@xZ>4ExscC1qsekhL zQgiFnrJ4M6e)dOmAC}v;mYbuM=KfN1f4O;J^2F!Ot)J+NjqOV$GNw$<8*AvvKALt3EI_yPiA0Wuzd^#7EoP3s@DT5xtLiJ@eKBeL?AYq8xv= zec-o;?;QSf+krXlwmf_3m)^&12NpZKE1i2voqN8Pgf8EtxU7(drb@806zr@7yGp^X zx#RbH7lK{o-~otq@1Y0UQlRnHsT-%J^x32HgAX=64IG5D_8wxZ|9S2q?caSNyYDaA zN5Su!jta!OVf-E&$-~4B-(wWAme~H?)I8e<6OjQ^UHcpOk{#*L* "AppConfig": + return cls(targets=[], default_action="move") + + +class ConfigManager: + """Liest und schreibt die JSON-Konfiguration.""" + + def __init__(self, config_path: Path) -> None: + self._config_path = Path(config_path) + + @property + def path(self) -> Path: + return self._config_path + + def load(self) -> AppConfig: + if not self._config_path.exists(): + return AppConfig.empty() + + try: + with self._config_path.open("r", encoding="utf-8") as f: + data = json.load(f) + except Exception: + # Fallback auf Default-Konfiguration bei Fehlern + return AppConfig.empty() + + targets_data = data.get("targets", []) + targets: List[TargetConfig] = [] + for t in targets_data: + key = str(t.get("key", "")).strip() + path = str(t.get("path", "")).strip() + if not key or not path: + continue + label = str(t.get("label", "")).strip() + targets.append(TargetConfig(key=key, path=path, label=label)) + + default_action: ActionType = "move" + if data.get("default_action") == "copy": + default_action = "copy" + + return AppConfig(targets=targets, default_action=default_action) + + def save(self, config: AppConfig) -> None: + self._config_path.parent.mkdir(parents=True, exist_ok=True) + data: Dict[str, object] = { + "targets": [asdict(t) for t in config.targets], + "default_action": config.default_action, + } + with self._config_path.open("w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) diff --git a/photo_sorter/core/file_operations.py b/photo_sorter/core/file_operations.py new file mode 100644 index 0000000..6b91ac1 --- /dev/null +++ b/photo_sorter/core/file_operations.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional +import shutil + +from .config_manager import ActionType + + +@dataclass +class FileAction: + """Repräsentiert eine einzelne Dateiaktion für Undo.""" + + original_path: Path + new_path: Path + action: ActionType + index: int # Bildindex in der Liste zum Zeitpunkt der Aktion + + +class FileOperationError(Exception): + pass + + +class FileOperations: + """Kapselt Move/Copy-Logik und Undo-Stack.""" + + def __init__(self) -> None: + self._history: List[FileAction] = [] + + @staticmethod + def _unique_destination(dest_dir: Path, filename: str) -> Path: + dest_dir.mkdir(parents=True, exist_ok=True) + base = Path(filename).stem + suffix = Path(filename).suffix + candidate = dest_dir / filename + counter = 1 + while candidate.exists(): + candidate = dest_dir / f"{base}_{counter}{suffix}" + counter += 1 + return candidate + + def move_or_copy( + self, path: Path, dest_dir: Path, action: ActionType, index: int + ) -> Path: + path = Path(path) + dest_dir = Path(dest_dir) + if not path.exists(): + raise FileOperationError(f"Datei nicht gefunden: {path}") + + dest = self._unique_destination(dest_dir, path.name) + + if action == "move": + shutil.move(str(path), str(dest)) + else: + shutil.copy2(str(path), str(dest)) + + file_action = FileAction( + original_path=path, + new_path=dest, + action=action, + index=index, + ) + self._history.append(file_action) + return dest + + def undo_last(self) -> Optional[FileAction]: + if not self._history: + return None + + action = self._history.pop() + + if action.action == "move": + # Zurück an den Originalort verschieben + action.original_path.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(action.new_path), str(action.original_path)) + else: + # Copy rückgängig: Kopie löschen, Original bleibt unberührt + if action.new_path.exists(): + action.new_path.unlink() + + return action diff --git a/photo_sorter/core/image_loader.py b/photo_sorter/core/image_loader.py new file mode 100644 index 0000000..88fb872 --- /dev/null +++ b/photo_sorter/core/image_loader.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from pathlib import Path +from typing import List, Optional + + +IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"} + + +class ImageLoader: + """Verwaltet die Liste der Bilder und den aktuellen Index.""" + + def __init__(self) -> None: + self._images: List[Path] = [] + self._current_index: int = -1 + self._source_dir: Optional[Path] = None + + @property + def source_dir(self) -> Optional[Path]: + return self._source_dir + + @property + def images(self) -> List[Path]: + return list(self._images) + + @property + def current_index(self) -> int: + return self._current_index + + @property + def count(self) -> int: + return len(self._images) + + def load_from_directory(self, directory: Path) -> None: + directory = Path(directory) + self._source_dir = directory + if not directory.is_dir(): + self._images = [] + self._current_index = -1 + return + + files = sorted( + p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in IMAGE_EXTENSIONS + ) + self._images = files + self._current_index = 0 if self._images else -1 + + def get_current(self) -> Optional[Path]: + if 0 <= self._current_index < len(self._images): + return self._images[self._current_index] + return None + + def next(self) -> Optional[Path]: + if not self._images: + return None + if self._current_index < len(self._images) - 1: + self._current_index += 1 + return self.get_current() + + def previous(self) -> Optional[Path]: + if not self._images: + return None + if self._current_index > 0: + self._current_index -= 1 + return self.get_current() + + def update_current_path(self, new_path: Path) -> None: + """Aktualisiert den Pfad des aktuellen Bildes (z.B. nach Move/Undo).""" + if 0 <= self._current_index < len(self._images): + self._images[self._current_index] = Path(new_path) + + def set_index(self, index: int) -> Optional[Path]: + if not self._images: + self._current_index = -1 + return None + if 0 <= index < len(self._images): + self._current_index = index + return self.get_current() diff --git a/photo_sorter/main.py b/photo_sorter/main.py new file mode 100644 index 0000000..6302c1b --- /dev/null +++ b/photo_sorter/main.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +from PyQt6.QtWidgets import QApplication + +from photo_sorter.core.config_manager import ConfigManager +from photo_sorter.ui.main_window import MainWindow + + +def main() -> int: + app = QApplication(sys.argv) + + base_dir = Path(__file__).resolve().parent + config_path = base_dir / "config.json" + config_manager = ConfigManager(config_path) + + window = MainWindow(config_manager) + window.show() + + return app.exec() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/photo_sorter/ui/__pycache__/main_window.cpython-312.pyc b/photo_sorter/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..94da31037a0f099c8d5558e81d6499c116972aa9 GIT binary patch literal 12762 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 X^T None: + super().__init__(parent) + self._original_pixmap: Optional[QPixmap] = None + + self._label = QLabel(self) + self._label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self._label.setBackgroundRole(self._label.backgroundRole()) + self._label.setScaledContents(False) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self._label) + + def clear(self) -> None: + self._original_pixmap = None + self._label.clear() + + def _load_pixmap_with_exif(self, path: Path) -> Optional[QPixmap]: + if Image is None: + pixmap = QPixmap(str(path)) + return pixmap if not pixmap.isNull() else None + + try: + img = Image.open(path) + # EXIF-Orientation anwenden, falls vorhanden + try: + exif = img._getexif() # type: ignore[attr-defined] + if exif and ExifTags: + orientation_key = next( + (k for k, v in ExifTags.TAGS.items() if v == "Orientation"), None + ) + if orientation_key and orientation_key in exif: + orientation = exif[orientation_key] + if orientation == 3: + img = img.rotate(180, expand=True) + elif orientation == 6: + img = img.rotate(270, expand=True) + elif orientation == 8: + img = img.rotate(90, expand=True) + except Exception: + pass + + img = img.convert("RGBA") + data = img.tobytes("raw", "RGBA") + qimg = QImage( + data, + img.width, + img.height, + QImage.Format.Format_RGBA8888, + ) + pixmap = QPixmap.fromImage(qimg) + return pixmap + except Exception: + pixmap = QPixmap(str(path)) + return pixmap if not pixmap.isNull() else None + + def set_image(self, path: Optional[Path]) -> None: + if path is None: + self.clear() + + if path is None: + return + + pixmap = self._load_pixmap_with_exif(path) + if pixmap is None or pixmap.isNull(): + self.clear() + return + + self._original_pixmap = pixmap + self._update_scaled_pixmap() + + def resizeEvent(self, event) -> None: # type: ignore[override] + super().resizeEvent(event) + self._update_scaled_pixmap() + + def _update_scaled_pixmap(self) -> None: + if self._original_pixmap is None: + return + target_size = self._label.size() + if not target_size.isValid(): + return + scaled = self._original_pixmap.scaled( + target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + self._label.setPixmap(scaled) diff --git a/photo_sorter/ui/main_window.py b/photo_sorter/ui/main_window.py new file mode 100644 index 0000000..dd8eae4 --- /dev/null +++ b/photo_sorter/ui/main_window.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Dict, Optional + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QAction, QKeySequence +from PyQt6.QtWidgets import ( + QFileDialog, + QLabel, + QMainWindow, + QStatusBar, + QVBoxLayout, + QWidget, + QMessageBox, +) + +from photo_sorter.core.config_manager import AppConfig, ConfigManager, TargetConfig +from photo_sorter.core.file_operations import FileOperations, FileOperationError +from photo_sorter.core.image_loader import ImageLoader +from photo_sorter.ui.image_viewer import ImageViewer +from photo_sorter.ui.settings_dialog import SettingsDialog, KEYS +from photo_sorter.ui.target_bar import TargetBar, TargetDisplay + + +class MainWindow(QMainWindow): + def __init__(self, config_manager: ConfigManager, parent: Optional[QWidget] = None) -> None: # type: ignore[name-defined] + super().__init__(parent) + self.setWindowTitle("Photo Sorter") + self.resize(1000, 700) + + self._config_manager = config_manager + self._config: AppConfig = self._config_manager.load() + + self._image_loader = ImageLoader() + self._file_ops = FileOperations() + + self._targets_by_key: Dict[str, TargetConfig] = {t.key: t for t in self._config.targets} + + self._setup_ui() + self._apply_config_to_ui() + + # Beim Start direkt Quellverzeichnis wählen + self._select_source_directory(initial=True) + + # --- UI Setup --- + + def _setup_ui(self) -> None: + central = QWidget(self) + self.setCentralWidget(central) + + layout = QVBoxLayout(central) + layout.setContentsMargins(8, 8, 8, 4) + layout.setSpacing(8) + + self._info_label = QLabel("Kein Bild geladen", self) + self._info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(self._info_label) + + self._viewer = ImageViewer(self) + layout.addWidget(self._viewer, stretch=1) + + self._target_bar = TargetBar(self) + layout.addWidget(self._target_bar) + + status = QStatusBar(self) + self.setStatusBar(status) + + self._create_menus() + + def _create_menus(self) -> None: + menubar = self.menuBar() + + file_menu = menubar.addMenu("Datei") + + open_src_action = QAction("Quellverzeichnis öffnen…", self) + open_src_action.setShortcut(QKeySequence.StandardKey.Open) + open_src_action.triggered.connect(self._select_source_directory) # type: ignore[arg-type] + file_menu.addAction(open_src_action) + + file_menu.addSeparator() + + exit_action = QAction("Beenden", self) + exit_action.setShortcut(QKeySequence.StandardKey.Quit) + exit_action.triggered.connect(self.close) # type: ignore[arg-type] + file_menu.addAction(exit_action) + + settings_menu = menubar.addMenu("Einstellungen") + + targets_action = QAction("Ziele konfigurieren…", self) + targets_action.triggered.connect(self._open_settings) # type: ignore[arg-type] + settings_menu.addAction(targets_action) + + # --- Konfiguration & Zielleiste --- + + def _apply_config_to_ui(self) -> None: + self._targets_by_key = {t.key: t for t in self._config.targets} + displays = [TargetDisplay(key=t.key, label=t.label) for t in self._config.targets] + self._target_bar.set_targets(displays) + + def _open_settings(self) -> None: + dialog = SettingsDialog(self._config, self) + if dialog.exec() == dialog.DialogCode.Accepted: # type: ignore[comparison-overlap] + new_config = dialog.get_config() + self._config = new_config + self._config_manager.save(new_config) + self._apply_config_to_ui() + + # --- Quellverzeichnis & Bildanzeige --- + + def _select_source_directory(self, initial: bool = False) -> None: + directory = QFileDialog.getExistingDirectory(self, "Quellverzeichnis wählen") + if not directory: + if initial: + # Wenn beim ersten Start nichts gewählt wird, bleibt die App einfach leer geöffnet. + return + return + + self._image_loader.load_from_directory(Path(directory)) + if self._image_loader.count == 0: + QMessageBox.information( + self, + "Keine Bilder", + "Im gewählten Verzeichnis wurden keine unterstützten Bilddateien gefunden.", + ) + self._viewer.clear() + self._info_label.setText("Keine Bilder gefunden") + return + + self._show_current_image() + + def _show_current_image(self) -> None: + path = self._image_loader.get_current() + count = self._image_loader.count + index = self._image_loader.current_index + if path is None: + self._viewer.clear() + self._info_label.setText("Kein Bild geladen") + return + + self._viewer.set_image(path) + self._info_label.setText(f"Bild {index + 1} / {count} – {path.name}") + + # --- Tastatursteuerung --- + + def keyPressEvent(self, event) -> None: # type: ignore[override] + key = event.key() + + if key in (Qt.Key.Key_Right, Qt.Key.Key_Space): + self._skip_forward() + return + if key == Qt.Key.Key_Left: + self._go_back() + return + if key in (Qt.Key.Key_Z,) and (event.modifiers() & Qt.KeyboardModifier.ControlModifier): + self._undo_action() + return + if key == Qt.Key.Key_Z: + self._undo_action() + return + + # Zieltasten 1–0 + key_char = None + if key == Qt.Key.Key_1: + key_char = "1" + elif key == Qt.Key.Key_2: + key_char = "2" + elif key == Qt.Key.Key_3: + key_char = "3" + elif key == Qt.Key.Key_4: + key_char = "4" + elif key == Qt.Key.Key_5: + key_char = "5" + elif key == Qt.Key.Key_6: + key_char = "6" + elif key == Qt.Key.Key_7: + key_char = "7" + elif key == Qt.Key.Key_8: + key_char = "8" + elif key == Qt.Key.Key_9: + key_char = "9" + elif key == Qt.Key.Key_0: + key_char = "0" + + if key_char is not None: + self._apply_target_action(key_char) + return + + super().keyPressEvent(event) + + def _skip_forward(self) -> None: + if self._image_loader.count == 0: + return + self._image_loader.next() + self._show_current_image() + + def _go_back(self) -> None: + if self._image_loader.count == 0: + return + self._image_loader.previous() + self._show_current_image() + + def _apply_target_action(self, key: str) -> None: + if self._image_loader.count == 0: + return + target = self._targets_by_key.get(key) + if not target: + return + + current = self._image_loader.get_current() + if current is None: + return + + try: + new_path = self._file_ops.move_or_copy( + current, + Path(target.path), + self._config.default_action, + self._image_loader.current_index, + ) + except FileOperationError as e: + QMessageBox.warning(self, "Fehler beim Verschieben/Kopieren", str(e)) + return + + # Pfad in der Bildliste aktualisieren (für Zurückblättern etc.) + self._image_loader.update_current_path(new_path) + + # Visuelles Highlight des Ziels + self._target_bar.highlight_target(key) + + # Nächstes Bild laden + self._image_loader.next() + self._show_current_image() + + def _undo_action(self) -> None: + from photo_sorter.core.file_operations import FileAction + + action = self._file_ops.undo_last() + if action is None: + return + + # Bildliste wieder auf den Index der Aktion setzen + self._image_loader.set_index(action.index) + # Pfad in der Liste anpassen (Move: wieder Originalpfad, Copy: Original bleibt, Liste ändert sich nicht) + if action.action == "move": + self._image_loader.update_current_path(action.original_path) + + self._show_current_image() diff --git a/photo_sorter/ui/settings_dialog.py b/photo_sorter/ui/settings_dialog.py new file mode 100644 index 0000000..9cfc843 --- /dev/null +++ b/photo_sorter/ui/settings_dialog.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Dict, List, Optional + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QButtonGroup, + QDialog, + QDialogButtonBox, + QFileDialog, + QGridLayout, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QRadioButton, + QVBoxLayout, +) + +from photo_sorter.core.config_manager import AppConfig, TargetConfig, ActionType + + +KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] + + +class SettingsDialog(QDialog): + """Dialog zur Konfiguration der Ziele und der Standard-Aktion.""" + + def __init__(self, config: AppConfig, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle("Ziele konfigurieren") + + self._config = config + self._target_rows: Dict[str, Dict[str, object]] = {} + self._action: ActionType = config.default_action + + main_layout = QVBoxLayout(self) + + grid = QGridLayout() + grid.addWidget(QLabel("Taste"), 0, 0) + grid.addWidget(QLabel("Label"), 0, 1) + grid.addWidget(QLabel("Verzeichnis"), 0, 2) + grid.addWidget(QLabel("…"), 0, 3) + + existing_by_key: Dict[str, TargetConfig] = {t.key: t for t in config.targets} + + for row, key in enumerate(KEYS, start=1): + key_label = QLabel(key) + label_edit = QLineEdit() + path_edit = QLineEdit() + path_edit.setReadOnly(True) + browse_btn = QPushButton("Wählen…") + + def make_browse(k: str, pe: QLineEdit): + def browse() -> None: + directory = QFileDialog.getExistingDirectory(self, "Zielverzeichnis wählen") + if directory: + pe.setText(directory) + + return browse + + browse_btn.clicked.connect(make_browse(key, path_edit)) # type: ignore[arg-type] + + grid.addWidget(key_label, row, 0) + grid.addWidget(label_edit, row, 1) + grid.addWidget(path_edit, row, 2) + grid.addWidget(browse_btn, row, 3) + + existing = existing_by_key.get(key) + if existing: + label_edit.setText(existing.label) + path_edit.setText(existing.path) + + self._target_rows[key] = { + "label_edit": label_edit, + "path_edit": path_edit, + } + + main_layout.addLayout(grid) + + # Move/Copy Auswahl + action_layout = QHBoxLayout() + action_layout.addWidget(QLabel("Aktion:")) + self._move_radio = QRadioButton("Verschieben") + self._copy_radio = QRadioButton("Kopieren") + + bg = QButtonGroup(self) + bg.addButton(self._move_radio) + bg.addButton(self._copy_radio) + + if config.default_action == "copy": + self._copy_radio.setChecked(True) + else: + self._move_radio.setChecked(True) + + action_layout.addWidget(self._move_radio) + action_layout.addWidget(self._copy_radio) + action_layout.addStretch(1) + main_layout.addLayout(action_layout) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + parent=self, + ) + buttons.accepted.connect(self.accept) # type: ignore[arg-type] + buttons.rejected.connect(self.reject) # type: ignore[arg-type] + main_layout.addWidget(buttons) + + def get_config(self) -> AppConfig: + targets: List[TargetConfig] = [] + for key in KEYS: + row = self._target_rows[key] + label = row["label_edit"].text().strip() # type: ignore[assignment] + path = row["path_edit"].text().strip() # type: ignore[assignment] + if not path: + continue + targets.append(TargetConfig(key=key, path=path, label=label)) + + action: ActionType = "move" + if self._copy_radio.isChecked(): + action = "copy" + + return AppConfig(targets=targets, default_action=action) diff --git a/photo_sorter/ui/target_bar.py b/photo_sorter/ui/target_bar.py new file mode 100644 index 0000000..1a0cb7b --- /dev/null +++ b/photo_sorter/ui/target_bar.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, List, Optional + +from PyQt6.QtCore import Qt, QTimer +from PyQt6.QtWidgets import QLabel, QHBoxLayout, QWidget + + +@dataclass +class TargetDisplay: + key: str + label: str + + +class TargetBar(QWidget): + """Zeigt die konfigurierten Ziele als Leiste an und erlaubt visuelles Highlighting.""" + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(8, 4, 8, 4) + self._layout.setSpacing(12) + + self._labels: Dict[str, QLabel] = {} + + def set_targets(self, targets: List[TargetDisplay]) -> None: + # Alte Widgets entfernen + while self._layout.count(): + item = self._layout.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + self._labels.clear() + + for t in targets: + text = t.label or f"Ziel {t.key}" + lbl = QLabel(text, self) + lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) + lbl.setProperty("targetKey", t.key) + lbl.setStyleSheet( + "QLabel { padding: 4px 10px; border-radius: 4px; border: 1px solid #888; }" + ) + self._layout.addWidget(lbl) + self._labels[t.key] = lbl + + self._layout.addStretch(1) + + def highlight_target(self, key: str, duration_ms: int = 200) -> None: + lbl = self._labels.get(key) + if not lbl: + return + + original = lbl.styleSheet() + lbl.setStyleSheet( + "QLabel { padding: 4px 10px; border-radius: 4px; border: 2px solid #0078d7; " + "background-color: #e0f0ff; }" + ) + + def reset() -> None: + lbl.setStyleSheet(original) + + QTimer.singleShot(duration_ms, reset) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..173e806 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +PyQt6>=6.6 +Pillow>=10.0