gui.dialogs.settings_dialog

  1from pathlib import Path
  2
  3from PySide6.QtWidgets import (
  4    QDialog, QVBoxLayout, QHBoxLayout, QFormLayout, QGridLayout,
  5    QLabel, QLineEdit, QPushButton, QSpinBox,
  6    QDialogButtonBox, QGroupBox, QTabWidget, QWidget,
  7    QScrollArea, QColorDialog,
  8)
  9from gui.utils.dropdown_button import DropdownButton
 10from PySide6.QtCore import Qt, QTimer, Signal
 11from PySide6.QtGui import QColor
 12
 13from lib.slides_config import (
 14    get_courses_dir, save_courses_dir,
 15    get_language, save_language,
 16    get_theme, save_theme,
 17    get_theme_overrides, save_theme_overrides, clear_theme_overrides,
 18    get_display_settings, save_display_setting,
 19    get_generation_config, save_generation_param,
 20    CONFIG_PATH,
 21)
 22from gui import theme as _theme
 23from gui.theme import c, _THEMES, signal as theme_signal
 24from lib.i18n import _
 25
 26
 27_LANGUAGES = [('es', 'Español'), ('en', 'English')]
 28_ROUNDING_POLICIES = ['PassThrough', 'Round', 'Ceil', 'Floor', 'RoundPreferFloor']
 29
 30# Agrupación visual de las claves de color
 31_COLOR_GROUPS = [
 32    (_('Fondos'), [
 33        ('bg',          _('Fondo principal')),
 34        ('bg_deep',     _('Fondo editor / log')),
 35        ('bg_card',     _('Barras y cabeceras')),
 36        ('bg_surface',  _('Superficies de campos')),
 37        ('bg_hover',    _('Hover')),
 38        ('bg_selected', _('Seleccionado')),
 39    ]),
 40    (_('Bordes'), [
 41        ('border', _('Borde general')),
 42        ('sep',    _('Separadores')),
 43    ]),
 44    (_('Texto'), [
 45        ('text',          _('Texto principal')),
 46        ('text_muted',    _('Texto secundario')),
 47        ('text_dim',      _('Texto suave')),
 48        ('text_disabled', _('Texto desactivado')),
 49    ]),
 50    (_('Acento'), [
 51        ('accent',       _('Color de acento')),
 52        ('accent_hover', _('Acento hover')),
 53    ]),
 54    (_('Estado'), [
 55        ('ok',    _('Éxito / OK')),
 56        ('error', _('Error')),
 57        ('warn',  _('Aviso')),
 58    ]),
 59    (_('Log'), [
 60        ('log_ok',    _('Log OK')),
 61        ('log_error', _('Log error')),
 62        ('log_info',  _('Log info')),
 63    ]),
 64]
 65
 66
 67def _build_style() -> str:
 68    return f"""
 69QDialog   {{ background: {c('bg')}; color: {c('text')}; }}
 70QTabWidget::pane  {{ border: 1px solid {c('border')}; border-radius: 4px;
 71                    background: {c('bg')}; }}
 72QTabWidget::tab-bar {{ left: 4px; }}
 73QTabBar::tab {{
 74    background: {c('bg_card')}; color: {c('text_muted')};
 75    padding: 6px 18px; border: 1px solid {c('border')};
 76    border-bottom: none; border-radius: 4px 4px 0 0;
 77    margin-right: 2px; font-size: 12px;
 78}}
 79QTabBar::tab:selected {{
 80    background: {c('bg')}; color: {c('text')};
 81    border-bottom: 1px solid {c('bg')};
 82}}
 83QTabBar::tab:hover {{ background: {c('bg_hover')}; }}
 84QGroupBox {{
 85    color: {c('text_muted')}; border: 1px solid {c('border')};
 86    border-radius: 6px; margin-top: 10px;
 87    font-size: 11px; padding: 14px 8px 8px 8px;
 88}}
 89QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 4px; }}
 90QLabel    {{ color: {c('text')}; font-size: 12px; }}
 91QLabel#hint {{ color: {c('text_dim')}; font-size: 10px; }}
 92QLabel#group_title {{ color: {c('text_muted')}; font-size: 10px;
 93                      font-weight: bold; letter-spacing: 1px; }}
 94QLineEdit {{
 95    background: {c('bg_deep')}; color: {c('text')};
 96    border: 1px solid {c('border')}; border-radius: 4px;
 97    padding: 4px 8px; font-size: 12px; min-height: 26px;
 98}}
 99QPushButton#dropdown_btn {{
100    background: {c('bg_surface')}; color: {c('text')};
101    border: 1px solid {c('border')}; border-radius: 6px;
102    padding: 5px 10px 5px 10px; font-size: 12px;
103    text-align: left; min-width: 80px; min-height: 20px;
104}}
105QPushButton#dropdown_btn:hover  {{ background: {c('bg_hover')}; border-color: {c('accent')}; }}
106QPushButton#dropdown_btn:pressed {{ background: {c('bg_selected')}; }}
107QPushButton {{
108    background: {c('bg_selected')}; color: {c('text')};
109    border: none; border-radius: 4px;
110    padding: 5px 12px; font-size: 11px; min-height: 20px;
111}}
112QPushButton:hover {{ background: {c('bg_hover')}; }}
113QPushButton#browse {{ background: {c('bg_selected')}; }}
114QPushButton#browse:hover {{ background: {c('bg_hover')}; }}
115QPushButton#danger {{
116    background: transparent; color: {c('error')};
117    border: 1px solid {c('error')}; border-radius: 4px;
118    padding: 4px 12px; font-size: 11px;
119}}
120QPushButton#danger:hover {{ background: {c('error')}; color: #ffffff; }}
121QSpinBox {{
122    background: {c('bg_deep')}; color: {c('text')};
123    border: 1px solid {c('border')}; border-radius: 4px;
124    padding: 2px 6px; font-size: 12px; min-height: 0;
125}}
126QSpinBox::up-button, QSpinBox::down-button {{
127    background: {c('bg_surface')}; border: none; width: 16px;
128}}
129QScrollArea {{ border: none; background: {c('bg')}; }}
130QScrollArea > QWidget > QWidget {{ background: {c('bg')}; }}
131QPushButton#dialog_btn_primary {{
132    background: {c('accent')}; color: #ffffff; border: none;
133    border-radius: 8px; padding: 8px 24px; font-size: 13px;
134    font-weight: 500; min-width: 90px; min-height: 34px;
135}}
136QPushButton#dialog_btn_primary:hover {{ background: {c('accent_hover')}; }}
137QPushButton#dialog_btn_secondary {{
138    background: {c('bg_surface')}; color: {c('text_muted')};
139    border: 1px solid {c('border')}; border-radius: 8px;
140    padding: 8px 24px; font-size: 13px; min-width: 90px; min-height: 34px;
141}}
142QPushButton#dialog_btn_secondary:hover {{
143    background: {c('bg_hover')}; color: {c('text')};
144}}
145QDialogButtonBox QPushButton[text="Cancelar"]:hover,
146QDialogButtonBox QPushButton[text="Cancel"]:hover {{ background: {c('bg_hover')}; }}
147"""
148
149
150class _ColorRow(QWidget):
151    """Fila de edición de un color: muestra [swatch] [hex input] [pick button]."""
152
153    color_changed = Signal()
154
155    def __init__(self, key: str, base_color: str, override: str | None, parent=None):
156        super().__init__(parent)
157        self._key  = key
158        self._base = base_color
159
160        lay = QHBoxLayout(self)
161        lay.setContentsMargins(0, 0, 0, 0)
162        lay.setSpacing(6)
163
164        # Swatch (cuadrado de color)
165        self._swatch = QPushButton()
166        self._swatch.setFixedSize(28, 22)
167        self._swatch.setToolTip(_('Elegir color'))
168        self._swatch.clicked.connect(self._pick)
169        lay.addWidget(self._swatch)
170
171        # Campo hex
172        self._hex = QLineEdit()
173        self._hex.setFixedWidth(88)
174        self._hex.setMaxLength(9)
175        self._hex.textChanged.connect(self._on_text)
176        lay.addWidget(self._hex)
177
178        # Botón reset
179        btn_reset = QPushButton('↺')
180        btn_reset.setFixedSize(24, 22)
181        btn_reset.setToolTip(_('Restaurar valor por defecto'))
182        btn_reset.clicked.connect(self._reset)
183        lay.addWidget(btn_reset)
184
185        lay.addStretch()
186
187        self.set_color(override if override else base_color)
188
189    # ── API ──────────────────────────────────────────────────────────────────
190
191    def color(self) -> str:
192        """Devuelve el color actual (#rrggbb)."""
193        return self._hex.text().strip() or self._base
194
195    def is_modified(self) -> bool:
196        return self.color().lower() != self._base.lower()
197
198    def set_color(self, hex_color: str) -> None:
199        self._hex.blockSignals(True)
200        self._hex.setText(hex_color)
201        self._hex.blockSignals(False)
202        self._update_swatch(hex_color)
203
204    # ── Slots ─────────────────────────────────────────────────────────────────
205
206    def _pick(self) -> None:
207        current = QColor(self._hex.text().strip())
208        chosen  = QColorDialog.getColor(current, self, _('Elegir color'))
209        if chosen.isValid():
210            self.set_color(chosen.name())
211            self.color_changed.emit()
212
213    def _on_text(self, text: str) -> None:
214        self._update_swatch(text)
215        if QColor(text.strip()).isValid():
216            self.color_changed.emit()
217
218    def _reset(self) -> None:
219        self.set_color(self._base)
220        self.color_changed.emit()
221
222    def _update_swatch(self, hex_color: str) -> None:
223        qc = QColor(hex_color)
224        if qc.isValid():
225            fg = '#ffffff' if qc.lightness() < 128 else '#000000'
226            self._swatch.setStyleSheet(
227                f'QPushButton{{background:{hex_color};border:1px solid {c("border")};'
228                f'border-radius:3px;color:{fg};font-size:9px;}}'
229            )
230        else:
231            self._swatch.setStyleSheet(
232                f'QPushButton{{background:{c("bg_selected")};border:1px solid {c("error")};'
233                f'border-radius:3px;}}'
234            )
235
236
237class _ThemeTab(QWidget):
238    """Pestaña de edición de colores para un tema (dark o light)."""
239
240    any_color_changed = Signal()
241
242    def __init__(self, theme_name: str, parent=None):
243        super().__init__(parent)
244        self._theme_name = theme_name
245        self._rows: dict[str, _ColorRow] = {}
246        self._build_ui()
247
248    def _build_ui(self) -> None:
249        outer = QVBoxLayout(self)
250        outer.setContentsMargins(0, 0, 0, 0)
251
252        scroll = QScrollArea()
253        scroll.setWidgetResizable(True)
254        scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
255        outer.addWidget(scroll)
256
257        container = QWidget()
258        scroll.setWidget(container)
259        vlay = QVBoxLayout(container)
260        vlay.setContentsMargins(12, 8, 12, 8)
261        vlay.setSpacing(8)
262
263        base     = _THEMES[self._theme_name]
264        overrides = get_theme_overrides(self._theme_name)
265
266        for group_label, keys in _COLOR_GROUPS:
267            box = QGroupBox(group_label)
268            grid = QGridLayout(box)
269            grid.setSpacing(4)
270            grid.setColumnStretch(1, 1)
271
272            for row_i, (key, desc) in enumerate(keys):
273                lbl = QLabel(desc)
274                lbl.setFixedWidth(160)
275                grid.addWidget(lbl, row_i, 0)
276
277                color_row = _ColorRow(key, base[key], overrides.get(key))
278                color_row.color_changed.connect(self.any_color_changed)
279                self._rows[key] = color_row
280                grid.addWidget(color_row, row_i, 1)
281
282            vlay.addWidget(box)
283
284        # Botón de reset
285        reset_btn = QPushButton(_('Restaurar todos los colores por defecto'))
286        reset_btn.setObjectName('danger')
287        reset_btn.clicked.connect(self._reset_all)
288        vlay.addWidget(reset_btn)
289        vlay.addStretch()
290
291    def _reset_all(self) -> None:
292        for row in self._rows.values():
293            row.set_color(row._base)
294
295    def get_overrides(self) -> dict[str, str]:
296        """Devuelve solo los colores que difieren del base."""
297        return {k: row.color() for k, row in self._rows.items() if row.is_modified()}
298
299
300class SettingsDialog(QDialog):
301    """Diálogo de configuración con pestañas General / Temas."""
302
303    def __init__(self, parent=None):
304        super().__init__(parent)
305        self.setWindowTitle(_('Configuración'))
306        self.setMinimumSize(620, 620)
307        self.setStyleSheet(_build_style())
308        self._orig_courses    = get_courses_dir()
309        self._orig_lang       = get_language() or 'es'
310        self._orig_theme      = get_theme()
311        self._orig_display    = dict(get_display_settings())
312        self._orig_generation = dict(get_generation_config())
313        self._orig_overrides = {
314            'dark':  dict(get_theme_overrides('dark')),
315            'light': dict(get_theme_overrides('light')),
316        }
317        self._preview_applied = False   # se activa solo si el usuario cambia algo
318        # Debounce: espera 120 ms tras el último cambio antes de aplicar preview
319        self._preview_timer = QTimer(self)
320        self._preview_timer.setSingleShot(True)
321        self._preview_timer.setInterval(120)
322        self._preview_timer.timeout.connect(self._apply_preview)
323        self._build_ui()
324        self._connect_preview()
325
326    # ── Construcción ─────────────────────────────────────────────────────────
327
328    def _build_ui(self) -> None:
329        lay = QVBoxLayout(self)
330        lay.setContentsMargins(16, 12, 16, 12)
331        lay.setSpacing(10)
332
333        self._tabs = QTabWidget()
334        self._tabs.addTab(self._make_general_tab(),    _('⚙  General'))
335        self._tabs.addTab(self._make_generation_tab(), _('▶  Generación'))
336        self._tabs.addTab(self._make_themes_tab(),     _('🎨  Temas'))
337        lay.addWidget(self._tabs)
338
339        hint = QLabel(_('Fichero: {}').format(CONFIG_PATH))
340        hint.setObjectName('hint')
341        lay.addWidget(hint)
342
343        bbox = QDialogButtonBox()
344        btn_save   = bbox.addButton(_('Guardar'), QDialogButtonBox.ButtonRole.AcceptRole)
345        btn_cancel = bbox.addButton(_('Cancelar'), QDialogButtonBox.ButtonRole.RejectRole)
346        btn_save.setObjectName('dialog_btn_primary')
347        btn_cancel.setObjectName('dialog_btn_secondary')
348        bbox.accepted.connect(self._save)
349        bbox.rejected.connect(self.reject)
350        lay.addWidget(bbox)
351
352    # ── Pestaña General ───────────────────────────────────────────────────────
353
354    def _make_general_tab(self) -> QWidget:
355        tab = QWidget()
356        vlay = QVBoxLayout(tab)
357        vlay.setContentsMargins(12, 8, 12, 8)
358        vlay.setSpacing(10)
359        vlay.addWidget(self._make_general_group())
360        vlay.addWidget(self._make_appearance_group())
361        vlay.addWidget(self._make_display_group())
362        vlay.addStretch()
363        return tab
364
365    def _make_general_group(self) -> QGroupBox:
366        box = QGroupBox(_('Cursos'))
367        form = QFormLayout(box)
368        form.setSpacing(8)
369
370        row = QHBoxLayout()
371        row.setSpacing(4)
372        self._dir_edit = QLineEdit()
373        current = get_courses_dir()
374        if current:
375            self._dir_edit.setText(str(current))
376        self._dir_edit.setPlaceholderText(str(Path.home() / 'cursos'))
377        row.addWidget(self._dir_edit)
378        btn = QPushButton(_('Explorar…'))
379        btn.setObjectName('browse')
380        btn.clicked.connect(self._browse)
381        row.addWidget(btn)
382        form.addRow(_('Directorio de cursos:'), row)
383        return box
384
385    def _make_appearance_group(self) -> QGroupBox:
386        box = QGroupBox(_('Apariencia'))
387        form = QFormLayout(box)
388        form.setSpacing(8)
389
390        self._theme_combo = DropdownButton()
391        self._theme_combo.addItem(_('Oscuro (dark)'),      userData='dark')
392        self._theme_combo.addItem(_('Claro (light)'),      userData='light')
393        self._theme_combo.addItem(_('Auto (escritorio)'),  userData='auto')
394        self._theme_combo.setFixedWidth(200)
395        cur = get_theme()
396        for i in range(self._theme_combo.count()):
397            if self._theme_combo.itemData(i) == cur:
398                self._theme_combo.setCurrentIndex(i)
399                break
400        form.addRow(_('Tema activo:'), self._theme_combo)
401
402        self._lang_combo = DropdownButton()
403        for code, label in _LANGUAGES:
404            self._lang_combo.addItem(label, userData=code)
405        self._lang_combo.setFixedWidth(200)
406        current_lang = get_language() or 'es'
407        for i, (code, _lbl) in enumerate(_LANGUAGES):
408            if code == current_lang:
409                self._lang_combo.setCurrentIndex(i)
410                break
411        form.addRow(_('Idioma:'), self._lang_combo)
412
413        hint = QLabel(_('Los cambios de apariencia se aplican al reiniciar.'))
414        hint.setObjectName('hint')
415        form.addRow('', hint)
416        return box
417
418    def _make_display_group(self) -> QGroupBox:
419        box = QGroupBox(_('Pantalla'))
420        form = QFormLayout(box)
421        form.setVerticalSpacing(10)
422        form.setHorizontalSpacing(12)
423        ds = get_display_settings()
424
425        self._scale_edit = QLineEdit(ds['scale_factor'])
426        self._scale_edit.setFixedWidth(200)
427        scale_row = QHBoxLayout()
428        scale_row.setSpacing(10)
429        scale_row.addWidget(self._scale_edit)
430        sh = QLabel(_('1.0 = normal · 1.5 = 150% · 2.0 = 4K'))
431        sh.setObjectName('hint')
432        scale_row.addWidget(sh)
433        scale_row.addStretch()
434        form.addRow(_('Factor de escala:'), scale_row)
435
436        self._highdpi_combo = DropdownButton()
437        self._highdpi_combo.addItems(['1', '0'])
438        self._highdpi_combo.setFixedWidth(200)
439        self._highdpi_combo.setCurrentText(ds['highdpi_scaling'])
440        form.addRow(_('HiDPI scaling:'), self._highdpi_combo)
441
442        self._auto_combo = DropdownButton()
443        self._auto_combo.addItems(['1', '0'])
444        self._auto_combo.setFixedWidth(200)
445        self._auto_combo.setCurrentText(ds['auto_screen_scale'])
446        form.addRow(_('Auto screen scale:'), self._auto_combo)
447
448        self._rounding_combo = DropdownButton()
449        self._rounding_combo.addItems(_ROUNDING_POLICIES)
450        self._rounding_combo.setFixedWidth(200)
451        if ds['scale_rounding_policy'] in _ROUNDING_POLICIES:
452            self._rounding_combo.setCurrentText(ds['scale_rounding_policy'])
453        form.addRow(_('Redondeo:'), self._rounding_combo)
454
455        dh = QLabel(_('Los cambios de pantalla se aplican al reiniciar.'))
456        dh.setObjectName('hint')
457        form.addRow('', dh)
458        return box
459
460    def _connect_preview(self) -> None:
461        """Conecta todos los controles de apariencia al preview en vivo."""
462        self._theme_combo.currentIndexChanged.connect(self._preview_timer.start)
463        self._dark_tab.any_color_changed.connect(self._preview_timer.start)
464        self._light_tab.any_color_changed.connect(self._preview_timer.start)
465
466    def _apply_preview(self) -> None:
467        """Aplica el tema+colores actuales a la interfaz sin guardar en disco."""
468        self._preview_applied = True
469        theme_name = self._theme_combo.currentData()
470        if theme_name == 'auto':
471            from lib.slides_config import _detect_desktop_theme
472            theme_name = _detect_desktop_theme()
473
474        dark_ov  = self._dark_tab.get_overrides()
475        light_ov = self._light_tab.get_overrides()
476        active_ov = dark_ov if theme_name == 'dark' else light_ov
477
478        import gui.theme as _t
479        _t._current = theme_name if theme_name in _t._THEMES else 'dark'
480        _t.set_overrides(active_ov)
481        _t.apply_live()
482        self.setStyleSheet(_build_style())
483
484    # ── Pestaña Generación ────────────────────────────────────────────────────
485
486    def _make_generation_tab(self) -> QWidget:
487        tab = QWidget()
488        vlay = QVBoxLayout(tab)
489        vlay.setContentsMargins(12, 8, 12, 8)
490        vlay.setSpacing(10)
491
492        gen = get_generation_config()
493
494        # ── Grupo: partición de slides ────────────────────────────────────────
495        split_box = QGroupBox(_('Partición de slides'))
496        split_form = QFormLayout(split_box)
497        split_form.setVerticalSpacing(10)
498        split_form.setHorizontalSpacing(12)
499
500        self._gen_code_split = QSpinBox()
501        self._gen_code_split.setRange(5, 200)
502        self._gen_code_split.setValue(gen['code_split_lines'])
503        self._gen_code_split.setFixedWidth(80)
504        row = QHBoxLayout()
505        row.setSpacing(8)
506        row.addWidget(self._gen_code_split)
507        h = QLabel(_('líneas  — slides de código se parten en [N/M] al superarlo'))
508        h.setObjectName('hint')
509        row.addWidget(h)
510        row.addStretch()
511        split_form.addRow(_('Máx. líneas código:'), row)
512
513        self._gen_list_split = QSpinBox()
514        self._gen_list_split.setRange(3, 100)
515        self._gen_list_split.setValue(gen['list_split_items'])
516        self._gen_list_split.setFixedWidth(80)
517        row2 = QHBoxLayout()
518        row2.setSpacing(8)
519        row2.addWidget(self._gen_list_split)
520        h2 = QLabel(_('ítems  — bullets, objetivos, índice, conclusiones'))
521        h2.setObjectName('hint')
522        row2.addWidget(h2)
523        row2.addStretch()
524        split_form.addRow(_('Máx. ítems lista:'), row2)
525
526        self._gen_compare_split = QSpinBox()
527        self._gen_compare_split.setRange(5, 200)
528        self._gen_compare_split.setValue(gen['compare_split_lines'])
529        self._gen_compare_split.setFixedWidth(80)
530        row3 = QHBoxLayout()
531        row3.setSpacing(8)
532        row3.addWidget(self._gen_compare_split)
533        h3 = QLabel(_('líneas por columna  — slides de tipo comparar'))
534        h3.setObjectName('hint')
535        row3.addWidget(h3)
536        row3.addStretch()
537        split_form.addRow(_('Máx. líneas comparar:'), row3)
538
539        vlay.addWidget(split_box)
540
541        # ── Grupo: tabla de fuentes ───────────────────────────────────────────
542        font_box = QGroupBox(_('Tamaño de fuente según líneas de código'))
543        font_vlay = QVBoxLayout(font_box)
544        font_vlay.setSpacing(4)
545
546        sizes = gen['code_font_sizes']
547        grid = QGridLayout()
548        grid.setSpacing(4)
549        grid.setColumnStretch(1, 1)
550        prev = 0
551        for i, (max_n, pt) in enumerate(sizes):
552            if max_n >= 9999:
553                rango = f'> {prev} líneas'
554            elif i == 0:
555                rango = f'≤ {max_n} líneas'
556            else:
557                rango = f'{prev + 1}{max_n} líneas'
558            lbl_rango = QLabel(rango)
559            lbl_rango.setObjectName('hint')
560            lbl_pt = QLabel(f'{pt}pt')
561            grid.addWidget(lbl_rango, i, 0)
562            grid.addWidget(lbl_pt,    i, 1)
563            prev = max_n
564        font_vlay.addLayout(grid)
565
566        hint_font = QLabel(_('Para cambiar la tabla edita generation.code_font_sizes en temario.yaml'))
567        hint_font.setObjectName('hint')
568        hint_font.setWordWrap(True)
569        font_vlay.addWidget(hint_font)
570
571        vlay.addWidget(font_box)
572        vlay.addStretch()
573        return tab
574
575    # ── Pestaña Temas ─────────────────────────────────────────────────────────
576
577    def _make_themes_tab(self) -> QWidget:
578        tab = QWidget()
579        vlay = QVBoxLayout(tab)
580        vlay.setContentsMargins(0, 4, 0, 0)
581
582        sub_tabs = QTabWidget()
583        self._dark_tab  = _ThemeTab('dark')
584        self._light_tab = _ThemeTab('light')
585        sub_tabs.addTab(self._dark_tab,  _('🌙  Oscuro'))
586        sub_tabs.addTab(self._light_tab, _('☀  Claro'))
587        vlay.addWidget(sub_tabs)
588        return tab
589
590    # ── Slots ─────────────────────────────────────────────────────────────────
591
592    def _browse(self) -> None:
593        from PySide6.QtWidgets import QFileDialog
594        start = self._dir_edit.text().strip() or str(Path.home())
595        chosen = QFileDialog.getExistingDirectory(
596            self, _('Seleccionar directorio de cursos'), start,
597            QFileDialog.Option.ShowDirsOnly,
598        )
599        if chosen:
600            self._dir_edit.setText(chosen)
601
602    def _save(self) -> None:
603        # courses_dir
604        text = self._dir_edit.text().strip()
605        if text:
606            path = Path(text).expanduser()
607            if not path.exists():
608                path.mkdir(parents=True, exist_ok=True)
609            save_courses_dir(path)
610
611        # apariencia
612        save_theme(self._theme_combo.currentData())
613        save_language(self._lang_combo.currentData())
614
615        # display
616        scale = self._scale_edit.text().strip() or '1.0'
617        try:
618            float(scale)
619        except ValueError:
620            scale = '1.0'
621        save_display_setting('scale_factor',          scale)
622        save_display_setting('highdpi_scaling',       self._highdpi_combo.currentText())
623        save_display_setting('auto_screen_scale',     self._auto_combo.currentText())
624        save_display_setting('scale_rounding_policy', self._rounding_combo.currentText())
625
626        # generación
627        save_generation_param('code_split_lines',    self._gen_code_split.value())
628        save_generation_param('list_split_items',    self._gen_list_split.value())
629        save_generation_param('compare_split_lines', self._gen_compare_split.value())
630
631        # overrides de color
632        dark_ov  = self._dark_tab.get_overrides()
633        light_ov = self._light_tab.get_overrides()
634        save_theme_overrides(dark_ov,  'dark')
635        save_theme_overrides(light_ov, 'light')
636
637        # Asegurar que el estado en memoria refleja lo guardado
638        self._preview_timer.stop()
639        self._apply_preview()
640
641        self.accept()
642
643    def reject(self) -> None:
644        """Cancela: restaura el tema original solo si se aplicó algún preview."""
645        if self._preview_applied:
646            import gui.theme as _t
647            _t._current = self._orig_theme if self._orig_theme in _t._THEMES else 'dark'
648            _t.set_overrides(self._orig_overrides.get(_t._current, {}))
649            _t.apply_live()
650        super().reject()
651
652    # ── API pública ────────────────────────────────────────────────────────────
653
654    def courses_dir_was_changed(self) -> bool:
655        return get_courses_dir() != self._orig_courses
656
657    def language_was_changed(self) -> bool:
658        return (get_language() or 'es') != self._orig_lang
659
660    def theme_was_changed(self) -> bool:
661        return get_theme() != self._orig_theme
662
663    def display_settings_was_changed(self) -> bool:
664        return dict(get_display_settings()) != self._orig_display
665
666    def generation_was_changed(self) -> bool:
667        cur = get_generation_config()
668        return (
669            cur['code_split_lines']    != self._orig_generation['code_split_lines'] or
670            cur['list_split_items']    != self._orig_generation['list_split_items'] or
671            cur['compare_split_lines'] != self._orig_generation['compare_split_lines']
672        )
class SettingsDialog(PySide6.QtWidgets.QDialog):
301class SettingsDialog(QDialog):
302    """Diálogo de configuración con pestañas General / Temas."""
303
304    def __init__(self, parent=None):
305        super().__init__(parent)
306        self.setWindowTitle(_('Configuración'))
307        self.setMinimumSize(620, 620)
308        self.setStyleSheet(_build_style())
309        self._orig_courses    = get_courses_dir()
310        self._orig_lang       = get_language() or 'es'
311        self._orig_theme      = get_theme()
312        self._orig_display    = dict(get_display_settings())
313        self._orig_generation = dict(get_generation_config())
314        self._orig_overrides = {
315            'dark':  dict(get_theme_overrides('dark')),
316            'light': dict(get_theme_overrides('light')),
317        }
318        self._preview_applied = False   # se activa solo si el usuario cambia algo
319        # Debounce: espera 120 ms tras el último cambio antes de aplicar preview
320        self._preview_timer = QTimer(self)
321        self._preview_timer.setSingleShot(True)
322        self._preview_timer.setInterval(120)
323        self._preview_timer.timeout.connect(self._apply_preview)
324        self._build_ui()
325        self._connect_preview()
326
327    # ── Construcción ─────────────────────────────────────────────────────────
328
329    def _build_ui(self) -> None:
330        lay = QVBoxLayout(self)
331        lay.setContentsMargins(16, 12, 16, 12)
332        lay.setSpacing(10)
333
334        self._tabs = QTabWidget()
335        self._tabs.addTab(self._make_general_tab(),    _('⚙  General'))
336        self._tabs.addTab(self._make_generation_tab(), _('▶  Generación'))
337        self._tabs.addTab(self._make_themes_tab(),     _('🎨  Temas'))
338        lay.addWidget(self._tabs)
339
340        hint = QLabel(_('Fichero: {}').format(CONFIG_PATH))
341        hint.setObjectName('hint')
342        lay.addWidget(hint)
343
344        bbox = QDialogButtonBox()
345        btn_save   = bbox.addButton(_('Guardar'), QDialogButtonBox.ButtonRole.AcceptRole)
346        btn_cancel = bbox.addButton(_('Cancelar'), QDialogButtonBox.ButtonRole.RejectRole)
347        btn_save.setObjectName('dialog_btn_primary')
348        btn_cancel.setObjectName('dialog_btn_secondary')
349        bbox.accepted.connect(self._save)
350        bbox.rejected.connect(self.reject)
351        lay.addWidget(bbox)
352
353    # ── Pestaña General ───────────────────────────────────────────────────────
354
355    def _make_general_tab(self) -> QWidget:
356        tab = QWidget()
357        vlay = QVBoxLayout(tab)
358        vlay.setContentsMargins(12, 8, 12, 8)
359        vlay.setSpacing(10)
360        vlay.addWidget(self._make_general_group())
361        vlay.addWidget(self._make_appearance_group())
362        vlay.addWidget(self._make_display_group())
363        vlay.addStretch()
364        return tab
365
366    def _make_general_group(self) -> QGroupBox:
367        box = QGroupBox(_('Cursos'))
368        form = QFormLayout(box)
369        form.setSpacing(8)
370
371        row = QHBoxLayout()
372        row.setSpacing(4)
373        self._dir_edit = QLineEdit()
374        current = get_courses_dir()
375        if current:
376            self._dir_edit.setText(str(current))
377        self._dir_edit.setPlaceholderText(str(Path.home() / 'cursos'))
378        row.addWidget(self._dir_edit)
379        btn = QPushButton(_('Explorar…'))
380        btn.setObjectName('browse')
381        btn.clicked.connect(self._browse)
382        row.addWidget(btn)
383        form.addRow(_('Directorio de cursos:'), row)
384        return box
385
386    def _make_appearance_group(self) -> QGroupBox:
387        box = QGroupBox(_('Apariencia'))
388        form = QFormLayout(box)
389        form.setSpacing(8)
390
391        self._theme_combo = DropdownButton()
392        self._theme_combo.addItem(_('Oscuro (dark)'),      userData='dark')
393        self._theme_combo.addItem(_('Claro (light)'),      userData='light')
394        self._theme_combo.addItem(_('Auto (escritorio)'),  userData='auto')
395        self._theme_combo.setFixedWidth(200)
396        cur = get_theme()
397        for i in range(self._theme_combo.count()):
398            if self._theme_combo.itemData(i) == cur:
399                self._theme_combo.setCurrentIndex(i)
400                break
401        form.addRow(_('Tema activo:'), self._theme_combo)
402
403        self._lang_combo = DropdownButton()
404        for code, label in _LANGUAGES:
405            self._lang_combo.addItem(label, userData=code)
406        self._lang_combo.setFixedWidth(200)
407        current_lang = get_language() or 'es'
408        for i, (code, _lbl) in enumerate(_LANGUAGES):
409            if code == current_lang:
410                self._lang_combo.setCurrentIndex(i)
411                break
412        form.addRow(_('Idioma:'), self._lang_combo)
413
414        hint = QLabel(_('Los cambios de apariencia se aplican al reiniciar.'))
415        hint.setObjectName('hint')
416        form.addRow('', hint)
417        return box
418
419    def _make_display_group(self) -> QGroupBox:
420        box = QGroupBox(_('Pantalla'))
421        form = QFormLayout(box)
422        form.setVerticalSpacing(10)
423        form.setHorizontalSpacing(12)
424        ds = get_display_settings()
425
426        self._scale_edit = QLineEdit(ds['scale_factor'])
427        self._scale_edit.setFixedWidth(200)
428        scale_row = QHBoxLayout()
429        scale_row.setSpacing(10)
430        scale_row.addWidget(self._scale_edit)
431        sh = QLabel(_('1.0 = normal · 1.5 = 150% · 2.0 = 4K'))
432        sh.setObjectName('hint')
433        scale_row.addWidget(sh)
434        scale_row.addStretch()
435        form.addRow(_('Factor de escala:'), scale_row)
436
437        self._highdpi_combo = DropdownButton()
438        self._highdpi_combo.addItems(['1', '0'])
439        self._highdpi_combo.setFixedWidth(200)
440        self._highdpi_combo.setCurrentText(ds['highdpi_scaling'])
441        form.addRow(_('HiDPI scaling:'), self._highdpi_combo)
442
443        self._auto_combo = DropdownButton()
444        self._auto_combo.addItems(['1', '0'])
445        self._auto_combo.setFixedWidth(200)
446        self._auto_combo.setCurrentText(ds['auto_screen_scale'])
447        form.addRow(_('Auto screen scale:'), self._auto_combo)
448
449        self._rounding_combo = DropdownButton()
450        self._rounding_combo.addItems(_ROUNDING_POLICIES)
451        self._rounding_combo.setFixedWidth(200)
452        if ds['scale_rounding_policy'] in _ROUNDING_POLICIES:
453            self._rounding_combo.setCurrentText(ds['scale_rounding_policy'])
454        form.addRow(_('Redondeo:'), self._rounding_combo)
455
456        dh = QLabel(_('Los cambios de pantalla se aplican al reiniciar.'))
457        dh.setObjectName('hint')
458        form.addRow('', dh)
459        return box
460
461    def _connect_preview(self) -> None:
462        """Conecta todos los controles de apariencia al preview en vivo."""
463        self._theme_combo.currentIndexChanged.connect(self._preview_timer.start)
464        self._dark_tab.any_color_changed.connect(self._preview_timer.start)
465        self._light_tab.any_color_changed.connect(self._preview_timer.start)
466
467    def _apply_preview(self) -> None:
468        """Aplica el tema+colores actuales a la interfaz sin guardar en disco."""
469        self._preview_applied = True
470        theme_name = self._theme_combo.currentData()
471        if theme_name == 'auto':
472            from lib.slides_config import _detect_desktop_theme
473            theme_name = _detect_desktop_theme()
474
475        dark_ov  = self._dark_tab.get_overrides()
476        light_ov = self._light_tab.get_overrides()
477        active_ov = dark_ov if theme_name == 'dark' else light_ov
478
479        import gui.theme as _t
480        _t._current = theme_name if theme_name in _t._THEMES else 'dark'
481        _t.set_overrides(active_ov)
482        _t.apply_live()
483        self.setStyleSheet(_build_style())
484
485    # ── Pestaña Generación ────────────────────────────────────────────────────
486
487    def _make_generation_tab(self) -> QWidget:
488        tab = QWidget()
489        vlay = QVBoxLayout(tab)
490        vlay.setContentsMargins(12, 8, 12, 8)
491        vlay.setSpacing(10)
492
493        gen = get_generation_config()
494
495        # ── Grupo: partición de slides ────────────────────────────────────────
496        split_box = QGroupBox(_('Partición de slides'))
497        split_form = QFormLayout(split_box)
498        split_form.setVerticalSpacing(10)
499        split_form.setHorizontalSpacing(12)
500
501        self._gen_code_split = QSpinBox()
502        self._gen_code_split.setRange(5, 200)
503        self._gen_code_split.setValue(gen['code_split_lines'])
504        self._gen_code_split.setFixedWidth(80)
505        row = QHBoxLayout()
506        row.setSpacing(8)
507        row.addWidget(self._gen_code_split)
508        h = QLabel(_('líneas  — slides de código se parten en [N/M] al superarlo'))
509        h.setObjectName('hint')
510        row.addWidget(h)
511        row.addStretch()
512        split_form.addRow(_('Máx. líneas código:'), row)
513
514        self._gen_list_split = QSpinBox()
515        self._gen_list_split.setRange(3, 100)
516        self._gen_list_split.setValue(gen['list_split_items'])
517        self._gen_list_split.setFixedWidth(80)
518        row2 = QHBoxLayout()
519        row2.setSpacing(8)
520        row2.addWidget(self._gen_list_split)
521        h2 = QLabel(_('ítems  — bullets, objetivos, índice, conclusiones'))
522        h2.setObjectName('hint')
523        row2.addWidget(h2)
524        row2.addStretch()
525        split_form.addRow(_('Máx. ítems lista:'), row2)
526
527        self._gen_compare_split = QSpinBox()
528        self._gen_compare_split.setRange(5, 200)
529        self._gen_compare_split.setValue(gen['compare_split_lines'])
530        self._gen_compare_split.setFixedWidth(80)
531        row3 = QHBoxLayout()
532        row3.setSpacing(8)
533        row3.addWidget(self._gen_compare_split)
534        h3 = QLabel(_('líneas por columna  — slides de tipo comparar'))
535        h3.setObjectName('hint')
536        row3.addWidget(h3)
537        row3.addStretch()
538        split_form.addRow(_('Máx. líneas comparar:'), row3)
539
540        vlay.addWidget(split_box)
541
542        # ── Grupo: tabla de fuentes ───────────────────────────────────────────
543        font_box = QGroupBox(_('Tamaño de fuente según líneas de código'))
544        font_vlay = QVBoxLayout(font_box)
545        font_vlay.setSpacing(4)
546
547        sizes = gen['code_font_sizes']
548        grid = QGridLayout()
549        grid.setSpacing(4)
550        grid.setColumnStretch(1, 1)
551        prev = 0
552        for i, (max_n, pt) in enumerate(sizes):
553            if max_n >= 9999:
554                rango = f'> {prev} líneas'
555            elif i == 0:
556                rango = f'≤ {max_n} líneas'
557            else:
558                rango = f'{prev + 1}{max_n} líneas'
559            lbl_rango = QLabel(rango)
560            lbl_rango.setObjectName('hint')
561            lbl_pt = QLabel(f'{pt}pt')
562            grid.addWidget(lbl_rango, i, 0)
563            grid.addWidget(lbl_pt,    i, 1)
564            prev = max_n
565        font_vlay.addLayout(grid)
566
567        hint_font = QLabel(_('Para cambiar la tabla edita generation.code_font_sizes en temario.yaml'))
568        hint_font.setObjectName('hint')
569        hint_font.setWordWrap(True)
570        font_vlay.addWidget(hint_font)
571
572        vlay.addWidget(font_box)
573        vlay.addStretch()
574        return tab
575
576    # ── Pestaña Temas ─────────────────────────────────────────────────────────
577
578    def _make_themes_tab(self) -> QWidget:
579        tab = QWidget()
580        vlay = QVBoxLayout(tab)
581        vlay.setContentsMargins(0, 4, 0, 0)
582
583        sub_tabs = QTabWidget()
584        self._dark_tab  = _ThemeTab('dark')
585        self._light_tab = _ThemeTab('light')
586        sub_tabs.addTab(self._dark_tab,  _('🌙  Oscuro'))
587        sub_tabs.addTab(self._light_tab, _('☀  Claro'))
588        vlay.addWidget(sub_tabs)
589        return tab
590
591    # ── Slots ─────────────────────────────────────────────────────────────────
592
593    def _browse(self) -> None:
594        from PySide6.QtWidgets import QFileDialog
595        start = self._dir_edit.text().strip() or str(Path.home())
596        chosen = QFileDialog.getExistingDirectory(
597            self, _('Seleccionar directorio de cursos'), start,
598            QFileDialog.Option.ShowDirsOnly,
599        )
600        if chosen:
601            self._dir_edit.setText(chosen)
602
603    def _save(self) -> None:
604        # courses_dir
605        text = self._dir_edit.text().strip()
606        if text:
607            path = Path(text).expanduser()
608            if not path.exists():
609                path.mkdir(parents=True, exist_ok=True)
610            save_courses_dir(path)
611
612        # apariencia
613        save_theme(self._theme_combo.currentData())
614        save_language(self._lang_combo.currentData())
615
616        # display
617        scale = self._scale_edit.text().strip() or '1.0'
618        try:
619            float(scale)
620        except ValueError:
621            scale = '1.0'
622        save_display_setting('scale_factor',          scale)
623        save_display_setting('highdpi_scaling',       self._highdpi_combo.currentText())
624        save_display_setting('auto_screen_scale',     self._auto_combo.currentText())
625        save_display_setting('scale_rounding_policy', self._rounding_combo.currentText())
626
627        # generación
628        save_generation_param('code_split_lines',    self._gen_code_split.value())
629        save_generation_param('list_split_items',    self._gen_list_split.value())
630        save_generation_param('compare_split_lines', self._gen_compare_split.value())
631
632        # overrides de color
633        dark_ov  = self._dark_tab.get_overrides()
634        light_ov = self._light_tab.get_overrides()
635        save_theme_overrides(dark_ov,  'dark')
636        save_theme_overrides(light_ov, 'light')
637
638        # Asegurar que el estado en memoria refleja lo guardado
639        self._preview_timer.stop()
640        self._apply_preview()
641
642        self.accept()
643
644    def reject(self) -> None:
645        """Cancela: restaura el tema original solo si se aplicó algún preview."""
646        if self._preview_applied:
647            import gui.theme as _t
648            _t._current = self._orig_theme if self._orig_theme in _t._THEMES else 'dark'
649            _t.set_overrides(self._orig_overrides.get(_t._current, {}))
650            _t.apply_live()
651        super().reject()
652
653    # ── API pública ────────────────────────────────────────────────────────────
654
655    def courses_dir_was_changed(self) -> bool:
656        return get_courses_dir() != self._orig_courses
657
658    def language_was_changed(self) -> bool:
659        return (get_language() or 'es') != self._orig_lang
660
661    def theme_was_changed(self) -> bool:
662        return get_theme() != self._orig_theme
663
664    def display_settings_was_changed(self) -> bool:
665        return dict(get_display_settings()) != self._orig_display
666
667    def generation_was_changed(self) -> bool:
668        cur = get_generation_config()
669        return (
670            cur['code_split_lines']    != self._orig_generation['code_split_lines'] or
671            cur['list_split_items']    != self._orig_generation['list_split_items'] or
672            cur['compare_split_lines'] != self._orig_generation['compare_split_lines']
673        )

