gui.panels.sidebar

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