gui.panels.yaml_editor
1from pathlib import Path 2 3import yaml 4 5from PySide6.QtWidgets import ( 6 QWidget, QVBoxLayout, QHBoxLayout, QPlainTextEdit, 7 QPushButton, QLabel, QLineEdit, QTextEdit, 8) 9from PySide6.QtGui import ( 10 QFont, QTextCursor, QKeySequence, QShortcut, 11 QTextCharFormat, QColor, QBrush, 12) 13from PySide6.QtCore import Signal, Qt, QTimer 14 15from gui.panels.slide_assistant import SLIDE_TYPES, SlideAssistant 16 17 18class _AutoIndentEditor(QPlainTextEdit): 19 """QPlainTextEdit con auto-indentado: Enter replica la indentación de la línea actual.""" 20 21 def keyPressEvent(self, event) -> None: 22 if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 23 cursor = self.textCursor() 24 block_text = cursor.block().text() 25 indent = len(block_text) - len(block_text.lstrip(' ')) 26 super().keyPressEvent(event) 27 self.textCursor().insertText(' ' * indent) 28 else: 29 super().keyPressEvent(event) 30from gui.theme import c, signal as theme_signal 31from gui.utils.dropdown_button import DropdownButton 32from gui.utils.yaml_highlighter import YamlHighlighter 33from lib.i18n import _ 34 35 36class YamlEditorPanel(QWidget): 37 content_changed = Signal() 38 saved = Signal(Path) 39 40 def __init__(self, parent=None): 41 super().__init__(parent) 42 self._path: Path | None = None 43 self._dirty = False 44 self._matches: list[QTextCursor] = [] 45 self._match_idx: int = -1 46 self._search_sels: list = [] 47 self._error_sels: list = [] 48 49 self._validate_timer = QTimer(self) 50 self._validate_timer.setSingleShot(True) 51 self._validate_timer.setInterval(400) 52 self._validate_timer.timeout.connect(self._validate_yaml) 53 54 from lib.slides_config import get_autosave_seconds 55 _autosave_secs = get_autosave_seconds() 56 self._autosave_timer: QTimer | None = None 57 if _autosave_secs > 0: 58 self._autosave_timer = QTimer(self) 59 self._autosave_timer.setInterval(_autosave_secs * 1000) 60 self._autosave_timer.timeout.connect(self._autosave) 61 self._autosave_timer.start() 62 63 self._build_ui() 64 65 theme_signal().changed.connect(self._on_theme_changed) 66 67 # ── Construcción ───────────────────────────────────────────────────────── 68 69 def _build_ui(self) -> None: 70 layout = QVBoxLayout(self) 71 layout.setContentsMargins(0, 0, 0, 0) 72 layout.setSpacing(0) 73 74 layout.addWidget(self._make_editor_bar()) 75 76 self._error_bar_widget = self._make_error_bar() 77 self._error_bar_widget.setVisible(False) 78 layout.addWidget(self._error_bar_widget) 79 80 self._search_bar_widget = self._make_search_bar() 81 self._search_bar_widget.setVisible(False) 82 layout.addWidget(self._search_bar_widget) 83 84 self._editor = _AutoIndentEditor() 85 self._editor.setObjectName('yaml_editor') 86 self._editor.setFont(QFont('Liberation Mono', 12)) 87 self._editor.textChanged.connect(self._on_text_changed) 88 layout.addWidget(self._editor) 89 90 from gui import theme as _t 91 self._highlighter = YamlHighlighter(self._editor.document(), _t.get()) 92 93 QShortcut(QKeySequence('Ctrl+S'), self).activated.connect(self.save) 94 QShortcut(QKeySequence('Ctrl+F'), self).activated.connect(self._open_search) 95 QShortcut(QKeySequence('Escape'), self._editor).activated.connect(self._close_search) 96 97 def _make_editor_bar(self) -> QWidget: 98 bar = QWidget() 99 bar.setObjectName('file_bar') 100 bar.setFixedHeight(40) 101 hlay = QHBoxLayout(bar) 102 hlay.setContentsMargins(10, 0, 10, 0) 103 hlay.setSpacing(6) 104 105 # Zona izquierda — tipo de snippet + insertar 106 self._tipo_combo = DropdownButton() 107 self._tipo_combo.addItems(SLIDE_TYPES) 108 self._tipo_combo.setFixedWidth(150) 109 hlay.addWidget(self._tipo_combo) 110 111 btn_insert = QPushButton(_('Insertar')) 112 btn_insert.setObjectName('insert_btn') 113 btn_insert.setFixedHeight(26) 114 btn_insert.setToolTip(_('Insertar snippet en el cursor (↩)')) 115 btn_insert.clicked.connect(self._insert_snippet) 116 hlay.addWidget(btn_insert) 117 118 hlay.addStretch(1) 119 120 # Zona central — nombre de fichero (anchor visual) 121 self._lbl_file = QLabel('—') 122 self._lbl_file.setObjectName('file_name_label') 123 self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter) 124 self._lbl_file.setMinimumWidth(100) 125 hlay.addWidget(self._lbl_file) 126 127 hlay.addStretch(1) 128 129 # Zona derecha — Revertir (ghost sutil, activo solo cuando hay cambios) 130 self._btn_revert = QPushButton(_('Revertir')) 131 self._btn_revert.setObjectName('revert_btn') 132 self._btn_revert.setFixedHeight(26) 133 self._btn_revert.setToolTip(_('Descartar cambios desde el último guardado')) 134 self._btn_revert.setEnabled(False) 135 self._btn_revert.clicked.connect(self.revert) 136 hlay.addWidget(self._btn_revert) 137 138 return bar 139 140 def _make_error_bar(self) -> QWidget: 141 bar = QWidget() 142 bar.setObjectName('yaml_error_bar') 143 bar.setFixedHeight(24) 144 hlay = QHBoxLayout(bar) 145 hlay.setContentsMargins(12, 2, 12, 2) 146 hlay.setSpacing(4) 147 self._error_label = QLabel('') 148 self._error_label.setObjectName('yaml_error_label') 149 hlay.addWidget(self._error_label) 150 hlay.addStretch() 151 return bar 152 153 def _make_search_bar(self) -> QWidget: 154 bar = QWidget() 155 bar.setObjectName('search_bar') 156 bar.setFixedHeight(44) 157 hlay = QHBoxLayout(bar) 158 hlay.setContentsMargins(12, 6, 12, 6) 159 hlay.setSpacing(8) 160 161 self._search_input = QLineEdit() 162 self._search_input.setObjectName('search_input') 163 self._search_input.setPlaceholderText(_('Buscar… (Intro=siguiente, Shift+Intro=anterior)')) 164 self._search_input.setFixedWidth(320) 165 self._search_input.textChanged.connect(self._update_search) 166 self._search_input.returnPressed.connect(self._search_next) 167 hlay.addWidget(self._search_input) 168 169 self._search_info = QLabel('') 170 self._search_info.setObjectName('search_info') 171 self._search_info.setMinimumWidth(52) 172 self._search_info.setAlignment(Qt.AlignmentFlag.AlignCenter) 173 hlay.addWidget(self._search_info) 174 175 for text, tip, slot in ( 176 ('↑', _('Anterior (Shift+Intro)'), self._search_prev), 177 ('↓', _('Siguiente (Intro)'), self._search_next), 178 ): 179 btn = QPushButton(text) 180 btn.setObjectName('bar_btn') 181 btn.setFixedSize(28, 28) 182 btn.setToolTip(tip) 183 btn.clicked.connect(slot) 184 hlay.addWidget(btn) 185 186 btn_close = QPushButton('✕') 187 btn_close.setObjectName('bar_btn') 188 btn_close.setFixedSize(28, 28) 189 btn_close.setToolTip(_('Cerrar (Esc)')) 190 btn_close.clicked.connect(self._close_search) 191 hlay.addWidget(btn_close) 192 193 hlay.addStretch() 194 195 QShortcut(QKeySequence('Shift+Return'), self._search_input).activated.connect( 196 self._search_prev 197 ) 198 QShortcut(QKeySequence('Escape'), self._search_input).activated.connect( 199 self._close_search 200 ) 201 return bar 202 203 # ── API pública ─────────────────────────────────────────────────────────── 204 205 def open_file(self, path: Path) -> None: 206 self._path = path 207 self._editor.blockSignals(True) 208 self._editor.setPlainText(path.read_text(encoding='utf-8')) 209 self._editor.blockSignals(False) 210 self._set_dirty(False) 211 self._validate_yaml() 212 if self._search_bar_widget.isVisible(): 213 self._update_search() 214 215 def save(self) -> None: 216 if self._path is None: 217 return 218 self._path.write_text(self._editor.toPlainText(), encoding='utf-8') 219 self._set_dirty(False) 220 self.saved.emit(self._path) 221 222 def revert(self) -> None: 223 if self._path is None: 224 return 225 self._editor.blockSignals(True) 226 self._editor.setPlainText(self._path.read_text(encoding='utf-8')) 227 self._editor.blockSignals(False) 228 self._set_dirty(False) 229 230 def scroll_to_slide(self, section_idx: int, slide_idx: int) -> None: 231 line = self._find_line(section_idx, slide_idx) 232 self._go_to_line(line) 233 234 def scroll_to_field(self, field: str) -> None: 235 lines = self._editor.toPlainText().splitlines() 236 for i, ln in enumerate(lines): 237 if ln.startswith(f'{field}:'): 238 self._go_to_line(i) 239 return 240 241 def toPlainText(self) -> str: 242 return self._editor.toPlainText() 243 244 def set_text(self, text: str) -> None: 245 self._editor.setPlainText(text) 246 247 # ── Búsqueda ───────────────────────────────────────────────────────────── 248 249 def _open_search(self) -> None: 250 self._search_bar_widget.setVisible(True) 251 selected = self._editor.textCursor().selectedText().strip() 252 if selected and '\n' not in selected: 253 self._search_input.setText(selected) 254 self._search_input.setFocus() 255 self._search_input.selectAll() 256 self._update_search() 257 258 def _close_search(self) -> None: 259 if not self._search_bar_widget.isVisible(): 260 return 261 self._search_bar_widget.setVisible(False) 262 self._search_sels = [] 263 self._matches = [] 264 self._match_idx = -1 265 self._update_extra_selections() 266 self._editor.setFocus() 267 268 def _update_search(self) -> None: 269 query = self._search_input.text() 270 self._matches = [] 271 self._match_idx = -1 272 self._search_sels = [] 273 274 if not query: 275 self._search_info.setText('') 276 self._search_input.setStyleSheet('') 277 self._update_extra_selections() 278 return 279 280 doc = self._editor.document() 281 cursor = QTextCursor(doc) 282 while True: 283 cursor = doc.find(query, cursor) 284 if cursor.isNull(): 285 break 286 self._matches.append(QTextCursor(cursor)) 287 288 if self._matches: 289 self._search_input.setStyleSheet('') 290 self._match_idx = 0 291 self._apply_highlights() 292 self._goto_match(0) 293 else: 294 self._search_info.setText(_('—')) 295 self._search_input.setStyleSheet(f'border-color: {c("error")};') 296 self._update_extra_selections() 297 298 def _apply_highlights(self) -> None: 299 fmt_all = QTextCharFormat() 300 fmt_all.setBackground(QBrush(QColor(245, 158, 11, 80))) 301 302 fmt_current = QTextCharFormat() 303 fmt_current.setBackground(QBrush(QColor(59, 130, 246, 160))) 304 fmt_current.setForeground(QBrush(QColor('#ffffff'))) 305 306 self._search_sels = [] 307 for i, cur in enumerate(self._matches): 308 sel = QTextEdit.ExtraSelection() 309 sel.cursor = cur 310 sel.format = fmt_current if i == self._match_idx else fmt_all 311 self._search_sels.append(sel) 312 313 self._update_extra_selections() 314 n = len(self._matches) 315 self._search_info.setText(f'{self._match_idx + 1}/{n}') 316 317 def _goto_match(self, idx: int) -> None: 318 if not self._matches: 319 return 320 self._match_idx = idx % len(self._matches) 321 self._apply_highlights() 322 self._editor.setTextCursor(self._matches[self._match_idx]) 323 self._editor.ensureCursorVisible() 324 325 def _search_next(self) -> None: 326 if self._matches: 327 self._goto_match(self._match_idx + 1) 328 329 def _search_prev(self) -> None: 330 if self._matches: 331 self._goto_match(self._match_idx - 1) 332 333 # ── Validación YAML ─────────────────────────────────────────────────────── 334 335 def _validate_yaml(self) -> None: 336 text = self._editor.toPlainText() 337 self._error_sels = [] 338 try: 339 yaml.safe_load(text) 340 self._error_bar_widget.setVisible(False) 341 except yaml.YAMLError as exc: 342 line = -1 343 msg = str(exc).split('\n')[0] 344 if hasattr(exc, 'problem_mark') and exc.problem_mark: 345 line = exc.problem_mark.line 346 msg = f'Línea {line + 1}: {exc.problem or msg}' 347 self._error_label.setText(msg) 348 self._error_bar_widget.setVisible(True) 349 if line >= 0: 350 block = self._editor.document().findBlockByLineNumber(line) 351 cursor = QTextCursor(block) 352 cursor.select(QTextCursor.SelectionType.LineUnderCursor) 353 fmt = QTextCharFormat() 354 fmt.setBackground(QBrush(QColor(239, 68, 68, 55))) 355 fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline) 356 fmt.setUnderlineColor(QColor('#ef4444')) 357 sel = QTextEdit.ExtraSelection() 358 sel.cursor = cursor 359 sel.format = fmt 360 self._error_sels = [sel] 361 self._update_extra_selections() 362 363 def _update_extra_selections(self) -> None: 364 self._editor.setExtraSelections(self._search_sels + self._error_sels) 365 366 # ── Snippets ───────────────────────────────────────────────────────────── 367 368 def _insert_snippet(self) -> None: 369 tipo = self._tipo_combo.currentText() 370 snippet = SlideAssistant.get_snippet(tipo) 371 cursor = self._editor.textCursor() 372 373 current_line = cursor.block().text() 374 indent = self._detect_slide_indent(cursor) 375 adjusted = self._adjust_indent(snippet, indent) 376 377 if current_line.strip(): 378 # Línea con contenido: insertar al final en nueva línea 379 cursor.movePosition(QTextCursor.MoveOperation.EndOfLine) 380 cursor.insertText('\n' + adjusted) 381 else: 382 # Línea en blanco: reemplazar respetando la indentación del contexto 383 cursor.select(QTextCursor.SelectionType.BlockUnderCursor) 384 cursor.insertText(adjusted.rstrip('\n')) 385 386 self._editor.setTextCursor(cursor) 387 self._editor.ensureCursorVisible() 388 389 def _detect_slide_indent(self, cursor: QTextCursor) -> int: 390 """Busca hacia arriba el nivel de indentación correcto para insertar un slide. 391 392 Ignora la línea actual (que puede ser una propiedad con más indent) 393 y sube hasta encontrar un marcador semántico de contexto. 394 """ 395 b = cursor.block() 396 while b.isValid(): 397 text = b.text() 398 stripped = text.lstrip() 399 if stripped.startswith('- tipo:'): 400 return len(text) - len(stripped) 401 if stripped.startswith('slides:'): 402 return (len(text) - len(stripped)) + 2 403 if stripped.startswith('- indice:') or stripped.startswith('- titulo:'): 404 return (len(text) - len(stripped)) + 4 405 b = b.previous() 406 return 6 # fallback: indentación estándar del formato 407 408 @staticmethod 409 def _adjust_indent(snippet: str, to_indent: int, from_indent: int = 6) -> str: 410 """Reindenta el snippet de from_indent a to_indent espacios.""" 411 if from_indent == to_indent: 412 return snippet 413 delta = to_indent - from_indent 414 result = [] 415 for line in snippet.split('\n'): 416 if not line.strip(): 417 result.append(line) 418 elif delta > 0: 419 result.append(' ' * delta + line) 420 else: 421 strip = min(-delta, len(line) - len(line.lstrip())) 422 result.append(line[strip:]) 423 return '\n'.join(result) 424 425 # ── Búsqueda de línea ───────────────────────────────────────────────────── 426 427 def _find_line(self, section_idx: int, slide_idx: int) -> int: 428 lines = self._editor.toPlainText().splitlines() 429 section_starts = [ 430 i for i, ln in enumerate(lines) 431 if ln.lstrip().startswith('- indice:') or ln.lstrip().startswith('- titulo:') 432 ] 433 if section_idx < 0 or section_idx >= len(section_starts): 434 return 0 435 sec_start = section_starts[section_idx] 436 sec_end = ( 437 section_starts[section_idx + 1] 438 if section_idx + 1 < len(section_starts) 439 else len(lines) 440 ) 441 if slide_idx == -1: 442 return sec_start 443 count = 0 444 for i in range(sec_start, sec_end): 445 if lines[i].lstrip().startswith('- tipo:'): 446 if count == slide_idx: 447 return i 448 count += 1 449 return sec_start 450 451 def _go_to_line(self, line: int) -> None: 452 block = self._editor.document().findBlockByLineNumber(line) 453 cursor = QTextCursor(block) 454 self._editor.setTextCursor(cursor) 455 self._editor.ensureCursorVisible() 456 457 # ── Estado dirty ────────────────────────────────────────────────────────── 458 459 def _on_text_changed(self) -> None: 460 self._set_dirty(True) 461 self.content_changed.emit() 462 self._validate_timer.start() 463 if self._search_bar_widget.isVisible() and self._search_input.text(): 464 self._update_search() 465 466 def _set_dirty(self, dirty: bool, _force: bool = False) -> None: 467 state_changed = dirty != self._dirty or _force 468 self._dirty = dirty 469 if self._path: 470 self._lbl_file.setText(f'{self._path.name} ●' if dirty else self._path.name) 471 if state_changed: 472 color = c('text') if dirty else c('text_muted') 473 self._lbl_file.setStyleSheet( 474 f'color:{color}; font-size:13px; font-weight:500;' 475 ) 476 else: 477 self._lbl_file.setText('—') 478 if state_changed: 479 self._lbl_file.setStyleSheet( 480 f'color:{c("text_muted")}; font-size:13px; font-weight:500;' 481 ) 482 self._btn_revert.setEnabled(bool(dirty and self._path)) 483 484 def _autosave(self) -> None: 485 if self._dirty and self._path is not None: 486 self.save() 487 488 def _on_theme_changed(self) -> None: 489 self._set_dirty(self._dirty, _force=True) 490 from gui import theme as _t 491 self._highlighter.set_theme(_t.get())
37class YamlEditorPanel(QWidget): 38 content_changed = Signal() 39 saved = Signal(Path) 40 41 def __init__(self, parent=None): 42 super().__init__(parent) 43 self._path: Path | None = None 44 self._dirty = False 45 self._matches: list[QTextCursor] = [] 46 self._match_idx: int = -1 47 self._search_sels: list = [] 48 self._error_sels: list = [] 49 50 self._validate_timer = QTimer(self) 51 self._validate_timer.setSingleShot(True) 52 self._validate_timer.setInterval(400) 53 self._validate_timer.timeout.connect(self._validate_yaml) 54 55 from lib.slides_config import get_autosave_seconds 56 _autosave_secs = get_autosave_seconds() 57 self._autosave_timer: QTimer | None = None 58 if _autosave_secs > 0: 59 self._autosave_timer = QTimer(self) 60 self._autosave_timer.setInterval(_autosave_secs * 1000) 61 self._autosave_timer.timeout.connect(self._autosave) 62 self._autosave_timer.start() 63 64 self._build_ui() 65 66 theme_signal().changed.connect(self._on_theme_changed) 67 68 # ── Construcción ───────────────────────────────────────────────────────── 69 70 def _build_ui(self) -> None: 71 layout = QVBoxLayout(self) 72 layout.setContentsMargins(0, 0, 0, 0) 73 layout.setSpacing(0) 74 75 layout.addWidget(self._make_editor_bar()) 76 77 self._error_bar_widget = self._make_error_bar() 78 self._error_bar_widget.setVisible(False) 79 layout.addWidget(self._error_bar_widget) 80 81 self._search_bar_widget = self._make_search_bar() 82 self._search_bar_widget.setVisible(False) 83 layout.addWidget(self._search_bar_widget) 84 85 self._editor = _AutoIndentEditor() 86 self._editor.setObjectName('yaml_editor') 87 self._editor.setFont(QFont('Liberation Mono', 12)) 88 self._editor.textChanged.connect(self._on_text_changed) 89 layout.addWidget(self._editor) 90 91 from gui import theme as _t 92 self._highlighter = YamlHighlighter(self._editor.document(), _t.get()) 93 94 QShortcut(QKeySequence('Ctrl+S'), self).activated.connect(self.save) 95 QShortcut(QKeySequence('Ctrl+F'), self).activated.connect(self._open_search) 96 QShortcut(QKeySequence('Escape'), self._editor).activated.connect(self._close_search) 97 98 def _make_editor_bar(self) -> QWidget: 99 bar = QWidget() 100 bar.setObjectName('file_bar') 101 bar.setFixedHeight(40) 102 hlay = QHBoxLayout(bar) 103 hlay.setContentsMargins(10, 0, 10, 0) 104 hlay.setSpacing(6) 105 106 # Zona izquierda — tipo de snippet + insertar 107 self._tipo_combo = DropdownButton() 108 self._tipo_combo.addItems(SLIDE_TYPES) 109 self._tipo_combo.setFixedWidth(150) 110 hlay.addWidget(self._tipo_combo) 111 112 btn_insert = QPushButton(_('Insertar')) 113 btn_insert.setObjectName('insert_btn') 114 btn_insert.setFixedHeight(26) 115 btn_insert.setToolTip(_('Insertar snippet en el cursor (↩)')) 116 btn_insert.clicked.connect(self._insert_snippet) 117 hlay.addWidget(btn_insert) 118 119 hlay.addStretch(1) 120 121 # Zona central — nombre de fichero (anchor visual) 122 self._lbl_file = QLabel('—') 123 self._lbl_file.setObjectName('file_name_label') 124 self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter) 125 self._lbl_file.setMinimumWidth(100) 126 hlay.addWidget(self._lbl_file) 127 128 hlay.addStretch(1) 129 130 # Zona derecha — Revertir (ghost sutil, activo solo cuando hay cambios) 131 self._btn_revert = QPushButton(_('Revertir')) 132 self._btn_revert.setObjectName('revert_btn') 133 self._btn_revert.setFixedHeight(26) 134 self._btn_revert.setToolTip(_('Descartar cambios desde el último guardado')) 135 self._btn_revert.setEnabled(False) 136 self._btn_revert.clicked.connect(self.revert) 137 hlay.addWidget(self._btn_revert) 138 139 return bar 140 141 def _make_error_bar(self) -> QWidget: 142 bar = QWidget() 143 bar.setObjectName('yaml_error_bar') 144 bar.setFixedHeight(24) 145 hlay = QHBoxLayout(bar) 146 hlay.setContentsMargins(12, 2, 12, 2) 147 hlay.setSpacing(4) 148 self._error_label = QLabel('') 149 self._error_label.setObjectName('yaml_error_label') 150 hlay.addWidget(self._error_label) 151 hlay.addStretch() 152 return bar 153 154 def _make_search_bar(self) -> QWidget: 155 bar = QWidget() 156 bar.setObjectName('search_bar') 157 bar.setFixedHeight(44) 158 hlay = QHBoxLayout(bar) 159 hlay.setContentsMargins(12, 6, 12, 6) 160 hlay.setSpacing(8) 161 162 self._search_input = QLineEdit() 163 self._search_input.setObjectName('search_input') 164 self._search_input.setPlaceholderText(_('Buscar… (Intro=siguiente, Shift+Intro=anterior)')) 165 self._search_input.setFixedWidth(320) 166 self._search_input.textChanged.connect(self._update_search) 167 self._search_input.returnPressed.connect(self._search_next) 168 hlay.addWidget(self._search_input) 169 170 self._search_info = QLabel('') 171 self._search_info.setObjectName('search_info') 172 self._search_info.setMinimumWidth(52) 173 self._search_info.setAlignment(Qt.AlignmentFlag.AlignCenter) 174 hlay.addWidget(self._search_info) 175 176 for text, tip, slot in ( 177 ('↑', _('Anterior (Shift+Intro)'), self._search_prev), 178 ('↓', _('Siguiente (Intro)'), self._search_next), 179 ): 180 btn = QPushButton(text) 181 btn.setObjectName('bar_btn') 182 btn.setFixedSize(28, 28) 183 btn.setToolTip(tip) 184 btn.clicked.connect(slot) 185 hlay.addWidget(btn) 186 187 btn_close = QPushButton('✕') 188 btn_close.setObjectName('bar_btn') 189 btn_close.setFixedSize(28, 28) 190 btn_close.setToolTip(_('Cerrar (Esc)')) 191 btn_close.clicked.connect(self._close_search) 192 hlay.addWidget(btn_close) 193 194 hlay.addStretch() 195 196 QShortcut(QKeySequence('Shift+Return'), self._search_input).activated.connect( 197 self._search_prev 198 ) 199 QShortcut(QKeySequence('Escape'), self._search_input).activated.connect( 200 self._close_search 201 ) 202 return bar 203 204 # ── API pública ─────────────────────────────────────────────────────────── 205 206 def open_file(self, path: Path) -> None: 207 self._path = path 208 self._editor.blockSignals(True) 209 self._editor.setPlainText(path.read_text(encoding='utf-8')) 210 self._editor.blockSignals(False) 211 self._set_dirty(False) 212 self._validate_yaml() 213 if self._search_bar_widget.isVisible(): 214 self._update_search() 215 216 def save(self) -> None: 217 if self._path is None: 218 return 219 self._path.write_text(self._editor.toPlainText(), encoding='utf-8') 220 self._set_dirty(False) 221 self.saved.emit(self._path) 222 223 def revert(self) -> None: 224 if self._path is None: 225 return 226 self._editor.blockSignals(True) 227 self._editor.setPlainText(self._path.read_text(encoding='utf-8')) 228 self._editor.blockSignals(False) 229 self._set_dirty(False) 230 231 def scroll_to_slide(self, section_idx: int, slide_idx: int) -> None: 232 line = self._find_line(section_idx, slide_idx) 233 self._go_to_line(line) 234 235 def scroll_to_field(self, field: str) -> None: 236 lines = self._editor.toPlainText().splitlines() 237 for i, ln in enumerate(lines): 238 if ln.startswith(f'{field}:'): 239 self._go_to_line(i) 240 return 241 242 def toPlainText(self) -> str: 243 return self._editor.toPlainText() 244 245 def set_text(self, text: str) -> None: 246 self._editor.setPlainText(text) 247 248 # ── Búsqueda ───────────────────────────────────────────────────────────── 249 250 def _open_search(self) -> None: 251 self._search_bar_widget.setVisible(True) 252 selected = self._editor.textCursor().selectedText().strip() 253 if selected and '\n' not in selected: 254 self._search_input.setText(selected) 255 self._search_input.setFocus() 256 self._search_input.selectAll() 257 self._update_search() 258 259 def _close_search(self) -> None: 260 if not self._search_bar_widget.isVisible(): 261 return 262 self._search_bar_widget.setVisible(False) 263 self._search_sels = [] 264 self._matches = [] 265 self._match_idx = -1 266 self._update_extra_selections() 267 self._editor.setFocus() 268 269 def _update_search(self) -> None: 270 query = self._search_input.text() 271 self._matches = [] 272 self._match_idx = -1 273 self._search_sels = [] 274 275 if not query: 276 self._search_info.setText('') 277 self._search_input.setStyleSheet('') 278 self._update_extra_selections() 279 return 280 281 doc = self._editor.document() 282 cursor = QTextCursor(doc) 283 while True: 284 cursor = doc.find(query, cursor) 285 if cursor.isNull(): 286 break 287 self._matches.append(QTextCursor(cursor)) 288 289 if self._matches: 290 self._search_input.setStyleSheet('') 291 self._match_idx = 0 292 self._apply_highlights() 293 self._goto_match(0) 294 else: 295 self._search_info.setText(_('—')) 296 self._search_input.setStyleSheet(f'border-color: {c("error")};') 297 self._update_extra_selections() 298 299 def _apply_highlights(self) -> None: 300 fmt_all = QTextCharFormat() 301 fmt_all.setBackground(QBrush(QColor(245, 158, 11, 80))) 302 303 fmt_current = QTextCharFormat() 304 fmt_current.setBackground(QBrush(QColor(59, 130, 246, 160))) 305 fmt_current.setForeground(QBrush(QColor('#ffffff'))) 306 307 self._search_sels = [] 308 for i, cur in enumerate(self._matches): 309 sel = QTextEdit.ExtraSelection() 310 sel.cursor = cur 311 sel.format = fmt_current if i == self._match_idx else fmt_all 312 self._search_sels.append(sel) 313 314 self._update_extra_selections() 315 n = len(self._matches) 316 self._search_info.setText(f'{self._match_idx + 1}/{n}') 317 318 def _goto_match(self, idx: int) -> None: 319 if not self._matches: 320 return 321 self._match_idx = idx % len(self._matches) 322 self._apply_highlights() 323 self._editor.setTextCursor(self._matches[self._match_idx]) 324 self._editor.ensureCursorVisible() 325 326 def _search_next(self) -> None: 327 if self._matches: 328 self._goto_match(self._match_idx + 1) 329 330 def _search_prev(self) -> None: 331 if self._matches: 332 self._goto_match(self._match_idx - 1) 333 334 # ── Validación YAML ─────────────────────────────────────────────────────── 335 336 def _validate_yaml(self) -> None: 337 text = self._editor.toPlainText() 338 self._error_sels = [] 339 try: 340 yaml.safe_load(text) 341 self._error_bar_widget.setVisible(False) 342 except yaml.YAMLError as exc: 343 line = -1 344 msg = str(exc).split('\n')[0] 345 if hasattr(exc, 'problem_mark') and exc.problem_mark: 346 line = exc.problem_mark.line 347 msg = f'Línea {line + 1}: {exc.problem or msg}' 348 self._error_label.setText(msg) 349 self._error_bar_widget.setVisible(True) 350 if line >= 0: 351 block = self._editor.document().findBlockByLineNumber(line) 352 cursor = QTextCursor(block) 353 cursor.select(QTextCursor.SelectionType.LineUnderCursor) 354 fmt = QTextCharFormat() 355 fmt.setBackground(QBrush(QColor(239, 68, 68, 55))) 356 fmt.setUnderlineStyle(QTextCharFormat.UnderlineStyle.WaveUnderline) 357 fmt.setUnderlineColor(QColor('#ef4444')) 358 sel = QTextEdit.ExtraSelection() 359 sel.cursor = cursor 360 sel.format = fmt 361 self._error_sels = [sel] 362 self._update_extra_selections() 363 364 def _update_extra_selections(self) -> None: 365 self._editor.setExtraSelections(self._search_sels + self._error_sels) 366 367 # ── Snippets ───────────────────────────────────────────────────────────── 368 369 def _insert_snippet(self) -> None: 370 tipo = self._tipo_combo.currentText() 371 snippet = SlideAssistant.get_snippet(tipo) 372 cursor = self._editor.textCursor() 373 374 current_line = cursor.block().text() 375 indent = self._detect_slide_indent(cursor) 376 adjusted = self._adjust_indent(snippet, indent) 377 378 if current_line.strip(): 379 # Línea con contenido: insertar al final en nueva línea 380 cursor.movePosition(QTextCursor.MoveOperation.EndOfLine) 381 cursor.insertText('\n' + adjusted) 382 else: 383 # Línea en blanco: reemplazar respetando la indentación del contexto 384 cursor.select(QTextCursor.SelectionType.BlockUnderCursor) 385 cursor.insertText(adjusted.rstrip('\n')) 386 387 self._editor.setTextCursor(cursor) 388 self._editor.ensureCursorVisible() 389 390 def _detect_slide_indent(self, cursor: QTextCursor) -> int: 391 """Busca hacia arriba el nivel de indentación correcto para insertar un slide. 392 393 Ignora la línea actual (que puede ser una propiedad con más indent) 394 y sube hasta encontrar un marcador semántico de contexto. 395 """ 396 b = cursor.block() 397 while b.isValid(): 398 text = b.text() 399 stripped = text.lstrip() 400 if stripped.startswith('- tipo:'): 401 return len(text) - len(stripped) 402 if stripped.startswith('slides:'): 403 return (len(text) - len(stripped)) + 2 404 if stripped.startswith('- indice:') or stripped.startswith('- titulo:'): 405 return (len(text) - len(stripped)) + 4 406 b = b.previous() 407 return 6 # fallback: indentación estándar del formato 408 409 @staticmethod 410 def _adjust_indent(snippet: str, to_indent: int, from_indent: int = 6) -> str: 411 """Reindenta el snippet de from_indent a to_indent espacios.""" 412 if from_indent == to_indent: 413 return snippet 414 delta = to_indent - from_indent 415 result = [] 416 for line in snippet.split('\n'): 417 if not line.strip(): 418 result.append(line) 419 elif delta > 0: 420 result.append(' ' * delta + line) 421 else: 422 strip = min(-delta, len(line) - len(line.lstrip())) 423 result.append(line[strip:]) 424 return '\n'.join(result) 425 426 # ── Búsqueda de línea ───────────────────────────────────────────────────── 427 428 def _find_line(self, section_idx: int, slide_idx: int) -> int: 429 lines = self._editor.toPlainText().splitlines() 430 section_starts = [ 431 i for i, ln in enumerate(lines) 432 if ln.lstrip().startswith('- indice:') or ln.lstrip().startswith('- titulo:') 433 ] 434 if section_idx < 0 or section_idx >= len(section_starts): 435 return 0 436 sec_start = section_starts[section_idx] 437 sec_end = ( 438 section_starts[section_idx + 1] 439 if section_idx + 1 < len(section_starts) 440 else len(lines) 441 ) 442 if slide_idx == -1: 443 return sec_start 444 count = 0 445 for i in range(sec_start, sec_end): 446 if lines[i].lstrip().startswith('- tipo:'): 447 if count == slide_idx: 448 return i 449 count += 1 450 return sec_start 451 452 def _go_to_line(self, line: int) -> None: 453 block = self._editor.document().findBlockByLineNumber(line) 454 cursor = QTextCursor(block) 455 self._editor.setTextCursor(cursor) 456 self._editor.ensureCursorVisible() 457 458 # ── Estado dirty ────────────────────────────────────────────────────────── 459 460 def _on_text_changed(self) -> None: 461 self._set_dirty(True) 462 self.content_changed.emit() 463 self._validate_timer.start() 464 if self._search_bar_widget.isVisible() and self._search_input.text(): 465 self._update_search() 466 467 def _set_dirty(self, dirty: bool, _force: bool = False) -> None: 468 state_changed = dirty != self._dirty or _force 469 self._dirty = dirty 470 if self._path: 471 self._lbl_file.setText(f'{self._path.name} ●' if dirty else self._path.name) 472 if state_changed: 473 color = c('text') if dirty else c('text_muted') 474 self._lbl_file.setStyleSheet( 475 f'color:{color}; font-size:13px; font-weight:500;' 476 ) 477 else: 478 self._lbl_file.setText('—') 479 if state_changed: 480 self._lbl_file.setStyleSheet( 481 f'color:{c("text_muted")}; font-size:13px; font-weight:500;' 482 ) 483 self._btn_revert.setEnabled(bool(dirty and self._path)) 484 485 def _autosave(self) -> None: 486 if self._dirty and self._path is not None: 487 self.save() 488 489 def _on_theme_changed(self) -> None: 490 self._set_dirty(self._dirty, _force=True) 491 from gui import theme as _t 492 self._highlighter.set_theme(_t.get())
QWidget(self, /, parent: PySide6.QtWidgets.QWidget | None = None, f: PySide6.QtCore.Qt.WindowType = Default(Qt.WindowFlags), *, modal: bool | None = None, windowModality: PySide6.QtCore.Qt.WindowModality | None = None, enabled: bool | None = None, geometry: PySide6.QtCore.QRect | None = None, frameGeometry: PySide6.QtCore.QRect | None = None, normalGeometry: PySide6.QtCore.QRect | None = None, x: int | None = None, y: int | None = None, pos: PySide6.QtCore.QPoint | None = None, frameSize: PySide6.QtCore.QSize | None = None, size: PySide6.QtCore.QSize | None = None, width: int | None = None, height: int | None = None, rect: PySide6.QtCore.QRect | None = None, childrenRect: PySide6.QtCore.QRect | None = None, childrenRegion: PySide6.QtGui.QRegion | None = None, sizePolicy: PySide6.QtWidgets.QSizePolicy | None = None, minimumSize: PySide6.QtCore.QSize | None = None, maximumSize: PySide6.QtCore.QSize | None = None, minimumWidth: int | None = None, minimumHeight: int | None = None, maximumWidth: int | None = None, maximumHeight: int | None = None, sizeIncrement: PySide6.QtCore.QSize | None = None, baseSize: PySide6.QtCore.QSize | None = None, palette: PySide6.QtGui.QPalette | None = None, font: PySide6.QtGui.QFont | None = None, cursor: PySide6.QtGui.QCursor | None = None, mouseTracking: bool | None = None, tabletTracking: bool | None = None, isActiveWindow: bool | None = None, focusPolicy: PySide6.QtCore.Qt.FocusPolicy | None = None, focus: bool | None = None, contextMenuPolicy: PySide6.QtCore.Qt.ContextMenuPolicy | None = None, updatesEnabled: bool | None = None, visible: bool | None = None, minimized: bool | None = None, maximized: bool | None = None, fullScreen: bool | None = None, sizeHint: PySide6.QtCore.QSize | None = None, minimumSizeHint: PySide6.QtCore.QSize | None = None, acceptDrops: bool | None = None, windowTitle: str | None = None, windowIcon: PySide6.QtGui.QIcon | None = None, windowIconText: str | None = None, windowOpacity: float | None = None, windowModified: bool | None = None, toolTip: str | None = None, toolTipDuration: int | None = None, statusTip: str | None = None, whatsThis: str | None = None, accessibleName: str | None = None, accessibleDescription: str | None = None, accessibleIdentifier: str | None = None, layoutDirection: PySide6.QtCore.Qt.LayoutDirection | None = None, autoFillBackground: bool | None = None, styleSheet: str | None = None, locale: PySide6.QtCore.QLocale | None = None, windowFilePath: str | None = None, inputMethodHints: PySide6.QtCore.Qt.InputMethodHint | None = None) -> None
41 def __init__(self, parent=None): 42 super().__init__(parent) 43 self._path: Path | None = None 44 self._dirty = False 45 self._matches: list[QTextCursor] = [] 46 self._match_idx: int = -1 47 self._search_sels: list = [] 48 self._error_sels: list = [] 49 50 self._validate_timer = QTimer(self) 51 self._validate_timer.setSingleShot(True) 52 self._validate_timer.setInterval(400) 53 self._validate_timer.timeout.connect(self._validate_yaml) 54 55 from lib.slides_config import get_autosave_seconds 56 _autosave_secs = get_autosave_seconds() 57 self._autosave_timer: QTimer | None = None 58 if _autosave_secs > 0: 59 self._autosave_timer = QTimer(self) 60 self._autosave_timer.setInterval(_autosave_secs * 1000) 61 self._autosave_timer.timeout.connect(self._autosave) 62 self._autosave_timer.start() 63 64 self._build_ui() 65 66 theme_signal().changed.connect(self._on_theme_changed)
206 def open_file(self, path: Path) -> None: 207 self._path = path 208 self._editor.blockSignals(True) 209 self._editor.setPlainText(path.read_text(encoding='utf-8')) 210 self._editor.blockSignals(False) 211 self._set_dirty(False) 212 self._validate_yaml() 213 if self._search_bar_widget.isVisible(): 214 self._update_search()