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

YamlEditorPanel(parent=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)
def content_changed(unknown):
def saved(unknown):
def open_file(self, path: pathlib.Path) -> None:
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()
def save(self) -> None:
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)
def revert(self) -> None:
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)
def scroll_to_slide(self, section_idx: int, slide_idx: int) -> None:
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)
def scroll_to_field(self, field: str) -> None:
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
def toPlainText(self) -> str:
257    def toPlainText(self) -> str:
258        return self._editor.toPlainText()
def set_text(self, text: str) -> None:
260    def set_text(self, text: str) -> None:
261        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 )