commit 51d10b6fa8a8b60a714a894943377221cc93e435 from: ale date: Fri Jul 31 00:18:50 2026 UTC Sync del scroll: un anchor por párrafo (no solo por imagen/fórmula) y arregla el salto de imágenes Dos bugs quedaban con el sync por línea-ancla: 1. Se anclaba CADA widget de un grupo (imagen + su caption, o cada foto de una gallery en la misma línea de origen), y el algoritmo de selección se queda con el ÚLTIMO anchor que empata esa línea — al llegar ahí el scroll saltaba directo a la caption (o a la última foto), dejando la imagen en sí arriba, fuera de vista. Ahora es un solo anchor por línea de origen distinta, apuntando siempre al primer widget ahí. 2. Un tramo largo de texto plano sin imágenes/fórmulas en el medio se montaba como un único widget Markdown gigante con un solo anchor — así que después de la última fórmula/imagen del documento, no había más anchors y el preview se quedaba pegado ahí por el resto del documento por más que se siguiera bajando en el editor. Ahora cada párrafo (separado por línea en blanco) es su propio widget con su propio anchor, repartidos parejo en todo el texto y no solo donde hay medios. _render_segment ahora devuelve (widget, línea_o_None) en vez de solo widgets — None para los que no deben ser su propio punto de anclaje (la caption de una imagen). 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("")])