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 show(self) -> None:
352    def show(self) -> None:
353        if self._was_maximized:
354            super().showMaximized()
355        else:
356            super().show()
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 changeEvent(self, event) -> None:
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)
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)
staticMetaObject = PySide6.QtCore.QMetaObject("MainWindow" inherits "QMainWindow": )