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