commit 9ee28605fccfb2af048a3d221c9a573f44c44534 from: ale date: Thu Jul 30 23:21:55 2026 UTC Preview: detecta el protocolo de imágenes antes de que Textual tome la terminal, sync por línea-ancla, indicador de carga Causa real del renderizado pixelado/borroso: textual_image negocia Kitty TGP/Sixel mandando escape sequences y leyendo la respuesta del terminal, pero esa negociación solo funciona ANTES de que Textual ponga la terminal en raw mode + alt screen. Como el import era lazy (recién al abrir el editor), la negociación fallaba y caía al renderer de half-cell (bloques de color de baja resolución — exactamente lo que se veía). Se fuerza el import arriba de todo en app.py, antes de NezzontliApp().run(). El sync de scroll entre el TextArea y el preview era por proporción de alturas, que se rompe en cuanto el preview tiene bloques de altura fija (imágenes, fórmulas) distintos a la del texto — quedaban desfazados y al volver a editar no se veía la línea correspondiente. Ahora se ancla por línea de origen real: TextArea es un widget de líneas monoespaciadas así que scroll_y ya es el número de línea top, y se busca qué widget del preview corresponde a esa línea (tokenize ahora devuelve el offset de cada segmento) en vez de escalar por altura. También: indicador '⏳ Renderizando...' mientras se resuelven imágenes/ fórmulas, y el render de cada segmento corre en paralelo (asyncio.gather) en vez de secuencial, para que abrir el editor no se sienta tan lento. 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;