gui.panels.sidebar

  1from pathlib import Path
  2
  3from PySide6.QtWidgets import (
  4    QWidget, QVBoxLayout, QHBoxLayout, QLabel,
  5    QPushButton, QListWidget, QListWidgetItem, QMenu, QSplitter,
  6    QMessageBox, QSpinBox,
  7)
  8from PySide6.QtCore import Signal, Qt, QSettings, QSize, QEvent
  9
 10from gui.panels.slide_navigator import SlideNavigator
 11from gui.utils.path_setup import get_courses, get_yamls
 12from gui.theme import signal as theme_signal
 13from lib.i18n import _
 14from lib.slides_config import get_last_session, save_last_session
 15
 16
 17class _UnitRow(QWidget):
 18    """Fila de presentación con botón ✕ siempre visible."""
 19    delete_requested = Signal(Path)
 20
 21    def __init__(self, yaml_path: Path, deletable: bool = True, label: str = '', parent=None):
 22        super().__init__(parent)
 23        self._path      = yaml_path
 24        self._deletable = deletable
 25
 26        hlay = QHBoxLayout(self)
 27        hlay.setContentsMargins(10, 0, 6, 0)
 28        hlay.setSpacing(4)
 29
 30        self._lbl = QLabel(label or yaml_path.name)
 31        self._lbl.setObjectName('unit_row_label')
 32        self._lbl.setMinimumWidth(0)
 33        hlay.addWidget(self._lbl, 1)
 34
 35        if deletable:
 36            self._btn_del = QPushButton('✕')
 37            self._btn_del.setObjectName('unit_delete_btn')
 38            self._btn_del.setFixedSize(18, 18)
 39            self._btn_del.clicked.connect(lambda: self.delete_requested.emit(self._path))
 40            hlay.addWidget(self._btn_del)
 41        else:
 42            self._btn_del = None
 43
 44    def set_selected(self, selected: bool) -> None:
 45        from gui.theme import c
 46        color = '#ffffff' if selected else c('text')
 47        self._lbl.setStyleSheet(f'color:{color}; background:transparent;')
 48
 49
 50class Sidebar(QWidget):
 51    """Sidebar combinada: selector de curso + lista de unidades + árbol de slides.
 52
 53    Sustituye a CourseBar + SlideNavigator en el layout principal.
 54    Expone las mismas señales que ambos componentes.
 55    """
 56
 57    course_changed             = Signal(Path)
 58    unit_changed               = Signal(Path)
 59    new_course_requested       = Signal()
 60    new_presentation_requested = Signal()
 61    slide_selected             = Signal(int, int)
 62
 63    def __init__(self, parent=None):
 64        super().__init__(parent)
 65        self.setObjectName('sidebar')
 66        self.setMinimumWidth(180)
 67        self.setMaximumWidth(340)
 68
 69        self._yamls: list[Path] = []
 70        self._courses: list[Path] = []
 71        self._current_course: Path | None = None
 72        self._settings = QSettings('temario', 'gui')
 73        self._spin_version: QSpinBox | None = None
 74
 75        self._build_ui()
 76        self._load_courses()
 77        theme_signal().changed.connect(self._on_theme_changed)
 78
 79    # ── Construcción ─────────────────────────────────────────────────────────
 80
 81    def _build_ui(self) -> None:
 82        layout = QVBoxLayout(self)
 83        layout.setContentsMargins(0, 0, 0, 0)
 84        layout.setSpacing(0)
 85
 86        layout.addWidget(self._make_header())
 87
 88        inner_splitter = QSplitter(Qt.Orientation.Vertical)
 89        inner_splitter.setObjectName('sidebar_inner_splitter')
 90        inner_splitter.setChildrenCollapsible(False)
 91
 92        units_widget = self._make_units_section()
 93        inner_splitter.addWidget(units_widget)
 94
 95        slides_widget = self._make_slides_section()
 96        inner_splitter.addWidget(slides_widget)
 97
 98        saved = self._settings.value('sidebar/inner_splitter')
 99        if saved:
