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