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
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 )