Commit Diff


commit - eabf2e720ed1f9f352c93c56ace8614e9237a0c0
commit + 9ee28605fccfb2af048a3d221c9a573f44c44534
blob - 2dfb660c20f8b186bfdc9cebe0f6404d979ae5cf
blob + c2c047e0ab1c4301224eba74145037256dc84a0b
--- nezzontli_ctl/app.py
+++ nezzontli_ctl/app.py
@@ -1,8 +1,35 @@
+import threading
+
 from textual.app import App
 
 from nezzontli_ctl.theme import GRUVBOX
 
+# textual_image detecta el protocolo de imágenes del terminal (Kitty TGP /
+# Sixel / half-cell) mandando escape sequences y esperando la respuesta —
+# eso solo funciona ANTES de que Textual ponga la terminal en raw mode +
+# alt screen. Si se importa recién al abrir el editor (como pasaba antes,
+# import lazy adentro de screens/editor.py), la detección ya no puede leer
+# la respuesta, falla, y cae al renderer de half-cell (pixelado y lento por
+# los timeouts de esas queries fallidas).
+try:
+    import textual_image.widget  # noqa: F401
+except ImportError:
+    pass
 
+
+def _warm_up_matplotlib() -> None:
+    """El import de matplotlib.mathtext tarda ~1-2s la primera vez — lo
+    calentamos en un hilo aparte apenas arranca la app para que el primer
+    $...$ que se escriba en el editor no se sienta lento."""
+    try:
+        import matplotlib.mathtext  # noqa: F401
+    except ImportError:
+        pass
+
+
+threading.Thread(target=_warm_up_matplotlib, daemon=True).start()
+
+
 class NezzontliApp(App):
     CSS_PATH = "styles.tcss"
     TITLE = "nezzontli-ctl"
blob - 57cc5600720bd7c08d03c9adbcce665ab366bf00
blob + 1f7074771f4cbd3209a6430a9c8eea95fb271854
--- nezzontli_ctl/preview.py
+++ nezzontli_ctl/preview.py
@@ -111,35 +111,38 @@ def tokenize(text):
 
     matches.sort(key=lambda t: t[0])
 
+    # El 4to campo (o 3ro para "text") es el offset de carácter donde
+    # arranca el segmento en `text` — lo usa EditorScreen para anclar el
+    # scroll del preview a la línea del cursor en el editor.
     segments = []
     cursor = 0
     for start, end, kind, m in matches:
         if start < cursor:
             continue
         if start > cursor:
-            segments.append(("text", text[cursor:start]))
+            segments.append(("text", text[cursor:start], cursor))
 
         if kind == "gallery":
             for qm in _QUOTED_RE.finditer(m.group(1)):
                 path, _, alt = qm.group(1).partition("::")
-                segments.append(("image", path, alt))
+                segments.append(("image", path, alt, start))
         elif kind == "photo":
             kv = _parse_kv_args(m.group(1))
             if "path" in kv:
-                segments.append(("image", kv["path"], kv.get("alt", "")))
+                segments.append(("image", kv["path"], kv.get("alt", ""), start))
         elif kind == "image_shortcode":
             kv = _parse_kv_args(m.group(1))
             if "url" in kv:
-                segments.append(("image", kv["url"], kv.get("alt", "")))
+                segments.append(("image", kv["url"], kv.get("alt", ""), start))
         elif kind == "md_image":
-            segments.append(("image", m.group(2), m.group(1)))
+            segments.append(("image", m.group(2), m.group(1), start))
         elif kind == "display_math":
-            segments.append(("math", m.group(1).strip(), True))
+            segments.append(("math", m.group(1).strip(), True, start))
         elif kind == "inline_math":
-            segments.append(("math", m.group(1).strip(), False))
+            segments.append(("math", m.group(1).strip(), False, start))
 
         cursor = end
 
     if cursor < len(text):
-        segments.append(("text", text[cursor:]))
+        segments.append(("text", text[cursor:], cursor))
     return segments
blob - c904fa90e277c1465f58d003acc673ab88e4b3bd
blob + 68aa889c0a839498cee0eb8f084e70fa7f370646
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
@@ -32,6 +32,11 @@ class EditorScreen(ModalScreen[str]):
         self._initial_text = initial_text
         self._title = title
         self._debounce_timer = None
+        # (línea de origen, widget) por cada widget montado en el preview,
+        # en orden — permite anclar el scroll a la línea exacta que se está
+        # editando en vez de una proporción de alturas (que se rompe apenas
+        # hay imágenes/fórmulas con alturas fijas distintas a las del texto).
+        self._preview_anchors: list[tuple[int, object]] = []
 
     def compose(self):
         with Vertical():
@@ -57,13 +62,27 @@ class EditorScreen(ModalScreen[str]):
         )
 
     def _sync_preview_scroll(self, old_value: float, new_value: float) -> None:
