Import USB + éditeur complet + widgets

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-03-21 19:28:20 +01:00
parent 6598e4073f
commit d6d431689c
11 changed files with 811 additions and 54 deletions

6
.gitignore vendored
View File

@@ -10,3 +10,9 @@ build/
*.swp *.swp
*.swo *.swo
*~ *~
data/photos/*
data/exports/*
!data/photos/.gitkeep
!data/exports/.gitkeep
*.log
.DS_Store

0
data/exports/.gitkeep Normal file
View File

0
data/photos/.gitkeep Normal file
View File

0
src/services/__init__.py Normal file
View File

View File

@@ -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

View File

@@ -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 ( from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem, QLabel, QGraphicsScene, QGraphicsView, QGraphicsPixmapItem,
QToolBar QGraphicsTextItem, QFileDialog, QInputDialog
) )
from PyQt6.QtCore import Qt, QRectF 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): class EditorScreen(QWidget):
@@ -15,17 +23,22 @@ class EditorScreen(QWidget):
self.main_window = main_window self.main_window = main_window
self.current_image_path = None self.current_image_path = None
self.pixmap_item = None self.pixmap_item = None
self.crop_overlay = None
self.is_cropping = False
self.multipose_zones = None
self.multipose_images = {}
self._setup_ui() self._setup_ui()
def _setup_ui(self): def _setup_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setSpacing(10) layout.setSpacing(5)
layout.setContentsMargins(5, 5, 5, 5)
# Header # Header
header = QHBoxLayout() header = QHBoxLayout()
back_btn = QPushButton("Retour") back_btn = QPushButton("Retour")
back_btn.setFont(QFont("", 14)) 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) header.addWidget(back_btn)
title = QLabel("Éditeur") title = QLabel("Éditeur")
@@ -37,10 +50,8 @@ class EditorScreen(QWidget):
print_btn.setFont(QFont("", 14)) print_btn.setFont(QFont("", 14))
print_btn.setStyleSheet(""" print_btn.setStyleSheet("""
QPushButton { QPushButton {
background-color: #4CAF50; background-color: #4CAF50; color: white;
color: white; border-radius: 10px; padding: 10px 20px;
border-radius: 10px;
padding: 10px 20px;
} }
QPushButton:pressed { background-color: #388E3C; } QPushButton:pressed { background-color: #388E3C; }
""") """)
@@ -48,39 +59,53 @@ class EditorScreen(QWidget):
header.addWidget(print_btn) header.addWidget(print_btn)
layout.addLayout(header) 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.scene = QGraphicsScene()
self.view = QGraphicsView(self.scene) self.view = QGraphicsView(self.scene)
self.view.setRenderHint(QPainter.RenderHint.Antialiasing) self.view.setRenderHint(QPainter.RenderHint.Antialiasing)
self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) self.view.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
self.view.setDragMode(QGraphicsView.DragMode.ScrollHandDrag) self.view.setStyleSheet("background-color: #333; border: none;")
self.view.setStyleSheet("background-color: #333;") self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
layout.addWidget(self.view, 1) self.view.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
center.addWidget(self.view, 1)
layout.addLayout(center, 1)
# Barre d'outils # Barre d'outils
tools = QHBoxLayout() tools = QHBoxLayout()
tools.setSpacing(10) tools.setSpacing(8)
tool_buttons = [ tool_buttons = [
("Recadrer", self._crop), ("Recadrer", self._toggle_crop),
("Rotation", self._rotate), ("Rotation", self._rotate),
("N&B", self._filter_grayscale), ("N&B", self._filter_grayscale),
("Sépia", self._filter_sepia), ("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), ("Multi-pose", self._multipose),
("Texte", self._add_text), ("Texte", self._add_text),
("Annuler", self._reset_image),
] ]
for label, callback in tool_buttons: for label, callback in tool_buttons:
btn = QPushButton(label) btn = QPushButton(label)
btn.setFont(QFont("", 13)) btn.setFont(QFont("", 12))
btn.setMinimumHeight(50) btn.setMinimumHeight(50)
btn.setStyleSheet(""" btn.setStyleSheet("""
QPushButton { QPushButton {
background-color: #555; background-color: #555; color: white;
color: white; border-radius: 8px; padding: 8px 12px;
border-radius: 8px;
padding: 8px 15px;
} }
QPushButton:pressed { background-color: #777; } QPushButton:pressed { background-color: #777; }
""") """)
@@ -91,54 +116,295 @@ class EditorScreen(QWidget):
def load_image(self, path): def load_image(self, path):
self.current_image_path = path self.current_image_path = path
self.multipose_zones = None
self.multipose_images = {}
self._cancel_crop()
self.scene.clear() self.scene.clear()
pixmap = QPixmap(path) pixmap = QPixmap(path)
self.pixmap_item = self.scene.addPixmap(pixmap) self.pixmap_item = self.scene.addPixmap(pixmap)
self.scene.setSceneRect(QRectF(pixmap.rect()))
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) 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): def _go_print(self):
# TODO: exporter le canvas édité en image path = self._export_to_file()
self.main_window.show_print(self.current_image_path) self.main_window.show_print(path)
def _crop(self): # --- Recadrage ---
# TODO: outil de recadrage interactif def _toggle_crop(self):
pass 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): def _rotate(self):
if self.pixmap_item: if not self.pixmap_item:
self.pixmap_item.setRotation(self.pixmap_item.rotation() + 90) 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): def _filter_grayscale(self):
if not self.current_image_path: from PIL import ImageOps
return self._apply_pil_filter(lambda img: ImageOps.grayscale(img).convert("RGBA"))
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)
def _filter_sepia(self): 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 return
from PIL import Image, ImageOps # Taille sticker = 20% de la largeur de la scène
img = Image.open(self.current_image_path) scene_w = self.scene.sceneRect().width()
sepia = ImageOps.grayscale(img) if scene_w > 0:
sepia = ImageOps.colorize(sepia, "#704214", "#C0A080") target_w = int(scene_w * 0.2)
# Convertir PIL -> QPixmap pixmap = pixmap.scaledToWidth(target_w, Qt.TransformationMode.SmoothTransformation)
data = sepia.convert("RGBA").tobytes("raw", "RGBA") item = self.scene.addPixmap(pixmap)
qimg = QImage(data, sepia.width, sepia.height, QImage.Format.Format_RGBA8888) item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsMovable, True)
self.scene.clear() item.setFlag(QGraphicsPixmapItem.GraphicsItemFlag.ItemIsSelectable, True)
self.pixmap_item = self.scene.addPixmap(QPixmap.fromImage(qimg)) # Centrer
self.view.fitInView(self.pixmap_item, Qt.AspectRatioMode.KeepAspectRatio) sr = self.scene.sceneRect()
item.setPos(sr.center().x() - pixmap.width() / 2,
sr.center().y() - pixmap.height() / 2)
def _add_goodies(self): def _apply_frame(self, path):
# TODO: panneau de sélection de stickers """Applique un cadre par-dessus l'image (en overlay)."""
pass 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): def _multipose(self):
# TODO: sélection de templates multi-photos name, zones = MultiposeDialog.get_template(self)
pass 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): def _add_text(self):
# TODO: ajout de texte éditable sur le canvas text, ok = QInputDialog.getText(self, "Ajouter du texte", "Texte :")
pass 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)

View File

@@ -6,12 +6,15 @@ from PyQt6.QtWidgets import (
from PyQt6.QtCore import Qt from PyQt6.QtCore import Qt
from PyQt6.QtGui import QFont from PyQt6.QtGui import QFont
from services.usb_watcher import UsbWatcher
class HomeScreen(QWidget): class HomeScreen(QWidget):
def __init__(self, main_window): def __init__(self, main_window):
super().__init__() super().__init__()
self.main_window = main_window self.main_window = main_window
self._setup_ui() self._setup_ui()
self._setup_usb_watcher()
def _setup_ui(self): def _setup_ui(self):
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
@@ -29,6 +32,13 @@ class HomeScreen(QWidget):
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter) subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(subtitle) 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 # Boutons d'import
buttons_layout = QHBoxLayout() buttons_layout = QHBoxLayout()
buttons_layout.setSpacing(20) buttons_layout.setSpacing(20)
@@ -40,10 +50,12 @@ class HomeScreen(QWidget):
("Email", "Recevoir par mail", self._import_email), ("Email", "Recevoir par mail", self._import_email),
] ]
self.buttons = {}
for label, desc, callback in modes: for label, desc, callback in modes:
btn = self._create_mode_button(label, desc) btn = self._create_mode_button(label, desc)
btn.clicked.connect(callback) btn.clicked.connect(callback)
buttons_layout.addWidget(btn) buttons_layout.addWidget(btn)
self.buttons[label] = btn
layout.addLayout(buttons_layout) layout.addLayout(buttons_layout)
@@ -61,10 +73,53 @@ class HomeScreen(QWidget):
QPushButton:pressed { QPushButton:pressed {
background-color: #1976D2; background-color: #1976D2;
} }
QPushButton:disabled {
background-color: #9E9E9E;
}
""") """)
return btn 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): def _import_usb(self):
if self._usb_photos:
self.main_window.import_screen.set_photos(self._usb_photos)
self.main_window.show_import() self.main_window.show_import()
def _import_wifi(self): def _import_wifi(self):

View File

View File

@@ -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())

View File

@@ -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()

View File

@@ -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