gui.dialogs.settings_dialog

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

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

SettingsDialog(parent=None)
319    def __init__(self, parent=None):
320        super().__init__(parent)
321        self.setWindowTitle(_('Configuración'))
322        self.setMinimumSize(620, 620)
323        self.setStyleSheet(_build_style())
324        self._orig_courses    = get_courses_dir()
325        self._orig_lang       = get_language() or 'es'
326        self._orig_theme      = get_theme()
327        self._orig_display    = dict(get_display_settings())
328        self._orig_generation = dict(get_generation_config())
329        self._orig_overrides = {
330            'dark':  dict(get_theme_overrides('dark')),
331            'light': dict(get_theme_overrides('light')),
332        }
333        self._preview_applied = False   # se activa solo si el usuario cambia algo
334        # Debounce: espera 120 ms tras el último cambio antes de aplicar preview
335        self._preview_timer = QTimer(self)
336        self._preview_timer.setSingleShot(True)
337        self._preview_timer.setInterval(120)
338        self._preview_timer.timeout.connect(self._apply_preview)
339        self._build_ui()
340        self._connect_preview()
def reject(self) -> None:
659    def reject(self) -> None:
660        """Cancela: restaura el tema original solo si se aplicó algún preview."""
661        if self._preview_applied:
662            import gui.theme as _t
663            _t._current = self._orig_theme if self._orig_theme in _t._THEMES else 'dark'
664            _t.set_overrides(self._orig_overrides.get(_t._current, {}))
665            _t.apply_live()
666        super().reject()

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

def courses_dir_was_changed(self) -> bool:
670    def courses_dir_was_changed(self) -> bool:
671        return get_courses_dir() != self._orig_courses
def language_was_changed(self) -> bool:
673    def language_was_changed(self) -> bool:
674        return (get_language() or 'es') != self._orig_lang
def theme_was_changed(self) -> bool:
676    def theme_was_changed(self) -> bool:
677        return get_theme() != self._orig_theme
def display_settings_was_changed(self) -> bool:
679    def display_settings_was_changed(self) -> bool:
680        return dict(get_display_settings()) != self._orig_display
def generation_was_changed(self) -> bool:
682    def generation_was_changed(self) -> bool:
683        cur = get_generation_config()
684        return (
685            cur['code_split_lines']    != self._orig_generation['code_split_lines'] or
686            cur['list_split_items']    != self._orig_generation['list_split_items'] or
687            cur['compare_split_lines'] != self._orig_generation['compare_split_lines']
688        )
staticMetaObject = PySide6.QtCore.QMetaObject("SettingsDialog" inherits "QDialog": )