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