gui.theme

Sistema de temas de color — Apple HIG.

Uso:

from gui.theme import c, setup, apply_global, signal, apply_live

setup('dark') # al inicio, antes de crear widgets c('bg') # color del tema activo apply_global(app) # aplica QSS global a QApplication signal().changed # señal emitida al cambiar el tema en vivo apply_live(app) # actualiza QSS + emite señal → todos los widgets se refrescan

  1# Copyright (C) 2026  David Vaquero <pepesan@gmail.com>
  2#
  3# This program is free software: you can redistribute it and/or modify
  4# it under the terms of the GNU General Public License as published by
  5# the Free Software Foundation, either version 3 of the License, or
  6# (at your option) any later version.
  7#
  8# This program is distributed in the hope that it will be useful,
  9# but WITHOUT ANY WARRANTY; without even the implied warranty of
 10# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 11# GNU General Public License for more details.
 12#
 13# You should have received a copy of the GNU General Public License
 14# along with this program.  If not, see <https://www.gnu.org/licenses/>.
 15
 16"""
 17Sistema de temas de color — Apple HIG.
 18
 19Uso:
 20    from gui.theme import c, setup, apply_global, signal, apply_live
 21
 22    setup('dark')           # al inicio, antes de crear widgets
 23    c('bg')                 # color del tema activo
 24    apply_global(app)       # aplica QSS global a QApplication
 25    signal().changed        # señal emitida al cambiar el tema en vivo
 26    apply_live(app)         # actualiza QSS + emite señal → todos los widgets se refrescan
 27"""
 28
 29from __future__ import annotations
 30
 31_THEMES: dict[str, dict[str, str]] = {
 32
 33    # ── Dark — Apple macOS Dark Mode ─────────────────────────────────────────
 34    'dark': {
 35        'bg':           '#1C1C1E',  # systemBackground
 36        'bg_deep':      '#000000',  # editor textarea
 37        'bg_card':      '#2C2C2E',  # secondarySystemBackground (sidebar, toolbar)
 38        'bg_surface':   '#3A3A3C',  # tertiarySystemBackground (inputs, elevated)
 39        'bg_hover':     '#48484A',  # hover state
 40        'bg_selected':  '#1C3968',  # selection (accent suavizado)
 41        'border':       '#38383A',  # separator sutil
 42        'text':         '#FFFFFF',  # label
 43        'text_muted':   '#9A9A9E',  # secondaryLabel
 44        'text_dim':     '#636366',  # tertiaryLabel
 45        'text_disabled':'#48484A',  # quaternaryLabel
 46        'accent':       '#0A84FF',  # systemBlue dark
 47        'accent_hover': '#409CFF',
 48        'ok':           '#30D158',  # systemGreen dark
 49        'error':        '#FF453A',  # systemRed dark
 50        'warn':         '#FFD60A',  # systemYellow dark
 51        'log_ok':       '#32D74B',
 52        'log_error':    '#FF6961',
 53        'log_info':     '#E5E5EA',
 54        'sep':          '#545458',
 55    },
 56
 57    # ── Light — Apple macOS Light Mode ───────────────────────────────────────
 58    'light': {
 59        'bg':           '#FFFFFF',  # systemBackground
 60        'bg_deep':      '#FAFAFA',  # editor textarea
 61        'bg_card':      '#F2F2F7',  # secondarySystemBackground (sidebar, toolbar)
 62        'bg_surface':   '#E5E5EA',  # tertiarySystemBackground (inputs)
 63        'bg_hover':     '#E8E8ED',  # hover state
 64        'bg_selected':  '#D1E3FF',  # selection (accent suavizado)
 65        'border':       '#C6C6C8',  # separator
 66        'text':         '#000000',  # label
 67        'text_muted':   '#636366',  # secondaryLabel
 68        'text_dim':     '#8E8E93',  # tertiaryLabel
 69        'text_disabled':'#AEAEB2',  # quaternaryLabel
 70        'accent':       '#007AFF',  # systemBlue
 71        'accent_hover': '#0071E3',
 72        'ok':           '#34C759',  # systemGreen
 73        'error':        '#FF3B30',  # systemRed
 74        'warn':         '#FF9500',  # systemOrange
 75        'log_ok':       '#25A244',
 76        'log_error':    '#D70015',
 77        'log_info':     '#1D1D1F',
 78        'sep':          '#C6C6C8',
 79    },
 80}
 81
 82_current:   str             = 'dark'
 83_overrides: dict[str, str]  = {}
 84_signals                    = None   # lazy init — requiere QApplication
 85
 86
 87# ── Señal ──────────────────────────────────────────────────────────────────────
 88
 89def signal():
 90    """Devuelve el singleton ThemeSignals (creado lazy para no requerir QApp en import)."""
 91    global _signals
 92    if _signals is None:
 93        from PySide6.QtCore import QObject, Signal  # type: ignore[attr-defined]
 94
 95        class _ThemeSignals(QObject):
 96            changed = Signal()
 97
 98        _signals = _ThemeSignals()
 99    return _signals