100            inner_splitter.restoreState(saved)
101        else:
102            inner_splitter.setSizes([180, 440])
103
104        inner_splitter.splitterMoved.connect(
105            lambda: self._settings.setValue(
106                'sidebar/inner_splitter', inner_splitter.saveState()
107            )
108        )
109
110        layout.addWidget(inner_splitter, 1)
111
112    def _make_header(self) -> QWidget:
113        header = QWidget()
114        header.setObjectName('sidebar_header')
115        header.setFixedHeight(52)
116
117        hlay = QHBoxLayout(header)
118        hlay.setContentsMargins(8, 4, 8, 4)
119        hlay.setSpacing(4)
120
121        self._course_btn = QPushButton('—  ▾')
122        self._course_btn.setObjectName('sidebar_course_btn')
123        self._course_btn.setSizePolicy(
124            self._course_btn.sizePolicy().horizontalPolicy(),
125            self._course_btn.sizePolicy().verticalPolicy(),
126        )
127        self._course_btn.clicked.connect(self._show_course_menu)
128        hlay.addWidget(self._course_btn, 1)
129
130        self._lbl_version = QLabel('')
131        self._lbl_version.setObjectName('sidebar_version')
132        self._lbl_version.setToolTip(_('Haz clic para cambiar la versión'))
133        self._lbl_version.setCursor(Qt.CursorShape.PointingHandCursor)
134        self._lbl_version.installEventFilter(self)
135        self._refresh_version_style()
136        hlay.addWidget(self._lbl_version)
137
138        self._spin_version = QSpinBox()
139        self._spin_version.setRange(0, 9999)
140        self._spin_version.setFixedWidth(64)
141        self._spin_version.setObjectName('sidebar_version_spin')
142        self._spin_version.setVisible(False)
143        self._spin_version.installEventFilter(self)
144        self._spin_version.editingFinished.connect(self._commit_version_edit)
145        hlay.addWidget(self._spin_version)
146
147        btn_new = QPushButton('+')
148        btn_new.setObjectName('sidebar_icon_btn')
149        btn_new.setToolTip(_('Nueva presentación'))
150        btn_new.setFixedSize(28, 28)
151        btn_new.clicked.connect(self.new_presentation_requested.emit)
152        hlay.addWidget(btn_new)
153
154        btn_reload = QPushButton('⟳')
155        btn_reload.setObjectName('sidebar_icon_btn')
156        btn_reload.setToolTip(_('Recargar presentaciones desde disco'))
157        btn_reload.setFixedSize(28, 28)
158        btn_reload.clicked.connect(self._reload_from_disk)
159        hlay.addWidget(btn_reload)
160
161        return header
162
163    def _make_units_section(self) -> QWidget:
164        w = QWidget()
165        w.setObjectName('sidebar')
166        vlay = QVBoxLayout(w)
167        vlay.setContentsMargins(0, 0, 0, 0)
168        vlay.setSpacing(0)
169
170        lbl = QLabel(_('Presentaciones'))
171        lbl.setObjectName('section_header')
172        vlay.addWidget(lbl)
173
174        self._units_list = QListWidget()
175        self._units_list.setObjectName('units_list')
176        self._units_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
177        self._units_list.currentRowChanged.connect(self._on_unit_row_changed)
178        vlay.addWidget(self._units_list, 1)
179
180        return w
181
182    def _make_slides_section(self) -> QWidget:
183        w = QWidget()
184        w.setObjectName('sidebar')
185        vlay = QVBoxLayout(w)
186        vlay.setContentsMargins(0, 0, 0, 0)
187        vlay.setSpacing(0)
188
189        lbl = QLabel(_('Diapositivas'))
190        lbl.setObjectName('section_header')
191        vlay.addWidget(lbl)
192
193        self._navigator = SlideNavigator()
194        vlay.addWidget(self._navigator, 1)
195
196        self._navigator.slide_selected.connect(self.slide_selected)
197
198        return w
199
200    # ── Navegación de cursos ──────────────────────────────────────────────────
201
202    def _show_course_menu(self) -> None:
203        menu = QMenu(self)
204        for course in self._courses:
205            act = menu.addAction(course.name)
206            act.setData(course)
207            if course == self._current_course:
208                act.setCheckable(True)
209                act.setChecked(True)
210        if self._courses:
211            menu.addSeparator()
212        act_new = menu.addAction(_('Nuevo curso…'))
213        act_new.triggered.connect(self.new_course_requested.emit)
214        menu.addSeparator()
215        act_reload = menu.addAction(_('Recargar cursos'))
216        act_reload.triggered.connect(self._refresh_courses)
217
218        chosen = menu.exec(self._course_btn.mapToGlobal(
219            self._course_btn.rect().bottomLeft()
220        ))
221        if chosen and chosen.data():
222            self._select_course(self._courses.index(chosen.data()))
223
224    def _load_courses(self) -> None:
225        self._courses = get_courses()
226        if not self._courses:
227            self._course_btn.setText(_('—  ▾'))
228            return
229        last_course, last_yaml = get_last_session()
230        idx = 0
231        if last_course:
232            for i, c in enumerate(self._courses):
233                if str(c) == last_course:
234                    idx = i
235                    break
236        self._select_course(idx)
237        # Restaurar la última presentación activa dentro del curso
238        if last_yaml:
239            for i, y in enumerate(self._yamls):
240                if str(y) == last_yaml:
241                    self._units_list.setCurrentRow(i)
242                    break
243
244    def _reload_from_disk(self) -> None:
245        """Recarga la lista de YAMLs y el contenido del fichero abierto desde disco."""
246        if self._current_course is None:
247            return
248        current = self.current_yaml()
249        self._reload_tabs(self._current_course, restore_yaml=current)
250        self._update_version_label(self._current_course)
251
252    def _refresh_courses(self) -> None:
253        old_course = self._current_course
254        old_yaml   = self.current_yaml()
255        self._courses = get_courses()
256        if not self._courses:
257            self._course_btn.setText(_('—  ▾'))
258            self._current_course = None
259            self._reload_tabs(None)
260            return
261        if old_course in self._courses:
262            idx     = self._courses.index(old_course)
263            restore = old_yaml          # mismo curso → intentar restaurar yaml
264        else:
265            idx     = 0
266            restore = None              # curso desaparecido → empezar desde el principio
267        self._select_course(idx, restore_yaml=restore)
268
269    def _select_course(self, idx: int, restore_yaml: Path | None = None) -> None:
270        course = self._courses[idx]
271        self._current_course = course
272        name = course.name
273        display = name if len(name) <= 22 else name[:20] + '…'
274        self._course_btn.setText(f'{display}  ▾')
275        self._update_version_label(course)
276        save_last_session(str(course), None)
277        self.course_changed.emit(course)
278        self._reload_tabs(course, restore_yaml=restore_yaml)
279
280    def _reload_tabs(self, course_dir: Path | None,
281                     restore_yaml: Path | None = None) -> None:
282        self._units_list.blockSignals(True)
283        self._units_list.clear()
284        self._yamls = get_yamls(course_dir) if course_dir else []
285        for y in self._yamls:
286            item = QListWidgetItem()
287            item.setData(Qt.ItemDataRole.UserRole, y)
288            item.setSizeHint(QSize(0, 30))
289            self._units_list.addItem(item)
290            row_w = _UnitRow(y)
291            row_w.delete_requested.connect(self._confirm_delete_yaml)
292            row_w.set_selected(False)
293            self._units_list.setItemWidget(item, row_w)
294
295        if course_dir:
296            tpl_yaml = course_dir / 'template' / 'template.yaml'
297            if tpl_yaml.exists():
298                self._yamls.append(tpl_yaml)
299                item = QListWidgetItem()
300                item.setData(Qt.ItemDataRole.UserRole, tpl_yaml)
301                item.setSizeHint(QSize(0, 30))
302                self._units_list.addItem(item)
303                row_w = _UnitRow(tpl_yaml, deletable=False, label=_('⚙  template.yaml'))
304                row_w.set_selected(False)
305                self._units_list.setItemWidget(item, row_w)
306        self._units_list.blockSignals(False)
307        if self._yamls:
308            target = 0
309            if restore_yaml and restore_yaml in self._yamls:
310                target = self._yamls.index(restore_yaml)
311            self._units_list.setCurrentRow(target)
312            self._update_row_colors(target)
313            self.unit_changed.emit(self._yamls[target])
314
315    def _update_row_colors(self, selected_row: int) -> None:
316        for i in range(self._units_list.count()):
317            w = self._units_list.itemWidget(self._units_list.item(i))
318            if isinstance(w, _UnitRow):
319                w.set_selected(i == selected_row)
320
321    def _confirm_delete_yaml(self, yaml_path: Path) -> None:
322        dlg = QMessageBox(self)
323        dlg.setWindowTitle(_('Borrar presentación'))
324        dlg.setText(_('¿Borrar <b>{}</b>?').format(yaml_path.name))
325        dlg.setInformativeText(_('El fichero YAML se eliminará del disco. Esta acción no se puede deshacer.'))
326        dlg.setIcon(QMessageBox.Icon.Warning)
327        btn_del = dlg.addButton(_('Borrar'), QMessageBox.ButtonRole.DestructiveRole)
328        btn_del.setStyleSheet(
329            'background:#FF453A; color:#fff; border:none; border-radius:6px;'
330            ' padding:6px 18px; font-weight:600;'
331        )
332        dlg.addButton(_('Cancelar'), QMessageBox.ButtonRole.RejectRole)
333        dlg.setDefaultButton(btn_del)
334        dlg.exec()
335        if dlg.clickedButton() is btn_del:
336            self._delete_yaml(yaml_path)
337
338    def _delete_yaml(self, yaml_path: Path) -> None:
339        row = self._yamls.index(yaml_path) if yaml_path in self._yamls else -1
340        try:
341            yaml_path.unlink()
342        except OSError as exc:
343            QMessageBox.critical(self, _('Error'), str(exc))
344            return
345        # Selecciona la presentación anterior/siguiente antes de recargar
346        next_row = max(0, row - 1) if row > 0 else 0
347        self._reload_tabs(self._current_course)
348        if self._yamls:
349            target = min(next_row, len(self._yamls) - 1)
350            self._units_list.setCurrentRow(target)
351
352    def _on_unit_row_changed(self, row: int) -> None:
353        self._update_row_colors(row)
354        if 0 <= row < len(self._yamls):
355            save_last_session(None, str(self._yamls[row]))
356            self.unit_changed.emit(self._yamls[row])
357
358    # ── API pública ───────────────────────────────────────────────────────────
359
360    def current_course(self) -> Path | None:
361        return self._current_course
362
363    def current_yaml(self) -> Path | None:
364        row = self._units_list.currentRow()
365        if 0 <= row < len(self._yamls):
366            return self._yamls[row]
367        return None
368
369    def select_course_by_path(self, path: Path) -> None:
370        """Navega al curso con esa ruta si está en la lista."""
371        path = path.resolve()
372        for i, c in enumerate(self._courses):
373            if c.resolve() == path:
374                self._select_course(i)
375                return
376
377    def rebuild_slides(self, data: dict) -> None:
378        self._navigator.rebuild(data)
379
380    def reload_courses(self) -> None:
381        self._refresh_courses()
382
383    def reload_tabs(self, course_dir: Path) -> None:
384        self._reload_tabs(course_dir)
385        # Restaurar selección si el yaml recién creado está en la lista
386        # (el llamador puede cambiar la selección después)
387
388    def select_yaml(self, path: Path) -> None:
389        """Selecciona un YAML concreto en la lista de unidades."""
390        if path in self._yamls:
391            idx = self._yamls.index(path)
392            self._units_list.setCurrentRow(idx)
393
394    # ── Versión del curso ─────────────────────────────────────────────────────
395
396    def _update_version_label(self, course_dir: Path | None) -> None:
397        if course_dir is None:
398            self._lbl_version.setText('')
399            return
400        vtxt = course_dir / 'version.txt'
401        try:
402            ver = vtxt.read_text(encoding='utf-8').strip()
403            self._lbl_version.setText(f'v{ver}')
404        except OSError:
405            self._lbl_version.setText('')
406
407    def _refresh_version_style(self) -> None:
408        from gui.theme import c
409        self._lbl_version.setStyleSheet(
410            f'color: {c("text_muted")}; font-size: 11px;'
411        )
412
413    def refresh_version(self) -> None:
414        """Releer version.txt del curso activo (llamar tras un build exitoso)."""
415        self._update_version_label(self._current_course)
416
417    # ── Edición inline de versión ─────────────────────────────────────────────
418
419    def eventFilter(self, obj, event) -> bool:
420        if obj is self._lbl_version and event.type() == QEvent.Type.MouseButtonPress:
421            self._enter_version_edit()
422            return True
423        if (self._spin_version is not None
424                and obj is self._spin_version
425                and event.type() == QEvent.Type.KeyPress):
426            if event.key() == Qt.Key.Key_Escape:
427                self._cancel_version_edit()
428                return True
429        return super().eventFilter(obj, event)
430
431    def _enter_version_edit(self) -> None:
432        if self._current_course is None:
433            return
434        txt = self._lbl_version.text()
435        try:
436            current_v = int(txt.lstrip('v')) if txt else 0
437        except ValueError:
438            current_v = 0
439        self._spin_version.setValue(current_v)
440        self._lbl_version.setVisible(False)
441        self._spin_version.setVisible(True)
442        self._spin_version.setFocus()
443        self._spin_version.selectAll()
444
445    def _commit_version_edit(self) -> None:
446        if not self._spin_version.isVisible():
447            return
448        from cli.__main__ import _set_version
449        new_v = self._spin_version.value()
450        self._spin_version.blockSignals(True)
451        self._spin_version.setVisible(False)
452        self._spin_version.blockSignals(False)
453        self._lbl_version.setVisible(True)
454        if self._current_course is not None:
455            _set_version(self._current_course, new_v)
456            self._update_version_label(self._current_course)
457
458    def _cancel_version_edit(self) -> None:
459        self._spin_version.blockSignals(True)
460        self._spin_version.setVisible(False)
461        self._spin_version.blockSignals(False)
462        self._lbl_version.setVisible(True)
463
464    # ── Tema ──────────────────────────────────────────────────────────────────
465
466    def _on_theme_changed(self) -> None:
467        self._refresh_version_style()
468        self._update_version_label(self._current_course)
469        self.update()