From d6d431689c5f340b36d6f4570c116dfed881d244 Mon Sep 17 00:00:00 2001 From: Jules Date: Sat, 21 Mar 2026 19:28:20 +0100 Subject: [PATCH] =?UTF-8?q?Import=20USB=20+=20=C3=A9diteur=20complet=20+?= =?UTF-8?q?=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Service USB watcher (polling montages, scan photos récursif) - Écran accueil avec détection USB live (bouton vert + compteur) - Éditeur : crop interactif avec overlay/grille des tiers - Éditeur : filtres Pillow (N&B, sépia, vintage, contraste) - Éditeur : stickers/goodies draggable + cadres overlay - Éditeur : multi-pose (5 templates, remplissage auto) - Éditeur : texte éditable draggable - Éditeur : rotation, annulation, export scène PNG - Widget CropOverlay avec poignées tactiles - Widget GoodiesPanel (stickers + cadres depuis assets/) - Widget MultiposeDialog (prévisualisation templates) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 6 + data/exports/.gitkeep | 0 data/photos/.gitkeep | 0 src/services/__init__.py | 0 src/services/usb_watcher.py | 81 +++++++ src/ui/screens/editor_screen.py | 372 +++++++++++++++++++++++++---- src/ui/screens/home_screen.py | 57 ++++- src/ui/widgets/__init__.py | 0 src/ui/widgets/crop_overlay.py | 123 ++++++++++ src/ui/widgets/goodies_panel.py | 104 ++++++++ src/ui/widgets/multipose_dialog.py | 122 ++++++++++ 11 files changed, 811 insertions(+), 54 deletions(-) create mode 100644 data/exports/.gitkeep create mode 100644 data/photos/.gitkeep create mode 100644 src/services/__init__.py create mode 100644 src/services/usb_watcher.py create mode 100644 src/ui/widgets/__init__.py create mode 100644 src/ui/widgets/crop_overlay.py create mode 100644 src/ui/widgets/goodies_panel.py create mode 100644 src/ui/widgets/multipose_dialog.py diff --git a/.gitignore b/.gitignore index 4e32412..679186f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ build/ *.swp *.swo *~ +data/photos/* +data/exports/* +!data/photos/.gitkeep +!data/exports/.gitkeep +*.log +.DS_Store diff --git a/data/exports/.gitkeep b/data/exports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/photos/.gitkeep b/data/photos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/services/__init__.py b/src/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/services/usb_watcher.py b/src/services/usb_watcher.py new file mode 100644 index 0000000..64e7148 --- /dev/null +++ b/src/services/usb_watcher.py @@ -0,0 +1,81 @@ +"""Service de détection USB — surveille l'insertion de clés USB et liste les photos.""" + +import os +import subprocess +import threading +from pathlib import Path +from PyQt6.QtCore import QObject, pyqtSignal, QThread, QTimer + +SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp", ".heic"} +USB_MOUNT_BASE = "/media" + + +class UsbWatcher(QObject): + """Surveille les montages USB et émet les photos trouvées.""" + + usb_inserted = pyqtSignal(str, list) # (mount_path, [photo_paths]) + usb_removed = pyqtSignal(str) # mount_path + + def __init__(self, parent=None): + super().__init__(parent) + self._known_mounts = set() + self._timer = QTimer(self) + self._timer.timeout.connect(self._poll_mounts) + + def start(self, interval_ms=2000): + """Commence la surveillance par polling.""" + self._poll_mounts() + self._timer.start(interval_ms) + + def stop(self): + self._timer.stop() + + def _get_usb_mounts(self): + """Retourne les points de montage USB actuels.""" + mounts = set() + try: + result = subprocess.run( + ["findmnt", "-rno", "TARGET,FSTYPE", "-t", "vfat,exfat,ntfs,ext4"], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().splitlines(): + parts = line.split() + if parts and parts[0].startswith(("/media", "/mnt/usb")): + mounts.add(parts[0]) + except (subprocess.TimeoutExpired, FileNotFoundError): + # Fallback : scan /media + if os.path.isdir(USB_MOUNT_BASE): + for entry in os.scandir(USB_MOUNT_BASE): + if entry.is_dir() and os.path.ismount(entry.path): + mounts.add(entry.path) + return mounts + + def _poll_mounts(self): + """Vérifie les changements de montages USB.""" + current = self._get_usb_mounts() + + # Nouvelles clés + for mount in current - self._known_mounts: + photos = self._scan_photos(mount) + self.usb_inserted.emit(mount, photos) + + # Clés retirées + for mount in self._known_mounts - current: + self.usb_removed.emit(mount) + + self._known_mounts = current + + def _scan_photos(self, mount_path): + """Scanne récursivement un dossier pour trouver les photos.""" + photos = [] + try: + for root, _, files in os.walk(mount_path): + # Skip dossiers cachés et système + if any(part.startswith(".") for part in Path(root).parts): + continue + for f in sorted(files): + if Path(f).suffix.lower() in SUPPORTED_EXTENSIONS: + photos.append(os.path.join(root, f)) + except PermissionError: + pass + return photos diff --git a/src/ui/screens/editor_screen.py b/src/ui/screens/editor_screen.py index 450afcb..9179ecd 100644 --- a/src/ui/screens/editor_screen.py +++ b/src/ui/screens/editor_screen.py @@ -1,12 +1,20 @@ -"""Écran éditeur — recadrage, goodies, effets, multi-pose.""" +"""Écran éditeur — recadrage, goodies, effets, multi-pose, texte.""" +import os +import tempfile from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, - QToolBar + QGraphicsTextItem, QFileDialog, QInputDialog ) from PyQt6.QtCore import Qt, QRectF -from PyQt6.QtGui import QFont, QPixmap, QImage, QPainter +from PyQt6.QtGui import ( + QFont, QPixmap, QImage, QPainter, QColor, QTransform +) + +from ui.widgets.crop_overlay import CropOverlay +from ui.widgets.goodies_panel import GoodiesPanel +from ui.widgets.multipose_dialog import MultiposeDialog, TEMPLATES class EditorScreen(QWidget): @@ -15,17 +23,22 @@ class EditorScreen(QWidget): self.main_window = main_window self.current_image_path = None self.pixmap_item = None + self.crop_overlay = None + self.is_cropping = False + self.multipose_zones = None + self.multipose_images = {} self._setup_ui() def _setup_ui(self): layout = QVBoxLayout(self) - layout.setSpacing(10) + layout.setSpacing(5) + layout.setContentsMargins(5, 5, 5, 5) # Header header = QHBoxLayout() back_btn = QPushButton("Retour") back_btn.setFont(QFont("", 14)) - back_btn.clicked.connect(self.main_window.show_import) + back_btn.clicked.connect(self._go_back) header.addWidget(back_btn) title = QLabel("Éditeur") @@ -37,10 +50,8 @@ class EditorScreen(QWidget): print_btn.setFont(QFont("", 14)) print_btn.setStyleSheet(""" QPushButton { - background-color: #4CAF50; - color: white; - border-radius: 10px; - padding: 10px 20px; + background-color: #4CAF50; color: white; + border-radius: 10px; padding: 10px 20px; } QPushButton:pressed { background-color: #388E3C; } """) @@ -48,39 +59,53 @@ class EditorScreen(QWidget): header.addWidget(print_btn) layout.addLayout(header) - # Zone d'édition (QGraphicsScene) + # Zone centrale : goodies panel + canvas + center = QHBoxLayout() + + # Panneau goodies (caché par défaut) + self.goodies_panel = GoodiesPanel() + self.goodies_panel.sticker_selected.connect(self._place_sticker) + self.goodies_panel.frame_selected.connect(self._apply_frame) + self.goodies_panel.hide() + center.addWidget(self.goodies_panel) + + # Canvas self.scene = QGraphicsScene() self.view = QGraphicsView(self.scene) self.view.setRenderHint(QPainter.RenderHint.Antialiasing) self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) - self.view.setStyleSheet("background-color: #333;") - layout.addWidget(self.view, 1) + self.view.setStyleSheet("background-color: #333; border: none;") + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + center.addWidget(self.view, 1) + + layout.addLayout(center, 1) # Barre d'outils tools = QHBoxLayout() - tools.setSpacing(10) + tools.setSpacing(8) tool_buttons = [ - ("Recadrer", self._crop), + ("Recadrer", self._toggle_crop), ("Rotation", self._rotate), ("N&B", self._filter_grayscale), ("Sépia", self._filter_sepia), - ("Goodies", self._add_goodies), + ("Vintage", self._filter_vintage), + ("Contraste", self._filter_contrast), + ("Goodies", self._toggle_goodies), ("Multi-pose", self._multipose), ("Texte", self._add_text), + ("Annuler", self._reset_image), ] for label, callback in tool_buttons: btn = QPushButton(label) - btn.setFont(QFont("", 13)) + btn.setFont(QFont("", 12)) btn.setMinimumHeight(50) btn.setStyleSheet(""" QPushButton { - background-color: #555; - color: white; - border-radius: 8px; - padding: 8px 15px; + background-color: #555; color: white; + border-radius: 8px; padding: 8px 12px; } QPushButton:pressed { background-color: #777; } """) @@ -91,54 +116,295 @@ class EditorScreen(QWidget): def load_image(self, path): self.current_image_path = path + self.multipose_zones = None + self.multipose_images = {} + self._cancel_crop() self.scene.clear() pixmap = QPixmap(path) self.pixmap_item = self.scene.addPixmap(pixmap) + self.scene.setSceneRect(QRectF(pixmap.rect())) self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + def resizeEvent(self, event): + super().resizeEvent(event) + if self.pixmap_item: + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + # --- Export --- + def _export_scene(self): + """Exporte la scène complète en QImage haute résolution.""" + rect = self.scene.sceneRect() + image = QImage(int(rect.width()), int(rect.height()), QImage.Format.Format_ARGB32) + image.fill(QColor(255, 255, 255)) + painter = QPainter(image) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + self.scene.render(painter) + painter.end() + return image + + def _export_to_file(self): + """Exporte la scène dans un fichier temporaire et retourne le chemin.""" + image = self._export_scene() + path = os.path.join(tempfile.gettempdir(), "photostation_export.png") + image.save(path, "PNG") + return path + + # --- Navigation --- + def _go_back(self): + self._cancel_crop() + self.main_window.show_import() + def _go_print(self): - # TODO: exporter le canvas édité en image - self.main_window.show_print(self.current_image_path) + path = self._export_to_file() + self.main_window.show_print(path) - def _crop(self): - # TODO: outil de recadrage interactif - pass + # --- Recadrage --- + def _toggle_crop(self): + if self.is_cropping: + self._apply_crop() + else: + self._start_crop() + def _start_crop(self): + if not self.pixmap_item: + return + self.is_cropping = True + self.view.setDragMode(QGraphicsView.DragMode.NoDrag) + pr = self.pixmap_item.boundingRect() + # Crop initial = 80% centré + margin_x = pr.width() * 0.1 + margin_y = pr.height() * 0.1 + crop_rect = QRectF(margin_x, margin_y, + pr.width() - 2 * margin_x, pr.height() - 2 * margin_y) + self.crop_overlay = CropOverlay(crop_rect) + self.crop_overlay.set_bounds(pr) + self.scene.addItem(self.crop_overlay) + + def _apply_crop(self): + if not self.crop_overlay or not self.pixmap_item: + return + crop_rect = self.crop_overlay.get_crop_rect() + pixmap = self.pixmap_item.pixmap() + cropped = pixmap.copy(crop_rect.toRect()) + self.scene.removeItem(self.crop_overlay) + self.crop_overlay = None + self.is_cropping = False + self.scene.clear() + self.pixmap_item = self.scene.addPixmap(cropped) + self.scene.setSceneRect(QRectF(cropped.rect())) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + def _cancel_crop(self): + if self.crop_overlay: + self.scene.removeItem(self.crop_overlay) + self.crop_overlay = None + self.is_cropping = False + + # --- Rotation --- def _rotate(self): - if self.pixmap_item: - self.pixmap_item.setRotation(self.pixmap_item.rotation() + 90) + if not self.pixmap_item: + return + pixmap = self.pixmap_item.pixmap() + transform = QTransform().rotate(90) + rotated = pixmap.transformed(transform, Qt.TransformationMode.SmoothTransformation) + self.scene.clear() + self.pixmap_item = self.scene.addPixmap(rotated) + self.scene.setSceneRect(QRectF(rotated.rect())) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + + # --- Filtres --- + def _apply_pil_filter(self, pil_func): + """Applique un filtre Pillow sur l'image actuelle du canvas.""" + if not self.pixmap_item: + return + from PIL import Image + # QPixmap -> PIL + pixmap = self.pixmap_item.pixmap() + qimg = pixmap.toImage().convertToFormat(QImage.Format.Format_RGBA8888) + width, height = qimg.width(), qimg.height() + ptr = qimg.bits() + ptr.setsize(width * height * 4) + pil_img = Image.frombytes("RGBA", (width, height), bytes(ptr)) + + # Appliquer le filtre + result = pil_func(pil_img) + + # PIL -> QPixmap + data = result.convert("RGBA").tobytes("raw", "RGBA") + new_qimg = QImage(data, result.width, result.height, QImage.Format.Format_RGBA8888) + new_pixmap = QPixmap.fromImage(new_qimg) + + self.scene.clear() + self.pixmap_item = self.scene.addPixmap(new_pixmap) + self.scene.setSceneRect(QRectF(new_pixmap.rect())) + self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) def _filter_grayscale(self): - if not self.current_image_path: - return - pixmap = QPixmap(self.current_image_path) - image = pixmap.toImage().convertToFormat(QImage.Format.Format_Grayscale8) - self.scene.clear() - self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(image)) - self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + from PIL import ImageOps + self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA")) def _filter_sepia(self): - if not self.current_image_path: + from PIL import ImageOps + def sepia(img): + gray = ImageOps.grayscale(img) + return ImageOps.colorize(gray, "#704214", "#C0A080").convert("RGBA") + self._apply_pil_filter(sepia) + + def _filter_vintage(self): + from PIL import ImageOps, ImageEnhance + def vintage(img): + rgb = img.convert("RGB") + rgb = ImageEnhance.Color(rgb).enhance(0.6) + rgb = ImageEnhance.Contrast(rgb).enhance(1.2) + rgb = ImageEnhance.Brightness(rgb).enhance(0.9) + return rgb.convert("RGBA") + self._apply_pil_filter(vintage) + + def _filter_contrast(self): + from PIL import ImageEnhance + def contrast(img): + rgb = img.convert("RGB") + rgb = ImageEnhance.Contrast(rgb).enhance(1.5) + return rgb.convert("RGBA") + self._apply_pil_filter(contrast) + + # --- Goodies --- + def _toggle_goodies(self): + self.goodies_panel.setVisible(not self.goodies_panel.isVisible()) + + def _place_sticker(self, path): + """Ajoute un sticker draggable sur le canvas.""" + pixmap = QPixmap(path) + if pixmap.isNull(): return - from PIL import Image, ImageOps - img = Image.open(self.current_image_path) - sepia = ImageOps.grayscale(img) - sepia = ImageOps.colorize(sepia, "#704214", "#C0A080") - # Convertir PIL -> QPixmap - data = sepia.convert("RGBA").tobytes("raw", "RGBA") - qimg = QImage(data, sepia.width, sepia.height, QImage.Format.Format_RGBA8888) - self.scene.clear() - self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(qimg)) - self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) + # Taille sticker = 20% de la largeur de la scène + scene_w = self.scene.sceneRect().width() + if scene_w > 0: + target_w = int(scene_w * 0.2) + pixmap = pixmap.scaledToWidth(target_w, Qt.TransformationMode.SmoothTransformation) + item = self.scene.addPixmap(pixmap) + item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsMovable, True) + item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsSelectable, True) + # Centrer + sr = self.scene.sceneRect() + item.setPos(sr.center().x() - pixmap.width() / 2, + sr.center().y() - pixmap.height() / 2) - def _add_goodies(self): - # TODO: panneau de sélection de stickers - pass + def _apply_frame(self, path): + """Applique un cadre par-dessus l'image (en overlay).""" + if not self.pixmap_item: + return + frame_pixmap = QPixmap(path) + if frame_pixmap.isNull(): + return + sr = self.scene.sceneRect() + scaled = frame_pixmap.scaled( + int(sr.width()), int(sr.height()), + Qt.AspectRatioMode.IgnoreAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + frame_item = self.scene.addPixmap(scaled) + frame_item.setPos(sr.topLeft()) + frame_item.setZValue(100) + # --- Multi-pose --- def _multipose(self): - # TODO: sélection de templates multi-photos - pass + name, zones = MultiposeDialog.get_template(self) + if not zones: + return + self.multipose_zones = zones + self.multipose_images = {} + + # Demander les photos pour chaque zone + sr = self.scene.sceneRect() + canvas_w, canvas_h = int(sr.width()) or 1800, int(sr.height()) or 1200 + + self.scene.clear() + self.pixmap_item = None + + # Canvas blanc + white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32) + white.fill(QColor(255, 255, 255)) + bg = self.scene.addPixmap(QPixmap.fromImage(white)) + self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h)) + + # Dessiner les zones avec numéros + for i, (zx, zy, zw, zh) in enumerate(zones): + rx = int(zx * canvas_w) + ry = int(zy * canvas_h) + rw = int(zw * canvas_w) + rh = int(zh * canvas_h) + + # Bordure de zone + from PyQt6.QtWidgets import QGraphicsRectItem + from PyQt6.QtGui import QPen, QBrush + rect = self.scene.addRect(rx + 2, ry + 2, rw - 4, rh - 4, + QPen(QColor(100, 100, 100), 2), + QBrush(QColor(240, 240, 240))) + + # Numéro + text = self.scene.addText(str(i + 1), QFont("", 40)) + text.setDefaultTextColor(QColor(150, 150, 150)) + text.setPos(rx + rw / 2 - 20, ry + rh / 2 - 30) + + self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + + # Charger les photos depuis la galerie d'import + import_photos = self.main_window.import_screen.photos + if import_photos: + self._fill_multipose_from_list(import_photos, zones, canvas_w, canvas_h) + + def _fill_multipose_from_list(self, photos, zones, canvas_w, canvas_h): + """Remplit les zones multi-pose avec les premières photos disponibles.""" + self.scene.clear() + + # Canvas blanc + white = QImage(canvas_w, canvas_h, QImage.Format.Format_ARGB32) + white.fill(QColor(255, 255, 255)) + self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(white)) + self.scene.setSceneRect(QRectF(0, 0, canvas_w, canvas_h)) + + gap = 4 # espace entre les photos + + for i, (zx, zy, zw, zh) in enumerate(zones): + if i >= len(photos): + break + rx = int(zx * canvas_w) + gap + ry = int(zy * canvas_h) + gap + rw = int(zw * canvas_w) - gap * 2 + rh = int(zh * canvas_h) - gap * 2 + + pixmap = QPixmap(photos[i]) + if pixmap.isNull(): + continue + scaled = pixmap.scaled(rw, rh, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation) + item = self.scene.addPixmap(scaled) + # Centrer dans la zone + offset_x = rx + (rw - scaled.width()) / 2 + offset_y = ry + (rh - scaled.height()) / 2 + item.setPos(offset_x, offset_y) + + self.view.fitInView(self.scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio) + + # --- Texte --- def _add_text(self): - # TODO: ajout de texte éditable sur le canvas - pass + text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :") + if not ok or not text: + return + item = QGraphicsTextItem(text) + item.setFont(QFont("", 32)) + item.setDefaultTextColor(QColor(255, 255, 255)) + item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsMovable, True) + item.setFlag(QGraphicsTextItem.GraphicsItemFlag.ItemIsSelectable, True) + item.setTextInteractionFlags(Qt.TextInteractionFlag.TextEditorInteraction) + sr = self.scene.sceneRect() + item.setPos(sr.center().x() - 100, sr.center().y() - 20) + self.scene.addItem(item) + + # --- Reset --- + def _reset_image(self): + if self.current_image_path: + self.load_image(self.current_image_path) diff --git a/src/ui/screens/home_screen.py b/src/ui/screens/home_screen.py index f9ffc14..36cd228 100644 --- a/src/ui/screens/home_screen.py +++ b/src/ui/screens/home_screen.py @@ -6,12 +6,15 @@ from PyQt6.QtWidgets import ( from PyQt6.QtCore import Qt from PyQt6.QtGui import QFont +from services.usb_watcher import UsbWatcher + class HomeScreen(QWidget): def __init__(self, main_window): super().__init__() self.main_window = main_window self._setup_ui() + self._setup_usb_watcher() def _setup_ui(self): layout = QVBoxLayout(self) @@ -29,6 +32,13 @@ class HomeScreen(QWidget): subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(subtitle) + # Status USB + self.usb_status = QLabel("") + self.usb_status.setFont(QFont("", 14)) + self.usb_status.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.usb_status.setStyleSheet("color: #4CAF50;") + layout.addWidget(self.usb_status) + # Boutons d'import buttons_layout = QHBoxLayout() buttons_layout.setSpacing(20) @@ -40,10 +50,12 @@ class HomeScreen(QWidget): ("Email", "Recevoir par mail", self._import_email), ] + self.buttons = {} for label, desc, callback in modes: btn = self._create_mode_button(label, desc) btn.clicked.connect(callback) buttons_layout.addWidget(btn) + self.buttons[label] = btn layout.addLayout(buttons_layout) @@ -61,11 +73,54 @@ class HomeScreen(QWidget): QPushButton:pressed { background-color: #1976D2; } + QPushButton:disabled { + background-color: #9E9E9E; + } """) return btn + def _setup_usb_watcher(self): + self.usb_watcher = UsbWatcher(self) + self.usb_watcher.usb_inserted.connect(self._on_usb_inserted) + self.usb_watcher.usb_removed.connect(self._on_usb_removed) + self.usb_watcher.start() + self._usb_photos = [] + self._usb_mount = None + + def _on_usb_inserted(self, mount_path, photos): + self._usb_mount = mount_path + self._usb_photos = photos + count = len(photos) + self.usb_status.setText(f"Clé USB détectée — {count} photo{'s' if count != 1 else ''}") + self.buttons["USB"].setStyleSheet(""" + QPushButton { + background-color: #4CAF50; + color: white; + border-radius: 15px; + padding: 20px; + } + QPushButton:pressed { background-color: #388E3C; } + """) + + def _on_usb_removed(self, mount_path): + if mount_path == self._usb_mount: + self._usb_mount = None + self._usb_photos = [] + self.usb_status.setText("") + self.buttons["USB"].setStyleSheet(""" + QPushButton { + background-color: #2196F3; + color: white; + border-radius: 15px; + padding: 20px; + } + QPushButton:pressed { background-color: #1976D2; } + """) + def _import_usb(self): - self.main_window.show_import() + if self._usb_photos: + self.main_window.import_screen.set_photos(self._usb_photos) + self.main_window.show_import() def _import_wifi(self): self.main_window.show_import() diff --git a/src/ui/widgets/__init__.py b/src/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/widgets/crop_overlay.py b/src/ui/widgets/crop_overlay.py new file mode 100644 index 0000000..bd3930d --- /dev/null +++ b/src/ui/widgets/crop_overlay.py @@ -0,0 +1,123 @@ +"""Overlay de recadrage interactif sur QGraphicsScene.""" + +from PyQt6.QtWidgets import QGraphicsRectItem, QGraphicsItem +from PyQt6.QtCore import Qt, QRectF, QPointF +from PyQt6.QtGui import QPen, QBrush, QColor, QPainter + +HANDLE_SIZE = 20 + + +class CropOverlay(QGraphicsRectItem): + """Rectangle de recadrage avec poignées tactiles redimensionnables.""" + + def __init__(self, rect, parent=None): + super().__init__(rect, parent) + self.setPen(QPen(QColor(255, 255, 255, 200), 2, Qt.PenStyle.DashLine)) + self.setBrush(QBrush(Qt.BrushStyle.NoBrush)) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True) + self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True) + self.setAcceptHoverEvents(True) + + self._resizing = False + self._resize_edge = None + self._start_rect = None + self._start_pos = None + self._bounds = None + + def set_bounds(self, rect): + """Limites dans lesquelles le crop peut bouger (la photo).""" + self._bounds = rect + + def paint(self, painter, option, widget=None): + super().paint(painter, option, widget) + r = self.rect() + + # Assombrir l'extérieur + if self._bounds: + painter.setBrush(QBrush(QColor(0, 0, 0, 120))) + painter.setPen(Qt.PenStyle.NoPen) + # Les 4 zones sombres autour du crop + b = self.mapRectFromScene(self._bounds) + # Haut + painter.drawRect(QRectF(b.left(), b.top(), b.width(), r.top() - b.top())) + # Bas + painter.drawRect(QRectF(b.left(), r.bottom(), b.width(), b.bottom() - r.bottom())) + # Gauche + painter.drawRect(QRectF(b.left(), r.top(), r.left() - b.left(), r.height())) + # Droite + painter.drawRect(QRectF(r.right(), r.top(), b.right() - r.right(), r.height())) + + # Grille des tiers + painter.setPen(QPen(QColor(255, 255, 255, 80), 1)) + for i in range(1, 3): + x = r.left() + r.width() * i / 3 + y = r.top() + r.height() * i / 3 + painter.drawLine(QPointF(x, r.top()), QPointF(x, r.bottom())) + painter.drawLine(QPointF(r.left(), y), QPointF(r.right(), y)) + + # Poignées aux coins + painter.setBrush(QBrush(QColor(255, 255, 255))) + painter.setPen(Qt.PenStyle.NoPen) + hs = HANDLE_SIZE / 2 + for corner in [r.topLeft(), r.topRight(), r.bottomLeft(), r.bottomRight()]: + painter.drawEllipse(corner, hs, hs) + + def mousePressEvent(self, event): + pos = event.pos() + edge = self._detect_edge(pos) + if edge: + self._resizing = True + self._resize_edge = edge + self._start_rect = QRectF(self.rect()) + self._start_pos = pos + event.accept() + else: + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if self._resizing: + delta = event.pos() - self._start_pos + r = QRectF(self._start_rect) + e = self._resize_edge + + if "left" in e: + r.setLeft(r.left() + delta.x()) + if "right" in e: + r.setRight(r.right() + delta.x()) + if "top" in e: + r.setTop(r.top() + delta.y()) + if "bottom" in e: + r.setBottom(r.bottom() + delta.y()) + + # Taille minimum + if r.width() >= 50 and r.height() >= 50: + self.setRect(r) + event.accept() + else: + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + if self._resizing: + self._resizing = False + self._resize_edge = None + event.accept() + else: + super().mouseReleaseEvent(event) + + def _detect_edge(self, pos): + r = self.rect() + margin = HANDLE_SIZE + edges = "" + if abs(pos.y() - r.top()) < margin: + edges += "top" + elif abs(pos.y() - r.bottom()) < margin: + edges += "bottom" + if abs(pos.x() - r.left()) < margin: + edges += "left" + elif abs(pos.x() - r.right()) < margin: + edges += "right" + return edges or None + + def get_crop_rect(self): + """Retourne le rectangle de crop en coordonnées scène.""" + return self.mapRectToScene(self.rect()) diff --git a/src/ui/widgets/goodies_panel.py b/src/ui/widgets/goodies_panel.py new file mode 100644 index 0000000..e3415d5 --- /dev/null +++ b/src/ui/widgets/goodies_panel.py @@ -0,0 +1,104 @@ +"""Panneau de sélection de goodies (stickers, cadres).""" + +import os +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QScrollArea, QGridLayout, + QPushButton, QLabel +) +from PyQt6.QtCore import Qt, pyqtSignal, QSize +from PyQt6.QtGui import QFont, QPixmap, QIcon + +GOODIES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "goodies") +FRAMES_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "..", "assets", "frames") + + +class GoodiesPanel(QWidget): + """Panneau latéral de sélection de stickers et cadres.""" + + sticker_selected = pyqtSignal(str) # chemin du sticker + frame_selected = pyqtSignal(str) # chemin du cadre + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + # Titre stickers + sticker_label = QLabel("Stickers") + sticker_label.setFont(QFont("", 14, QFont.Weight.Bold)) + sticker_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(sticker_label) + + # Grille stickers + sticker_scroll = QScrollArea() + sticker_scroll.setWidgetResizable(True) + sticker_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + sticker_widget = QWidget() + self.sticker_grid = QGridLayout(sticker_widget) + self.sticker_grid.setSpacing(5) + sticker_scroll.setWidget(sticker_widget) + layout.addWidget(sticker_scroll, 1) + + # Titre cadres + frame_label = QLabel("Cadres") + frame_label.setFont(QFont("", 14, QFont.Weight.Bold)) + frame_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(frame_label) + + # Grille cadres + frame_scroll = QScrollArea() + frame_scroll.setWidgetResizable(True) + frame_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + frame_widget = QWidget() + self.frame_grid = QGridLayout(frame_widget) + self.frame_grid.setSpacing(5) + frame_scroll.setWidget(frame_widget) + layout.addWidget(frame_scroll, 1) + + self._load_assets() + + def _load_assets(self): + self._load_items(GOODIES_DIR, self.sticker_grid, self.sticker_selected) + self._load_items(FRAMES_DIR, self.frame_grid, self.frame_selected) + + def _load_items(self, directory, grid, signal): + directory = os.path.normpath(directory) + if not os.path.isdir(directory): + return + items = sorted([ + f for f in os.listdir(directory) + if f.lower().endswith((".png", ".svg", ".webp")) + ]) + cols = 2 + for i, filename in enumerate(items): + path = os.path.join(directory, filename) + btn = QPushButton() + btn.setFixedSize(85, 85) + pixmap = QPixmap(path) + if not pixmap.isNull(): + btn.setIcon(QIcon(pixmap)) + btn.setIconSize(QSize(75, 75)) + btn.setStyleSheet(""" + QPushButton { + border: 2px solid transparent; + border-radius: 8px; + background: #444; + } + QPushButton:pressed { border-color: #2196F3; } + """) + btn.clicked.connect(lambda checked, p=path: signal.emit(p)) + grid.addWidget(btn, i // cols, i % cols) + + def refresh(self): + """Recharge les assets depuis le disque.""" + # Clear grids + for grid in (self.sticker_grid, self.frame_grid): + while grid.count(): + item = grid.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._load_assets() diff --git a/src/ui/widgets/multipose_dialog.py b/src/ui/widgets/multipose_dialog.py new file mode 100644 index 0000000..bd00934 --- /dev/null +++ b/src/ui/widgets/multipose_dialog.py @@ -0,0 +1,122 @@ +"""Dialogue de sélection de template multi-pose.""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QGridLayout +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QFont, QPainter, QColor, QPen + + +# Templates : liste de rectangles normalisés (x, y, w, h) en pourcentage [0-1] +TEMPLATES = { + "2 horizontal": [ + (0.0, 0.0, 0.5, 1.0), + (0.5, 0.0, 0.5, 1.0), + ], + "2 vertical": [ + (0.0, 0.0, 1.0, 0.5), + (0.0, 0.5, 1.0, 0.5), + ], + "3 gauche": [ + (0.0, 0.0, 0.5, 1.0), + (0.5, 0.0, 0.5, 0.5), + (0.5, 0.5, 0.5, 0.5), + ], + "4 grille": [ + (0.0, 0.0, 0.5, 0.5), + (0.5, 0.0, 0.5, 0.5), + (0.0, 0.5, 0.5, 0.5), + (0.5, 0.5, 0.5, 0.5), + ], + "3 bande": [ + (0.0, 0.0, 0.333, 1.0), + (0.333, 0.0, 0.334, 1.0), + (0.667, 0.0, 0.333, 1.0), + ], +} + + +class TemplatePreview(QPushButton): + """Bouton avec prévisualisation d'un template.""" + + def __init__(self, name, zones, parent=None): + super().__init__(parent) + self.name = name + self.zones = zones + self.setFixedSize(150, 120) + self.setStyleSheet(""" + QPushButton { border: 2px solid #555; border-radius: 8px; background: #333; } + QPushButton:pressed { border-color: #2196F3; } + """) + + def paintEvent(self, event): + super().paintEvent(event) + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + w, h = self.width() - 20, self.height() - 35 + ox, oy = 10, 5 + + painter.setPen(QPen(QColor(255, 255, 255, 150), 2)) + for (zx, zy, zw, zh) in self.zones: + rect_x = ox + zx * w + 2 + rect_y = oy + zy * h + 2 + rect_w = zw * w - 4 + rect_h = zh * h - 4 + painter.setBrush(QColor(33, 150, 243, 60)) + painter.drawRoundedRect(int(rect_x), int(rect_y), int(rect_w), int(rect_h), 4, 4) + + # Nom en bas + painter.setPen(QColor(255, 255, 255)) + painter.setFont(QFont("", 10)) + painter.drawText( + 0, self.height() - 25, self.width(), 20, + Qt.AlignmentFlag.AlignCenter, self.name + ) + painter.end() + + +class MultiposeDialog(QDialog): + """Dialogue pour choisir un template multi-pose.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Multi-pose") + self.selected_template = None + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + title = QLabel("Choisir un template") + title.setFont(QFont("", 18, QFont.Weight.Bold)) + title.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(title) + + grid = QGridLayout() + grid.setSpacing(15) + + for i, (name, zones) in enumerate(TEMPLATES.items()): + preview = TemplatePreview(name, zones) + preview.clicked.connect(lambda checked, n=name: self._select(n)) + grid.addWidget(preview, i // 3, i % 3) + + layout.addLayout(grid) + + # Annuler + cancel = QPushButton("Annuler") + cancel.setFont(QFont("", 14)) + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select(self, name): + self.selected_template = name + self.accept() + + @staticmethod + def get_template(parent=None): + dialog = MultiposeDialog(parent) + if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_template: + return dialog.selected_template, TEMPLATES[dialog.selected_template] + return None, None