100
101
102# ── API pública ────────────────────────────────────────────────────────────────
103
104def setup(name: str) -> None:
105    """Establece el tema activo. Llamar antes de crear widgets."""
106    global _current
107    _current = name if name in _THEMES else 'dark'
108    try:
109        from lib.slides_config import get_theme_overrides
110        set_overrides(get_theme_overrides(_current))
111    except Exception:
112        pass
113
114
115def set_overrides(overrides: dict) -> None:
116    global _overrides
117    _overrides = {k: v for k, v in (overrides or {}).items()
118                  if isinstance(k, str) and isinstance(v, str)}
119
120
121def get() -> str:
122    return _current
123
124
125def all_keys() -> list[str]:
126    return list(_THEMES['dark'].keys())
127
128
129def c(key: str) -> str:
130    """Color activo: override > tema > fallback rojo."""
131    if key in _overrides:
132        return _overrides[key]
133    return _THEMES.get(_current, _THEMES['dark']).get(key, '#ff0000')
134
135
136def apply_global(app) -> None:
137    """Aplica el QSS global a QApplication."""
138    app.setStyleSheet(_build_global_qss())
139
140
141def apply_live(app=None) -> None:
142    """Actualiza QSS global y emite signal().changed → todos los widgets se refrescan."""
143    if app is None:
144        from PySide6.QtWidgets import QApplication
145        app = QApplication.instance()
146    if app:
147        app.setStyleSheet(_build_global_qss())
148    signal().changed.emit()
149
150
151# ── QSS global ────────────────────────────────────────────────────────────────
152
153def _rgba(key: str, alpha: float) -> str:
154    """Convierte c(key) hex a rgba para usar en QSS con transparencia."""
155    h = c(key).lstrip('#')
156    r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
157    return f'rgba({r},{g},{b},{alpha})'
158
159
160def _build_global_qss() -> str:
161    """Construye el QSS completo usando los colores del tema activo (Apple HIG)."""
162    return f"""
163/* ── Base ─────────────────────────────────────────── */
164QMainWindow, QDialog, QWidget {{
165    background: {c('bg')};
166    color: {c('text')};
167    font-size: 13px;
168}}
169
170/* ── Toolbar ──────────────────────────────────────── */
171QToolBar {{
172    background: {c('bg_card')};
173    border-bottom: 1px solid {c('border')};
174    spacing: 2px;
175    padding: 4px 8px;
176    min-height: 44px;
177}}
178QToolBar QToolButton {{
179    background: transparent;
180    color: {c('text')};
181    border: none;
182    border-radius: 7px;
183    padding: 6px 14px;
184    font-size: 13px;
185}}
186QToolBar QToolButton:hover    {{ background: {c('bg_hover')}; }}
187QToolBar QToolButton:pressed  {{ background: {c('bg_surface')}; }}
188QToolBar QToolButton:disabled {{ color: {c('text_disabled')}; }}
189QToolBar QToolButton:checked {{
190    background: {_rgba('accent', 0.18)};
191    color: {c('accent')};
192    border-radius: 7px;
193}}
194QToolBar QToolButton:checked:hover {{
195    background: {_rgba('accent', 0.28)};
196}}
197QToolBar QToolButton#toolbar_primary {{
198    background: {c('accent')};
199    color: #ffffff;
200    font-weight: 600;
201    padding: 6px 18px;
202    border-radius: 7px;
203}}
204QToolBar QToolButton#toolbar_primary:hover    {{ background: {c('accent_hover')}; }}
205QToolBar QToolButton#toolbar_primary:pressed  {{ background: {c('accent_hover')}; }}
206QToolBar QToolButton#toolbar_primary:disabled {{
207    background: {c('bg_surface')};
208    color: {c('text_disabled')};
209}}
210QToolBar QToolButton#toolbar_destructive:hover {{
211    color: {c('warn')};
212    background: {_rgba('warn', 0.10)};
213}}
214QToolBar::separator {{
215    background: {c('sep')};
216    width: 1px;
217    margin: 6px 8px;
218}}
219
220/* ── StatusBar ────────────────────────────────────── */
221QStatusBar {{
222    background: {c('bg_card')};
223    color: {c('text_dim')};
224    font-size: 11px;
225    border-top: 1px solid {c('border')};
226}}
227QStatusBar QLabel {{
228    color: {c('text_dim')};
229    font-size: 11px;
230    padding: 0 3px;
231}}
232
233/* ── Splitter ─────────────────────────────────────── */
234QSplitter::handle {{ background: {c('border')}; }}
235QSplitter::handle:horizontal {{ width: 1px; }}
236QSplitter::handle:vertical   {{ height: 1px; }}
237
238/* ── Sidebar ──────────────────────────────────────── */
239QWidget#sidebar {{
240    background: {c('bg_card')};
241    border-right: 1px solid {c('border')};
242}}
243QWidget#sidebar_header {{
244    background: {c('bg_card')};
245    border-bottom: 1px solid {c('border')};
246}}
247QPushButton#sidebar_course_btn {{
248    background: transparent;
249    color: {c('text')};
250    border: none;
251    font-size: 14px;
252    font-weight: 600;
253    text-align: left;
254    padding: 2px 8px;
255}}
256QPushButton#sidebar_course_btn:hover {{ color: {c('accent')}; }}
257QPushButton#sidebar_course_btn:pressed {{ color: {c('accent_hover')}; }}
258QPushButton#sidebar_icon_btn {{
259    background: transparent;
260    color: {c('text_muted')};
261    border: none;
262    border-radius: 5px;
263    font-size: 14px;
264    padding: 3px 5px;
265    min-width: 24px;
266    min-height: 24px;
267}}
268QPushButton#sidebar_icon_btn:hover {{
269    background: {c('bg_hover')};
270    color: {c('text')};
271}}
272QLabel#section_header {{
273    color: {c('text_dim')};
274    font-size: 10px;
275    font-weight: 700;
276    padding: 10px 14px 3px 14px;
277    background: transparent;
278}}
279
280/* ── Units list (source list) ─────────────────────── */
281QListWidget#units_list {{
282    background: transparent;
283    border: none;
284    font-size: 13px;
285    padding: 2px 0;
286    outline: none;
287}}
288QListWidget#units_list::item {{
289    padding: 0;
290    border-radius: 6px;
291    margin: 1px 8px;
292    color: {c('text')};
293}}
294QListWidget#units_list::item:selected {{
295    background: {c('accent')};
296    color: #FFFFFF;
297}}
298QListWidget#units_list::item:hover:!selected {{
299    background: {c('bg_hover')};
300}}
301QLabel#unit_row_label {{
302    background: transparent;
303    font-size: 13px;
304    color: {c('text')};
305}}
306QPushButton#unit_delete_btn {{
307    background: transparent;
308    color: {c('text')};
309    border: none;
310    border-radius: 4px;
311    font-size: 12px;
312    font-weight: 600;
313    padding: 0;
314    min-width: 18px;
315    max-width: 18px;
316    min-height: 18px;
317    max-height: 18px;
318}}
319QPushButton#unit_delete_btn:hover {{
320    background: {c('error')};
321    color: #ffffff;
322}}
323
324/* ── TreeWidget (SlideNavigator) ──────────────────── */
325QTreeWidget {{
326    background: transparent;
327    border: none;
328    font-size: 12px;
329    font-family: "Liberation Mono";
330    outline: none;
331    color: {c('text_muted')};
332}}
333QTreeWidget::item {{
334    padding: 4px 8px;
335    border-radius: 6px;
336    margin: 1px 6px;
337}}
338QTreeWidget::item:selected {{
339    background: {c('accent')};
340    color: #FFFFFF;
341}}
342QTreeWidget::item:hover:!selected {{ background: {c('bg_hover')}; }}
343QTreeWidget::branch {{ background: transparent; }}
344
345/* ── QMenu ────────────────────────────────────────── */
346QMenu {{
347    background: {c('bg_surface')};
348    color: {c('text')};
349    border: 1px solid {c('border')};
350    border-radius: 10px;
351    padding: 6px 0;
352}}
353QMenu::item {{ padding: 7px 28px 7px 16px; font-size: 13px; }}
354QMenu::item:selected {{
355    background: {c('accent')};
356    color: #FFFFFF;
357    border-radius: 5px;
358    margin: 0 5px;
359}}
360QMenu::item:checked  {{ color: {c('accent')}; font-weight: 600; }}
361QMenu::separator     {{ height: 1px; background: {c('border')}; margin: 4px 0; }}
362
363/* ── YAML error bar ───────────────────────────────── */
364QWidget#yaml_error_bar {{
365    background: {c('error')}22;
366    border-bottom: 1px solid {c('error')}66;
367}}
368QLabel#yaml_error_label {{
369    color: {c('error')};
370    font-size: 11px;
371    font-family: "Liberation Mono";
372}}
373
374/* ── Search bar ──────────────────────────────────── */
375QWidget#search_bar {{
376    background: {c('bg_card')};
377    border-bottom: 1px solid {c('border')};
378}}
379QLineEdit#search_input {{
380    background: {c('bg_surface')};
381    color: {c('text')};
382    border: 1.5px solid {c('border')};
383    border-radius: 7px;
384    padding: 4px 10px;
385    font-size: 13px;
386    min-height: 28px;
387}}
388QLineEdit#search_input:focus {{ border-color: {c('accent')}; }}
389QLabel#search_info {{
390    color: {c('text_muted')};
391    font-size: 11px;
392    font-family: "Liberation Mono";
393}}
394
395/* ── YAML Editor ──────────────────────────────────── */
396QWidget#file_bar {{ background: {c('bg_card')}; border-bottom: 1px solid {c('border')}; }}
397QLabel#file_name_label {{
398    color: {c('text_muted')};
399    font-size: 13px;
400    font-weight: 500;
401}}
402QPushButton#bar_btn {{
403    background: {c('bg_surface')};
404    color: {c('text_muted')};
405    border: none;
406    padding: 3px 10px;
407    font-size: 11px;
408    border-radius: 5px;
409    min-height: 24px;
410}}
411QPushButton#bar_btn:hover {{ background: {c('bg_hover')}; color: {c('text')}; }}
412QPushButton#insert_btn {{
413    background: {c('accent')};
414    color: #ffffff;
415    border: none;
416    padding: 3px 12px;
417    font-size: 12px;
418    border-radius: 6px;
419    font-weight: 500;
420    min-height: 26px;
421}}
422QPushButton#insert_btn:hover {{ background: {c('accent_hover')}; }}
423QPushButton#revert_btn {{
424    background: transparent;
425    color: {c('text_dim')};
426    border: 1px solid {c('border')};
427    border-radius: 6px;
428    padding: 2px 12px;
429    font-size: 12px;
430    min-height: 0;
431}}
432QPushButton#revert_btn:hover {{
433    color: {c('error')};
434    border-color: {c('error')};
435}}
436QPushButton#revert_btn:disabled {{
437    color: {c('text_disabled')};
438    border-color: transparent;
439}}
440QPlainTextEdit#yaml_editor {{
441    background: {c('bg_deep')};
442    color: {c('text')};
443    border: none;
444    selection-background-color: {c('bg_selected')};
445    font-family: "Liberation Mono";
446    font-size: 13px;
447}}
448
449/* ── Log Panel ────────────────────────────────────── */
450QWidget#log_header {{
451    background: {c('bg_card')};
452    border-top: 1px solid {c('border')};
453    border-bottom: 1px solid {c('border')};
454}}
455QLabel#log_title {{
456    color: {c('text_muted')};
457    font-size: 11px;
458    font-weight: 600;
459}}
460QPushButton#log_btn {{
461    background: transparent;
462    color: {c('text_muted')};
463    border: none;
464    padding: 2px 8px;
465    font-size: 11px;
466    border-radius: 5px;
467    min-height: 22px;
468}}
469QPushButton#log_btn:hover {{
470    background: {c('bg_hover')};
471    color: {c('text')};
472}}
473QPushButton#log_collapse_btn {{
474    background: transparent;
475    color: {c('text_dim')};
476    border: none;
477    padding: 2px 6px;
478    font-size: 12px;
479    border-radius: 4px;
480    min-width: 24px;
481    min-height: 22px;
482}}
483QPushButton#log_collapse_btn:hover {{
484    background: {c('bg_hover')};
485    color: {c('text_muted')};
486}}
487QPlainTextEdit#log_text {{
488    background: {c('bg_deep')};
489    color: {c('text')};
490    border: none;
491    selection-background-color: {c('bg_selected')};
492    font-family: "Liberation Mono";
493    font-size: 11px;
494}}
495
496/* ── Progress bar (build) ─────────────────────────── */
497QProgressBar#build_progress {{
498    background: {c('border')};
499    border: none;
500    border-radius: 0;
501}}
502QProgressBar#build_progress::chunk {{
503    background: {c('accent')};
504    border-radius: 0;
505}}
506
507/* ── Results bar (log panel) ──────────────────────── */
508QWidget#results_bar {{
509    background: {c('bg_card')};
510    border-top: 1px solid {c('border')};
511}}
512QScrollArea#results_scroll {{
513    background: {c('bg_card')};
514    border: none;
515    border-top: 1px solid {c('border')};
516}}
517QLabel#results_label {{
518    color: {c('text_muted')};
519    font-size: 12px;
520}}
521QPushButton#open_result_btn {{
522    background: {c('bg_surface')};
523    color: {c('text')};
524    border: 1px solid {c('border')};
525    border-radius: 6px;
526    padding: 2px 10px;
527    font-size: 11px;
528    font-weight: 500;
529    min-height: 0;
530}}
531QPushButton#open_result_btn:hover {{
532    background: {c('accent')};
533    color: #ffffff;
534    border-color: {c('accent')};
535}}
536
537/* ── Dialogs: GroupBox, Forms ─────────────────────── */
538QGroupBox {{
539    color: {c('text_muted')};
540    border: 1px solid {c('border')};
541    border-radius: 10px;
542    margin-top: 14px;
543    font-size: 12px;
544    padding: 18px 12px 12px 12px;
545}}
546QGroupBox::title {{
547    subcontrol-origin: margin;
548    left: 12px;
549    padding: 0 4px;
550    color: {c('text_muted')};
551    font-weight: 600;
552}}
553QLabel  {{ color: {c('text')}; font-size: 13px; }}
554QLabel#hint  {{ color: {c('text_dim')}; font-size: 11px; }}
555QLineEdit {{
556    background: {c('bg_surface')};
557    color: {c('text')};
558    border: 1.5px solid {c('border')};
559    border-radius: 8px;
560    padding: 7px 10px;
561    font-size: 13px;
562    min-height: 34px;
563    selection-background-color: {c('accent')};
564    selection-color: #FFFFFF;
565}}
566QLineEdit:focus {{ border-color: {c('accent')}; }}
567QLineEdit:disabled {{
568    color: {c('text_disabled')};
569    background: {c('bg_card')};
570}}
571QComboBox {{
572    background: {c('bg_surface')};
573    color: {c('text')};
574    border: 1.5px solid {c('border')};
575    border-radius: 8px;
576    padding: 6px 10px;
577    font-size: 13px;
578    min-width: 80px;
579    min-height: 34px;
580}}
581QComboBox:focus {{ border-color: {c('accent')}; }}
582QComboBox::drop-down {{ border: none; width: 20px; }}
583QComboBox QAbstractItemView {{
584    background: {c('bg_surface')};
585    color: {c('text')};
586    border: 1px solid {c('border')};
587    border-radius: 8px;
588    selection-background-color: {c('accent')};
589    selection-color: #FFFFFF;
590    padding: 4px;
591}}
592
593/* ── TabWidget (Settings) ─────────────────────────── */
594QTabWidget::pane {{
595    border: 1px solid {c('border')};
596    border-radius: 8px;
597    background: {c('bg')};
598    top: -1px;
599}}
600QTabWidget::tab-bar {{ left: 8px; }}
601QTabBar::tab {{
602    background: {c('bg_card')};
603    color: {c('text_muted')};
604    padding: 7px 20px;
605    border: 1px solid {c('border')};
606    border-bottom: none;
607    border-radius: 8px 8px 0 0;
608    margin-right: 2px;
609    font-size: 13px;
610}}
611QTabBar::tab:selected {{
612    background: {c('bg')};
613    color: {c('text')};
614    font-weight: 500;
615}}
616QTabBar::tab:hover:!selected {{ background: {c('bg_hover')}; }}
617
618/* ── Buttons (generic) ────────────────────────────── */
619QPushButton {{
620    background: {c('bg_surface')};
621    color: {c('text')};
622    border: none;
623    border-radius: 8px;
624    padding: 7px 16px;
625    font-size: 13px;
626    min-height: 32px;
627}}
628QPushButton:hover {{ background: {c('bg_hover')}; }}
629QPushButton:pressed {{ background: {c('bg_selected')}; }}
630QPushButton:disabled {{ color: {c('text_disabled')}; background: {c('bg_card')}; }}
631QPushButton#browse {{
632    background: {c('bg_surface')};
633    border: 1px solid {c('border')};
634    padding: 6px 12px;
635    min-height: 30px;
636}}
637QPushButton#browse:hover {{ background: {c('bg_hover')}; }}
638QPushButton#danger {{
639    background: transparent;
640    color: {c('error')};
641    border: 1.5px solid {c('error')};
642    border-radius: 8px;
643    padding: 6px 16px;
644    font-size: 13px;
645}}
646QPushButton#danger:hover {{ background: {c('error')}; color: #ffffff; }}
647
648/* ── DialogButtonBox ──────────────────────────────── */
649QDialogButtonBox QPushButton {{
650    background: {c('accent')};
651    color: #ffffff;
652    border: none;
653    border-radius: 8px;
654    padding: 8px 24px;
655    font-size: 13px;
656    font-weight: 500;
657    min-width: 90px;
658    min-height: 34px;
659}}
660QDialogButtonBox QPushButton:hover {{ background: {c('accent_hover')}; }}
661QDialogButtonBox QPushButton[text="Cancelar"],
662QDialogButtonBox QPushButton[text="Cancel"] {{
663    background: {c('bg_surface')};
664    color: {c('text_muted')};
665    border: 1px solid {c('border')};
666}}
667QDialogButtonBox QPushButton[text="Cancelar"]:hover,
668QDialogButtonBox QPushButton[text="Cancel"]:hover {{
669    background: {c('bg_hover')};
670    color: {c('text')};
671}}
672
673/* ── CheckBox & RadioButton ───────────────────────── */
674QCheckBox, QRadioButton {{
675    color: {c('text')};
676    font-size: 13px;
677    spacing: 6px;
678}}
679QCheckBox::indicator, QRadioButton::indicator {{
680    width: 16px;
681    height: 16px;
682    border: 1.5px solid {c('border')};
683    border-radius: 4px;
684    background: {c('bg_surface')};
685}}
686QRadioButton::indicator {{ border-radius: 8px; }}
687QCheckBox::indicator:checked, QRadioButton::indicator:checked {{
688    background: {c('accent')};
689    border-color: {c('accent')};
690}}
691
692/* ── Scrollbars ───────────────────────────────────── */
693QScrollBar:vertical {{
694    background: transparent;
695    width: 8px;
696    border: none;
697    margin: 2px 0;
698}}
699QScrollBar::handle:vertical {{
700    background: {c('sep')};
701    border-radius: 4px;
702    min-height: 28px;
703    margin: 0 2px;
704}}
705QScrollBar::handle:vertical:hover {{ background: {c('text_dim')}; }}
706QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
707QScrollBar:horizontal {{
708    background: transparent;
709    height: 8px;
710    border: none;
711    margin: 0 2px;
712}}
713QScrollBar::handle:horizontal {{
714    background: {c('sep')};
715    border-radius: 4px;
716    min-width: 28px;
717    margin: 2px 0;
718}}
719QScrollBar::handle:horizontal:hover {{ background: {c('text_dim')}; }}
720QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{ width: 0; }}
721
722/* ── Scroll area ──────────────────────────────────── */
723QScrollArea {{ border: none; background: {c('bg')}; }}
724QScrollArea > QWidget > QWidget {{ background: {c('bg')}; }}
725
726/* ── Tooltip ──────────────────────────────────────── */
727QToolTip {{
728    background: {c('bg_surface')};
729    color: {c('text')};
730    border: 1px solid {c('border')};
731    padding: 5px 10px;
732    border-radius: 6px;
733    font-size: 12px;
734}}
735"""
def signal():
 90def signal():
 91    """Devuelve el singleton ThemeSignals (creado lazy para no requerir QApp en import)."""
 92    global _signals
 93    if _signals is None:
 94        from PySide6.QtCore import QObject, Signal  # type: ignore[attr-defined]
 95
 96        class _ThemeSignals(QObject):
 97            changed = Signal()
 98
 99        _signals = _ThemeSignals()
