feat: llimphi standalone — framework UI soberano extraído del monorepo

Motor gráfico Llimphi como workspace independiente: bucle Elm
(input→update→view→layout→raster→present) sobre wgpu+vello+taffy+parley.
Núcleo (hal/raster/layout/text/ui/theme/surface/motion/icons) + ~40 widgets
+ módulos, sin dependencias al resto del monorepo. cargo check --workspace
pasa (64 crates). Puerta de entrada: cargo run -p llimphi-ui --example counter.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 04:23:42 +00:00
commit e65e9cc623
286 changed files with 46136 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
# Llimphi · Android
Port nativo de Llimphi a Android. Una `NativeActivity` en C que
delega al `android_main` que `android-activity` exporta desde la
`.so` Rust, idéntico patrón que un binario `main()` en desktop.
## Estado
| crate | estado |
|---|---|
| `clear-screen-android` | ✓ APK firmado v2, instalable en Android 7+ |
| resto de apps Llimphi | pendientes — el patrón es reusar `android_main` |
## Tesis
El motor Llimphi (HAL + raster + layout + text + ui) **no se toca**.
Lo único nuevo por target Android es:
1. Entry-point `#[no_mangle] android_main(app: AndroidApp)` en vez de
`fn main()`.
2. Construir el `EventLoop` con `with_android_app(app)` para que
`winit` reciba `Resumed` / `Suspended` / `InputAvailable` desde el
Looper de Android.
3. Recrear la `Surface` en cada `Resumed`: Android invalida la
NativeWindow al pasar a background. El `App::state: Option<State>`
ya está estructurado para eso.
Las apps existentes que viven sobre Llimphi compilan sin cambios — lo
que se reescribe es el **lifecycle wrapper**, no la lógica de render
ni los widgets.
## Cómo construir
Una sola pasada — el script wrapper:
```sh
./scripts/build-android.sh clear-screen-android
```
Resultado: `target/x/release/android/clear-screen-android.apk`
firmado con APK Signature Scheme v2, listo para
`adb install -r <apk>`.
## Setup inicial (una vez por máquina)
```sh
# Targets Rust
rustup target add aarch64-linux-android x86_64-linux-android
# Wrapper de build de Rust mobile (binario `x`)
cargo install xbuild
# NDK r27c (~640 MB descomprimido, ~1.5 GB)
curl -L -o /tmp/ndk.zip \
https://dl.google.com/android/repository/android-ndk-r27c-linux.zip
unzip /tmp/ndk.zip -d $HOME/
export ANDROID_NDK_HOME=$HOME/android-ndk-r27c
# SDK (sólo build-tools + platform-tools, no se necesita la plataforma
# completa porque el APK se genera con aapt2 + apksigner del SDK).
# En Artix viene del paquete `android-sdk-build-tools`.
```
El script `build-android.sh` genera automáticamente un PEM RSA2048
self-signed en `~/.local/share/llimphi-android/debug.pem` la primera
vez que corre. Para firma de release usar un PEM propio y exportarlo
en `LLIMPHI_PEM`.
## Estructura del APK generado
```
clear-screen-android.apk
├── AndroidManifest.xml ← xbuild genera; NativeActivity
└── lib/arm64-v8a/
└── libclear_screen_android.so ← 7.5 MB sin strip, ~2 MB stripped
```
Sin assets, sin recursos, sin Java/Kotlin. Todo el "código" de la app
es la `.so` Rust. El bootstrap Java de NativeActivity lo provee el
framework Android.
## Apps por portar (orden de menor a mayor fricción)
Las apps que **menos** se modifican al portar son las que ya tienen
poca interacción con teclado/mouse y mucho rendering:
1. **mirada-image-viewer-llimphi** — visor de imágenes, gestos = ok
2. **nahual-text-viewer-llimphi** — sólo scroll + zoom
3. **nahual-image-viewer-llimphi** — idem
4. **pluma-md-reader** — visor markdown, mismo patrón que la web
5. **chasqui-explorer-llimphi** — listas y tarjetas, taps obvios
6. **shuma-shell-llimphi** — teclado virtual, ya casi no usa shortcuts
7. **mirada-app-llimphi** — el compositor; touch desktop = problema UX
Las apps con paleta de comandos (nada, pluma-app full) son las
**últimas** porque su UX core (Ctrl+Shift+P, multi-pane splitter,
file picker) necesita ser repensada para touch.
## Próximos hitos
- **Tier 1.5**: hello-world con vello rasterizando un texto + figura
(smoke test del stack raster completo en Android).
- **Tier 2**: portar `mirada-image-viewer-llimphi` — primer APK
funcional con UI real.
- **Tier 3**: input handling proper (touch events, soft keyboard,
back button), theming responsivo (dpi/density).
- **Tier 4**: distribución (Play Store internal track, F-Droid build
reproducible).
+49
View File
@@ -0,0 +1,49 @@
[package]
name = "clear-screen-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Demo Android Tier 1: pinta la pantalla con LEAD_GRAY usando llimphi-hal sobre Android NativeActivity."
# Android NativeActivity carga la lib nativa como .so via dlopen; el
# binario final es una `cdylib` con `android_main` exportado. xbuild /
# cargo-apk se encargan de empaquetar el .so dentro del APK.
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
# Activamos el feature de NativeActivity en winit para que linkee con la
# clase NativeActivity del NDK y reciba eventos de surface/input desde la
# Activity Java/Kotlin generada por android-activity.
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
# Metadata para xbuild / cargo-apk — define el manifiesto Android que se
# inyecta en el APK final.
[package.metadata.android]
package = "net.gioser.llimphi.clearscreen"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · clear_screen"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# clear-screen-android
> Smoke test del HAL Android de [llimphi](../../README.md).
App mínima que limpia la pantalla con un color sólido. Sirve para verificar que el HAL Android compila + corre + dibuja sin que el resto del stack ofusque el problema.
## Build
```sh
cargo apk build -p clear-screen-android
```
+11
View File
@@ -0,0 +1,11 @@
# clear-screen-android
> Android HAL smoke test of [llimphi](../../README.md).
Minimal app that clears the screen with a solid color. Verifies the Android HAL compiles + runs + draws without the rest of the stack obscuring the problem.
## Build
```sh
cargo apk build -p clear-screen-android
```
+291
View File
@@ -0,0 +1,291 @@
//! Demo Tier 1 Android: pinta la pantalla con LEAD_GRAY usando llimphi-hal.
//!
//! Logging exhaustivo en cada paso del bootstrap para diagnosticar
//! cuelgues en device real desde `adb logcat -s llimphi-android:V`.
//! Panic hook captura backtraces a logcat — sin esto el crash es
//! invisible (Android cierra el proceso silenciosamente).
//!
//! Orden de inicialización en `resumed`:
//! 1. crear Window via winit
//! 2. crear wgpu::Instance
//! 3. crear Surface con la NativeWindow
//! 4. request_adapter pasándole compatible_surface=Some(&surface)
//! 5. request_device
//! 6. configurar surface (formato, tamaño)
//! 7. crear textura intermedia + blitter (llimphi-hal::WinitSurface)
//!
//! El orden 3 antes que 4 es lo que **garantiza** que el adapter
//! elegido sabe presentar a esa NativeWindow concreta. Llamar
//! `Hal::new(None)` (como hacía la primera versión) elige un adapter
//! "cualquiera" y después la creación de surface puede fallar — o
//! peor, parecer OK y crashear en el primer `present`.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
const LEAD_GRAY: wgpu::Color = wgpu::Color {
r: 0.235,
g: 0.239,
b: 0.247,
a: 1.0,
};
const TAG: &str = "llimphi-android";
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
}
impl App {
fn new() -> Self {
Self {
state: None,
frames: 0,
last_report: Instant::now(),
}
}
/// Bootstrap: crea el estado completo o devuelve un mensaje
/// explicando dónde falló. **No panic-ea** — los panics en
/// `android_main` arrancan la cierre del proceso antes que el
/// logcat flushee.
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
log::info!("[boot] 1/7 creando Window");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · clear_screen"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
let size = window.inner_size();
log::info!(
"[boot] window ok · inner_size = {}x{}",
size.width,
size.height
);
log::info!("[boot] 2/7 creando wgpu::Instance");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
log::info!("[boot] instance ok · backends activos = {:?}", instance);
log::info!("[boot] 3/7 creando Surface contra la NativeWindow");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
log::info!("[boot] surface creada");
log::info!("[boot] 4/7 request_adapter (compatible_surface=Some)");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter devolvió None — sin GPU compatible".to_string())?;
let info = adapter.get_info();
log::info!(
"[boot] adapter ok · backend={:?} name={:?} driver={:?}",
info.backend,
info.name,
info.driver_info
);
log::info!("[boot] 5/7 request_device");
// En Android (Mali/Adreno entry-level) Limits::default suele exceder
// el hardware. using_resolution recorta lo recortable preservando
// los counts mínimos (5 storage buffers/stage que vello necesita).
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("clear-screen-android-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
log::info!("[boot] device + queue ok");
log::info!("[boot] 6/7 ensamblando Hal");
let hal = Hal {
instance,
adapter,
device,
queue,
};
log::info!("[boot] 7/7 envolviendo en WinitSurface (intermediate + blitter)");
// Crítico: usar `from_surface` (no `new`), pasando la surface que
// ya creamos en el paso 3. `WinitSurface::new` haría un segundo
// create_surface contra la misma NativeWindow y Android responde
// ERROR_NATIVE_WINDOW_IN_USE_KHR → panic.
let llimphi_surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface::from_surface: {e}"))?;
log::info!("[boot] ✓ bootstrap completo, pidiendo redraw");
window.request_redraw();
Ok(State {
window,
hal,
surface: llimphi_surface,
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed event");
match self.boot(event_loop) {
Ok(state) => self.state = Some(state),
Err(e) => {
log::error!("BOOT FAILED: {e}");
// No exit-amos para que el process siga vivo y se vea el
// log; el usuario cerrará la app manualmente.
}
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended event — liberando surface");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => {
log::info!("CloseRequested");
event_loop.exit();
}
WindowEvent::Resized(size) => {
log::info!("Resized → {}x{}", size.width, size.height);
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire falló ({e}); reconfigurando");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let mut encoder =
state
.hal
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("clear_screen-encoder"),
});
{
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("clear_screen-pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: frame.view(),
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(LEAD_GRAY),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
});
}
state.hal.queue.submit(std::iter::once(encoder.finish()));
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
log::info!("{fps:.1} fps");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
// Sin esto los panic son invisibles: Android mata el proceso antes
// que la línea de stderr llegue a logcat. set_hook redirige el panic
// info a log::error que sí sale en logcat (vía android_logger).
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown panic payload>");
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "<unknown location>".into());
log::error!("PANIC at {location} — {payload}");
// Forzar flush stdio del android_logger (mejor que nada).
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Debug)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop::build failed: {e}");
return;
}
};
event_loop.set_control_flow(ControlFlow::Poll);
log::info!("event_loop construido, entrando a run_app");
let mut app_handler = App::new();
if let Err(e) = event_loop.run_app(&mut app_handler) {
log::error!("run_app: {e}");
}
log::info!("android_main END");
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# ============================================================================
# build-android.sh — empaca un crate Llimphi-Android como APK firmado.
#
# Uso:
# ./build-android.sh <crate-dir> [arch] [profile]
#
# crate-dir : path al Cargo.toml del crate Android (cdylib + android_main)
# arch : arm64 | x64 (default arm64)
# profile : release | debug (default release)
#
# Requisitos:
# - rustup target add aarch64-linux-android x86_64-linux-android
# - cargo install xbuild (binario `x`)
# - cargo install cargo-ndk (opcional, sólo si querés build sin APK)
# - NDK r27+ en $ANDROID_NDK_HOME
# - Android SDK en $ANDROID_HOME (cmdline-tools + build-tools)
# - PEM dev en $LLIMPHI_PEM (se crea automáticamente la primera vez)
#
# Resultado:
# target/x/<profile>/android/<crate>.apk — APK firmado v2, instalable con
# `adb install -r <apk>`.
# ============================================================================
set -euo pipefail
CRATE_DIR="${1:?se requiere crate-dir como primer argumento}"
ARCH="${2:-arm64}"
PROFILE="${3:-release}"
# --- toolchain -------------------------------------------------------------
: "${ANDROID_NDK_HOME:=/home/sergio/android-ndk-r27c}"
: "${ANDROID_NDK_ROOT:=$ANDROID_NDK_HOME}"
: "${ANDROID_HOME:=/opt/android-sdk}"
: "${LLIMPHI_PEM:=$HOME/.local/share/llimphi-android/debug.pem}"
export ANDROID_NDK_HOME ANDROID_NDK_ROOT ANDROID_HOME
X_BIN="${X_BIN:-$HOME/.cargo/bin/x}"
test -x "$X_BIN" || { echo "❌ xbuild (cargo install xbuild)"; exit 1; }
test -d "$ANDROID_NDK_HOME" || { echo "❌ NDK no encontrado en $ANDROID_NDK_HOME"; exit 1; }
test -d "$ANDROID_HOME" || { echo "❌ SDK no encontrado en $ANDROID_HOME"; exit 1; }
# --- PEM de firma dev (RSA 2048 + cert auto-firmado) -----------------------
if [ ! -f "$LLIMPHI_PEM" ]; then
echo "→ generando PEM de firma dev en $LLIMPHI_PEM"
mkdir -p "$(dirname "$LLIMPHI_PEM")"
openssl req -x509 -newkey rsa:2048 \
-keyout "${LLIMPHI_PEM}.key" \
-out "${LLIMPHI_PEM}.cert" \
-days 36500 -nodes \
-subj "/CN=llimphi-dev/O=gioser/C=AR" 2>/dev/null
cat "${LLIMPHI_PEM}.key" "${LLIMPHI_PEM}.cert" > "$LLIMPHI_PEM"
fi
# --- flags -----------------------------------------------------------------
PROFILE_FLAG="--release"
[ "$PROFILE" = "debug" ] && PROFILE_FLAG="--debug"
# --- build ----------------------------------------------------------------
cd "$CRATE_DIR"
CRATE_NAME=$(grep '^name *=' Cargo.toml | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
echo "→ building $CRATE_NAME · $ARCH · $PROFILE"
"$X_BIN" build \
--platform android \
--arch "$ARCH" \
--format apk \
$PROFILE_FLAG \
--pem "$LLIMPHI_PEM"
# --- locate + verify -------------------------------------------------------
APK=$(find ../../../../target/x/$PROFILE/android -name "${CRATE_NAME}.apk" 2>/dev/null | head -1)
[ -z "$APK" ] && APK=$(find . -name "${CRATE_NAME}.apk" 2>/dev/null | head -1)
[ -z "$APK" ] && { echo "❌ APK no encontrado"; exit 1; }
APK=$(readlink -f "$APK")
SIZE=$(du -h "$APK" | cut -f1)
APKSIGNER="$ANDROID_HOME/build-tools/37.0.0/apksigner"
if [ -x "$APKSIGNER" ]; then
if "$APKSIGNER" verify --min-sdk-version 24 "$APK" 2>/dev/null; then
echo "✓ firma verificada (APK Signature Scheme v2)"
else
echo "⚠ firma no verifica"
fi
fi
echo "$APK ($SIZE)"
echo
echo "Instalar en device:"
echo " adb install -r $APK"
+43
View File
@@ -0,0 +1,43 @@
[package]
name = "vello-hello-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Tier 1.5 Android: vello + llimphi-raster pintando una chacana animada como smoke test del stack completo."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
[package.metadata.android]
package = "net.gioser.llimphi.vellohello"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · vello-hello"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# vello-hello-android
> Vello hello-world Android de [llimphi](../../README.md).
App que dibuja un par de shapes con `vello` sobre el HAL Android. Siguiente paso después de [`clear-screen-android`](../clear-screen-android/README.md): valida que vello/wgpu corren en el dispositivo.
## Build
```sh
cargo apk build -p vello-hello-android
```
+11
View File
@@ -0,0 +1,11 @@
# vello-hello-android
> Vello hello-world Android of [llimphi](../../README.md).
App that draws a couple of shapes with `vello` over the Android HAL. Next step after [`clear-screen-android`](../clear-screen-android/README.md): validates that vello/wgpu run on the device.
## Build
```sh
cargo apk build -p vello-hello-android
```
+376
View File
@@ -0,0 +1,376 @@
//! Tier 1.5 Android: chacana animada con vello + llimphi-raster.
//!
//! Smoke test del stack raster completo en device móvil:
//! wgpu (Vulkan/Adreno) → llimphi-hal (intermediate Rgba8) →
//! vello::Scene (kurbo paths + peniko brushes) →
//! llimphi_raster::Renderer (compute pipeline AA) →
//! blit a swapchain.
//!
//! El bootstrap es el mismo orden estricto que `clear-screen-android`:
//! create_surface antes que request_adapter (compatible_surface=Some),
//! WinitSurface::from_surface (no `new`), panic hook al logcat.
//!
//! Si esta app pinta y mantiene fps en device, todas las apps Llimphi
//! basadas en vello están listas para portar mecánicamente — solo hay
//! que envolver su `build_scene` con este shell.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
use llimphi_raster::kurbo::{Affine, BezPath, Circle, Stroke};
use llimphi_raster::peniko::{Color, Fill};
use llimphi_raster::{vello, Renderer};
const TAG: &str = "llimphi-vello";
// Paleta gioser (mismos hex que la web/Llimphi-theme).
const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
const ACCENT_CYAN: Color = Color::from_rgba8(0xA6, 0xD8, 0xFF, 255);
const ACCENT_AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
const ACCENT_BLUE: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
const ACCENT_VIOLET: Color = Color::from_rgba8(0xC3, 0x9C, 0xE8, 255);
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: Renderer,
scene: vello::Scene,
}
struct App {
state: Option<State>,
started: Instant,
frames: u64,
last_report: Instant,
}
impl App {
fn new() -> Self {
let now = Instant::now();
Self {
state: None,
started: now,
frames: 0,
last_report: now,
}
}
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
log::info!("[boot] 1/8 Window");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · vello-hello"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
log::info!("[boot] 2/8 wgpu::Instance");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
log::info!("[boot] 3/8 Surface (única create_surface en este boot)");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
log::info!("[boot] 4/8 Adapter compatible con surface");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter → None".to_string())?;
let info = adapter.get_info();
log::info!(
"[boot] adapter ok · {:?} · {} · {:?}",
info.backend,
info.name,
info.driver_info
);
log::info!("[boot] 5/8 Device + Queue");
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("vello-hello-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
log::info!("[boot] 6/8 Hal");
let hal = Hal {
instance,
adapter,
device,
queue,
};
log::info!("[boot] 7/8 WinitSurface::from_surface");
let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface: {e}"))?;
log::info!("[boot] 8/8 vello Renderer");
let renderer =
Renderer::new(&hal).map_err(|e| format!("Renderer::new: {e}"))?;
log::info!("[boot] ✓ stack raster listo, primer redraw");
window.request_redraw();
Ok(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed");
match self.boot(event_loop) {
Ok(s) => self.state = Some(s),
Err(e) => log::error!("BOOT FAILED: {e}"),
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended — liberando state");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
log::info!("Resized → {}x{}", size.width, size.height);
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire {e}, reconfig");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
let t = self.started.elapsed().as_secs_f64();
state.scene.reset();
build_chacana(&mut state.scene, w as f64, h as f64, t);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
COSMOS_NIGHT,
) {
log::error!("render: {e}");
}
state.surface.present(frame, &state.hal);
self.frames += 1;
let elapsed = self.last_report.elapsed();
if elapsed.as_secs() >= 1 {
let fps = self.frames as f64 / elapsed.as_secs_f64();
log::info!("{fps:.1} fps · {w}x{h}");
self.frames = 0;
self.last_report = Instant::now();
}
state.window.request_redraw();
}
_ => {}
}
}
}
/// Construye la chacana (cruz andina escalonada) animada, centrada en el
/// viewport. El sol central late con sin(t); cuatro rayos cardinales
/// rotan en una vuelta cada 12 s; halo cyan constante.
fn build_chacana(scene: &mut vello::Scene, w: f64, h: f64, t: f64) {
let cx = w * 0.5;
let cy = h * 0.5;
let unit = (w.min(h)) * 0.06; // tamaño de la escala de la cruz
// Halo radial (anillo cyan suave)
scene.stroke(
&Stroke::new(2.0),
Affine::IDENTITY,
Color::from_rgba8(0xA6, 0xD8, 0xFF, 80),
None,
&Circle::new((cx, cy), unit * 4.6),
);
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
Color::from_rgba8(0xA6, 0xD8, 0xFF, 140),
None,
&Circle::new((cx, cy), unit * 4.0),
);
// Rayos cardinales rotantes (4 trazos a 90°)
let theta = t * (std::f64::consts::TAU / 12.0); // 1 vuelta cada 12 s
let rotate = Affine::translate((cx, cy)) * Affine::rotate(theta);
for i in 0..4 {
let angle = i as f64 * std::f64::consts::FRAC_PI_2;
let dir = (angle.cos(), angle.sin());
let mut p = BezPath::new();
p.move_to((dir.0 * unit * 3.2, dir.1 * unit * 3.2));
p.line_to((dir.0 * unit * 4.4, dir.1 * unit * 4.4));
scene.stroke(
&Stroke::new(1.5),
rotate,
ACCENT_BLUE,
None,
&p,
);
}
// Chacana: cruz escalonada de 12 puntas. Construida como BezPath.
// La forma clásica: cuadrado central + escalones en 4 direcciones.
let chacana = chacana_path(unit);
let center = Affine::translate((cx, cy));
// Glow ambar exterior
scene.stroke(
&Stroke::new(6.0),
center,
Color::from_rgba8(0xE8, 0xC9, 0x7A, 110),
None,
&chacana,
);
// Outline cyan
scene.stroke(
&Stroke::new(2.0),
center,
ACCENT_CYAN,
None,
&chacana,
);
// Relleno violeta tenue
scene.fill(
Fill::NonZero,
center,
Color::from_rgba8(0xC3, 0x9C, 0xE8, 40),
None,
&chacana,
);
// Sol central que late
let pulse = 1.0 + 0.18 * (t * 1.8).sin();
let r_sun = unit * 0.7 * pulse;
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
ACCENT_AMBER,
None,
&Circle::new((cx, cy), r_sun),
);
// Corona
scene.stroke(
&Stroke::new(1.0),
Affine::IDENTITY,
Color::from_rgba8(0xE8, 0xC9, 0x7A, 120),
None,
&Circle::new((cx, cy), r_sun * 1.7),
);
// Punto interior violeta para contraste
scene.fill(
Fill::NonZero,
Affine::IDENTITY,
ACCENT_VIOLET,
None,
&Circle::new((cx, cy), r_sun * 0.35),
);
}
/// Path de la chacana centrada en el origen, con `u` como ancho de cada
/// escalón. Reconstruye la forma clásica de 12 esquinas escalonadas
/// (3 escalones por cada brazo cardinal).
fn chacana_path(u: f64) -> BezPath {
let mut p = BezPath::new();
// Empezamos en la esquina superior-derecha del brazo norte y vamos
// en sentido horario alrededor de toda la cruz.
p.move_to((u, 3.0 * u));
p.line_to((u, u));
p.line_to((3.0 * u, u));
p.line_to((3.0 * u, -u));
p.line_to((u, -u));
p.line_to((u, -3.0 * u));
p.line_to((-u, -3.0 * u));
p.line_to((-u, -u));
p.line_to((-3.0 * u, -u));
p.line_to((-3.0 * u, u));
p.line_to((-u, u));
p.line_to((-u, 3.0 * u));
p.close_path();
p
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown>");
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<?>".into());
log::error!("PANIC at {loc} — {payload}");
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop: {e}");
return;
}
};
event_loop.set_control_flow(ControlFlow::Poll);
let mut handler = App::new();
if let Err(e) = event_loop.run_app(&mut handler) {
log::error!("run_app: {e}");
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "vello-text-android"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Tier 1.75 Android: parley + vello + llimphi-text rasterizando texto multi-script con fallback CJK/Arabic via fontique."
[lib]
crate-type = ["cdylib"]
[dependencies]
llimphi-hal = { path = "../../llimphi-hal" }
llimphi-raster = { path = "../../llimphi-raster" }
llimphi-text = { path = "../../llimphi-text" }
winit = { workspace = true, features = ["android-native-activity"] }
wgpu.workspace = true
vello.workspace = true
pollster.workspace = true
# `log` se declara aquí (no en el bloque condicional Android) para que
# `cargo check --workspace` en host pase: los macros de `log` son no-op
# sin logger instalado. En Android, `android_logger` (más abajo) instala
# el sink real hacia `logcat`.
log = "0.4"
[target.'cfg(target_os = "android")'.dependencies]
android-activity = { version = "0.6", features = ["native-activity"] }
android_logger = "0.14"
[package.metadata.android]
package = "net.gioser.llimphi.vellotext"
build_targets = ["aarch64-linux-android", "x86_64-linux-android"]
min_sdk_version = 24
target_sdk_version = 34
[package.metadata.android.application]
label = "Llimphi · vello-text"
debuggable = true
[package.metadata.android.application.activity]
config_changes = "orientation|screenSize|keyboardHidden"
launch_mode = "singleTop"
orientation = "unspecified"
+11
View File
@@ -0,0 +1,11 @@
# vello-text-android
> Text shaping Android de [llimphi](../../README.md).
Dibuja texto con `vello` + `fontdue` sobre Android. Tercer hito: confirma que [`llimphi-text`](../../llimphi-text/README.md) shapea correctamente con DPI de móvil.
## Build
```sh
cargo apk build -p vello-text-android
```
+11
View File
@@ -0,0 +1,11 @@
# vello-text-android
> Android text shaping of [llimphi](../../README.md).
Draws text with `vello` + `fontdue` on Android. Third milestone: confirms [`llimphi-text`](../../llimphi-text/README.md) shapes correctly with mobile DPI.
## Build
```sh
cargo apk build -p vello-text-android
```
+406
View File
@@ -0,0 +1,406 @@
//! Tier 1.75 Android: texto multi-script con parley + vello + llimphi-text.
//!
//! Verifica que en Android funciona:
//! - parley::FontContext::new() resolviendo fuentes via fontique sobre
//! /system/fonts (Roboto + Noto fallback CJK/Arabic vienen en todas
//! las builds AOSP).
//! - shaping con kerning, ligaduras, bidi, fallback inter-script en
//! una misma línea.
//! - rasterización de glifos por vello::Scene::draw_glyphs (compute
//! pipeline sobre la intermediate Rgba8).
//!
//! Si esta corre estable y se ven los tres scripts (latino, arábigo,
//! CJK) sin tofu (cuadrados vacíos), llimphi-ui está habilitado en
//! Android — el resto de las apps (text-viewer, file-explorer,
//! pluma-md-reader) usan exactamente esta misma pipa.
//!
//! El factor de scale por DPI se calcula desde el `inner_size` real
//! del Window que Android nos pasa (ya incluye la densidad del
//! display). En desktop el window es 960x540 lógico; en mobile típico
//! es ~1080x2400 físico → fuentes 2-3× más grandes para legibilidad.
use std::sync::Arc;
use std::time::Instant;
use llimphi_hal::winit::application::ApplicationHandler;
use llimphi_hal::winit::event::WindowEvent;
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
use llimphi_raster::peniko::Color;
use llimphi_raster::vello;
use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter};
const TAG: &str = "llimphi-text";
const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
const FG_TEXT: Color = Color::from_rgba8(0xD6, 0xDE, 0xE8, 255);
const FG_MUTED: Color = Color::from_rgba8(0x8C, 0x98, 0xAA, 255);
const ACCENT: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
const AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \
geometrías exactas, sin cajas negras. شكراً 你好 こんにちは — el shaping \
de parley maneja kerning, ligaduras y fallback CJK/Árabe en la misma \
línea, resuelto por fontique sobre las fuentes Noto de Android.";
const TECNICO: &str = "stack: wgpu(Vulkan) → llimphi-hal → vello compute → \
parley shaping → fontique fallback. APK firmado v2, ~7 MB stripped.";
struct State {
window: Arc<Window>,
hal: Hal,
surface: WinitSurface,
renderer: llimphi_raster::Renderer,
scene: vello::Scene,
typesetter: Typesetter,
}
struct App {
state: Option<State>,
frames: u64,
last_report: Instant,
/// `None` antes del primer present; al loguearse pasa a `Some` para
/// no spamear. Mide el tiempo "tiempo en pantalla" real del usuario.
first_paint: Option<Instant>,
started: Instant,
}
impl App {
fn new() -> Self {
Self {
state: None,
frames: 0,
last_report: Instant::now(),
first_paint: None,
started: Instant::now(),
}
}
fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
// Timings paso a paso — Android tarda 3-5s en el cold-start,
// queremos saber si es vello shader compile, fontique scan,
// request_device, o el primer render. `step` toma el delta
// desde la marca anterior y lo loguea.
let t0 = Instant::now();
let mut tprev = t0;
let mut step = |name: &str| {
let now = Instant::now();
let dt = now.duration_since(tprev);
let total = now.duration_since(t0);
log::info!(
"[boot+{:>5}ms] {} (+{}ms)",
total.as_millis(),
name,
dt.as_millis()
);
tprev = now;
};
step("0/9 START");
let window = event_loop
.create_window(WindowAttributes::default().with_title("llimphi · vello-text"))
.map_err(|e| format!("create_window: {e}"))?;
let window = Arc::new(window);
let size = window.inner_size();
step(&format!("1/9 Window {}x{}", size.width, size.height));
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::all(),
..Default::default()
});
step("2/9 wgpu::Instance");
let surface = instance
.create_surface(window.clone())
.map_err(|e| format!("create_surface: {e}"))?;
step("3/9 Surface");
let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
force_fallback_adapter: false,
compatible_surface: Some(&surface),
}))
.ok_or_else(|| "request_adapter → None".to_string())?;
let info = adapter.get_info();
step(&format!("4/9 Adapter {:?} {}", info.backend, info.name));
let limits = wgpu::Limits::default().using_resolution(adapter.limits());
let (device, queue) = pollster::block_on(adapter.request_device(
&wgpu::DeviceDescriptor {
label: Some("vello-text-device"),
required_features: wgpu::Features::empty(),
required_limits: limits,
memory_hints: wgpu::MemoryHints::Performance,
},
None,
))
.map_err(|e| format!("request_device: {e}"))?;
step("5/9 Device + Queue");
let hal = Hal {
instance,
adapter,
device,
queue,
};
step("6/9 Hal armado");
let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
.map_err(|e| format!("WinitSurface: {e}"))?;
step("7/9 WinitSurface::from_surface");
// Sospechoso #1: vello compila ~20 shaders WGSL + crea pipelines
// de compute. En desktop ~150ms; en Adreno entry-level estimamos
// 1-3s. Si es esto, la solución es pipeline_cache persistente.
let renderer =
llimphi_raster::Renderer::new(&hal).map_err(|e| format!("Renderer: {e}"))?;
step("8/9 vello Renderer (shaders + pipelines)");
// Sospechoso #2: fontique escanea /system/fonts y parsea cada
// TTF/OTF para indexar metadata (family, style, scripts).
// Android tiene ~50-80 fuentes Noto + Roboto.
let typesetter = Typesetter::new();
step("9/9 Typesetter (fontique scan /system/fonts)");
log::info!(
"[boot ✓ total {}ms] stack texto listo",
t0.elapsed().as_millis()
);
window.request_redraw();
Ok(State {
window,
hal,
surface,
renderer,
scene: vello::Scene::new(),
typesetter,
})
}
}
impl ApplicationHandler for App {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
log::info!("Resumed");
match self.boot(event_loop) {
Ok(s) => self.state = Some(s),
Err(e) => log::error!("BOOT FAILED: {e}"),
}
}
fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
log::info!("Suspended");
self.state = None;
}
fn window_event(
&mut self,
event_loop: &ActiveEventLoop,
_id: WindowId,
event: WindowEvent,
) {
let Some(state) = self.state.as_mut() else {
return;
};
match event {
WindowEvent::CloseRequested => event_loop.exit(),
WindowEvent::Resized(size) => {
state.surface.resize(size.width, size.height);
state.window.request_redraw();
}
WindowEvent::RedrawRequested => {
let frame = match state.surface.acquire() {
Ok(f) => f,
Err(e) => {
log::warn!("acquire {e}");
let (w, h) = state.surface.size();
state.surface.resize(w, h);
state.window.request_redraw();
return;
}
};
let (w, h) = frame.size();
state.scene.reset();
paint_page(&mut state.scene, &mut state.typesetter, w, h);
if let Err(e) = state.renderer.render(
&state.hal,
&state.scene,
&frame,
COSMOS_NIGHT,
) {
log::error!("render: {e}");
}
state.surface.present(frame, &state.hal);
if self.first_paint.is_none() {
let elapsed = self.started.elapsed();
log::info!(
"[FIRST PAINT] {}ms desde android_main START",
elapsed.as_millis()
);
self.first_paint = Some(Instant::now());
}
self.frames += 1;
if self.last_report.elapsed().as_secs() >= 2 {
let fps = self.frames as f64 / self.last_report.elapsed().as_secs_f64();
log::info!("{fps:.1} fps · {w}x{h}");
self.frames = 0;
self.last_report = Instant::now();
}
// No request_redraw: el texto es estático, evita drenar batería.
}
_ => {}
}
}
}
/// Pinta la página completa de texto. Escala las fuentes proporcionales al
/// ancho del viewport: en mobile (1080+ px) el texto queda ~1.4× más
/// grande que en desktop (960 px) — lectura cómoda con device a 30 cm.
fn paint_page(scene: &mut vello::Scene, ts: &mut Typesetter, w: u32, h: u32) {
// Escala lineal sobre el ancho del viewport. base = 1080 px → factor 1.0.
let scale = (w as f32 / 1080.0).clamp(0.6, 2.4);
let margin_x = (w as f64 * 0.06).max(24.0);
let margin_y = (h as f64 * 0.08).max(32.0);
let inner_w = (w as f32 - 2.0 * margin_x as f32).max(160.0);
// Título grande
draw_block(
scene,
ts,
&TextBlock {
text: "Llimphi",
size_px: 96.0 * scale,
color: FG_TEXT,
origin: (margin_x, margin_y),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Subtítulo en accent
draw_block(
scene,
ts,
&TextBlock {
text: "texto multi-script sobre Android",
size_px: 22.0 * scale,
color: ACCENT,
origin: (margin_x, margin_y + (110.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Línea separadora dorada (un guion largo en amber)
draw_block(
scene,
ts,
&TextBlock {
text: "",
size_px: 32.0 * scale,
color: AMBER,
origin: (margin_x, margin_y + (155.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Center,
line_height: 1.0,
italic: false,
font_family: None,
},
);
// Párrafo justificado con scripts mixtos
draw_block(
scene,
ts,
&TextBlock {
text: PARRAFO,
size_px: 22.0 * scale,
color: FG_TEXT,
origin: (margin_x, margin_y + (220.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Justify,
line_height: 1.5,
italic: false,
font_family: None,
},
);
// Pie técnico mute
draw_block(
scene,
ts,
&TextBlock {
text: TECNICO,
size_px: 16.0 * scale,
color: FG_MUTED,
origin: (margin_x, h as f64 - margin_y - (50.0 * scale as f64)),
max_width: Some(inner_w),
alignment: Alignment::Start,
line_height: 1.3,
italic: false,
font_family: None,
},
);
}
#[cfg(target_os = "android")]
fn install_panic_logger() {
std::panic::set_hook(Box::new(|info| {
let payload = info
.payload()
.downcast_ref::<&str>()
.copied()
.or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("<unknown>");
let loc = info
.location()
.map(|l| format!("{}:{}", l.file(), l.line()))
.unwrap_or_else(|| "<?>".into());
log::error!("PANIC at {loc} — {payload}");
}));
}
#[cfg(target_os = "android")]
#[no_mangle]
fn android_main(app: android_activity::AndroidApp) {
android_logger::init_once(
android_logger::Config::default()
.with_max_level(log::LevelFilter::Info)
.with_tag(TAG),
);
install_panic_logger();
log::info!("android_main START");
use llimphi_hal::winit::event_loop::EventLoopBuilder;
use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
{
Ok(el) => el,
Err(e) => {
log::error!("EventLoop: {e}");
return;
}
};
// Wait (no Poll): el texto es estático, el redraw lo dispara
// Resized/Resumed. Ahorra batería vs vello-hello que anima.
event_loop.set_control_flow(ControlFlow::Wait);
let mut handler = App::new();
if let Err(e) = event_loop.run_app(&mut handler) {
log::error!("run_app: {e}");
}
}