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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -10,3 +10,9 @@ build/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
data/photos/*
|
||||
data/exports/*
|
||||
!data/photos/.gitkeep
|
||||
!data/exports/.gitkeep
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
0
data/exports/.gitkeep
Normal file
0
data/exports/.gitkeep
Normal file
0
data/photos/.gitkeep
Normal file
0
data/photos/.gitkeep
Normal file
0
src/services/__init__.py
Normal file
0
src/services/__init__.py
Normal file
81
src/services/usb_watcher.py
Normal file
81
src/services/usb_watcher.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,10 +73,53 @@ 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):
|
||||
if self._usb_photos:
|
||||
self.main_window.import_screen.set_photos(self._usb_photos)
|
||||
self.main_window.show_import()
|
||||
|
||||
def _import_wifi(self):
|
||||
|
||||
0
src/ui/widgets/__init__.py
Normal file
0
src/ui/widgets/__init__.py
Normal file
123
src/ui/widgets/crop_overlay.py
Normal file
123
src/ui/widgets/crop_overlay.py
Normal 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())
|
||||
104
src/ui/widgets/goodies_panel.py
Normal file
104
src/ui/widgets/goodies_panel.py
Normal 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()
|
||||
122
src/ui/widgets/multipose_dialog.py
Normal file
122
src/ui/widgets/multipose_dialog.py
Normal 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
|
||||
Reference in New Issue
Block a user