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