Commit Diff


commit - 0bb0d5a98d03a51974236b5e5f6b8acc2d5118a0
commit + 51d10b6fa8a8b60a714a894943377221cc93e435
blob - bc9e19614b8f5e059f11d5fdd957c0e251747b72
blob + 63304b2e41e165570a3cebe1e71b59a1842dc2b6
--- nezzontli_ctl/preview.py
+++ nezzontli_ctl/preview.py
@@ -39,6 +39,28 @@ def _parse_kv_args(raw):
     return {m.group(1): m.group(2) for m in _KV_RE.finditer(raw)}
 
 
+def split_paragraphs_with_offsets(chunk):
+    """[(párrafo, offset_en_chunk), ...] separados por 1+ líneas en blanco,
+    salteando los que quedan vacíos. Un tramo largo de texto plano (sin
+    imágenes/fórmulas en el medio) se anclaba como un solo widget gigante
+    para el sync del scroll — recién al llegar a la SIGUIENTE fórmula/
+    imagen (si la había) se volvía a mover el preview. Sin más fórmulas
+    después, se quedaba pegado ahí el resto del documento. Con un anchor
+    por párrafo, el sync tiene puntos de referencia repartidos parejo en
+    todo el texto, no solo donde hay imágenes/fórmulas."""
+    parts = []
+    cursor = 0
+    for m in re.finditer(r"\n{2,}", chunk):
+        para = chunk[cursor:m.start()]
+        if para.strip():
+            parts.append((para, cursor))
+        cursor = m.end()
+    tail = chunk[cursor:]
+    if tail.strip():
+        parts.append((tail, cursor))
+    return parts
+
+
 def _strip_wrapper_macro(tex, name):
     pattern = re.compile(r"\\" + name + r"\{([^{}]*)\}")
     while pattern.search(tex):
blob - f2346d8626a6f324198ff677526b52e90a3cb0e2
blob + 449b1de96f14e7e358f0990b628145392eff0e71
--- nezzontli_ctl/screens/editor.py
+++ nezzontli_ctl/screens/editor.py
@@ -101,41 +101,51 @@ 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
+    async def _render_segment(self, segment, display_text: str) -> list:
+        """Renderiza un segmento a [(widget, línea_o_None), ...]. `None`
+        para widgets que no deben ser su propio punto de anclaje del sync
+        (ej. la caption debajo de una imagen). 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 []
+            _, chunk, start = segment
+            entries = []
+            for para, offset_in_chunk in preview_mod.split_paragraphs_with_offsets(chunk):
+                line = display_text.count("\n", 0, start + offset_in_chunk)
+                entries.append((Markdown(para), line))
+            return entries
 
         if kind == "math":
-            _, tex, is_display, _start = segment
+            _, tex, is_display, start = segment
+            line = display_text.count("\n", 0, start)
             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")]
+                return [(AutoImage(str(png_path), classes="preview-math"), line)]
+            return [(Static(f"$$ {tex} $$", classes="preview-fallback"), line)]
 
         if kind == "image":
-            _, ref, alt, _start = segment
+            _, ref, alt, start = segment
+            line = display_text.count("\n", 0, start)
             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")]
+                entries = [(AutoImage(str(img_path), classes="preview-image"), line)]
             else:
-                widgets = [Static(f"[img no encontrada: {ref}]", classes="preview-fallback")]
+                entries = [(Static(f"[img no encontrada: {ref}]", classes="preview-fallback"), line)]
             if alt:
-                widgets.append(Static(alt, classes="preview-caption"))
-            return widgets
+                entries.append((Static(alt, classes="preview-caption"), None))
+            return entries
 
         return []
 
     async def _rebuild_preview(self, text: str) -> None:
         """Tokeniza `text` y remonta el preview como una mezcla de widgets
         Markdown (texto normal, con el LaTeX inline ya aproximado a Unicode
-        en el lugar) e Image (fórmulas en bloque $$...$$ renderizadas a PNG,
-        imágenes locales del repo o remotas por URL)."""
+        en el lugar, un widget por párrafo) e Image (fórmulas en bloque
+        $$...$$ renderizadas a PNG, imágenes locales del repo o remotas por
+        URL)."""
         preview_scroll = self.query_one("#editor-preview-scroll", VerticalScroll)
         display_text, segments = await asyncio.to_thread(preview_mod.tokenize, text)
 
@@ -146,16 +156,23 @@ class EditorScreen(ModalScreen[str]):
             )
 
         rendered_groups = await asyncio.gather(
-            *(self._render_segment(segment) for segment in segments)
+            *(self._render_segment(segment, display_text) for segment in segments)
         )
 
         widgets = []
         anchors = []
-        for segment, group in zip(segments, rendered_groups):
-            line = display_text.count("\n", 0, segment[-1])
-            for widget in group:
+        last_anchored_line = None
+        for group in rendered_groups:
+            for widget, line in group:
                 widgets.append(widget)
-                anchors.append((line, widget))
+                # Un anchor por línea de origen distinta, apuntando al
+                # PRIMER widget que aparece ahí (ej. la imagen, no su
+                # caption debajo; o la primera foto de una gallery con 20
+                # fotos en la misma línea) — anclar cada widget hacía que
+                # el scroll saltara al ÚLTIMO en vez de al primero.
+                if line is not None and line != last_anchored_line:
+                    anchors.append((line, widget))
+                    last_anchored_line = line
 
         await preview_scroll.remove_children()
         await preview_scroll.mount_all(widgets or [Markdown("")])