Diálogo de configuración con pestañas General / Temas.

SettingsDialog(parent=None)
304    def __init__(self, parent=None):
305        super().__init__(parent)
306        self.setWindowTitle(_('Configuración'))
307        self.setMinimumSize(620, 620)
308        self.setStyleSheet(_build_style())
309        self._orig_courses    = get_courses_dir()
310        self._orig_lang       = get_language() or 'es'
311        self._orig_theme      = get_theme()
312        self._orig_display    = dict(get_display_settings())
313        self._orig_generation = dict(get_generation_config())
314        self._orig_overrides = {
315            'dark':  dict(get_theme_overrides('dark')),
316            'light': dict(get_theme_overrides('light')),
317        }
318        self._preview_applied = False   # se activa solo si el usuario cambia algo
319        # Debounce: espera 120 ms tras el último cambio antes de aplicar preview
320        self._preview_timer = QTimer(self)
321        self._preview_timer.setSingleShot(True)
322        self._preview_timer.setInterval(120)
323        self._preview_timer.timeout.connect(self._apply_preview)
324        self._build_ui()
325        self._connect_preview()
def reject(self) -> None:
644    def reject(self) -> None:
645        """Cancela: restaura el tema original solo si se aplicó algún preview."""
646        if self._preview_applied:
647            import gui.theme as _t
648            _t._current = self._orig_theme if self._orig_theme in _t._THEMES else 'dark'
649            _t.set_overrides(self._orig_overrides.get(_t._current, {}))
650            _t.apply_live()
651        super().reject()

Cancela: restaura el tema original solo si se aplicó algún preview.

def courses_dir_was_changed(self) -> bool:
655    def courses_dir_was_changed(self) -> bool:
656        return get_courses_dir() != self._orig_courses
def language_was_changed(self) -> bool:
658    def language_was_changed(self) -> bool:
659        return (get_language() or 'es') != self._orig_lang
def theme_was_changed(self) -> bool:
661    def theme_was_changed(self) -> bool:
662        return get_theme() != self._orig_theme
def display_settings_was_changed(self) -> bool:
664    def display_settings_was_changed(self) -> bool:
665        return dict(get_display_settings()) != self._orig_display
def generation_was_changed(self) -> bool:
667    def generation_was_changed(self) -> bool:
668        cur = get_generation_config()
669        return (
670            cur['code_split_lines']    != self._orig_generation['code_split_lines'] or
671            cur['list_split_items']    != self._orig_generation['list_split_items'] or
672            cur['compare_split_lines'] != self._orig_generation['compare_split_lines']
673        )
staticMetaObject = PySide6.QtCore.QMetaObject("SettingsDialog" inherits "QDialog": )