gui.main_window
1import sys 2import yaml 3 4from PySide6.QtWidgets import ( 5 QMainWindow, QWidget, QVBoxLayout, QSplitter, QToolBar, QStatusBar, 6 QLabel, QFileDialog, QMessageBox, 7) 8from PySide6.QtCore import Qt, QSettings, QSize, QThread 9from PySide6.QtGui import QAction, QKeySequence, QResizeEvent, QShowEvent 10 11from gui.panels.sidebar import Sidebar 12from gui.panels.log_panel import LogPanel 13from gui.panels.slide_navigator import NODE_PORTADA, NODE_OBJETIVOS, NODE_CONCLUSION, NODE_REFERENCIAS 14from gui.panels.yaml_editor import YamlEditorPanel 15from gui.utils.path_setup import get_courses, COURSES_DIR, get_courses_dir 16from lib.i18n import _ 17from gui.workers.build_worker import BuildWorker 18 19_LOG_HEADER_H = 36 # altura mínima del panel log cuando está colapsado 20 21 22class MainWindow(QMainWindow): 23 def __init__(self): 24 super().__init__() 25 self.setWindowTitle(_('Temario — Generador de Presentaciones')) 26 self.resize(1280, 760) 27 self._settings = QSettings('temario', 'gui') 28 self._thread: QThread | None = None 29 self._worker: BuildWorker | None = None 30 self._watch_thread: QThread | None = None 31 self._watch_worker: BuildWorker | None = None 32 self._last_build_course: 'Path | None' = None 33 self._last_new_course_name: str | None = None 34 self._log_expand_size: int = 200 35 self._sidebar_expand_size: int = 240 36 self._compact_toolbar: bool = False 37 self._tb_labels: list = [] # [(action, full_text, compact_text)] 38 self._compact_threshold: int = 0 # medido en showEvent desde toolbar.sizeHint() 39 self._was_maximized: bool = False 40 41 self._build_ui() 42 self._build_toolbar() 43 self._connect_signals() 44 self._restore_geometry() 45 46 # ── Construcción de la UI ───────────────────────────────────────────────── 47 48 def _build_ui(self) -> None: 49 central = QWidget() 50 vlay = QVBoxLayout(central) 51 vlay.setContentsMargins(0, 0, 0, 0) 52 vlay.setSpacing(0) 53 54 self._splitter_main = QSplitter(Qt.Orientation.Horizontal) 55 vlay.addWidget(self._splitter_main) 56 57 self.setCentralWidget(central) 58 59 # Sidebar izquierda: curso + unidades + slides 60 self._sidebar = Sidebar() 61 self._splitter_main.addWidget(self._sidebar) 62 63 # Área derecha: editor (arriba) + log (abajo) 64 self._splitter_right = QSplitter(Qt.Orientation.Vertical) 65 self._splitter_main.addWidget(self._splitter_right) 66 67 self._editor = YamlEditorPanel() 68 self._splitter_right.addWidget(self._editor) 69 70 self.log = LogPanel() 71 self._splitter_right.addWidget(self.log) 72 73 self._splitter_main.setSizes([240, 1040]) 74 self._splitter_main.setCollapsible(0, True) 75 self._splitter_right.setSizes([560, 200]) 76 self._splitter_right.setCollapsible(1, True) 77 78 self._status_slide = QLabel('') 79 sb = QStatusBar() 80 sb.addPermanentWidget(self._status_slide) 81 self.setStatusBar(sb) 82 83 def _build_toolbar(self) -> None: 84 self._toolbar = QToolBar('Principal') 85 tb = self._toolbar 86 tb.setMovable(False) 87 tb.setIconSize(QSize(16, 16)) 88 self.addToolBar(tb) 89 90 def _reg(a: QAction, full: str, compact: str) -> QAction: 91 self._tb_labels.append((a, full, compact)) 92 return a 93 94 # ── Sidebar toggle ──────────────────────────────────────────────────── 95 self._act_sidebar_toggle = _reg( 96 QAction(_('◧ Sidebar'), self), _('◧ Sidebar'), '◧', 97 ) 98 self._act_sidebar_toggle.setShortcut(QKeySequence('Ctrl+\\')) 99 self._act_sidebar_toggle.setToolTip(_('Mostrar / ocultar panel lateral (Ctrl+\\)')) 100 self._act_sidebar_toggle.triggered.connect(self._toggle_sidebar) 101 tb.addAction(self._act_sidebar_toggle) 102 103 tb.addSeparator() 104 105 # ── Flujo de creación: 1. Nuevo curso → 2. Import ODP ───────────────── 106 self._act_new_course = _reg( 107 QAction(_('+ Nuevo curso'), self), _('+ Nuevo curso'), '+', 108 ) 109 self._act_new_course.setShortcut(QKeySequence('Ctrl+Shift+N')) 110 self._act_new_course.setToolTip(_('Crear un nuevo curso (Ctrl+Shift+N)')) 111 self._act_new_course.triggered.connect(self._on_new_course) 112 tb.addAction(self._act_new_course) 113 114 self._act_import = _reg( 115 QAction(_('⬆ Import ODP'), self), _('⬆ Import ODP'), '⬆', 116 ) 117 self._act_import.setToolTip(_('Importar plantilla ODP → genera template.yaml')) 118 self._act_import.triggered.connect(self._on_import_odp) 119 tb.addAction(self._act_import) 120 121 tb.addSeparator() 122 123 # ── Generación ──────────────────────────────────────────────────────── 124 self._act_build = _reg( 125 QAction(_('▶ Build'), self), _('▶ Build'), '▶', 126 ) 127 self._act_build.setShortcut(QKeySequence('Ctrl+B')) 128 self._act_build.setToolTip(_('Generar ODP+PDF del YAML activo (Ctrl+B)')) 129 self._act_build.triggered.connect(self._on_build) 130 tb.addAction(self._act_build) 131 _btn = tb.widgetForAction(self._act_build) 132 if _btn: 133 _btn.setObjectName('toolbar_primary') 134 _btn.style().unpolish(_btn) 135 _btn.style().polish(_btn) 136 137 self._act_build_all = _reg( 138 QAction(_('▶▶ Build todo'), self), _('▶▶ Build todo'), '▶▶', 139 ) 140 self._act_build_all.setToolTip(_('Generar todos los YAMLs del curso activo')) 141 self._act_build_all.triggered.connect(self._on_build_all) 142 tb.addAction(self._act_build_all) 143 144 self._act_watch = _reg( 145 QAction(_('👁 Watch'), self), _('👁 Watch'), '👁', 146 ) 147 self._act_watch.setShortcut(QKeySequence('Ctrl+W')) 148 self._act_watch.setToolTip(_('Regenerar automáticamente al guardar cambios (Ctrl+W)')) 149 self._act_watch.setCheckable(True) 150 self._act_watch.triggered.connect(self._on_watch_toggle) 151 tb.addAction(self._act_watch) 152 153 tb.addSeparator() 154 155 # ── Gestión ─────────────────────────────────────────────────────────── 156 self._act_new_pres = _reg( 157 QAction(_('+ Nueva presentación'), self), _('+ Nueva presentación'), '+', 158 ) 159 self._act_new_pres.setShortcut(QKeySequence('Ctrl+N')) 160 self._act_new_pres.setToolTip(_('Crear nueva presentación YAML en el curso activo (Ctrl+N)')) 161 self._act_new_pres.triggered.connect(self._on_new_presentation) 162 tb.addAction(self._act_new_pres) 163 164 self._act_clean = _reg( 165 QAction(_('🧹 Clean'), self), _('🧹 Clean'), '🧹', 166 ) 167 self._act_clean.setToolTip(_('Borrar ficheros generados')) 168 self._act_clean.triggered.connect(self._on_clean) 169 tb.addAction(self._act_clean) 170 _btn = tb.widgetForAction(self._act_clean) 171 if _btn: 172 _btn.setObjectName('toolbar_destructive') 173 _btn.style().unpolish(_btn) 174 _btn.style().polish(_btn) 175 176 tb.addSeparator() 177 178 # ── Aplicación ──────────────────────────────────────────────────────── 179 self._act_courses_dir = _reg( 180 QAction(_('⚙ Ajustes'), self), _('⚙ Ajustes'), '⚙', 181 ) 182 self._act_courses_dir.setToolTip(_('Configuración de la aplicación')) 183 self._act_courses_dir.triggered.connect(self._on_settings) 184 tb.addAction(self._act_courses_dir) 185 186 self._act_about = _reg( 187 QAction(_('○ Acerca de'), self), _('○ Acerca de'), '○', 188 ) 189 self._act_about.setToolTip(_('Información sobre la aplicación')) 190 self._act_about.triggered.connect(self._on_about) 191 tb.addAction(self._act_about) 192 193 def _connect_signals(self) -> None: 194 self._sidebar.unit_changed.connect(self._on_unit_changed) 195 self._sidebar.course_changed.connect(self._on_course_changed) 196 self._sidebar.new_course_requested.connect(self._on_new_course) 197 self._sidebar.new_presentation_requested.connect(self._on_new_presentation) 198 self._sidebar.slide_selected.connect(self._on_slide_selected) 199 self._editor.saved.connect(self._on_yaml_saved) 200 self.log.collapse_toggled.connect(self._on_log_collapse_toggled) 201 202 yaml_path = self._sidebar.current_yaml() 203 if yaml_path: 204 self._on_unit_changed(yaml_path) 205 206 # ── Handlers de señales ─────────────────────────────────────────────────── 207 208 def _on_unit_changed(self, yaml_path) -> None: 209 try: 210 data = yaml.safe_load(yaml_path.read_text(encoding='utf-8')) or {} 211 self._sidebar.rebuild_slides(data) 212 self._editor.open_file(yaml_path) 213 self._status_slide.setText('') 214 except Exception as exc: 215 self.log.append_line(_('Error cargando {}: {}').format(yaml_path.name, exc), 'error') 216 217 def _on_course_changed(self, course_dir) -> None: 218 self._sidebar.rebuild_slides({}) 219 self._status_slide.setText('') 220 221 _FIXED_FIELDS = { 222 NODE_PORTADA: ('meta', 'Portada'), 223 NODE_OBJETIVOS: ('objetivos', 'Objetivos'), 224 NODE_CONCLUSION: ('conclusion', 'Conclusión'), 225 NODE_REFERENCIAS: ('referencias', 'Referencias'), 226 } 227 228 def _on_slide_selected(self, sec_idx: int, slide_idx: int) -> None: 229 if sec_idx in self._FIXED_FIELDS: 230 field, label = self._FIXED_FIELDS[sec_idx] 231 self._editor.scroll_to_field(field) 232 self._status_slide.setText(label) 233 return 234 if sec_idx < 0: 235 return 236 self._editor.scroll_to_slide(sec_idx, slide_idx) 237 self._status_slide.setText(f'§ {sec_idx + 1} · {slide_idx + 1}') 238 239 def _on_yaml_saved(self, yaml_path) -> None: 240 try: 241 data = yaml.safe_load(yaml_path.read_text(encoding='utf-8')) or {} 242 self._sidebar.rebuild_slides(data) 243 except Exception as exc: 244 self.log.append_line(_('Error al releer {}: {}').format(yaml_path.name, exc), 'error') 245 246 # ── Acciones del toolbar ────────────────────────────────────────────────── 247 248 def _on_build(self) -> None: 249 yaml_path = self._sidebar.current_yaml() 250 if yaml_path is None: 251 self.log.append_line(_('No hay unidad activa.'), 'error') 252 return 253 if yaml_path.parent.name != 'content': 254 self.log.append_line(_('El fichero seleccionado no es una presentación generatable.'), 'info') 255 return 256 self._run_worker('build', yamls=[yaml_path]) 257 258 def _on_build_all(self) -> None: 259 course_dir = self._sidebar.current_course() 260 if course_dir is None: 261 self.log.append_line(_('No hay curso activo.'), 'error') 262 return 263 from gui.utils.path_setup import get_yamls 264 yamls = get_yamls(course_dir) 265 if not yamls: 266 self.log.append_line(_('No se encontraron YAMLs en {}.').format(course_dir.name), 'error') 267 return 268 self._run_worker('build', yamls=yamls) 269 270 def _on_clean(self) -> None: 271 from gui.dialogs.clean_dialog import CleanDialog 272 courses = get_courses() 273 dlg = CleanDialog(courses, self._sidebar.current_course(), parent=self) 274 if dlg.exec() != dlg.DialogCode.Accepted: 275 return 276 selected = dlg.selected_courses() 277 if not selected: 278 self.log.append_line('No se seleccionó ningún curso.', 'info') 279 return 280 self._run_worker('clean', course_dirs=selected, tipo=dlg.selected_tipo()) 281 282 def _on_new_course(self) -> None: 283 from gui.dialogs.new_course_dialog import NewCourseDialog 284 dlg = NewCourseDialog(parent=self) 285 if dlg.exec() != dlg.DialogCode.Accepted: 286 return 287 name = dlg.course_name() 288 self._run_worker('new', name=name) 289 290 def _on_new_presentation(self) -> None: 291 course_dir = self._sidebar.current_course() 292 if course_dir is None: 293 self.log.append_line(_('No hay curso activo.'), 'error') 294 return 295 from gui.dialogs.new_presentation_dialog import NewPresentationDialog 296 dlg = NewPresentationDialog(course_dir, parent=self) 297 if dlg.exec() != dlg.DialogCode.Accepted: 298 return 299 new_yaml = dlg.create_yaml() 300 self._sidebar.reload_tabs(course_dir) 301 if new_yaml in self._sidebar._yamls: 302 self._sidebar.select_yaml(new_yaml) 303 self.log.append_line(_('✓ Creada: {}').format(new_yaml.name), 'ok') 304 305 def _on_import_odp(self) -> None: 306 path, _ = QFileDialog.getOpenFileName( 307 self, 'Seleccionar plantilla ODP', 308 str(get_courses_dir() or COURSES_DIR), 'ODP (*.odp)', 309 ) 310 if not path: 311 return 312 self._run_worker('import_odp', odp_path=path) 313 314 def _on_about(self) -> None: 315 from gui.dialogs.about_dialog import AboutDialog 316 AboutDialog(self).exec() 317 318 def _on_settings(self) -> None: 319 from gui.dialogs.settings_dialog import SettingsDialog 320 # Guardar geometría antes: el re-polishing del QSS al cerrar 321 # puede alterar el tamaño de la ventana en displays HiDPI. 322 _geom = self.saveGeometry() 323 _main = self._splitter_main.saveState() 324 _right = self._splitter_right.saveState() 325 dlg = SettingsDialog(self) 326 accepted = dlg.exec() == dlg.DialogCode.Accepted 327 self.restoreGeometry(_geom) 328 self._splitter_main.restoreState(_main) 329 self._splitter_right.restoreState(_right) 330 if not accepted: 331 return 332 if dlg.courses_dir_was_changed(): 333 self._sidebar.reload_courses() 334 from lib.slides_config import get_courses_dir 335 self.log.append_line( 336 _('Directorio de cursos cambiado a: {}').format(get_courses_dir()), 'ok' 337 ) 338 if dlg.language_was_changed(): 339 self._restart_app() 340 if dlg.display_settings_was_changed(): 341 QMessageBox.information( 342 self, 343 _('Reinicio necesario'), 344 _('Los ajustes de pantalla (escala, HiDPI…) los lee Qt antes de ' 345 'arrancar la aplicación y no se pueden aplicar en caliente.\n\n' 346 'Cierra y vuelve a abrir la aplicación para que tengan efecto.'), 347 ) 348 349 # ── Toolbar compacta ────────────────────────────────────────────────────── 350 351 def show(self) -> None: 352 if self._was_maximized: 353 super().showMaximized() 354 else: 355 super().show() 356 357 def showEvent(self, event: QShowEvent) -> None: 358 super().showEvent(event) 359 if self._compact_threshold == 0: 360 # Mide el ancho que necesita la toolbar en modo completo. 361 # Se hace aquí porque sizeHint() requiere que el widget esté polished. 362 self._compact_threshold = self._toolbar.sizeHint().width() + 20 363 # Aplica el modo correcto si la ventana ya fue restaurada pequeña 364 compact = self.width() < self._compact_threshold 365 if compact != self._compact_toolbar: 366 self._set_compact_toolbar(compact) 367 368 def changeEvent(self, event) -> None: 369 super().changeEvent(event) 370 from PySide6.QtCore import QEvent 371 if event.type() == QEvent.Type.WindowStateChange: 372 self._was_maximized = bool(self.windowState() & Qt.WindowState.WindowMaximized) 373 374 def resizeEvent(self, event: QResizeEvent) -> None: 375 super().resizeEvent(event) 376 if self._compact_threshold == 0: 377 return # aún no se ha medido la toolbar 378 compact = event.size().width() < self._compact_threshold 379 if compact != self._compact_toolbar: 380 self._set_compact_toolbar(compact) 381 382 def _set_compact_toolbar(self, compact: bool) -> None: 383 self._compact_toolbar = compact 384 for action, full, short in self._tb_labels: 385 # _act_sidebar_toggle se gestiona en _toggle_sidebar 386 if action is self._act_sidebar_toggle: 387 continue 388 action.setText(short if compact else full) 389 # Actualiza el sidebar toggle según su estado actual 390 sidebar_open = self._sidebar.isVisible() 391 if compact: 392 self._act_sidebar_toggle.setText('◧' if sidebar_open else '◩') 393 else: 394 self._act_sidebar_toggle.setText(_('◧ Sidebar') if sidebar_open else _('◩ Sidebar')) 395 396 # ── Sidebar colapsable ──────────────────────────────────────────────────── 397 398 def _toggle_sidebar(self) -> None: 399 if self._sidebar.isVisible(): 400 self._sidebar_expand_size = self._splitter_main.sizes()[0] or self._sidebar_expand_size 401 self._sidebar.hide() 402 if self._compact_toolbar: 403 self._act_sidebar_toggle.setText('◩') 404 else: 405 self._act_sidebar_toggle.setText(_('◩ Sidebar')) 406 else: 407 self._sidebar.show() 408 total = sum(self._splitter_main.sizes()) 409 size = max(self._sidebar_expand_size, 180) 410 self._splitter_main.setSizes([size, total - size]) 411 if self._compact_toolbar: 412 self._act_sidebar_toggle.setText('◧') 413 else: 414 self._act_sidebar_toggle.setText(_('◧ Sidebar')) 415 416 # ── Log colapsable ──────────────────────────────────────────────────────── 417 418 def _on_log_collapse_toggled(self, collapsed: bool) -> None: 419 sizes = self._splitter_right.sizes() 420 total = sum(sizes) 421 if collapsed: 422 self._log_expand_size = max(sizes[1], 80) 423 self._splitter_right.setSizes([total - _LOG_HEADER_H, _LOG_HEADER_H]) 424 else: 425 expand = max(self._log_expand_size, 120) 426 self._splitter_right.setSizes([total - expand, expand]) 427 428 # ── Gestión del worker / hilo ───────────────────────────────────────────── 429 430 @staticmethod 431 def _find_latest_output(course_dir: 'Path') -> 'tuple[Path | None, Path | None]': 432 from pathlib import Path as _P 433 def _newest(d: _P, pattern: str) -> '_P | None': 434 if not d.exists(): 435 return None 436 files = list(d.glob(pattern)) 437 return max(files, key=lambda f: f.stat().st_mtime) if files else None 438 odp = _newest(course_dir / 'output' / 'odp', '*.odp') 439 pdf = _newest(course_dir / 'output' / 'pdf', '*.pdf') 440 return odp, pdf 441 442 def _run_worker(self, task: str, **kwargs) -> None: 443 try: 444 running = self._thread is not None and self._thread.isRunning() 445 except RuntimeError: 446 running = False 447 self._thread = None 448 if running: 449 self.log.append_line(_('Ya hay una operación en curso.'), 'error') 450 return 451 452 self.log.append_line(_('▶ {} …').format(task.upper()), 'info') 453 self.log.hide_open_buttons() 454 self._set_busy(True) 455 if task == 'build': 456 self._last_build_course = self._sidebar.current_course() 457 else: 458 self._last_build_course = None 459 self._last_new_course_name = kwargs.get('name') if task == 'new' else None 460 461 self._worker = BuildWorker(task, **kwargs) 462 self._thread = QThread(self) 463 self._worker.moveToThread(self._thread) 464 465 self._thread.started.connect(self._worker.run) 466 self._worker.log_line.connect(self.log.append_line) 467 self._worker.progress.connect(self.log.set_progress) 468 self._worker.files_generated.connect(self.log.show_build_results) 469 self._worker.finished.connect(self._on_worker_finished) 470 self._worker.finished.connect(self._thread.quit) 471 self._worker.finished.connect(self._worker.deleteLater) 472 self._thread.finished.connect(self._thread.deleteLater) 473 474 self._thread.start() 475 476 def _on_worker_finished(self, ok: bool) -> None: 477 self._set_busy(False) 478 if ok: 479 self.log.append_line(_('✓ Listo.'), 'ok') 480 if self._last_new_course_name: 481 self._navigate_to_new_course(self._last_new_course_name) 482 self._last_new_course_name = None 483 self._sidebar.refresh_version() 484 else: 485 self.log.append_line(_('✗ La operación terminó con errores.'), 'error') 486 487 def _restart_app(self) -> None: 488 """Guarda el estado y reinicia el proceso para aplicar el cambio de idioma.""" 489 import os 490 # Guardar estado antes de salir (os.execl no dispara closeEvent) 491 currently = bool(self.windowState() & Qt.WindowState.WindowMaximized) 492 self._settings.setValue('window_maximized', currently or self._was_maximized) 493 self._settings.setValue('geometry', self.saveGeometry()) 494 self._settings.setValue('splitter_main', self._splitter_main.saveState()) 495 self._settings.setValue('splitter_right_v2', self._splitter_right.saveState()) 496 self._settings.setValue('sidebar_hidden', not self._sidebar.isVisible()) 497 self._settings.setValue('log_collapsed', self.log._collapsed) 498 os.execl(sys.executable, sys.executable, *sys.argv) 499 500 def _navigate_to_new_course(self, name: str) -> None: 501 """Recarga la lista de cursos y navega al curso recién creado.""" 502 from lib.slides_config import get_courses_dir as _cfg_courses_dir 503 courses_base = _cfg_courses_dir() or get_courses_dir() 504 self._sidebar.reload_courses() 505 if courses_base: 506 new_course = courses_base / name 507 self._sidebar.select_course_by_path(new_course) 508 509 # ── Watch ───────────────────────────────────────────────────────────────── 510 511 def _on_watch_toggle(self, checked: bool) -> None: 512 if checked: 513 self._start_watch() 514 else: 515 self._stop_watch() 516 517 def _start_watch(self) -> None: 518 from lib.watcher import WATCHDOG_AVAILABLE 519 if not WATCHDOG_AVAILABLE: 520 self.log.append_line( 521 _('watchdog no instalado. Ejecuta: uv sync --group watch'), 'error' 522 ) 523 self._act_watch.setChecked(False) 524 return 525 526 course_dir = self._sidebar.current_course() 527 if course_dir is None: 528 self.log.append_line(_('No hay curso activo.'), 'error') 529 self._act_watch.setChecked(False) 530 return 531 532 from gui.utils.path_setup import get_yamls 533 yamls = get_yamls(course_dir) 534 if not yamls: 535 self.log.append_line(_('No hay YAMLs en el curso activo.'), 'error') 536 self._act_watch.setChecked(False) 537 return 538 539 self.log.append_line( 540 _('👁 Watch activo — {} fichero(s). Ctrl+W para parar.').format(len(yamls)), 541 'info', 542 ) 543 self._watch_worker = BuildWorker('watch', yamls=yamls, no_pdf=False, template=None) 544 self._watch_thread = QThread(self) 545 self._watch_worker.moveToThread(self._watch_thread) 546 547 self._watch_thread.started.connect(self._watch_worker.run) 548 self._watch_worker.log_line.connect(self.log.append_line) 549 self._watch_worker.finished.connect(self._on_watch_finished) 550 self._watch_worker.finished.connect(self._watch_thread.quit) 551 self._watch_worker.finished.connect(self._watch_worker.deleteLater) 552 self._watch_thread.finished.connect(self._watch_thread.deleteLater) 553 554 self._watch_thread.start() 555 self._set_busy(True) 556 557 def _stop_watch(self) -> None: 558 if self._watch_thread is not None: 559 try: 560 if self._watch_thread.isRunning(): 561 self._watch_thread.requestInterruption() 562 self._watch_thread.quit() 563 self._watch_thread.wait(2000) 564 except RuntimeError: 565 pass 566 self._watch_thread = None 567 self._watch_worker = None 568 self._act_watch.setChecked(False) 569 self._set_busy(False) 570 self.log.append_line(_('Watch detenido.'), 'info') 571 572 def _on_watch_finished(self, ok: bool) -> None: 573 self._watch_thread = None 574 self._watch_worker = None 575 self._act_watch.setChecked(False) 576 self._set_busy(False) 577 if not ok: 578 self.log.append_line(_('Watch terminó con errores.'), 'error') 579 580 def _set_busy(self, busy: bool) -> None: 581 for act in (self._act_build, self._act_build_all, self._act_new_pres, 582 self._act_clean, self._act_import, self._act_courses_dir): 583 act.setEnabled(not busy) 584 585 # ── Geometría persistente ───────────────────────────────────────────────── 586 587 def _restore_geometry(self) -> None: 588 self._was_maximized = self._settings.value('window_maximized', False, type=bool) 589 if not self._was_maximized: 590 # Solo restaurar geometría en ventana normal: el blob de una ventana 591 # maximizada resetea el windowState a NoState en Qt6/GNOME, lo que 592 # impide que showMaximized() funcione al abrir. 593 geom = self._settings.value('geometry') 594 if geom: 595 self.restoreGeometry(geom) 596 main_sizes = self._settings.value('splitter_main') 597 if main_sizes: 598 self._splitter_main.restoreState(main_sizes) 599 right_sizes = self._settings.value('splitter_right_v2') 600 if right_sizes: 601 self._splitter_right.restoreState(right_sizes) 602 if self._settings.value('sidebar_hidden', False, type=bool): 603 self._sidebar.hide() 604 if self._settings.value('log_collapsed', False, type=bool): 605 self.log._toggle_collapse() 606 607 def closeEvent(self, event) -> None: 608 currently = bool(self.windowState() & Qt.WindowState.WindowMaximized) 609 self._settings.setValue('window_maximized', currently or self._was_maximized) 610 self._settings.setValue('geometry', self.saveGeometry()) 611 self._settings.setValue('splitter_main', self._splitter_main.saveState()) 612 self._settings.setValue('splitter_right_v2', self._splitter_right.saveState()) 613 self._settings.setValue('sidebar_hidden', not self._sidebar.isVisible()) 614 self._settings.setValue('log_collapsed', self.log._collapsed) 615 super().closeEvent(event)
class
MainWindow(PySide6.QtWidgets.QMainWindow):
23class MainWindow(QMainWindow): 24 def __init__(self): 25 super().__init__() 26 self.setWindowTitle(_('Temario — Generador de Presentaciones')) 27 self.resize(1280, 760) 28 self._settings = QSettings('temario', 'gui') 29 self._thread: QThread | None = None 30 self._worker: BuildWorker | None = None 31 self._watch_thread: QThread | None = None 32 self._watch_worker: BuildWorker | None = None 33 self._last_build_course: 'Path | None' = None 34 self._last_new_course_name: str | None = None 35 self._log_expand_size: int = 200 36 self._sidebar_expand_size: int = 240 37 self._compact_toolbar: bool = False 38 self._tb_labels: list = [] # [(action, full_text, compact_text)] 39 self._compact_threshold: int = 0 # medido en showEvent desde toolbar.sizeHint() 40 self._was_maximized: bool = False 41 42 self._build_ui() 43 self._build_toolbar() 44 self._connect_signals() 45 self._restore_geometry() 46 47 # ── Construcción de la UI ───────────────────────────────────────────────── 48 49 def _build_ui(self) -> None: 50 central = QWidget() 51 vlay = QVBoxLayout(central) 52 vlay.setContentsMargins(0, 0, 0, 0) 53 vlay.setSpacing(0) 54 55 self._splitter_main = QSplitter(Qt.Orientation.Horizontal) 56 vlay.addWidget(self._splitter_main) 57 58 self.setCentralWidget(central) 59 60 # Sidebar izquierda: curso + unidades + slides 61 self._sidebar = Sidebar() 62 self._splitter_main.addWidget(self._sidebar) 63 64 # Área derecha: editor (arriba) + log (abajo) 65 self._splitter_right = QSplitter(Qt.Orientation.Vertical) 66 self._splitter_main.addWidget(self._splitter_right) 67 68 self._editor = YamlEditorPanel() 69 self._splitter_right.addWidget(self._editor) 70 71 self.log = LogPanel() 72 self._splitter_right.addWidget(self.log) 73 74 self._splitter_main.setSizes([240, 1040]) 75 self._splitter_main.setCollapsible(0, True) 76 self._splitter_right.setSizes([560, 200]) 77 self._splitter_right.setCollapsible(1, True) 78 79 self._status_slide = QLabel('') 80 sb = QStatusBar() 81 sb.addPermanentWidget(self._status_slide) 82 self.setStatusBar(sb) 83 84 def _build_toolbar(self) -> None: 85 self._toolbar = QToolBar('Principal') 86 tb = self._toolbar 87 tb.setMovable(False) 88 tb.setIconSize(QSize(16, 16)) 89 self.addToolBar(tb) 90 91 def _reg(a: QAction, full: str, compact: str) -> QAction: 92 self._tb_labels.append((a, full, compact)) 93 return a 94 95 # ── Sidebar toggle ──────────────────────────────────────────────────── 96 self._act_sidebar_toggle = _reg( 97 QAction(_('◧ Sidebar'), self), _('◧ Sidebar'), '◧', 98 ) 99 self._act_sidebar_toggle.setShortcut(QKeySequence('Ctrl+\\')) 100 self._act_sidebar_toggle.setToolTip(_('Mostrar / ocultar panel lateral (Ctrl+\\)')) 101 self._act_sidebar_toggle.triggered.connect(self._toggle_sidebar) 102 tb.addAction(self._act_sidebar_toggle) 103 104 tb.addSeparator() 105 106 # ── Flujo de creación: 1. Nuevo curso → 2. Import ODP ───────────────── 107 self._act_new_course = _reg( 108 QAction(_('+ Nuevo curso'), self), _('+ Nuevo curso'), '+', 109 ) 110 self._act_new_course.setShortcut(QKeySequence('Ctrl+Shift+N')) 111 self._act_new_course.setToolTip(_('Crear un nuevo curso (Ctrl+Shift+N)')) 112 self._act_new_course.triggered.connect(self._on_new_course) 113 tb.addAction(self._act_new_course) 114 115 self._act_import = _reg( 116 QAction(_('⬆ Import ODP'), self), _('⬆ Import ODP'), '⬆', 117 ) 118 self._act_import.setToolTip(_('Importar plantilla ODP → genera template.yaml')) 119 self._act_import.triggered.connect(self._on_import_odp) 120 tb.addAction(self._act_import) 121 122 tb.addSeparator() 123 124 # ── Generación ──────────────────────────────────────────────────────── 125 self._act_build = _reg( 126 QAction(_('▶ Build'), self), _('▶ Build'), '▶', 127 ) 128 self._act_build.setShortcut(QKeySequence('Ctrl+B')) 129 self._act_build.setToolTip(_('Generar ODP+PDF del YAML activo (Ctrl+B)')) 130 self._act_build.triggered.connect(self._on_build) 131 tb.addAction(self._act_build) 132 _btn = tb.widgetForAction(self._act_build) 133 if _btn: 134 _btn.setObjectName('toolbar_primary') 135 _btn.style().unpolish(_btn) 136 _btn.style().polish(_btn) 137 138 self._act_build_all = _reg( 139 QAction(_('▶▶ Build todo'), self), _('▶▶ Build todo'), '▶▶', 140 ) 141 self._act_build_all.setToolTip(_('Generar todos los YAMLs del curso activo')) 142 self._act_build_all.triggered.connect(self._on_build_all) 143 tb.addAction(self._act_build_all) 144 145 self._act_watch = _reg( 146 QAction(_('👁 Watch'), self), _('👁 Watch'), '👁', 147 ) 148 self._act_watch.setShortcut(QKeySequence('Ctrl+W')) 149 self._act_watch.setToolTip(_('Regenerar automáticamente al guardar cambios (Ctrl+W)')) 150 self._act_watch.setCheckable(True) 151 self._act_watch.triggered.connect(self._on_watch_toggle) 152 tb.addAction(self._act_watch) 153 154 tb.addSeparator() 155 156 # ── Gestión ─────────────────────────────────────────────────────────── 157 self._act_new_pres = _reg( 158 QAction(_('+ Nueva presentación'), self), _('+ Nueva presentación'), '+', 159 ) 160 self._act_new_pres.setShortcut(QKeySequence('Ctrl+N')) 161 self._act_new_pres.setToolTip(_('Crear nueva presentación YAML en el curso activo (Ctrl+N)')) 162 self._act_new_pres.triggered.connect(self._on_new_presentation) 163 tb.addAction(self._act_new_pres) 164 165 self._act_clean = _reg( 166 QAction(_('🧹 Clean'), self), _('🧹 Clean'), '🧹', 167 ) 168 self._act_clean.setToolTip(_('Borrar ficheros generados')) 169 self._act_clean.triggered.connect(self._on_clean) 170 tb.addAction(self._act_clean) 171 _btn = tb.widgetForAction(self._act_clean) 172 if _btn: 173 _btn.setObjectName('toolbar_destructive') 174 _btn.style().unpolish(_btn) 175 _btn.style().polish(_btn) 176 177 tb.addSeparator() 178 179 # ── Aplicación ──────────────────────────────────────────────────────── 180 self._act_courses_dir = _reg( 181 QAction(_('⚙ Ajustes'), self), _('⚙ Ajustes'), '⚙', 182 ) 183 self._act_courses_dir.setToolTip(_('Configuración de la aplicación')) 184 self._act_courses_dir.triggered.connect(self._on_settings) 185 tb.addAction(self._act_courses_dir) 186 187 self._act_about = _reg( 188 QAction(_('○ Acerca de'), self), _('○ Acerca de'), '○', 189 ) 190 self._act_about.setToolTip(_('Información sobre la aplicación')) 191 self._act_about.triggered.connect(self._on_about) 192 tb.addAction(self._act_about) 193 194 def _connect_signals(self) -> None: 195 self._sidebar.unit_changed.connect(self._on_unit_changed) 196 self._sidebar.course_changed.connect(self._on_course_changed) 197 self._sidebar.new_course_requested.connect(self._on_new_course) 198 self._sidebar.new_presentation_requested.connect(self._on_new_presentation) 199 self._sidebar.slide_selected.connect(self._on_slide_selected) 200 self._editor.saved.connect(self._on_yaml_saved) 201 self.log.collapse_toggled.connect(self._on_log_collapse_toggled) 202 203 yaml_path = self._sidebar.current_yaml() 204 if yaml_path: 205 self._on_unit_changed(yaml_path) 206 207 # ── Handlers de señales ─────────────────────────────────────────────────── 208 209 def _on_unit_changed(self, yaml_path) -> None: 210 try: 211 data = yaml.safe_load(yaml_path.read_text(encoding='utf-8')) or {} 212 self._sidebar.rebuild_slides(data) 213 self._editor.open_file(yaml_path) 214 self._status_slide.setText('') 215 except Exception as exc: 216 self.log.append_line(_('Error cargando {}: {}').format(yaml_path.name, exc), 'error') 217 218 def _on_course_changed(self, course_dir) -> None: 219 self._sidebar.rebuild_slides({}) 220 self._status_slide.setText('') 221 222 _FIXED_FIELDS = { 223 NODE_PORTADA: ('meta', 'Portada'), 224 NODE_OBJETIVOS: ('objetivos', 'Objetivos'), 225 NODE_CONCLUSION: ('conclusion', 'Conclusión'), 226 NODE_REFERENCIAS: ('referencias', 'Referencias'), 227 } 228 229 def _on_slide_selected(self, sec_idx: int, slide_idx: int) -> None: 230 if sec_idx in self._FIXED_FIELDS: 231 field, label = self._FIXED_FIELDS[sec_idx] 232 self._editor.scroll_to_field(field) 233 self._status_slide.setText(label) 234 return 235 if sec_idx < 0: 236 return 237 self._editor.scroll_to_slide(sec_idx, slide_idx) 238 self._status_slide.setText(f'§ {sec_idx + 1} · {slide_idx + 1}') 239 240 def _on_yaml_saved(self, yaml_path) -> None: 241 try: 242 data = yaml.safe_load(yaml_path.read_text(encoding='utf-8')) or {} 243 self._sidebar.rebuild_slides(data) 244 except Exception as exc: 245 self.log.append_line(_('Error al releer {}: {}').format(yaml_path.name, exc), 'error') 246 247 # ── Acciones del toolbar ────────────────────────────────────────────────── 248 249 def _on_build(self) -> None: 250 yaml_path = self._sidebar.current_yaml() 251 if yaml_path is None: 252 self.log.append_line(_('No hay unidad activa.'), 'error') 253 return 254 if yaml_path.parent.name != 'content': 255 self.log.append_line(_('El fichero seleccionado no es una presentación generatable.'), 'info') 256 return 257 self._run_worker('build', yamls=[yaml_path]) 258 259 def _on_build_all(self) -> None: 260 course_dir = self._sidebar.current_course() 261 if course_dir is None: 262 self.log.append_line(_('No hay curso activo.'), 'error') 263 return 264 from gui.utils.path_setup import get_yamls 265 yamls = get_yamls(course_dir) 266 if not yamls: 267 self.log.append_line(_('No se encontraron YAMLs en {}.').format(course_dir.name), 'error') 268 return 269 self._run_worker('build', yamls=yamls) 270 271 def _on_clean(self) -> None: 272 from gui.dialogs.clean_dialog import CleanDialog 273 courses = get_courses() 274 dlg = CleanDialog(courses, self._sidebar.current_course(), parent=self) 275 if dlg.exec() != dlg.DialogCode.Accepted: 276 return 277 selected = dlg.selected_courses() 278 if not selected: 279 self.log.append_line('No se seleccionó ningún curso.', 'info') 280 return 281 self._run_worker('clean', course_dirs=selected, tipo=dlg.selected_tipo()) 282 283 def _on_new_course(self) -> None: 284 from gui.dialogs.new_course_dialog import NewCourseDialog 285 dlg = NewCourseDialog(parent=self) 286 if dlg.exec() != dlg.DialogCode.Accepted: 287 return 288 name = dlg.course_name() 289 self._run_worker('new', name=name) 290 291 def _on_new_presentation(self) -> None: 292 course_dir = self._sidebar.current_course() 293 if course_dir is None: 294 self.log.append_line(_('No hay curso activo.'), 'error') 295 return 296 from gui.dialogs.new_presentation_dialog import NewPresentationDialog 297 dlg = NewPresentationDialog(course_dir, parent=self) 298 if dlg.exec() != dlg.DialogCode.Accepted: 299 return 300 new_yaml = dlg.create_yaml() 301 self._sidebar.reload_tabs(course_dir) 302 if new_yaml in self._sidebar._yamls: 303 self._sidebar.select_yaml(new_yaml) 304 self.log.append_line(_('✓ Creada: {}').format(new_yaml.name), 'ok') 305 306 def _on_import_odp(self) -> None: 307 path, _ = QFileDialog.getOpenFileName( 308 self, 'Seleccionar plantilla ODP', 309 str(get_courses_dir() or COURSES_DIR), 'ODP (*.odp)', 310 ) 311 if not path: 312 return 313 self._run_worker('import_odp', odp_path=path) 314 315 def _on_about(self) -> None: 316 from gui.dialogs.about_dialog import AboutDialog 317 AboutDialog(self).exec() 318 319 def _on_settings(self) -> None: 320 from gui.dialogs.settings_dialog import SettingsDialog 321 # Guardar geometría antes: el re-polishing del QSS al cerrar 322 # puede alterar el tamaño de la ventana en displays HiDPI. 323 _geom = self.saveGeometry() 324 _main = self._splitter_main.saveState() 325 _right = self._splitter_right.saveState() 326 dlg = SettingsDialog(self) 327 accepted = dlg.exec() == dlg.DialogCode.Accepted 328 self.restoreGeometry(_geom) 329 self._splitter_main.restoreState(_main) 330 self._splitter_right.restoreState(_right) 331 if not accepted: 332 return 333 if dlg.courses_dir_was_changed(): 334 self._sidebar.reload_courses() 335 from lib.slides_config import get_courses_dir 336 self.log.append_line( 337 _('Directorio de cursos cambiado a: {}').format(get_courses_dir()), 'ok' 338 ) 339 if dlg.language_was_changed(): 340 self._restart_app() 341 if dlg.display_settings_was_changed(): 342 QMessageBox.information( 343 self, 344 _('Reinicio necesario'), 345 _('Los ajustes de pantalla (escala, HiDPI…) los lee Qt antes de ' 346 'arrancar la aplicación y no se pueden aplicar en caliente.\n\n' 347 'Cierra y vuelve a abrir la aplicación para que tengan efecto.'), 348 ) 349 350 # ── Toolbar compacta ────────────────────────────────────────────────────── 351 352 def show(self) -> None: 353 if self._was_maximized: 354 super().showMaximized() 355 else: 356 super().show() 357 358 def showEvent(self, event: QShowEvent) -> None: 359 super().showEvent(event) 360 if self._compact_threshold == 0: 361 # Mide el ancho que necesita la toolbar en modo completo. 362 # Se hace aquí porque sizeHint() requiere que el widget esté polished. 363 self._compact_threshold = self._toolbar.sizeHint().width() + 20 364 # Aplica el modo correcto si la ventana ya fue restaurada pequeña 365 compact = self.width() < self._compact_threshold 366 if compact != self._compact_toolbar: 367 self._set_compact_toolbar(compact) 368 369 def changeEvent(self, event) -> None: 370 super().changeEvent(event) 371 from PySide6.QtCore import QEvent 372 if event.type() == QEvent.Type.WindowStateChange: 373 self._was_maximized = bool(self.windowState() & Qt.WindowState.WindowMaximized) 374 375 def resizeEvent(self, event: QResizeEvent) -> None: 376 super().resizeEvent(event) 377 if self._compact_threshold == 0: 378 return # aún no se ha medido la toolbar 379 compact = event.size().width() < self._compact_threshold 380 if compact != self._compact_toolbar: 381 self._set_compact_toolbar(compact) 382 383 def _set_compact_toolbar(self, compact: bool) -> None: 384 self._compact_toolbar = compact 385 for action, full, short in self._tb_labels: 386 # _act_sidebar_toggle se gestiona en _toggle_sidebar 387 if action is self._act_sidebar_toggle: 388 continue 389 action.setText(short if compact else full) 390 # Actualiza el sidebar toggle según su estado actual 391 sidebar_open = self._sidebar.isVisible() 392 if compact: 393 self._act_sidebar_toggle.setText('◧' if sidebar_open else '◩') 394 else: 395 self._act_sidebar_toggle.setText(_('◧ Sidebar') if sidebar_open else _('◩ Sidebar')) 396 397 # ── Sidebar colapsable ──────────────────────────────────────────────────── 398 399 def _toggle_sidebar(self) -> None: 400 if self._sidebar.isVisible(): 401 self._sidebar_expand_size = self._splitter_main.sizes()[0] or self._sidebar_expand_size 402 self._sidebar.hide() 403 if self._compact_toolbar: 404 self._act_sidebar_toggle.setText('◩') 405 else: 406 self._act_sidebar_toggle.setText(_('◩ Sidebar')) 407 else: 408 self._sidebar.show() 409 total = sum(self._splitter_main.sizes()) 410 size = max(self._sidebar_expand_size, 180) 411 self._splitter_main.setSizes([size, total - size]) 412 if self._compact_toolbar: 413 self._act_sidebar_toggle.setText('◧') 414 else: 415 self._act_sidebar_toggle.setText(_('◧ Sidebar')) 416 417 # ── Log colapsable ──────────────────────────────────────────────────────── 418 419 def _on_log_collapse_toggled(self, collapsed: bool) -> None: 420 sizes = self._splitter_right.sizes() 421 total = sum(sizes) 422 if collapsed: 423 self._log_expand_size = max(sizes[1], 80) 424 self._splitter_right.setSizes([total - _LOG_HEADER_H, _LOG_HEADER_H]) 425 else: 426 expand = max(self._log_expand_size, 120) 427 self._splitter_right.setSizes([total - expand, expand]) 428 429 # ── Gestión del worker / hilo ───────────────────────────────────────────── 430 431 @staticmethod 432 def _find_latest_output(course_dir: 'Path') -> 'tuple[Path | None, Path | None]': 433 from pathlib import Path as _P 434 def _newest(d: _P, pattern: str) -> '_P | None': 435 if not d.exists(): 436 return None 437 files = list(d.glob(pattern)) 438 return max(files, key=lambda f: f.stat().st_mtime) if files else None 439 odp = _newest(course_dir / 'output' / 'odp', '*.odp') 440 pdf = _newest(course_dir / 'output' / 'pdf', '*.pdf') 441 return odp, pdf 442 443 def _run_worker(self, task: str, **kwargs) -> None: 444 try: 445 running = self._thread is not None and self._thread.isRunning() 446 except RuntimeError: 447 running = False 448 self._thread = None 449 if running: 450 self.log.append_line(_('Ya hay una operación en curso.'), 'error') 451 return 452 453 self.log.append_line(_('▶ {} …').format(task.upper()), 'info') 454 self.log.hide_open_buttons() 455 self._set_busy(True) 456 if task == 'build': 457 self._last_build_course = self._sidebar.current_course() 458 else: 459 self._last_build_course = None 460 self._last_new_course_name = kwargs.get('name') if task == 'new' else None 461 462 self._worker = BuildWorker(task, **kwargs) 463 self._thread = QThread(self) 464 self._worker.moveToThread(self._thread) 465 466 self._thread.started.connect(self._worker.run) 467 self._worker.log_line.connect(self.log.append_line) 468 self._worker.progress.connect(self.log.set_progress) 469 self._worker.files_generated.connect(self.log.show_build_results) 470 self._worker.finished.connect(self._on_worker_finished) 471 self._worker.finished.connect(self._thread.quit) 472 self._worker.finished.connect(self._worker.deleteLater) 473 self._thread.finished.connect(self._thread.deleteLater) 474 475 self._thread.start() 476 477 def _on_worker_finished(self, ok: bool) -> None: 478 self._set_busy(False) 479 if ok: 480 self.log.append_line(_('✓ Listo.'), 'ok') 481 if self._last_new_course_name: 482 self._navigate_to_new_course(self._last_new_course_name) 483 self._last_new_course_name = None 484 self._sidebar.refresh_version() 485 else: 486 self.log.append_line(_('✗ La operación terminó con errores.'), 'error') 487 488 def _restart_app(self) -> None: 489 """Guarda el estado y reinicia el proceso para aplicar el cambio de idioma.""" 490 import os 491 # Guardar estado antes de salir (os.execl no dispara closeEvent) 492 currently = bool(self.windowState() & Qt.WindowState.WindowMaximized) 493 self._settings.setValue('window_maximized', currently or self._was_maximized) 494 self._settings.setValue('geometry', self.saveGeometry()) 495 self._settings.setValue('splitter_main', self._splitter_main.saveState()) 496 self._settings.setValue('splitter_right_v2', self._splitter_right.saveState()) 497 self._settings.setValue('sidebar_hidden', not self._sidebar.isVisible()) 498 self._settings.setValue('log_collapsed', self.log._collapsed) 499 os.execl(sys.executable, sys.executable, *sys.argv) 500 501 def _navigate_to_new_course(self, name: str) -> None: 502 """Recarga la lista de cursos y navega al curso recién creado.""" 503 from lib.slides_config import get_courses_dir as _cfg_courses_dir 504 courses_base = _cfg_courses_dir() or get_courses_dir() 505 self._sidebar.reload_courses() 506 if courses_base: 507 new_course = courses_base / name 508 self._sidebar.select_course_by_path(new_course) 509 510 # ── Watch ───────────────────────────────────────────────────────────────── 511 512 def _on_watch_toggle(self, checked: bool) -> None: 513 if checked: 514 self._start_watch() 515 else: 516 self._stop_watch() 517 518 def _start_watch(self) -> None: 519 from lib.watcher import WATCHDOG_AVAILABLE 520 if not WATCHDOG_AVAILABLE: 521 self.log.append_line( 522 _('watchdog no instalado. Ejecuta: uv sync --group watch'), 'error' 523 ) 524 self._act_watch.setChecked(False) 525 return 526 527 course_dir = self._sidebar.current_course() 528 if course_dir is None: 529 self.log.append_line(_('No hay curso activo.'), 'error') 530 self._act_watch.setChecked(False) 531 return 532 533 from gui.utils.path_setup import get_yamls 534 yamls = get_yamls(course_dir) 535 if not yamls: 536 self.log.append_line(_('No hay YAMLs en el curso activo.'), 'error') 537 self._act_watch.setChecked(False) 538 return 539 540 self.log.append_line( 541 _('👁 Watch activo — {} fichero(s). Ctrl+W para parar.').format(len(yamls)), 542 'info', 543 ) 544 self._watch_worker = BuildWorker('watch', yamls=yamls, no_pdf=False, template=None) 545 self._watch_thread = QThread(self) 546 self._watch_worker.moveToThread(self._watch_thread) 547 548 self._watch_thread.started.connect(self._watch_worker.run) 549 self._watch_worker.log_line.connect(self.log.append_line) 550 self._watch_worker.finished.connect(self._on_watch_finished) 551 self._watch_worker.finished.connect(self._watch_thread.quit) 552 self._watch_worker.finished.connect(self._watch_worker.deleteLater) 553 self._watch_thread.finished.connect(self._watch_thread.deleteLater) 554 555 self._watch_thread.start() 556 self._set_busy(True) 557 558 def _stop_watch(self) -> None: 559 if self._watch_thread is not None: 560 try: 561 if self._watch_thread.isRunning(): 562 self._watch_thread.requestInterruption() 563 self._watch_thread.quit() 564 self._watch_thread.wait(2000) 565 except RuntimeError: 566 pass 567 self._watch_thread = None 568 self._watch_worker = None 569 self._act_watch.setChecked(False) 570 self._set_busy(False) 571 self.log.append_line(_('Watch detenido.'), 'info') 572 573 def _on_watch_finished(self, ok: bool) -> None: 574 self._watch_thread = None 575 self._watch_worker = None 576 self._act_watch.setChecked(False) 577 self._set_busy(False) 578 if not ok: 579 self.log.append_line(_('Watch terminó con errores.'), 'error') 580 581 def _set_busy(self, busy: bool) -> None: 582 for act in (self._act_build, self._act_build_all, self._act_new_pres, 583 self._act_clean, self._act_import, self._act_courses_dir): 584 act.setEnabled(not busy) 585 586 # ── Geometría persistente ───────────────────────────────────────────────── 587 588 def _restore_geometry(self) -> None: 589 self._was_maximized = self._settings.value('window_maximized', False, type=bool) 590 if not self._was_maximized: 591 # Solo restaurar geometría en ventana normal: el blob de una ventana 592 # maximizada resetea el windowState a NoState en Qt6/GNOME, lo que 593 # impide que showMaximized() funcione al abrir. 594 geom = self._settings.value('geometry') 595 if geom: 596 self.restoreGeometry(geom) 597 main_sizes = self._settings.value('splitter_main') 598 if main_sizes: 599 self._splitter_main.restoreState(main_sizes) 600 right_sizes = self._settings.value('splitter_right_v2') 601 if right_sizes: 602 self._splitter_right.restoreState(right_sizes) 603 if self._settings.value('sidebar_hidden', False, type=bool): 604 self._sidebar.hide() 605 if self._settings.value('log_collapsed', False, type=bool): 606 self.log._toggle_collapse() 607 608 def closeEvent(self, event) -> None: 609 currently = bool(self.windowState() & Qt.WindowState.WindowMaximized) 610 self._settings.setValue('window_maximized', currently or self._was_maximized) 611 self._settings.setValue('geometry', self.saveGeometry()) 612 self._settings.setValue('splitter_main', self._splitter_main.saveState()) 613 self._settings.setValue('splitter_right_v2', self._splitter_right.saveState()) 614 self._settings.setValue('sidebar_hidden', not self._sidebar.isVisible()) 615 self._settings.setValue('log_collapsed', self.log._collapsed) 616 super().closeEvent(event)
QMainWindow(self, /, parent: PySide6.QtWidgets.QWidget | None = None, flags: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, iconSize: PySide6.QtCore.QSize | None = None, toolButtonStyle: PySide6.QtCore.Qt.ToolButtonStyle | None = None, animated: bool | None = None, documentMode: bool | None = None, tabShape: PySide6.QtWidgets.QTabWidget.TabShape | None = None, dockNestingEnabled: bool | None = None, dockOptions: PySide6.QtWidgets.QMainWindow.DockOption | None = None, unifiedTitleAndToolBarOnMac: bool | None = None) -> None
def
showEvent(self, event: PySide6.QtGui.QShowEvent) -> None:
358 def showEvent(self, event: QShowEvent) -> None: 359 super().showEvent(event) 360 if self._compact_threshold == 0: 361 # Mide el ancho que necesita la toolbar en modo completo. 362 # Se hace aquí porque sizeHint() requiere que el widget esté polished. 363 self._compact_threshold = self._toolbar.sizeHint().width() + 20 364 # Aplica el modo correcto si la ventana ya fue restaurada pequeña 365 compact = self.width() < self._compact_threshold 366 if compact != self._compact_toolbar: 367 self._set_compact_toolbar(compact)
def
resizeEvent(self, event: PySide6.QtGui.QResizeEvent) -> None:
375 def resizeEvent(self, event: QResizeEvent) -> None: 376 super().resizeEvent(event) 377 if self._compact_threshold == 0: 378 return # aún no se ha medido la toolbar 379 compact = event.size().width() < self._compact_threshold 380 if compact != self._compact_toolbar: 381 self._set_compact_toolbar(compact)
def
closeEvent(self, event) -> None:
608 def closeEvent(self, event) -> None: 609 currently = bool(self.windowState() & Qt.WindowState.WindowMaximized) 610 self._settings.setValue('window_maximized', currently or self._was_maximized) 611 self._settings.setValue('geometry', self.saveGeometry()) 612 self._settings.setValue('splitter_main', self._splitter_main.saveState()) 613 self._settings.setValue('splitter_right_v2', self._splitter_right.saveState()) 614 self._settings.setValue('sidebar_hidden', not self._sidebar.isVisible()) 615 self._settings.setValue('log_collapsed', self.log._collapsed) 616 super().closeEvent(event)