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())
class YamlEditorPanel(PySide6.QtWidgets.QWidget):
 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

YamlEditorPanel(parent=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)
def content_changed(unknown):
def saved(unknown):
def open_file(self, path: pathlib.Path) -> None:
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()
def save(self) -> None:
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)
def revert(self) -> None:
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)
def scroll_to_slide(self, section_idx: int, slide_idx: int) -> None:
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)
def scroll_to_field(self, field: str) -> None:
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
def toPlainText(self) -> str:
242    def toPlainText(self) -> str:
243        return self._editor.toPlainText()
def set_text(self, text: str) -> None:
245    def set_text(self, text: str) -> None:
246        self._editor.setPlainText(text)
staticMetaObject = PySide6.QtCore.QMetaObject("YamlEditorPanel" inherits "QWidget": Methods: #33 type=Signal, signature=content_changed() #34 type=Signal, signature=saved(PyObject), parameters=PyObject )