100    return _signals

Devuelve el singleton ThemeSignals (creado lazy para no requerir QApp en import).

def setup(name: str) -> None:
105def setup(name: str) -> None:
106    """Establece el tema activo. Llamar antes de crear widgets."""
107    global _current
108    _current = name if name in _THEMES else 'dark'
109    try:
110        from lib.slides_config import get_theme_overrides
111        set_overrides(get_theme_overrides(_current))
112    except Exception:
113        pass

Establece el tema activo. Llamar antes de crear widgets.

def set_overrides(overrides: dict) -> None:
116def set_overrides(overrides: dict) -> None:
117    global _overrides
118    _overrides = {k: v for k, v in (overrides or {}).items()
119                  if isinstance(k, str) and isinstance(v, str)}
def get() -> str:
122def get() -> str:
123    return _current
def all_keys() -> list[str]:
126def all_keys() -> list[str]:
127    return list(_THEMES['dark'].keys())
def c(key: str) -> str:
130def c(key: str) -> str:
131    """Color activo: override > tema > fallback rojo."""
132    if key in _overrides:
133        return _overrides[key]
134    return _THEMES.get(_current, _THEMES['dark']).get(key, '#ff0000')

Color activo: override > tema > fallback rojo.

def apply_global(app) -> None:
137def apply_global(app) -> None:
138    """Aplica el QSS global a QApplication."""
139    app.setStyleSheet(_build_global_qss())

Aplica el QSS global a QApplication.

def apply_live(app=None) -> None:
142def apply_live(app=None) -> None:
143    """Actualiza QSS global y emite signal().changed → todos los widgets se refrescan."""
144    if app is None:
145        from PySide6.QtWidgets import QApplication
146        app = QApplication.instance()
147    if app:
148        app.setStyleSheet(_build_global_qss())
149    signal().changed.emit()

Actualiza QSS global y emite signal().changed → todos los widgets se refrescan.