onboarding_widget.py 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905
  1. # -*- coding: utf-8 -*-
  2. """
  3. A.L.I.G. - OnboardingWidget
  4. """
  5. from PyQt6.QtWidgets import (
  6. QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
  7. QFrame, QLineEdit, QComboBox, QStackedWidget,
  8. )
  9. from PyQt6.QtCore import Qt, QRect, QPoint, QRectF, QSize, pyqtSignal
  10. import os
  11. import re
  12. import tempfile
  13. from PyQt6.QtGui import QFont, QColor, QPainter, QPen, QBrush, QPainterPath, QPixmap, QIcon
  14. # ═══════════════════════════════════════════════════════════════════
  15. # HIGHLIGHT OVERLAY
  16. # ═══════════════════════════════════════════════════════════════════
  17. class HighlightOverlay(QWidget):
  18. """
  19. Overlay plein-ecran pose sur la fenetre principale.
  20. - Assombrit tout sauf les zones cibles (interieur transparent)
  21. - Bordure orange fixe autour de chaque cible
  22. - Fleche droite depuis arrow_from vers le bord de la cible
  23. Pas d'animation.
  24. """
  25. def __init__(self, parent=None):
  26. super().__init__(parent)
  27. # WA_NoSystemBackground : Qt ne remplit pas le fond du widget avant paintEvent
  28. # WA_TransparentForMouseEvents : les clics passent au travers
  29. self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
  30. self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
  31. self.setAutoFillBackground(False)
  32. self._targets = []
  33. self._active = False
  34. def show_highlights(self, targets):
  35. """
  36. targets: list of dict
  37. { "rect": QRect, "arrow_from": QPoint or None }
  38. """
  39. self._targets = targets or []
  40. self._active = True
  41. if self.parent():
  42. self.resize(self.parent().size())
  43. self.raise_()
  44. self.show()
  45. self.update()
  46. def hide_highlights(self):
  47. self._active = False
  48. self._targets = []
  49. self.hide()
  50. self.update()
  51. def paintEvent(self, _):
  52. if not self._active or not self._targets:
  53. return
  54. qp = QPainter(self)
  55. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  56. W, H = self.width(), self.height()
  57. # Construire le path global = ecran entier MOINS les zones cibles
  58. # => le dim ne couvre que les zones hors cibles, les cibles restent
  59. # completement transparentes (on ne dessine rien dessus)
  60. full = QPainterPath()
  61. full.addRect(float(0), float(0), float(W), float(H))
  62. cutouts = QPainterPath()
  63. for t in self._targets:
  64. # no_border = zone onboarding : pad nul, coupe exactement la zone
  65. if t.get("no_border"):
  66. pad = 0
  67. elif t.get("small_padding"):
  68. pad = 3
  69. else:
  70. pad = 8
  71. r = t["rect"].adjusted(-pad, -pad, pad, pad)
  72. hole = QPainterPath()
  73. hole.addRoundedRect(
  74. float(r.x()), float(r.y()),
  75. float(r.width()), float(r.height()),
  76. 10.0, 10.0)
  77. cutouts = cutouts.united(hole)
  78. dim_path = full.subtracted(cutouts)
  79. qp.setPen(Qt.PenStyle.NoPen)
  80. qp.setBrush(QBrush(QColor(0, 0, 0, 160)))
  81. qp.drawPath(dim_path)
  82. # Bordure orange sur les cibles qui n'ont pas no_border=True
  83. qp.setPen(QPen(QColor("#FF9500"), 2))
  84. qp.setBrush(Qt.BrushStyle.NoBrush)
  85. for t in self._targets:
  86. if t.get("no_border"):
  87. continue
  88. pad = 3 if t.get("small_padding") else 8
  89. r = t["rect"].adjusted(-pad, -pad, pad, pad)
  90. qp.drawRoundedRect(r, 10, 10)
  91. qp.end()
  92. def resizeEvent(self, e):
  93. super().resizeEvent(e)
  94. if self.parent():
  95. self.resize(self.parent().size())
  96. # ═══════════════════════════════════════════════════════════════════
  97. # STEP BASE
  98. # ═══════════════════════════════════════════════════════════════════
  99. class _StepBase(QWidget):
  100. def __init__(self, t, colors, parent=None):
  101. super().__init__(parent)
  102. self.t = t
  103. self.c = colors
  104. self.setStyleSheet("background: transparent;")
  105. self._lo = QVBoxLayout(self)
  106. self._lo.setContentsMargins(24, 16, 24, 10)
  107. self._lo.setSpacing(10)
  108. self._lo.setAlignment(Qt.AlignmentFlag.AlignTop)
  109. # police sobre : titre en text (pas orange), 14px
  110. def _title(self, key):
  111. lbl = QLabel(self.t.get(key, key))
  112. col = self.c.get("text", "#dddddd")
  113. lbl.setStyleSheet(
  114. "color: " + col + "; font-weight: 700;"
  115. " border: none; background: transparent;")
  116. lbl.setWordWrap(True)
  117. return lbl
  118. def _body(self, key):
  119. lbl = QLabel(self.t.get(key, key))
  120. col = self.c.get("text_secondary", "#aaaaaa")
  121. lbl.setStyleSheet(
  122. "color: " + col + ";"
  123. " border: none; background: transparent;")
  124. lbl.setWordWrap(True)
  125. return lbl
  126. def _hint(self, key):
  127. lbl = QLabel(self.t.get(key, key))
  128. col = self.c.get("text_secondary", "#888888")
  129. lbl.setStyleSheet(
  130. "color: " + col + "; font-style: italic;"
  131. " border: none; background: transparent;")
  132. lbl.setWordWrap(True)
  133. return lbl
  134. def _sep(self):
  135. f = QFrame()
  136. f.setFixedHeight(1)
  137. f.setStyleSheet("background: #333333; border: none;")
  138. return f
  139. @staticmethod
  140. def _arrow_path(color="#aaaaaa"):
  141. """Retourne le chemin d'un SVG de flèche recolorisé."""
  142. try:
  143. from utils.paths import SVG_ICONS
  144. src = SVG_ICONS.get("ARROW_DOWN", "")
  145. if src and os.path.isfile(src):
  146. with open(src, "r", encoding="utf-8") as f:
  147. svg = f.read()
  148. svg = re.sub(r'fill\s*=\s*"[^"]*"', f'fill="{color}"', svg)
  149. svg = re.sub(r'fill\s*:\s*[^;}"]+', f'fill:{color}', svg)
  150. svg = re.sub(r'stroke\s*=\s*"[^"]*"', f'stroke="{color}"', svg)
  151. svg = re.sub(r'stroke\s*:\s*[^;}"]+', f'stroke:{color}', svg)
  152. tmp = tempfile.NamedTemporaryFile(
  153. suffix=f'_arrow_{color.strip("#")}.svg',
  154. delete=False, mode='w', encoding='utf-8')
  155. tmp.write(svg)
  156. tmp.close()
  157. return tmp.name.replace("\\", "/")
  158. except Exception:
  159. pass
  160. return ""
  161. def _combo(self, options):
  162. cb = QComboBox()
  163. cb.addItems(options)
  164. cb.setFixedHeight(30)
  165. # Forcer la font en pixels via setFont — évite le warning
  166. # QFont::setPointSize <= 0 causé par font-size: Npx en CSS
  167. f = QFont()
  168. f.setPointSizeF(9.75)
  169. cb.setFont(f)
  170. bg = self.c.get("bg_entry", "#2b2b2b")
  171. brd = self.c.get("border_strong", "#555555")
  172. col = self.c.get("text", "#dddddd")
  173. arr = self.c.get("arrow_color", "#aaaaaa")
  174. arrow_path = self._arrow_path(arr)
  175. arrow_css = (
  176. "QComboBox::drop-down { subcontrol-origin: padding; subcontrol-position: top right;"
  177. " width: 25px; border: none; background: transparent; }"
  178. " QComboBox::down-arrow { image: url(" + arrow_path + "); width: 12px; height: 8px; }"
  179. ) if arrow_path else "QComboBox::drop-down { border: none; }"
  180. cb.setStyleSheet(
  181. "QComboBox { background: " + bg + "; border: 1px solid " + brd + ";"
  182. " border-radius: 5px; color: " + col + "; padding: 3px 30px 3px 10px; }"
  183. + arrow_css +
  184. " QComboBox QAbstractItemView { background: " + bg + "; color: " + col + ";"
  185. " selection-background-color: " + self.c.get("combo_selection", "#1F6AA5") + "; }")
  186. return cb
  187. def _entry(self, placeholder="", default=""):
  188. e = QLineEdit()
  189. e.setPlaceholderText(placeholder)
  190. e.setText(default)
  191. e.setFixedHeight(30)
  192. bg = self.c.get("bg_entry", "#2b2b2b")
  193. brd = self.c.get("border_strong", "#555555")
  194. col = self.c.get("text", "#dddddd")
  195. _f = QFont(); _f.setPointSizeF(9.75)
  196. e.setFont(_f)
  197. e.setStyleSheet(
  198. "QLineEdit { background: " + bg + "; border: 1px solid " + brd + ";"
  199. " border-radius: 5px; color: " + col + "; padding: 3px 8px; }")
  200. return e
  201. def _row(self, label_key, widget):
  202. row = QHBoxLayout()
  203. col = self.c.get("text", "#dddddd")
  204. lbl = QLabel(self.t.get(label_key, label_key))
  205. _fr = QFont(); _fr.setPointSizeF(9.75)
  206. lbl.setFont(_fr)
  207. lbl.setStyleSheet(
  208. "color: " + col + "; border: none;"
  209. " background: transparent; min-width: 160px;")
  210. row.addWidget(lbl)
  211. row.addWidget(widget, 1)
  212. return row
  213. # Cadre sans fond (transparent) avec bordure coloree
  214. def _card_frame(self, border_color):
  215. f = QFrame()
  216. f.setStyleSheet(
  217. "QFrame { background: transparent; border: 1px solid " + border_color + ";"
  218. " border-radius: 8px; }"
  219. " QLabel { border: none; background: transparent; }")
  220. return f
  221. def get_values(self):
  222. return {}
  223. # ── Flag rendering ───────────────────────────────────────────────
  224. def _make_flag_bands(bands, vertical=False, size=28):
  225. """
  226. Génère un QPixmap circulaire à bandes.
  227. vertical=True : bandes verticales (ex: France)
  228. vertical=False : bandes horizontales (ex: Allemagne)
  229. """
  230. pix = QPixmap(size, size)
  231. pix.fill(QColor(0, 0, 0, 0))
  232. qp = QPainter(pix)
  233. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  234. clip = QPainterPath()
  235. clip.addEllipse(QRectF(0, 0, size, size))
  236. qp.setClipPath(clip)
  237. pos = 0.0
  238. for color, frac in bands:
  239. dim = size * frac
  240. if vertical:
  241. qp.fillRect(QRectF(pos, 0, dim + 1, size), QColor(color))
  242. else:
  243. qp.fillRect(QRectF(0, pos, size, dim + 1), QColor(color))
  244. pos += dim
  245. qp.end()
  246. return pix
  247. def _make_flag_from_image(path, size=28):
  248. """Charge un PNG/JPG et le découpe en cercle."""
  249. src = QPixmap(path)
  250. if src.isNull():
  251. return None
  252. src = src.scaled(size, size,
  253. Qt.AspectRatioMode.KeepAspectRatioByExpanding,
  254. Qt.TransformationMode.SmoothTransformation)
  255. # Centrer si nécessaire
  256. if src.width() > size or src.height() > size:
  257. x = (src.width() - size) // 2
  258. y = (src.height() - size) // 2
  259. src = src.copy(x, y, size, size)
  260. pix = QPixmap(size, size)
  261. pix.fill(QColor(0, 0, 0, 0))
  262. qp = QPainter(pix)
  263. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  264. clip = QPainterPath()
  265. clip.addEllipse(QRectF(0, 0, size, size))
  266. qp.setClipPath(clip)
  267. qp.drawPixmap(0, 0, src)
  268. qp.end()
  269. return pix
  270. # Mapping langue -> nom de fichier SVG dans assets/flags/
  271. _FLAG_SVG = {
  272. "English": "us.svg",
  273. "Français": "fr.svg",
  274. "Deutsch": "de.svg",
  275. }
  276. def _build_flag_pixmap(lang, size=28):
  277. """
  278. Charge le SVG du drapeau depuis assets/flags/ et le rasterise
  279. en cercle via QSvgRenderer. Fallback bandes colorées si absent.
  280. """
  281. try:
  282. from utils.paths import ASSETS_DIR
  283. import os
  284. from PyQt6.QtSvg import QSvgRenderer
  285. fname = _FLAG_SVG.get(lang)
  286. if fname:
  287. svg_path = os.path.join(ASSETS_DIR, "flags", fname)
  288. if os.path.isfile(svg_path):
  289. pix = QPixmap(size, size)
  290. pix.fill(QColor(0, 0, 0, 0))
  291. qp = QPainter(pix)
  292. qp.setRenderHint(QPainter.RenderHint.Antialiasing)
  293. # Clip circulaire
  294. clip = QPainterPath()
  295. clip.addEllipse(QRectF(0, 0, size, size))
  296. qp.setClipPath(clip)
  297. renderer = QSvgRenderer(svg_path)
  298. renderer.render(qp, QRectF(0, 0, size, size))
  299. qp.end()
  300. return pix
  301. except Exception:
  302. pass
  303. # Fallback dessiné si SVG absent ou erreur
  304. if lang == "Français":
  305. return _make_flag_bands(
  306. [("#002395", 1/3), ("#FFFFFF", 1/3), ("#ED2939", 1/3)],
  307. vertical=True, size=size)
  308. elif lang == "Deutsch":
  309. return _make_flag_bands(
  310. [("#000000", 1/3), ("#DD0000", 1/3), ("#FFCE00", 1/3)],
  311. vertical=False, size=size)
  312. else:
  313. return _make_flag_bands(
  314. [("#B22234", 1/13), ("#FFFFFF", 1/13)] * 6 + [("#B22234", 1/13)],
  315. vertical=False, size=size)
  316. # Langues disponibles (ordre d'affichage)
  317. _FLAGS = ["English", "Français", "Deutsch"]
  318. class _LangButton(QPushButton):
  319. """Petit bouton drapeau circulaire avec indicateur actif."""
  320. SIZE = 28
  321. def __init__(self, lang, is_active, parent=None):
  322. super().__init__(parent)
  323. self.lang = lang
  324. self.setFixedSize(self.SIZE + 4, self.SIZE + 4)
  325. self.setCursor(Qt.CursorShape.PointingHandCursor)
  326. self.setToolTip(lang)
  327. pix = _build_flag_pixmap(lang, self.SIZE)
  328. self.setIcon(QIcon(pix))
  329. self.setIconSize(QSize(self.SIZE, self.SIZE))
  330. self._active = is_active
  331. self._apply_style()
  332. def set_active(self, active):
  333. self._active = active
  334. self._apply_style()
  335. def _apply_style(self):
  336. if self._active:
  337. self.setStyleSheet(
  338. "QPushButton { background: transparent; border: 2px solid #FF9500;"
  339. " border-radius: " + str((self.SIZE + 4) // 2) + "px; padding: 0px; }"
  340. )
  341. else:
  342. self.setStyleSheet(
  343. "QPushButton { background: transparent; border: 2px solid transparent;"
  344. " border-radius: " + str((self.SIZE + 4) // 2) + "px; padding: 0px; }"
  345. " QPushButton:hover { border-color: #666666; }"
  346. )
  347. # ── Step 1 : Bienvenue ────────────────────────────────────────────
  348. class _Step1Welcome(_StepBase):
  349. highlight_names = ['onboarding_area']
  350. def __init__(self, t, colors, current_lang="English", parent=None):
  351. super().__init__(t, colors, parent)
  352. self._lang_btns = {}
  353. # ── Ligne titre + drapeaux ────────────────────────────
  354. top_row = QHBoxLayout()
  355. top_row.setContentsMargins(0, 0, 0, 0)
  356. top_row.setSpacing(6)
  357. title_lbl = QLabel(t.get("step1_title", "Welcome"))
  358. col_t = colors.get("text", "#dddddd")
  359. title_lbl.setStyleSheet(
  360. "color: " + col_t + "; font-weight: 700;"
  361. " border: none; background: transparent;")
  362. title_lbl.setWordWrap(True)
  363. top_row.addWidget(title_lbl, 1)
  364. # Drapeaux
  365. flags_row = QHBoxLayout()
  366. flags_row.setSpacing(4)
  367. flags_row.setContentsMargins(0, 0, 0, 0)
  368. for lang in _FLAGS: # _FLAGS est une liste
  369. btn = _LangButton(lang, lang == current_lang)
  370. btn.clicked.connect(lambda checked, l=lang: self._on_lang(l))
  371. flags_row.addWidget(btn)
  372. self._lang_btns[lang] = btn
  373. top_row.addLayout(flags_row)
  374. self._lo.addLayout(top_row)
  375. self._lo.addSpacing(4)
  376. self._lo.addWidget(self._body("step1_body"))
  377. self._lo.addSpacing(8)
  378. col = colors.get("text_secondary", "#aaaaaa")
  379. for key in ("step11_body", "step12_body", "step13_body", "step14_body"):
  380. row = QHBoxLayout()
  381. dot = QFrame()
  382. dot.setFixedSize(6, 6)
  383. dot.setStyleSheet("background: #ffffff; border-radius: 3px; border: none;")
  384. lbl = QLabel(t.get(key, ""))
  385. lbl.setStyleSheet(
  386. "color: " + col + "; border: none; background: transparent;")
  387. lbl.setWordWrap(True)
  388. row.addWidget(dot)
  389. row.addSpacing(8)
  390. row.addWidget(lbl, 1)
  391. self._lo.addLayout(row)
  392. self._lo.addStretch()
  393. def _on_lang(self, lang):
  394. for l, btn in self._lang_btns.items():
  395. btn.set_active(l == lang)
  396. # Remonter au OnboardingWidget via le parent
  397. p = self.parent()
  398. while p and not isinstance(p, OnboardingWidget):
  399. p = p.parent()
  400. if p:
  401. p.language_changed.emit(lang)
  402. # ── Step 2 : Config machine ───────────────────────────────────────
  403. class _Step2Config(_StepBase):
  404. highlight_names = ['onboarding_area']
  405. def __init__(self, t, colors, parent=None):
  406. super().__init__(t, colors, parent)
  407. # Forcer la font sur tout le step pour éviter le warning pixelSize
  408. _base_font = QFont(); _base_font.setPointSizeF(9.75)
  409. self.setFont(_base_font)
  410. self._lo.addWidget(self._title("step2_title"))
  411. self._lo.addWidget(self._body("step2_body"))
  412. # Ligne unique : combo réduit + M67 output inline
  413. col = colors.get("text", "#dddddd")
  414. cmd_row = QHBoxLayout()
  415. cmd_lbl = QLabel(t.get("cmd_mode_lbl", "Command mode:"))
  416. _fc = QFont(); _fc.setPointSizeF(9.75)
  417. cmd_lbl.setFont(_fc)
  418. cmd_lbl.setStyleSheet(
  419. "color: " + col + "; border: none;"
  420. " background: transparent; min-width: 120px;")
  421. self.cmd_combo = self._combo(["S (Spindle)", "M67 (Analog)"])
  422. self.cmd_combo.setFixedWidth(160)
  423. cmd_row.addWidget(cmd_lbl)
  424. cmd_row.addWidget(self.cmd_combo)
  425. cmd_row.addSpacing(10)
  426. # M67 output inline (masqué par défaut)
  427. self._m67_lbl = QLabel(t.get("m67_output_lbl", "Output:"))
  428. self._m67_lbl.setStyleSheet(
  429. "color: " + col + "; border: none; background: transparent;")
  430. _f13 = QFont(); _f13.setPointSizeF(9.75)
  431. self._m67_lbl.setFont(_f13)
  432. self.m67_entry = self._entry("0-3", "0")
  433. self.m67_entry.setFont(_f13)
  434. self.m67_entry.setFixedWidth(50)
  435. cmd_row.addWidget(self._m67_lbl)
  436. cmd_row.addWidget(self.m67_entry)
  437. cmd_row.addStretch()
  438. self._m67_lbl.setVisible(False)
  439. self.m67_entry.setVisible(False)
  440. self._lo.addLayout(cmd_row)
  441. def _on_cmd(i):
  442. self._m67_lbl.setVisible(i == 1)
  443. self.m67_entry.setVisible(i == 1)
  444. self.cmd_combo.currentIndexChanged.connect(_on_cmd)
  445. self._lo.addWidget(self._sep())
  446. self.firing_combo = self._combo(["M3/M5 (constant)", "M4/M5 (dynamic)"])
  447. self._lo.addLayout(self._row("firing_lbl", self.firing_combo))
  448. self._lo.addWidget(self._hint("firing_hint"))
  449. self._lo.addWidget(self._sep())
  450. self.ctrl_entry = self._entry("100 or 1000", "1000")
  451. self.ctrl_entry.setFixedWidth(90)
  452. self._lo.addLayout(self._row("ctrl_max_lbl", self.ctrl_entry))
  453. self._lo.addWidget(self._hint("ctrl_max_hint"))
  454. self._lo.addStretch()
  455. def get_values(self):
  456. cmd = "S (Spindle)" if self.cmd_combo.currentIndex() == 0 else "M67 (Analog)"
  457. fire = "M3/M5" if self.firing_combo.currentIndex() == 0 else "M4/M5"
  458. try: m67 = int(self.m67_entry.text())
  459. except ValueError: m67 = 0
  460. try: ctrl = int(self.ctrl_entry.text())
  461. except ValueError: ctrl = 1000
  462. return {"cmd_mode": cmd, "firing_mode": fire, "m67_e_num": m67, "ctrl_max": ctrl}
  463. # ── Step 3a : Settings ────────────────────────────────────────────
  464. class _Step3Settings(_StepBase):
  465. # highlight_names expose les cibles pour le dashboard
  466. highlight_names = ['onboarding_area', 'settings_card', 'settings_topbar_btn']
  467. def __init__(self, t, colors, parent=None):
  468. super().__init__(t, colors, parent)
  469. self._lo.addWidget(self._title("step3_title"))
  470. self._lo.addWidget(self._body("step3_body"))
  471. self._lo.addSpacing(4)
  472. col = colors.get("text_secondary", "#aaaaaa")
  473. col_accent = "#1F6AA5"
  474. for k in ("ptr_settings_topbar", "ptr_settings_card"):
  475. row = QHBoxLayout()
  476. dot = QFrame()
  477. dot.setFixedSize(6, 6)
  478. dot.setStyleSheet(
  479. "background: #ffffff; border-radius: 3px; border: none;")
  480. lbl = QLabel(t.get(k, k))
  481. lbl.setStyleSheet(
  482. "color: " + col + "; border: none; background: transparent;")
  483. row.addWidget(dot)
  484. row.addSpacing(8)
  485. row.addWidget(lbl, 1)
  486. self._lo.addLayout(row)
  487. self._lo.addStretch()
  488. # ── Step 3b : Calibration ─────────────────────────────────────────
  489. class _Step3Calib(_StepBase):
  490. highlight_names = ['onboarding_area', 'calibration_card']
  491. def __init__(self, t, colors, parent=None):
  492. super().__init__(t, colors, parent)
  493. self._lo.addWidget(self._title("ptr_calib_title"
  494. if "ptr_calib_title" in t else "step3_title"))
  495. self._lo.addSpacing(4)
  496. col = colors.get("text_secondary", "#aaaaaa")
  497. col_accent = "#FF9500"
  498. b2 = QLabel(t.get("ptr_calib_body", ""))
  499. b2.setStyleSheet(
  500. "color: " + col + "; border: none; background: transparent;")
  501. b2.setWordWrap(True)
  502. self._lo.addWidget(b2)
  503. row = QHBoxLayout()
  504. dot = QFrame()
  505. dot.setFixedSize(6, 6)
  506. dot.setStyleSheet(
  507. "background: #ffffff; border-radius: 3px; border: none;")
  508. b3 = QLabel(t.get("ptr_calib_card", ""))
  509. b3.setStyleSheet(
  510. "color: " + col + "; border: none; background: transparent;")
  511. row.addWidget(dot)
  512. row.addSpacing(8)
  513. row.addWidget(b3, 1)
  514. self._lo.addLayout(row)
  515. self._lo.addStretch()
  516. # ── Step 4 : Modes ────────────────────────────────────────────────
  517. # ── Step 3c : Home & Github ─────────────────────────────────────
  518. class _StepHome(_StepBase):
  519. highlight_names = ['onboarding_area', 'home_btn', 'github_btn']
  520. def __init__(self, t, colors, parent=None):
  521. super().__init__(t, colors, parent)
  522. self._lo.addWidget(self._title("step_home_title"))
  523. self._lo.addWidget(self._body("step_home_body"))
  524. self._lo.addSpacing(8)
  525. col = colors.get("text_secondary", "#aaaaaa")
  526. for k in ("ptr_home_desc", "ptr_github_desc"):
  527. row = QHBoxLayout()
  528. dot = QFrame()
  529. dot.setFixedSize(6, 6)
  530. dot.setStyleSheet("background: #ffffff; border-radius: 3px; border: none;")
  531. lbl = QLabel(t.get(k, k))
  532. lbl.setStyleSheet(
  533. "color: " + col + "; border: none; background: transparent;")
  534. lbl.setWordWrap(True)
  535. row.addWidget(dot)
  536. row.addSpacing(8)
  537. row.addWidget(lbl, 1)
  538. self._lo.addLayout(row)
  539. self._lo.addStretch()
  540. # ── Step 4 : G-Code Checker ─────────────────────────────────────
  541. class _StepChecker(_StepBase):
  542. highlight_names = ['onboarding_area', 'parser_card']
  543. def __init__(self, t, colors, parent=None):
  544. super().__init__(t, colors, parent)
  545. self._lo.addWidget(self._title("step_checker_title"))
  546. self._lo.addSpacing(4)
  547. self._lo.addWidget(self._body("step_checker_body"))
  548. self._lo.addSpacing(8)
  549. col = colors.get("text_secondary", "#aaaaaa")
  550. row = QHBoxLayout()
  551. dot = QFrame()
  552. dot.setFixedSize(6, 6)
  553. dot.setStyleSheet("background: #ffffff; border-radius: 3px; border: none;")
  554. lbl = QLabel(t.get("step_checker_card", ""))
  555. lbl.setStyleSheet(
  556. "color: " + col + "; border: none; background: transparent;")
  557. row.addWidget(dot)
  558. row.addSpacing(8)
  559. row.addWidget(lbl, 1)
  560. self._lo.addLayout(row)
  561. self._lo.addStretch()
  562. # ── Step 5 : Modes ───────────────────────────────────────────────
  563. class _Step4Modes(_StepBase):
  564. highlight_names = ['onboarding_area', 'modes_column']
  565. def __init__(self, t, colors, parent=None):
  566. super().__init__(t, colors, parent)
  567. self._lo.addWidget(self._title("step4_title"))
  568. self._lo.addWidget(self._body("step4_body"))
  569. for key, color, enabled in [
  570. ("mode_raster", "#1F6AA5", True),
  571. ("mode_dither", "#555555", False),
  572. ("mode_infill", "#555555", False),
  573. ]:
  574. row = QHBoxLayout()
  575. dot = QFrame()
  576. dot.setFixedSize(7, 7)
  577. dot.setStyleSheet(
  578. "background: " + ("#ffffff" if enabled else "#555555") + "; border-radius: 3px; border: none;")
  579. col = colors.get(
  580. "text" if enabled else "text_secondary",
  581. "#dddddd" if enabled else "#666666")
  582. lbl = QLabel(t.get(key, key))
  583. lbl.setStyleSheet(
  584. "color: " + col + ";"
  585. " border: none; background: transparent;")
  586. lbl.setWordWrap(True)
  587. row.addWidget(dot)
  588. row.addSpacing(6)
  589. row.addWidget(lbl, 1)
  590. self._lo.addLayout(row)
  591. self._lo.addSpacing(6)
  592. note = QLabel(t.get("finish_note", "All set — click Finish."))
  593. col_ok = colors.get("text_secondary", "#888888")
  594. note.setStyleSheet(
  595. "color: " + col_ok + ";"
  596. " border: none; background: transparent;")
  597. note.setAlignment(Qt.AlignmentFlag.AlignCenter)
  598. self._lo.addWidget(note)
  599. self._lo.addStretch()
  600. # ═══════════════════════════════════════════════════════════════════
  601. # ONBOARDING WIDGET
  602. # ═══════════════════════════════════════════════════════════════════
  603. class OnboardingWidget(QWidget):
  604. finished = pyqtSignal(dict)
  605. request_highlight = pyqtSignal(list)
  606. clear_highlight = pyqtSignal()
  607. language_changed = pyqtSignal(str) # émet la langue choisie
  608. TOTAL = 7 # Welcome / Config / Settings / Calib / Home+Github / Checker / Modes
  609. def __init__(self, colors: dict, translations: dict = None,
  610. current_lang: str = "English",
  611. emit_initial_highlight: bool = True, parent=None):
  612. super().__init__(parent)
  613. self.colors = colors
  614. self._step = 0
  615. self._vals = {}
  616. self.t = translations or {}
  617. self._current_lang = current_lang
  618. self._emit_initial_highlight = emit_initial_highlight
  619. self.setStyleSheet("background: transparent;")
  620. self.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
  621. self.setAutoFillBackground(False)
  622. root = QVBoxLayout(self)
  623. root.setContentsMargins(0, 0, 0, 0)
  624. root.setSpacing(0)
  625. self._stack = QStackedWidget()
  626. self._stack.setStyleSheet("background: transparent;")
  627. self._steps = [
  628. _Step1Welcome(self.t, colors, current_lang=self._current_lang), # 0
  629. _Step2Config(self.t, colors), # 1
  630. _Step3Settings(self.t, colors), # 2 -> highlight settings
  631. _Step3Calib(self.t, colors), # 3 -> highlight calibration
  632. _StepHome(self.t, colors), # 4 -> highlight home + github
  633. _StepChecker(self.t, colors), # 5 -> highlight parser_card
  634. _Step4Modes(self.t, colors), # 5 -> highlight modes_column
  635. ]
  636. for s in self._steps:
  637. self._stack.addWidget(s)
  638. root.addWidget(self._stack, 1)
  639. root.addWidget(self._build_dots())
  640. self._nav = self._build_nav()
  641. root.addWidget(self._nav)
  642. # Refresh UI sans émettre highlight — le premier highlight
  643. # sera déclenché par showEvent une fois la géométrie connue
  644. self._refresh(emit_highlight=False)
  645. # ── UI ────────────────────────────────────────────────────────
  646. def _build_dots(self):
  647. w = QWidget()
  648. w.setFixedHeight(20)
  649. w.setStyleSheet("background: transparent;")
  650. lo = QHBoxLayout(w)
  651. lo.setContentsMargins(20, 2, 20, 0)
  652. lo.setSpacing(6)
  653. self._dots = []
  654. for _ in range(self.TOTAL):
  655. d = QFrame()
  656. d.setFixedSize(7, 7)
  657. d.setStyleSheet("border-radius: 3px; background: #333333; border: none;")
  658. lo.addWidget(d)
  659. self._dots.append(d)
  660. lo.addStretch()
  661. return w
  662. def _build_nav(self):
  663. bar = QWidget()
  664. bar.setFixedHeight(46)
  665. bg = self.colors.get("bg_card_alt", "#222222")
  666. bar.setStyleSheet("background: " + bg + "; border-top: 1px solid #333333;")
  667. lo = QHBoxLayout(bar)
  668. lo.setContentsMargins(16, 7, 16, 7)
  669. lo.setSpacing(8)
  670. col_sec = self.colors.get("text_secondary", "#888888")
  671. col_txt = self.colors.get("text", "#dddddd")
  672. bg_e = self.colors.get("bg_entry", "#2b2b2b")
  673. self._btn_skip = QPushButton(self.t.get("btn_skip", "Skip"))
  674. self._btn_skip.setFixedHeight(30)
  675. self._btn_skip.setCursor(Qt.CursorShape.PointingHandCursor)
  676. self._btn_skip.setStyleSheet(
  677. "QPushButton { background: transparent; color: " + col_sec + ";"
  678. " border: 1px solid #444444; border-radius: 5px; padding: 0 12px; }"
  679. " QPushButton:hover { color: #dddddd; border-color: #666666; }")
  680. self._btn_skip.clicked.connect(self._on_skip)
  681. lo.addWidget(self._btn_skip)
  682. lo.addStretch()
  683. self._btn_prev = QPushButton(self.t.get("btn_prev", "Previous"))
  684. self._btn_prev.setFixedHeight(30)
  685. self._btn_prev.setCursor(Qt.CursorShape.PointingHandCursor)
  686. self._btn_prev.setStyleSheet(
  687. "QPushButton { background: " + bg_e + "; color: " + col_txt + ";"
  688. " border: 1px solid #555555; border-radius: 5px; padding: 0 14px; }"
  689. " QPushButton:hover { border-color: #888888; }")
  690. self._btn_prev.clicked.connect(self._on_prev)
  691. lo.addWidget(self._btn_prev)
  692. self._btn_next = QPushButton(self.t.get("btn_next", "Next"))
  693. self._btn_next.setFixedHeight(30)
  694. self._btn_next.setCursor(Qt.CursorShape.PointingHandCursor)
  695. self._btn_next.setStyleSheet(
  696. "QPushButton { background: #1F6AA5; color: white;"
  697. " border: none; border-radius: 5px; padding: 0 16px;"
  698. " font-weight: 700; }"
  699. " QPushButton:hover { background: #2a7fc5; }")
  700. self._btn_next.clicked.connect(self._on_next)
  701. lo.addWidget(self._btn_next)
  702. return bar
  703. # ── Refresh ───────────────────────────────────────────────────
  704. def _refresh(self, emit_highlight=True):
  705. brd = self.colors.get("border", "#333333")
  706. for i, d in enumerate(self._dots):
  707. if i < self._step:
  708. d.setStyleSheet("border-radius: 3px; background: #1F6AA5; border: none;")
  709. elif i == self._step:
  710. d.setStyleSheet("border-radius: 3px; background: #FF9500; border: none;")
  711. else:
  712. d.setStyleSheet("border-radius: 3px; background: " + brd + "; border: none;")
  713. self._btn_prev.setVisible(self._step > 0)
  714. is_last = self._step == self.TOTAL - 1
  715. lbl = self.t.get("btn_finish", "Finish") if is_last else self.t.get("btn_next", "Next")
  716. self._btn_next.setText(lbl)
  717. if is_last:
  718. self._btn_next.setStyleSheet(
  719. "QPushButton { background: #27ae60; color: white;"
  720. " border: none; border-radius: 5px; padding: 0 16px;"
  721. " font-weight: 700; }"
  722. " QPushButton:hover { background: #2ecc71; }")
  723. else:
  724. self._btn_next.setStyleSheet(
  725. "QPushButton { background: #1F6AA5; color: white;"
  726. " border: none; border-radius: 5px; padding: 0 16px;"
  727. " font-weight: 700; }"
  728. " QPushButton:hover { background: #2a7fc5; }")
  729. # L'émission du highlight est déléguée à _emit_highlight
  730. if emit_highlight:
  731. self._emit_highlight()
  732. def _emit_highlight(self):
  733. # Ne pas émettre si le widget n'est plus visible (onboarding terminé)
  734. if not self.isVisible():
  735. return
  736. current_step = self._steps[self._step]
  737. names = getattr(current_step, 'highlight_names', [])
  738. if names:
  739. self.request_highlight.emit(names)
  740. else:
  741. self.clear_highlight.emit()
  742. def showEvent(self, event):
  743. super().showEvent(event)
  744. if not self._emit_initial_highlight:
  745. return
  746. # La fenêtre peut être maximisée au démarrage — la géométrie
  747. # est instable pendant quelques cycles d'événements.
  748. # On surveille la stabilité via _wait_geometry_stable.
  749. self._geom_attempts = 0
  750. self._last_win_size = None
  751. from PyQt6.QtCore import QTimer
  752. self._geom_timer = QTimer(self)
  753. self._geom_timer.timeout.connect(self._wait_geometry_stable)
  754. self._geom_timer.start(60) # vérifie toutes les 60 ms
  755. def _wait_geometry_stable(self):
  756. """Émet le highlight une fois la taille de la fenêtre principale stable."""
  757. win = self.window()
  758. current_size = win.size()
  759. self._geom_attempts += 1
  760. if current_size == self._last_win_size or self._geom_attempts >= 15:
  761. # Taille stable ou timeout (15 × 60 ms = 900 ms max)
  762. self._geom_timer.stop()
  763. self._emit_highlight()
  764. else:
  765. self._last_win_size = current_size
  766. # ── Navigation ────────────────────────────────────────────────
  767. def _stop_geom_timer(self):
  768. """Stoppe le timer de stabilité géométrique s'il tourne encore."""
  769. timer = getattr(self, '_geom_timer', None)
  770. if timer and timer.isActive():
  771. timer.stop()
  772. def _collect(self):
  773. if self._step == 1:
  774. self._vals = self._steps[1].get_values()
  775. def _on_next(self):
  776. self._collect()
  777. if self._step < self.TOTAL - 1:
  778. self._step += 1
  779. self._stack.setCurrentIndex(self._step)
  780. self._refresh()
  781. else:
  782. self._stop_geom_timer()
  783. self.clear_highlight.emit()
  784. self.finished.emit(self._vals)
  785. def _on_prev(self):
  786. if self._step > 0:
  787. self._step -= 1
  788. self._stack.setCurrentIndex(self._step)
  789. self._refresh()
  790. def _on_skip(self):
  791. self._stop_geom_timer()
  792. self._collect()
  793. self.clear_highlight.emit()
  794. self.finished.emit(self._vals)
  795. # ── Theme ─────────────────────────────────────────────────────
  796. def apply_theme(self, colors):
  797. self.colors = colors
  798. bg = colors.get("bg_card_alt", "#222222")
  799. self._nav.setStyleSheet("background: " + bg + "; border-top: 1px solid #333333;")