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