commit - eabf2e720ed1f9f352c93c56ace8614e9237a0c0
commit + 9ee28605fccfb2af048a3d221c9a573f44c44534
blob - 2dfb660c20f8b186bfdc9cebe0f6404d979ae5cf
blob + c2c047e0ab1c4301224eba74145037256dc84a0b
--- nezzontli_ctl/app.py
+++ nezzontli_ctl/app.py
+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
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
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():
)
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:
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
margin: 1 0;
}
+.preview-loading {
+ color: $text-muted;
+ text-style: italic;
+ margin: 1 0;
+}
+
#confirm-diff {
height: 1fr;
border: solid $panel;