Arquitectura¶
Este documento explica cómo funciona Aurora por dentro y por qué está diseñado así. Está
pensado para contribuidores y para quien quiera entender qué pasa entre un Signal.set() y un
píxel en pantalla.
Idea central¶
Python describe, Rust ejecuta. La frontera se cruza poco y con mucha información.
Llamar de Python a código nativo es barato una vez, y carísimo diez mil veces por frame. Por eso Aurora nunca expone a Python operaciones de bajo nivel ("dibuja este rectángulo", "mide este texto"). Python produce un árbol de descripción completo, y Rust hace todo el trabajo pesado sobre ese árbol sin volver a consultar al intérprete.
Mapa del código¶
python/aurora/
├── __init__.py App: une la vista, el tema y el núcleo nativo
├── elements.py Element y primitivas (box, row, column, text, spacer)
├── canvas.py canvas(), Canvas y Path: graba comandos de dibujo en un lote
├── charts.py chart(): gráficos de línea, área, barras y dispersión
├── components/ Componentes con estilo, solo con primitivas (un módulo por familia)
│ ├── basic.py Botón, tarjeta, insignia, métrica, separador y avatar
│ ├── forms.py Campos de texto, casillas, interruptores, radio y deslizador
│ ├── navigation.py Pestañas, páginas, selector segmentado y acordeón
│ ├── overlays.py Menú, diálogo, tooltip y desplegable (`select`)
│ ├── feedback.py Progreso, spinner, esqueleto y toast
│ └── lists.py Lista virtualizada; `_shared`, `_state` y `_dropdown` son internos
├── inspector.py Panel del inspector y del profiler, acoplado a la vista con F12
├── signal.py Estado reactivo
├── theme.py Design tokens y temas MIDNIGHT / DAYLIGHT
├── _icons.py Iconos SVG compartidos (casilla, cheurón y flechas de orden)
└── _core.pyi Stubs del módulo nativo
src/ (crate aurora_core → módulo aurora._core)
├── lib.rs Funciones expuestas a Python: render_png, run, wake, Pilot
├── spec.rs Árbol de Python (dicts) → Node tipado; herencia de estilos de texto
├── a11y.rs Accesibilidad: semántica y acciones → árbol nativo de AccessKit
├── color.rs Parseo de colores e interpolación
├── render/ Motor por fases del frame (un módulo por fase)
│ ├── mod.rs Engine: estado, update (layout y composición) y orquestación
│ ├── style.rs Fase de estilo y animaciones declarativas (transiciones, entradas/salidas)
│ ├── layout.rs Árbol de Taffy y su reconciliación incremental
│ ├── geom.rs Geometría, regiones de hit-testing, capas y zonas dañadas
│ ├── paint.rs Pintura por zonas (repintado parcial) y recorrido del árbol
│ ├── draw.rs Dibujo de cajas, imágenes, barras y anillo de foco
│ ├── text.rs Párrafos de Skia («shaping»), medición y campos de texto
│ └── png.rs Render a PNG sin ventana
├── runtime/ Estado de la app en marcha (un módulo por dominio de entrada)
│ ├── mod.rs Runtime: árbol, despacho de eventos, wake y frame (update+paint)
│ ├── pointer.rs Hover, clics y arrastres (barras, gráficos y `on_drag`)
│ ├── keyboard.rs Manejadores `on_key`, Tab/Enter/Espacio y flechas
│ ├── editing.rs Campos de texto: edición, portapapeles, IME y cursor
│ ├── focus.rs Foco (`on_focus`/`on_blur`), Tab y revelado del nodo
│ ├── a11y.rs Acciones de un lector de pantalla (enfocar, activar, ajustar)
│ ├── wheel.rs Rueda, zoom de gráficos y barras de desplazamiento
│ └── clipboard.rs Portapapeles del sistema o en memoria
├── scroll.rs Estado de los contenedores scroll: desplazamiento, suavizado, barra
├── inspect.rs Inspector y profiler: modo selección, historial de frames, estado prestado a Python
├── keys.rs KeyEvent y parseo de combinaciones ("ctrl+s", "mod+k")
├── edit.rs Modelo de edición puro: texto, cursor, selección, deshacer, grafemas
├── input.rs Estado de los campos de texto y geometría de cursor y selección
├── image.rs Decodificación de imágenes (raster y SVG), caché y encaje (fit)
├── canvas.rs Lote de comandos de un canvas → Picture de Skia; cuándo volver a grabarlo
├── chart/ Gráficos nativos (un módulo por fase)
│ ├── mod.rs El modelo: `Chart`, `Series`, `ChartKind`
│ ├── parse.rs Vista sin copia de los búferes de Python → `Chart`
│ ├── view.rs Ventana visible (zoom/pan) y reducción de series (LTTB)
│ ├── scale.rs Escala de los ejes: pasos, ticks y etiquetas
│ ├── paint.rs Dibujo de ejes, rejilla, leyenda y series
│ └── cache.rs Grabado perezoso (`Charts`): cuándo volver a grabar
├── window.rs Bucle de ventanas (winit): host, comandos y una View por ventana
├── dialog.rs Diálogos nativos de archivo (rfd): abrir, guardar y carpetas
├── tray.rs Bandeja del sistema (tray-icon): icono, tooltip y menú
├── notify.rs Notificaciones del sistema (notify-rust): título, cuerpo e icono
├── identity.rs Identidad de la app en Windows (AUMID) y acceso directo del menú Inicio
├── menu.rs Menús nativos: la descripción de `aurora.menus` → menú de `muda`
├── surface/ Trait Surface: dónde se pinta y presenta cada frame
│ ├── cpu.rs Skia raster en el framebuffer de softbuffer
│ ├── vulkan.rs Skia Ganesh sobre Vulkan (vulkano), Windows y Linux
│ └── metal.rs Skia Ganesh sobre Metal (CAMetalLayer), macOS
└── pilot.rs Piloto headless sobre Runtime para tests de interacción
Ciclo de vida de un frame¶
┌────────────────────────── Python (con el GIL) ──────────────────────────┐
evento ─► │ callback del usuario ─► Signal.set() ─► view() ─► Element ─► to_spec() │
└──────────────────────────────────────────────────────────┬──────────────┘
│ dict anidado (1 cruce)
┌────────────────────────── Rust (sin el GIL) ─────────────▼──────────────┐
│ spec::parse → Node tipado, herencia de text props, keys estables │
│ Engine::restyle → estilo: qué cambió y en qué fase entra cada cambio │
│ Engine::tick → avanza transiciones hover/pressed (dt real) │
│ Engine::update → layout (Taffy + texto con Skia) y composición (capas) │
│ Engine::paint → Skia repinta solo la zona dañada del framebuffer │
│ present → la Surface (GPU o CPU) muestra el frame │
└─────────────────────────────────────────────────────────────────────────┘
- Evento. winit entrega un
CursorMovedoMouseInput. El bucle corre sin el GIL: mover el ratón por encima de la interfaz no toca Python en absoluto. - Hit-testing. Se busca el nodo pintado más arriba bajo el cursor y se sube por sus ancestros hasta el primero interactivo (ver más abajo).
- Callback. Solo al soltar el clic sobre el mismo nodo se toma el GIL, se ejecuta el callback y la vista se marca como sucia.
- Reconstrucción. Se llama a
view()y el árbol resultante se serializa a dicts en un único recorrido (Element.to_spec), resolviendo tokens de tema y lambdas de texto. - Parseo.
spec::parseconvierte los dicts enNodetipados. Es el único lugar del núcleo que conoce la forma de los datos de Python. Cada prop se consulta con una clave ya creada e internada (intern!): fabricar y hashear unPyStringpor consulta se llevaba buena parte de la fase. - Estilo. El árbol nuevo se compara con el anterior para decidir qué fases hay que rehacer (ver Invalidación por fases).
- Layout. Si hace falta, se actualiza un árbol Taffy paralelo. Los nodos de texto llevan un
Paragraphde Skia ya "shapeado" como contexto: el mismo párrafo se usa para medir y para pintar. Tras él se calculan las regiones de hit-testing y las capas. - Composición. Cada capa (el contenido de un
scroll) se coloca según su desplazamiento. - Pintura.
Painterrepinta la zona dañada: sombra → fondo → borde → texto → hijos, sin recorrer lo que queda fuera. Skia escribe directamente en la memoria del framebuffer.
Decisiones de diseño¶
Skia sobre Vello o un renderer propio¶
La calidad visual de una UI depende sobre todo de sus efectos: sombras suaves, blur, gradientes,
antialiasing y, por encima de todo, el texto. Skia ofrece todo eso maduro y probado en
producción, incluido un motor de párrafos (SkParagraph) con shaping, bidi y fallback de fuentes.
Vello es prometedor pero todavía tiene huecos en filtros y blur. El render queda detrás del
Engine, de modo que cambiar de backend no afecta a la API de Python.
Taffy para el layout¶
Implementa Flexbox y Grid fielmente a la especificación CSS, lo usan proyectos serios (Zed, Bevy, Dioxus) y expone una API de medición que encaja con el texto de Skia. Reimplementar flexbox no aporta nada diferencial.
Superficies: GPU con CPU de respaldo¶
La ventana pinta a través del trait Surface (src/surface/), que solo sabe hacer una cosa:
darle a Runtime::draw_frame un lienzo con su edad y presentar el resultado. Hay dos familias:
- GPU (por defecto): Skia Ganesh sobre Vulkan en Windows y Linux (con
vulkano, que carga el driver en tiempo de ejecución) y sobre Metal en
macOS (una
CAMetalLayeren la vista de la ventana). Direct3D no se usa porque no hay binarios precompilados de Skia cond3d+svg+webp, y Vulkan cubre Windows igual de bien. - CPU: winit + softbuffer + Skia raster. El formato de píxel de softbuffer (
0x00RRGGBB) coincide en little-endian con el N32 (BGRA) de Skia, así que Skia pinta directamente en el buffer de la ventana, sin copias cuando se repinta entero.
Con backend="auto" se intenta la GPU y, si no arranca (sin driver de Vulkan, máquina virtual,
solo un Vulkan por software como llvmpipe), se usa la CPU sin avisar. Si la GPU falla a mitad de
ejecución (dispositivo perdido), la ventana cambia a la CPU y sigue.
Las imágenes que presenta la GPU (swapchain, drawables de Metal) no conservan el frame anterior.
Para no perder el repintado parcial, la GPU pinta en un lienzo persistente (Backbuffer, una
textura del tamaño de la ventana, siempre con edad 1) y cada frame lo copia entero a la imagen que
se presenta: una copia de textura es casi gratis en la GPU. El frame se presenta con vsync (FIFO
en Vulkan).
Árbol completo por actualización, con ramas memorizadas¶
Aurora llama a la vista entera tras cada evento que cambia estado (también tras cada tecla en un campo de texto). Es una simplificación deliberada: el coste real es serializar el árbol en Python, que para apps medianas está muy por debajo de un frame. El diseño ya prepara la reactividad de grano fino de la v0.3:
- Los nodos tienen identidad estable (
keyexplícita o ruta en el árbol), que ya hoy se usa para conservar el estado de animación entre reconstrucciones. Signaltiene suscriptores, y es el punto natural para registrar dependencias por rama.
Reactividad de grano fino¶
Llamar a view() es lo que más cuesta en una vista grande: crear los Element, serializarlos y
parsear los dicts en Rust. @component (python/aurora/reactive.py) evita las tres cosas en las
ramas que no cambiaron:
- Dependencias. Cada
Signaltiene una versión que sube en cadaset. Mientras un componente se construye hay unFrameen una pila (por hilo) y cada lectura de unSignalanota su versión ahí. Lo mismo al serializar, porque los textos con lambda se evalúan ento_spec. Un componente anidado añade sus dependencias al que lo contiene. - Memoria por argumentos. La llamada se reutiliza si sus argumentos (hashables) coinciden con
una entrada cuyas dependencias siguen en la misma versión: devuelve el mismo
Element, cuya raíz guarda en_memola serialización (válida para el mismo tema). Lo memorizado se guarda por dueño —laApp, cadaWindow, unPilot—, así dos ventanas que comparten un componente tienen su entrada propia y la reconstrucción de una no olvida lo de la otra. Cada reconstrucción de un dueño es una generación, y al terminar se descartan las entradas que ese dueño no usó. - Parseo memorizado. El dict de un componente lleva
memo: Truey llega a Rust como el mismo objeto.spec::Parser, que vive en elRuntime, guarda elNodede cada uno por dirección (sosteniendo una referencia, para que la dirección no se reutilice) junto con la key de su raíz y las propiedades heredadas. Si las tres coinciden, copia el nodo en vez de leer cada propiedad de cada dict. - Keys. Un nodo sin key toma la de su padre más su posición, así que el subárbol de un nodo con key tiene las mismas keys esté donde esté: un componente con key sigue siendo reutilizable aunque se inserten filas antes que él, y el diff del layout lo empareja con su nodo de Taffy.
Cachés entre frames¶
Pintar es barato; lo caro es el layout, y sobre todo el texto: hacer el shaping de un párrafo y
maquetarlo en Skia. Por eso el Engine evita repetirlo:
- Layout. El árbol de Taffy, con los párrafos ya maquetados, vive entre frames
(
LayoutCache) y se reutiliza tal cual mientras no cambie nada de lo que lo determina: el árbol de nodos (si la fase de estilo encuentra un cambio que lo afecte), el tamaño de la ventana y el texto que muestran los campos (una huella de todos, porque se puede escribir o componer con el IME sin pasar por Python). Hover, scroll, animaciones y parpadeo solo repintan. - Diff por key. Si algo cambió, el árbol de Taffy no se rehace:
Engine::reconcilerecorre el árbol nuevo y cada nodo reutiliza el nodo de Taffy que tenía su key en el layout anterior. Solo lo marca sucio si cambia lo que afecta al layout: su estilo de Taffy (que es exactamente lo que el layout ve, así que colores, sombras u opacidad no cuentan), su contenido (elParagraphKeyde un texto, el tamaño natural de una imagen) o la lista de hijos. Taffy propaga la marca a los ancestros y conserva su caché en todo lo demás: cambiar el fondo de una fila no rehace el layout, y cambiar su texto solo recalcula esa rama. Los nodos con keys que ya no existen se eliminan. Los hijos se asignan siempre conset_children, que desengancha a un nodo reutilizado de su padre anterior si cambió de sitio. - Párrafos. El párrafo de un texto que no cambió sigue en su nodo. El de uno que cambia va a
una reserva indexada por
ParagraphKey(texto, fuente, tamaño, color, interlineado…) de la que otro nodo de este mismo layout puede tomarlo, por contenido y no por key: al reordenar textos no se repite el shaping. Lo que queda en la reserva tras el layout se descarta. - Mediciones. Taffy mide cada hoja varias veces por layout (mínimo, máximo, definitivo…) y
cada medición maqueta el párrafo. Cada
TextCtxmemoriza sus mediciones (restricciones → tamaño y baseline) y recuerda con qué ancho está maquetado, así quefinalize_textsolo lo vuelve a maquetar si el ancho final cambió.
Pilot.stats y la suite de benchmarks/ miden cada fase.
Invalidación por fases¶
Cada frame recorre cuatro fases, y cada cambio entra en la primera que le afecta: las anteriores no se repiten. Como en un navegador, lo caro es rehacer de más.
| Fase | Qué hace | La provoca |
|---|---|---|
Estilo (Engine::restyle) |
Compara el árbol nuevo con el anterior, nodo a nodo. | Una reconstrucción de la vista. |
Layout (Engine::update) |
Taffy, párrafos y geometría: regiones, capas y tinta. | Un cambio de estructura o de estilo de layout, el tamaño de la ventana, el texto de un campo. |
Composición (Engine::update) |
Coloca cada capa según su desplazamiento. | Desplazar un scroll. |
Pintura (Engine::paint) |
Repinta solo la zona dañada. | Colores, transiciones, cursor, tooltip, capas que se movieron. |
- Estilo.
restylerecorre a la vez el árbol anterior, el nuevo y el de layout. Si todos los nodos coinciden en lo que determina la geometría (keys, estructura, estilo de Taffy, texto y métricas de fuente, manejadores, capas,capture,focusable, sombra,press_scale…), el layout, las regiones y la tinta siguen valiendo: solo se apuntan para repintar los nodos cuyo estilo cambió. Un texto que solo cambia de color se vuelve a "shapear" en su propio nodo de Taffy, con el mismo ancho y las mismas mediciones. Cualquier otro cambio rehace el layout (que aun así es incremental, por key), y la comparación sigue hasta el final para anotar qué nodos cambiaron de aspecto (repainted_nodes: tipo, estilo, texto o propiedades de texto, emparejando los hijos por key). En la lista de 10 000 filas, cambiar la selección ya no rehace el layout (~57 ms): la comparación cuesta ~12 ms. - Capas y regiones. Tras un layout,
Engine::geometryregistra las regiones de hit-testing y las capas: la raíz, el contenido de cadascrolly cada capa superior (overlay, que se desplaza con sus ancestros pero escapa de su recorte). Las regiones guardan su rectángulo en coordenadas locales de su capa, las que tendrían con todos losscrollen el origen. La composición (place_layers) calcula el desplazamiento acumulado y el recorte de cada capa, fija los límites de cadaScrollStatey, si alguna se movió, recoloca las regiones: desplazar no rehace el layout ni recalcula las regiones. - Tinta. Cada nodo de layout guarda la tinta de su subárbol: hasta dónde puede pintar,
contando la sombra (3 sigmas de difuminado), el anillo de foco, el texto que desborda su caja y
press_scale. Los hijos de unscrollno cuentan (los recorta su caja) y las capas superiores tampoco (se pintan aparte). Aparte, cada nodo guarda su tinta propia, la que pinta él sin sus hijos; la de los descendientes de un nodo conpress_scale > 1es la del subárbol escalado, porque al pulsarlo se escalan con él. Un subárbol es sticky si contiene una capa superior (hay que recorrerlo para encolarla) o un campo de texto (su geometría se recalcula al pintarlo). - Layout parcial. Tras un layout,
geometrydevuelve las regiones y capas anteriores (las capas que siguen existiendo, por identidad: la raíz,scroll:<key>uoverlay:<key>, conservan lo que se pintó de ellas) yrelayout_damagelas compara por key con las nuevas. Un nodo daña su tinta propia, antes y después, si cambia su rectángulo, su tinta, su capa, su zona visible o su aspecto; si cambia de padre o de hermano anterior (puede quedar por encima o por debajo de otro), daña la tinta de todo su subárbol. Los nodos nuevos y los que desaparecen dañan su tinta propia: un hijo que cambia lo cubre su propia región, no la de su padre. La barra de unscrollcuyo contenido cambió de tamaño la repinta la composición (lo que se puede desplazar forma parte de lo pintado de la capa). Se repinta la ventana entera si cambió su tamaño o la escala, si no se sabe qué nodos cambiaron de aspecto (primer frame, una imagen que cambió en disco), si cambió más de la mitad de los nodos (comparar ya no compensa) o si hay keys repetidas. Escribir en el formulario de 500 filas pasa de pintar ~0,42 ms a ~0,16 ms, a cambio de ~0,35 ms en la fase de estilo y ~0,15 ms de comparar regiones (en el banco de pruebas, con un lienzo de 960 × 640 a escala 1: con más píxeles, pintar entero cuesta proporcionalmente más). - Daño. La zona a repintar es la unión de: las capas que se movieron (su zona visible), lo
que dañó un layout parcial, los nodos que cambiaron en la fase de estilo y aquellos cuya transición avanzó en
tick(su tinta, recortada a lo visible), los campos de texto cuyo aspecto cambió (una huella de cursor, selección, composición, parpadeo y foco), el tooltip (su burbuja antes y después) y lo que el lienzo no tenga del frame anterior. Si no hay nada, el frame no pinta: un frame ocioso cuesta lo que comparar unas huellas. Si la zona dañada pasa del 70 % de la ventana (FULL_PAINT_RATIO), se pinta entera: sale más barato que la superficie aparte y la copia. - Pintura parcial. El
Painterse salta los subárboles no sticky cuya tinta no toca la zona dañada. Skia recorta las aristas de una figura al borde del recorte y su antialiasing cerca del corte sale algo distinto del de la figura entera, así que recortar justo a la zona dañada dejaría bordes que no casan con lo de alrededor. Por eso la zona se pinta con un margen (CLIP_MARGIN) en una superficie aparte y se copia solo la zona dañada, alineada a píxeles físicos.tests/test_phases.pycomprueba que cada frame incremental coincide con un repintado completo. - Lienzo retenido. Pintar solo una parte exige que el lienzo conserve el frame anterior. El
piloto reutiliza su superficie; la GPU, su lienzo persistente; la CPU, la edad del buffer de
softbuffer (
age: hace cuántos frames se pintó lo que contiene) y elRuntimerecuerda la zona pintada de los últimos frames para ponerlo al día. Con edad 0 (buffer nuevo, o macOS, que no conserva el buffer) se repinta entero. Al presentar,present_with_damagesolo copia a la pantalla la zona pintada. - Un solo pintado por frame.
Runtime::draw_framehace layout y composición, avisa a loson_scroll(que ya conocen el desplazamiento y los límites) y, si alguno invalida la vista, reconstruye y vuelve a hacer layout antes de pintar, una sola vez.
Animaciones en Rust¶
Las transiciones de hover y pressed se interpolan en el núcleo con suavizado exponencial
(τ = 70 ms) usando el dt real entre frames. Python solo declara los estados de destino
(bg_hover, bg_pressed, press_scale). Así una animación nunca compite con tu código Python
por el GIL ni depende de su velocidad.
Las animaciones declarativas (transition, animate_layout, enter, exit) siguen la misma
idea. motion.rs tiene las curvas (muelle analítico o easing de CSS) y las pistas (Track: un
valor de cuatro componentes que va de un origen a un destino y que, al cambiar de destino, parte
del valor actual). El Engine guarda un Motion por key y las pone en marcha en dos momentos:
- Al reconstruir (
restyle→animate_changes): compara el estilo anterior y el nuevo de los nodos contransition, y de cada nodo conexitque desaparece graba una instantánea (Picture, con el layout anterior, que aún es el vigente) que se pinta encima de su capa mientras dura la salida (Ghost). No toca el layout: los demás ocupan su hueco enseguida. - Tras un layout (
update→start_motions): compara las regiones nuevas con las anteriores. Un nodo conanimate_layoutcuya caja local cambió se pinta desplazado desde la caja que mostraba (sus hijos se trasladan con él; un hijo animado solo añade la diferencia), y uno que aparece conenterarranca desde su efecto.
Los nodos transformados no pueden descartarse por su tinta (que es la de su caja final): ellos y
sus ancestros van en moving y dentro de ellos el pintor no recorta; motion_damage repinta en
cada frame la zona donde se pintaron y donde se pintan. El hit-testing usa siempre la caja final.
Hit-testing con burbujeo y barreras¶
Tras cada layout se registra una HitRegion por nodo (rectángulo e índice del padre), en orden
de pintura; la composición la mantiene en su posición absoluta. Para resolver un clic:
- Se toma la última región que contiene el punto (la pintada más arriba).
- Se sube por los padres hasta encontrar un nodo con
on_click, o hasta un nodo concapture=True, que corta la búsqueda.
Esto permite que un clic sobre el texto de un botón llegue al botón, y que un clic dentro de un modal no llegue al fondo que lo cierra.
Herencia de estilos de texto¶
Como en CSS, color, font_size, font_weight, font_family, line_height y letter_spacing
se heredan del contenedor más cercano que los define. La herencia se resuelve una sola vez en
spec::parse, no en cada pintura.
Hilos y el GIL¶
aurora._core.run libera el GIL (py.detach) durante todo el bucle de eventos y solo lo toma
para reconstruir la vista o ejecutar un callback. Esto permite que otros hilos de Python trabajen
mientras la ventana está abierta.
Cuando un Signal cambia, llama a _core.wake(), que se puede invocar desde cualquier hilo: sube
una época global (un contador atómico) y envía un evento propio al bucle de winit a través de
un EventLoopProxy. Cada ventana recuerda la época que atendió, así una marca despierta y
reconstruye todas las ventanas —lo correcto cuando comparten Signal— sin que la primera que
pinte la consuma para las demás. App.spawn() usa esto para las corrutinas: corren en un bucle
asyncio en un hilo aparte y cada set despierta la ventana.
Runtime, ventanas y piloto¶
El estado de una app en marcha —árbol, regiones de hit-testing, puntero, despacho de eventos—
vive en Runtime (src/runtime/), que no sabe nada de ventanas. Hay dos "conductores":
window.rstraduce los eventos de winit a llamadas alRuntimey presenta el frame en el framebuffer. El bucle es uno solo para todas las ventanas:Hostguarda unaViewporWindowId(ventana, superficie yRuntime). Python pide abrir, cerrar, cambiar el título o el tamaño con comandos (_core.window_open…), que viajan por elEventLoopProxyy son seguros desde cualquier hilo; la configuración de una ventana nueva espera en un registro (PENDING) hasta que el bucle la crea. La app termina al cerrarse su última ventana, salvo que viva sin ventanas (keep_alive, para la bandeja del sistema);App.quit()cierra todo. Elon_closede una ventana decide si el cierre sigue adelante.pilot.rs(Pilot, expuesto comoapp.pilot()) simula el puntero y pinta en una superficie en memoria. Como comparte elRuntime, un test de interacción ejercita exactamente la misma lógica que la ventana;Window.pilot()hace lo mismo sobre la vista de esa ventana.
Los manejadores se guardan por nombre (handlers: {"click": fn}) y se despachan con
Runtime::dispatch(key, evento, arg): añadir un evento nuevo no requiere tocar el bucle.
Scroll¶
Un nodo con scroll se maqueta con overflow: hidden en Taffy (tamaño mínimo automático 0, sin
reservar hueco para la barra) y sus hijos no se encogen en el eje desplazado. Taffy calcula
scroll_width()/scroll_height(), el desplazamiento máximo.
El estado (ScrollState en src/scroll.rs: desplazamiento, destino del suavizado, visibilidad de
la barra, arrastre) vive en el Engine por key, como las animaciones de hover. Cada scroll abre
una capa: tras el layout se registran sus límites (desplazamiento máximo y zona visible) y los
scroll que ya no existen se olvidan; en cada frame la composición fija esos límites en su
ScrollState (que ajusta el desplazamiento si el contenido encogió) y coloca la capa. Al pintar,
el Painter recorta a la caja de relleno y traslada a los hijos. Cada HitRegion lleva su clip
(la intersección de los scroll que la contienen), así que lo que está fuera de la zona visible no
recibe eventos.
La rueda se resuelve con las regiones del último frame: desde la región bajo el puntero se sube
por los ancestros hasta el primer scroll que todavía pueda moverse en esa dirección.
on_scroll y listas virtuales¶
Tras la composición, Runtime::notify_scroll compara el desplazamiento y la zona visible de cada
scroll con on_scroll (las keys se recogen al reconstruir) con lo último que se le avisó, y solo
llama si cambió. No marca la vista como sucia: se llama en cada frame de desplazamiento, y quien
necesite reconstruir cambia un Signal. Runtime::draw_frame (lo usan la ventana y el piloto)
aprovecha eso: si tras avisar la vista quedó invalidada, reconstruye antes de pintar, así que el
frame se pinta una sola vez y ya con las filas nuevas.
virtual_list (Python) se apoya en ello: con filas de alto fijo, el contenido es una caja con el
alto total y las filas visibles colocadas con position="absolute". Su on_scroll guarda la zona
visible en un Signal solo cuando cambia el rango de filas, así que desplazarse dentro del rango
no toca Python. Con 100 000 filas, una reconstrucción cuesta menos de 1 ms.
Taffy 0.15 maqueta un contenedor sin hijos como una hoja y entonces no vacía su lista de hijos
absolute ("hoisted"). Si una lista virtual se queda sin filas, el siguiente layout recorrería
nodos ya borrados. Por eso Engine::reconcile sustituye por un nodo nuevo cualquier nodo que pasa
de tener hijos a no tenerlos.
Imágenes¶
Un nodo image (Kind::Image) es una hoja cuyo contexto de layout es su tamaño natural
(Leaf::Image): se mide conservando la proporción. Antes de maquetar, Engine::load_images
resuelve cada src contra una ImageCache (src/image.rs) indexada por ruta + fecha + tamaño
del archivo (o por un hash de los bytes): cada imagen se decodifica una vez, con mipmaps para
reducir sin aliasing. Los SVG se cargan como svg::Dom de Skia y se pintan como vectores. Los
errores de carga se convierten en FileNotFoundError/ValueError al reconstruir la vista.
Canvas¶
Un nodo canvas (Kind::Canvas) es una caja normal para el layout. Su Node::draw es un
Drawing de Python (canvas.py): la función del usuario ya ligada al tema. No se llama al
construir la vista, porque entonces aún no se conoce el tamaño de la caja: Runtime::draw_frame
la llama tras el layout y antes de pintar, como on_scroll, a través de canvas::Canvases.
Drawing(width, height, force)ejecuta la función sobre unCanvasque solo acumula comandos (tuplas, con los pinceles deduplicados y los puntos enbytesdef32) y devuelve el lote.canvas::recordlo recorre una vez y lo graba en unPicturede Skia queEngineguarda por key.Painterlo dibuja recortado a la caja de contenido; hover, scroll o animaciones de otros nodos lo repintan sin volver a Python.Canvasesdecide cuándo llamar: tras una reconstrucción (a todos) o tras un layout (a los que cambiaron de tamaño).forceesTruesi cambió el tamaño o la función (otroDrawing, es decir, el nodo se volvió a serializar). Conforce=False—el dict memorizado de un@component— Python devuelveNonesi no cambió ningúnSignalque leyera la función en su última llamada: se conserva el dibujo.- Grabar un dibujo nuevo marca el nodo como cambiado de aspecto (
restyled): solo se repinta su zona. El dibujo no afecta a la geometría (same_geometrytrata el canvas como una caja), así que nunca obliga a rehacer el layout.
Gráficos¶
Un nodo chart (Kind::Chart, src/chart/) guarda sus series como vistas sin copia de los
búferes de Python (chart::Column): el núcleo lee su memoria con el GIL tomado, en el momento de
grabar. El dibujo completo (ejes, rejilla, leyenda y series) se graba en un Picture que se
repinta sin volver a Python, como el de un canvas; Charts decide cuándo volver a grabar: si el
Chart es otro objeto (la vista se reconstruyó con datos u opciones nuevas), si cambió la caja o
si cambió su ventana visible.
- Reducción (LTTB): cada serie se reduce a ~2 puntos por píxel (Largest-Triangle-Three-Buckets
en Rust), tramo a tramo: los
NaNcortan la línea y cada tramo recibe el presupuesto que le toca por su ancho en pantalla, así un pico vertical no se come los puntos de un tramo ancho. Con x ordenada, la ventana se acota con búsqueda binaria y solo se recorren los índices visibles (más un punto de margen a cada lado, para que el segmento que cruza el borde se dibuje); con x desordenada se filtra punto a punto. Un millón de puntos se graba en milisegundos. - Zoom y pan: con
zoom=True,Chartsguarda por key el eje x que grabó (el rectángulo del área de dibujo), el dominio de los datos y la ventana visible. La rueda acerca con el puntero como ancla, el arrastre y la rueda horizontal desplazan la ventana y el doble clic la devuelve al dominio completo; los tres son eventos que mutan la ventana y marcan el gráfico como sucio (dirty), así que el siguiente frame lo vuelve a grabar y repinta solo su zona. La ventana vive fuera delChart(que es inmutable y puede compartirse entre frames) y sobrevive a las reconstrucciones; se recorta al dominio al grabar, así que unos datos nuevos no la dejan fuera. - Sin zoom, el eje y sale del dominio completo; con zoom, de los puntos visibles (
y_min/y_maxlo fijan). El gráfico no toca Python para nada de esto: la interacción entera vive enRuntimeyCharts.
Campos de texto¶
Un text_input es un nodo input (Kind::Input): una hoja con párrafo, como text, cuyo valor
llega de Python en cada reconstrucción. La edición vive en Rust, en un InputState por key en el
Engine (src/input.rs): un Editor puro (src/edit.rs: texto, cursor, selección y deshacer,
con posiciones en bytes UTF-8 alineadas a grafemas), la composición del IME, el parpadeo y el
desplazamiento horizontal.
Cada tecla que el editor atiende modifica el Editor y llama a on_change(texto); el Signal se
actualiza y la vista se reconstruye. Al reconstruir, Engine::sync_inputs compara el valor que
llega de Python con el del editor: si difieren, Python manda (set_external), lo que permite vaciar
un campo al enviar o rechazar un cambio.
Skia mide las posiciones en UTF-16: input.rs convierte en ambos sentidos para colocar el cursor
(get_line_metrics_at y get_rects_for_range), pintar la selección y resolver un clic
(get_glyph_position_at_coordinate). Tras pintar, cada campo guarda una copia de su párrafo y su
origen para resolver clics y flechas verticales hasta el siguiente frame. El parpadeo no anima a
60 fps: la ventana programa un despertar (ControlFlow::WaitUntil) para el próximo cambio.
Teclado y foco¶
El foco es estado del Engine (focused y focus_visible), igual que el hover. El orden de Tab
es el de las HitRegion enfocables del último frame; si hay una región capture, solo cuentan sus
descendientes. Una tecla se entrega como KeyEvent (src/keys.rs) a los on_key subiendo desde
la región enfocada por sus padres; si ninguno devuelve un valor verdadero, el Runtime aplica el
comportamiento por defecto (foco, activación, desplazamiento). El anillo se anima con el mismo
suavizado que hover y pressed y se pinta después de los hijos.
Accesibilidad¶
La semántica vive en el spec: además del estilo, cada nodo puede traer role, label, checked…
(spec::A11y), y los componentes los declaran por ti. Con eso, a11y.rs construye el árbol de
AccessKit del frame —roles con nombre y estados, bounds en píxeles físicos desde las regiones, las
acciones que admite cada nodo— y una caja sin rol, sin nombre y sin contenido no aparece: el
andamiaje de layout es invisible para el lector.
El árbol no se construye por gusto: en la ventana, View::sync_a11y llama a update_if_active, y
sin lector conectado AccessKit no ejecuta el constructor — el coste en el frame caliente es una
comparación. El Adapter de accesskit_winit se crea con la ventana (oculta: es requisito) y otro
evento del bucle (UserEvent::Access) entrega lo que el lector pide: el primer árbol
(InitialTreeRequested) o una acción (ActionRequested), que Runtime::a11y_action traduce a los
caminos que ya existen —enfocar, activar (dispatch("click")), editar el Editor de un campo,
pulsar la flecha de un deslizador, revelar un nodo—. Los ids del árbol son estables por key, así el
foco del lector sobrevive a las reconstrucciones. El piloto usa el mismo constructor
(Pilot.a11y_tree) y el mismo camino de acciones (Pilot.a11y_action), así que los tests headless
ejercitan lo que verá la plataforma.
Por plataforma: Windows usa UIA (el adaptador subclasea el HWND), macOS NSAccessibility (subclasea
la NSView; VoiceOver conecta del todo con la app empaquetada) y Linux AT-SPI por D-Bus (zbus). En
los tres, sin escritorio accesible el adaptador queda inactivo y no pasa nada.
Tooltips¶
El tooltip es lo único de los componentes nuevos que vive en el núcleo, porque depende del hover y
del tiempo. Al recalcular el hover, el Runtime busca el primer ancestro con tooltip bajo el
puntero (Tip en el Engine); el reloj real (advance_clock) acumula tiempo y, pasado el
retardo, la burbuja aparece con el mismo suavizado que el hover. La ventana duerme hasta ese
momento con next_wakeup, como con el parpadeo del cursor. Se pinta al final del frame, encima de
las capas, y no registra HitRegion: no intercepta el ratón.
Escritorio: diálogos, bandeja y menús¶
La integración con el escritorio vive en módulos que comparten una idea: Python describe, Rust ejecuta, y las APIs del sistema se usan desde el hilo del bucle.
- Diálogos (
dialog.rs,rfd):aurora.dialogsabre los diálogos nativos de abrir/guardar archivo y carpetas. Son síncronos —el diálogo corre su propio bucle de mensajes mientras el de la app espera— y el GIL se suelta (Python::detach), así que los hilos y las corrutinas de Python siguen trabajando. El padre del diálogo es la ventana en curso: un thread-local quewindow.rsmantiene mientras elRuntimede una ventana construye su vista o corre un manejador. - Bandeja (
tray.rs,tray-icon): el icono se decodifica con Skia (ruta o bytes) y se crea en el hilo del bucle —al arrancar, o al atender el comandoTrayShow—, que en Windows y macOS es obligatorio; en macOS además se despierta elCFRunLooppara que aparezca al momento. Los eventos del icono y de su menú llegan por elEventLoopProxycomo cualquier comando. En Linux usa StatusNotifierItem (ksni): sin GTK y con su propio hilo de trabajo. - Menús (
menu.rs): la descripción deaurora.menus(dicts, como las vistas) se convierte en un menú demuda; cada acción se guarda por el id del ítem y elMenuEventdel bucle llama a la que toca. La barra se construye antes de crear el bucle —su HACCEL va en el hook de mensajes de Windows, que traduce los atajos conTranslateAcceleratorW; en macOS la gestiona AppKit— y se cuelga de cada ventana al crearse (en macOS, de la aplicación). Los contextuales se construyen al abrirse, en el hilo del bucle, y sus acciones viven hasta el siguiente. - Notificaciones (
notify.rs,notify-rust):app.notify(title, body, icon=...)pinta el aviso el sistema (Centro de notificaciones en Windows y macOS, libnotify/D-Bus en Linux). Si hay bucle, el aviso viaja como comando y se envía en su hilo (macOS lo prefiere); sin bucle, en el hilo que llama. El icono se pasa comoiconen el escritorio Linux y comoimage_pathen Windows y macOS. Conon_click, el aviso se envía con su handle y un hilo propio espera la respuesta (wait_for_response): el clic en el cuerpo llega como la acción «default» —en Linux el aviso la declara, que si no los servidores no la emiten— y el callback se llama con el GIL tomado, despertando el bucle. La identidad del aviso en Windows (AUMID) vive enidentity.rs: fija el AUMID del proceso y registra la app con un acceso directo del menú Inicio (IShellLinkW+IPropertyStore), que es lo que Windows exige para aceptar el AUMID de una app sin instalador.
Capas¶
Un nodo con layer="overlay" se maqueta en su sitio, pero el Painter lo aplaza: lo encola y lo
pinta al terminar el árbol, en coordenadas absolutas. Como sus HitRegion se registran después,
también gana el hit-testing; su índice de padre sigue apuntando a su padre en el árbol, así que el
burbujeo no cambia. Cada capa superior es también una capa de composición: se desplaza con los
scroll que la contienen, pero no la recortan.
Extender Aurora¶
- Un componente nuevo casi nunca necesita Rust: se compone con primitivas en la familia que le
corresponda dentro de
components/. Si se puede hacer así, hazlo así. - Una propiedad de estilo nueva toca tres lugares: el campo en
spec::Styley su parseo, su efecto entaffy_style(layout) o enPainter::paint(pintura), y su documentación enreference.md. Si hace que el nodo pinte fuera de su caja (como una sombra) o cambia las regiones de hit-testing, también va encompute_inky ensame_geometry(fase de estilo); si no, cambiarla ya solo repinta el nodo. - Un nuevo tipo de nodo (imagen, canvas, input) añade una variante a
spec::Kind, su medición en el layout y su pintura.
Cualquier cambio visual debería venir con un test que lo renderice (tests/test_render.py).