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