utils_qt.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. import os
  2. from PyQt6.QtWidgets import QLabel, QWidget, QVBoxLayout, QFrame, QProgressBar, QFileDialog
  3. from PyQt6.QtGui import QPixmap, QPainter, QColor
  4. from PyQt6.QtSvg import QSvgRenderer
  5. from PyQt6.QtCore import Qt, QSize, QPointF, QTimer
  6. # ══════════════════════════════════════════════════════════════════
  7. # DIALOGUES FICHIERS (QFileDialog isolé du stylesheet parent)
  8. # ══════════════════════════════════════════════════════════════════
  9. _FILE_DIALOG_QSS = """
  10. QDialog { background-color: #2b2b2b; color: #DCE4EE; }
  11. QLabel { color: #DCE4EE; background: transparent; }
  12. QLineEdit { background-color: #1a1a1a; color: #DCE4EE;
  13. border: 1px solid #444; border-radius: 4px; padding: 4px;
  14. selection-background-color: #1f538d; }
  15. QListView, QTreeView { background-color: #1e1e1e; color: #DCE4EE;
  16. border: 1px solid #333; outline: none; }
  17. QListView::item, QTreeView::item { color: #DCE4EE; }
  18. QListView::item:selected, QTreeView::item:selected { background-color: #1f538d; color: white; }
  19. QListView::item:hover, QTreeView::item:hover { background-color: #333333; }
  20. QHeaderView::section { background-color: #252525; color: #DCE4EE;
  21. border: 1px solid #333; padding: 4px; }
  22. QPushButton { background-color: #444444; color: #ffffff;
  23. border: 1px solid #555; border-radius: 4px; padding: 5px 14px; }
  24. QPushButton:hover { background-color: #555555; }
  25. QPushButton:default { background-color: #1f538d; border-color: #2a6dbd; }
  26. QComboBox { background-color: #1a1a1a; color: #DCE4EE;
  27. border: 1px solid #444; border-radius: 4px; padding: 3px 8px; }
  28. QComboBox QAbstractItemView { background-color: #1e1e1e; color: #DCE4EE;
  29. selection-background-color: #1f538d;
  30. border: 1px solid #444; }
  31. QToolButton { background-color: #333333; color: #DCE4EE; border: none; border-radius: 4px; }
  32. QToolButton:hover { background-color: #444444; }
  33. QToolButton:checked { background-color: #1f538d; }
  34. QSplitter::handle { background-color: #333333; }
  35. QScrollBar:vertical, QScrollBar:horizontal { background: #202020; }
  36. QScrollBar::handle:vertical, QScrollBar::handle:horizontal { background: #3e3e3e; border-radius: 3px; }
  37. QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; }
  38. """
  39. def _file_dialog(parent, title, default, name_filter, file_mode, accept_mode):
  40. """
  41. Construit un QFileDialog Qt-widget autonome.
  42. Evite le dialogue natif Linux (portal D-Bus, instable sous XCB) et isole
  43. le dialogue du stylesheet du parent (qui donnait un fond noir illisible).
  44. """
  45. dlg = QFileDialog(parent)
  46. dlg.setFileMode(file_mode)
  47. dlg.setAcceptMode(accept_mode)
  48. dlg.setOption(QFileDialog.Option.DontUseNativeDialog, True)
  49. if title:
  50. dlg.setWindowTitle(title)
  51. if name_filter:
  52. dlg.setNameFilter(name_filter)
  53. if default:
  54. dlg.selectFile(default)
  55. dlg.setStyleSheet(_FILE_DIALOG_QSS)
  56. ok = dlg.exec()
  57. selected = ""
  58. if ok and dlg.selectedFiles():
  59. selected = dlg.selectedFiles()[0]
  60. return selected, dlg.selectedNameFilter()
  61. def get_open_file(parent, title="", directory="", filter_=""):
  62. """Ouvre un sélecteur de fichier (widget Qt isolé). Retourne (path, selected_filter)."""
  63. return _file_dialog(parent, title, directory, filter_,
  64. QFileDialog.FileMode.ExistingFile,
  65. QFileDialog.AcceptMode.AcceptOpen)
  66. def get_save_file(parent, title="", directory="", filter_=""):
  67. """Ouvre un sélecteur d'enregistrement (widget Qt isolé). Retourne (path, selected_filter)."""
  68. return _file_dialog(parent, title, directory, filter_,
  69. QFileDialog.FileMode.AnyFile,
  70. QFileDialog.AcceptMode.AcceptSave)
  71. def get_existing_directory(parent, title="", directory=""):
  72. """Ouvre un sélecteur de dossier (widget Qt isolé)."""
  73. path, _ = _file_dialog(parent, title, directory, "",
  74. QFileDialog.FileMode.Directory,
  75. QFileDialog.AcceptMode.AcceptOpen)
  76. return path
  77. # ══════════════════════════════════════════════════════════════════
  78. # SVG / ICÔNES
  79. # ══════════════════════════════════════════════════════════════════
  80. def get_svg_pixmap(svg_path, size=QSize(32, 32), color_hex=None):
  81. """Génère un Pixmap coloré à partir d'un SVG (Fonction universelle)"""
  82. if not svg_path or not os.path.exists(svg_path):
  83. return QPixmap()
  84. renderer = QSvgRenderer(svg_path)
  85. pixmap = QPixmap(size)
  86. pixmap.fill(Qt.GlobalColor.transparent)
  87. painter = QPainter(pixmap)
  88. painter.setRenderHint(QPainter.RenderHint.Antialiasing)
  89. renderer.render(painter)
  90. if color_hex:
  91. painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
  92. painter.fillRect(pixmap.rect(), QColor(color_hex))
  93. painter.end()
  94. return pixmap
  95. # ══════════════════════════════════════════════════════════════════
  96. # TRADUCTIONS
  97. # ══════════════════════════════════════════════════════════════════
  98. def translate_ui_widgets(widgets_map, translations):
  99. """
  100. Parcourt un dictionnaire { widget: clé_traduction }
  101. et applique les nouveaux textes.
  102. """
  103. for widget, key in widgets_map.items():
  104. text = translations.get(key)
  105. if not text:
  106. alt_key = f"label_{key}"
  107. text = translations.get(alt_key)
  108. if text:
  109. if key.startswith("sec_"):
  110. text = text.upper()
  111. if hasattr(widget, 'setText'):
  112. widget.setText(text)
  113. elif hasattr(widget, 'setPlaceholderText'):
  114. widget.setPlaceholderText(text)
  115. elif hasattr(widget, 'setToolTip'):
  116. widget.setToolTip(text)
  117. # ══════════════════════════════════════════════════════════════════
  118. # LOADING OVERLAY (partagé Raster + Simulation)
  119. # ══════════════════════════════════════════════════════════════════
  120. def show_loading_overlay(parent_widget, message="Loading…"):
  121. """
  122. Affiche un overlay de chargement semi-transparent sur parent_widget.
  123. Retourne l'overlay (QWidget) — appeler overlay.hide() + deleteLater() pour l'effacer.
  124. """
  125. ov = QWidget(parent_widget)
  126. ov.setStyleSheet("background: rgba(20,20,20,210);")
  127. ov.resize(parent_widget.size())
  128. ov.show()
  129. ov.raise_()
  130. lo = QVBoxLayout(ov)
  131. lo.setAlignment(Qt.AlignmentFlag.AlignCenter)
  132. box = QFrame()
  133. box.setFixedWidth(320)
  134. box.setStyleSheet(
  135. "QFrame{background:#3a3a3a;border-radius:12px;border:1px solid #555;}"
  136. )
  137. bl = QVBoxLayout(box)
  138. bl.setContentsMargins(30, 20, 30, 25)
  139. bl.setSpacing(12)
  140. lbl = QLabel(message)
  141. lbl.setStyleSheet(
  142. "color:white;font-size:14px;font-weight:bold;border:none;background:transparent;"
  143. )
  144. lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
  145. bl.addWidget(lbl)
  146. pb = QProgressBar()
  147. pb.setFixedHeight(10)
  148. pb.setRange(0, 0) # indéterminé
  149. pb.setStyleSheet(
  150. "QProgressBar{background:#555;border-radius:5px;border:none;}"
  151. "QProgressBar::chunk{background:#1F6AA5;border-radius:5px;}"
  152. )
  153. bl.addWidget(pb)
  154. lo.addWidget(box)
  155. # Référence interne pour le texte (pour pouvoir le changer plus tard si besoin)
  156. ov._message_label = lbl
  157. return ov
  158. def hide_loading_overlay(overlay):
  159. """Masque et détruit l'overlay retourné par show_loading_overlay."""
  160. if overlay is not None:
  161. try:
  162. overlay.hide()
  163. overlay.deleteLater()
  164. except RuntimeError:
  165. pass
  166. # ══════════════════════════════════════════════════════════════════
  167. # PAN / ZOOM MIXIN (partagé Raster + Simulation)
  168. # ══════════════════════════════════════════════════════════════════
  169. class PanZoomMixin:
  170. """
  171. Mixin pour QWidget offrant zoom molette + pan clic-gauche/molette.
  172. Utilisation dans une sous-classe :
  173. class MyCanvas(PanZoomMixin, QWidget):
  174. def __init__(self):
  175. super().__init__()
  176. self.init_pan_zoom() # initialiser l'état
  177. def paintEvent(self, e):
  178. qp = QPainter(self)
  179. self.apply_pan_zoom_transform(qp)
  180. # … dessiner …
  181. qp.end()
  182. Méthodes à surcharger pour hooks personnalisés :
  183. _on_pan_zoom_changed(self) → appelé après chaque changement de vue
  184. """
  185. def init_pan_zoom(self, zoom_min=0.10, zoom_max=60.0, zoom_step=1.18):
  186. self._pz_zoom = 1.0
  187. self._pz_pan = QPointF(0.0, 0.0)
  188. self._pz_p0 = None # point de départ du drag
  189. self._pz_p0_pan = None
  190. self._pz_zoom_min = zoom_min
  191. self._pz_zoom_max = zoom_max
  192. self._pz_zoom_step = zoom_step
  193. # ── Applique la transformation au QPainter ──────────────────────────────
  194. def apply_pan_zoom_transform(self, painter):
  195. from PyQt6.QtGui import QTransform
  196. t = QTransform()
  197. t.translate(self._pz_pan.x(), self._pz_pan.y())
  198. t.scale(self._pz_zoom, self._pz_zoom)
  199. painter.setTransform(t)
  200. # ── Réinitialiser la vue ────────────────────────────────────────────────
  201. def reset_pan_zoom(self):
  202. self._pz_zoom = 1.0
  203. self._pz_pan = QPointF(0.0, 0.0)
  204. self.update()
  205. self._on_pan_zoom_changed()
  206. # ── Zoom centré sur un point écran ─────────────────────────────────────
  207. def zoom_at(self, cx, cy, factor):
  208. wx = (cx - self._pz_pan.x()) / self._pz_zoom
  209. wy = (cy - self._pz_pan.y()) / self._pz_zoom
  210. self._pz_zoom = max(self._pz_zoom_min,
  211. min(self._pz_zoom_max, self._pz_zoom * factor))
  212. self._pz_pan = QPointF(cx - wx * self._pz_zoom,
  213. cy - wy * self._pz_zoom)
  214. self.update()
  215. self._on_pan_zoom_changed()
  216. # ── Événements Qt ──────────────────────────────────────────────────────
  217. def wheelEvent(self, e):
  218. factor = self._pz_zoom_step if e.angleDelta().y() > 0 else 1.0 / self._pz_zoom_step
  219. pos = e.position()
  220. self.zoom_at(pos.x(), pos.y(), factor)
  221. def mousePressEvent(self, e):
  222. if e.button() in (Qt.MouseButton.LeftButton, Qt.MouseButton.MiddleButton):
  223. self._pz_p0 = e.pos()
  224. self._pz_p0_pan = QPointF(self._pz_pan)
  225. self.setCursor(Qt.CursorShape.ClosedHandCursor)
  226. def mouseMoveEvent(self, e):
  227. if self._pz_p0 is not None:
  228. d = e.pos() - self._pz_p0
  229. self._pz_pan = self._pz_p0_pan + QPointF(d.x(), d.y())
  230. self.update()
  231. self._on_pan_zoom_changed()
  232. def mouseReleaseEvent(self, e):
  233. self._pz_p0 = None
  234. self.setCursor(Qt.CursorShape.ArrowCursor)
  235. # ── Hook optionnel ────────────────────────────────────────────────────
  236. def _on_pan_zoom_changed(self):
  237. """Surcharger dans la classe pour réagir aux changements de vue."""
  238. pass
  239. # ── Utilitaire : convertir coordonnées écran → monde ──────────────────
  240. def screen_to_world(self, sx, sy):
  241. """Retourne (wx, wy) en coordonnées monde (avant zoom/pan)."""
  242. wx = (sx - self._pz_pan.x()) / self._pz_zoom
  243. wy = (sy - self._pz_pan.y()) / self._pz_zoom
  244. return wx, wy
  245. def world_to_screen(self, wx, wy):
  246. """Retourne (sx, sy) en coordonnées écran."""
  247. sx = wx * self._pz_zoom + self._pz_pan.x()
  248. sy = wy * self._pz_zoom + self._pz_pan.y()
  249. return sx, sy
  250. # ══════════════════════════════════════════════════════════════════
  251. # STYLE COMMUN DES DROPDOWNS (partagé Settings + Raster)
  252. # ══════════════════════════════════════════════════════════════════
  253. def get_combo_stylesheet(arrow_path: str) -> str:
  254. """
  255. Retourne le stylesheet QComboBox standard du projet.
  256. arrow_path : chemin vers l'icône SVG de la flèche (peut être None pour le défaut Qt).
  257. """
  258. arrow_str = ""
  259. if arrow_path:
  260. safe = arrow_path.replace("\\", "/")
  261. arrow_str = f"""
  262. QComboBox::down-arrow {{
  263. image: url({safe});
  264. width: 12px;
  265. height: 8px;
  266. }}
  267. """
  268. return f"""
  269. QComboBox {{
  270. background-color: #1e1e1e;
  271. border: 1px solid #444;
  272. border-radius: 5px;
  273. padding: 3px 30px 3px 10px;
  274. color: white;
  275. }}
  276. QComboBox::drop-down {{
  277. subcontrol-origin: padding;
  278. subcontrol-position: top right;
  279. width: 25px;
  280. border: none;
  281. background: transparent;
  282. }}
  283. {arrow_str}
  284. QComboBox QAbstractItemView {{
  285. background-color: #1e1e1e;
  286. color: white;
  287. selection-background-color: #444;
  288. border: 1px solid #444;
  289. }}
  290. """