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
|
*.swp
|
||||||
*.swo
|
*.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 (
|
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)
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
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