-        text_area = self.query_one("#editor-textarea", TextArea)
+        """TextArea es un widget de líneas monoespaciadas: scroll_y ES el
+        número de línea de origen que está arriba del viewport (no hace
+        falta convertir a proporción). Con eso buscamos qué widget del
+        preview corresponde a esa línea y lo llevamos al tope — anclado al
+        contenido real, no a una altura proporcional que se desalinea en
+        cuanto hay imágenes/fórmulas de altura fija."""
+        if not self._preview_anchors:
+            return
+        top_line = round(new_value)
+        target = self._preview_anchors[0][1]
+        for line, widget in self._preview_anchors:
+            if line <= top_line:
+                target = widget
+            else:
+                break
+        if not target.is_mounted:
+            # El preview se está reconstruyendo (worker en curso) y este
+            # anchor ya quedó viejo — el próximo rebuild trae anchors nuevos.
+            return
         preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
-        max_ta = text_area.max_scroll_y
-        if not max_ta:
-            return
-        ratio = max(0.0, min(1.0, new_value / max_ta))
-        preview_scroll.scroll_y = ratio * preview_scroll.max_scroll_y
+        preview_scroll.scroll_to_widget(target, animate=False, top=True)
 
     def on_text_area_changed(self, event: TextArea.Changed) -> None:
         if self._debounce_timer is not None:
@@ -76,37 +95,64 @@ class EditorScreen(ModalScreen[str]):
     def _start_rebuild(self, text: str) -> None:
         self.run_worker(self._rebuild_preview(text), exclusive=True, group="preview")
 
+    async def _render_segment(self, segment) -> list:
+        """Renderiza un segmento a 0+ widgets. Corre en paralelo para todos
+        los segmentos vía asyncio.gather (cada render_math/resolve_image
+        pesado va a un hilo aparte, así una fórmula lenta no bloquea a las
+        demás ni a la UI)."""
+        kind = segment[0]
+        if kind == "text":
+            _, chunk, _start = segment
+            return [Markdown(chunk)] if chunk.strip() else []
+
+        if kind == "math":
+            _, tex, is_display, _start = segment
+            png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display)
+            if png_path is not None and AutoImage is not None:
+                return [AutoImage(str(png_path), classes="preview-math")]
+            return [Static(f"[$] {tex}", classes="preview-fallback")]
+
+        if kind == "image":
+            _, ref, alt, _start = segment
+            img_path = await asyncio.to_thread(preview_mod.resolve_image, ref)
+            if img_path is not None and AutoImage is not None:
+                widgets = [AutoImage(str(img_path), classes="preview-image")]
+            else:
+                widgets = [Static(f"[img no encontrada: {ref}]", classes="preview-fallback")]
+            if alt:
+                widgets.append(Static(alt, classes="preview-caption"))
+            return widgets
+
+        return []
+
     async def _rebuild_preview(self, text: str) -> None:
         """Tokeniza `text` y remonta el preview como una mezcla de widgets
         Markdown (texto normal) e Image (fórmulas LaTeX renderizadas a PNG,
         imágenes locales del repo o remotas por URL)."""
+        preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
         segments = await asyncio.to_thread(preview_mod.tokenize, text)
+
+        if any(segment[0] in ("math", "image") for segment in segments):
+            await preview_scroll.remove_children()
+            await preview_scroll.mount(
+                Static("⏳ Renderizando fórmulas e imágenes…", classes="preview-loading")
+            )
+
+        rendered_groups = await asyncio.gather(
+            *(self._render_segment(segment) for segment in segments)
+        )
+
         widgets = []
-        for segment in segments:
-            kind = segment[0]
-            if kind == "text":
-                if segment[1].strip():
-                    widgets.append(Markdown(segment[1]))
-            elif kind == "math":
-                _, tex, is_display = segment
-                png_path = await asyncio.to_thread(preview_mod.render_math, tex, is_display)
-                if png_path is not None and AutoImage is not None:
-                    widgets.append(AutoImage(str(png_path), classes="preview-math"))
-                else:
-                    widgets.append(Static(f"[$] {tex}", classes="preview-fallback"))
-            elif kind == "image":
-                _, ref, alt = segment
-                img_path = await asyncio.to_thread(preview_mod.resolve_image, ref)
-                if img_path is not None and AutoImage is not None:
-                    widgets.append(AutoImage(str(img_path), classes="preview-image"))
-                else:
-                    widgets.append(Static(f"[img no encontrada: {ref}]", classes="preview-fallback"))
-                if alt:
-                    widgets.append(Static(alt, classes="preview-caption"))
+        anchors = []
+        for segment, group in zip(segments, rendered_groups):
+            line = text.count("\n", 0, segment[-1])
+            for widget in group:
+                widgets.append(widget)
+                anchors.append((line, widget))
 
-        preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
         await preview_scroll.remove_children()
         await preview_scroll.mount_all(widgets or [Markdown("")])
+        self._preview_anchors = anchors
 
     def action_submit(self) -> None:
         text = self.query_one("#editor-textarea", TextArea).text
blob - 5d85569a77942f99e135bf07b56a067f914c0561
blob + 9abd3deac120cea1b394aee79702fdaebd2566e0
--- nezzontli_ctl/styles.tcss
+++ nezzontli_ctl/styles.tcss
@@ -72,6 +72,12 @@
     margin: 1 0;
 }
 
+.preview-loading {
+    color: $text-muted;
+    text-style: italic;
+    margin: 1 0;
+}
+
 #confirm-diff {
     height: 1fr;
     border: solid $panel;