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 show(self) -> None:
367    def show(self) -> None:
368        if self._was_maximized:
369            super().showMaximized()
370        else:
371            super().show()
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 changeEvent(self, event) -> None:
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)
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)
staticMetaObject = PySide6.QtCore.QMetaObject("MainWindow" inherits "QMainWindow": )