tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
feat: llimphi standalone — framework UI soberano extraído del monorepo
1//! Tier 1.75 Android: texto multi-script con parley + vello + llimphi-text.
2//!
3//! Verifica que en Android funciona:
4//! - parley::FontContext::new() resolviendo fuentes via fontique sobre
5//! /system/fonts (Roboto + Noto fallback CJK/Arabic vienen en todas
6//! las builds AOSP).
7//! - shaping con kerning, ligaduras, bidi, fallback inter-script en
8//! una misma línea.
9//! - rasterización de glifos por vello::Scene::draw_glyphs (compute
10//! pipeline sobre la intermediate Rgba8).
11//!
12//! Si esta corre estable y se ven los tres scripts (latino, arábigo,
13//! CJK) sin tofu (cuadrados vacíos), llimphi-ui está habilitado en
14//! Android — el resto de las apps (text-viewer, file-explorer,
15//! pluma-md-reader) usan exactamente esta misma pipa.
16//!
17//! El factor de scale por DPI se calcula desde el `inner_size` real
18//! del Window que Android nos pasa (ya incluye la densidad del
19//! display). En desktop el window es 960x540 lógico; en mobile típico
20//! es ~1080x2400 físico → fuentes 2-3× más grandes para legibilidad.
21
22use std::sync::Arc;
23use std::time::Instant;
24
25use llimphi_hal::winit::application::ApplicationHandler;
26use llimphi_hal::winit::event::WindowEvent;
27use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
28use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
29use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
30use llimphi_raster::peniko::Color;
31use llimphi_raster::vello;
32use llimphi_text::{draw_block, Alignment, TextBlock, Typesetter};
33
34const TAG: &str = "llimphi-text";
35
36const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
37const FG_TEXT: Color = Color::from_rgba8(0xD6, 0xDE, 0xE8, 255);
38const FG_MUTED: Color = Color::from_rgba8(0x8C, 0x98, 0xAA, 255);
39const ACCENT: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
40const AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
41
42const PARRAFO: &str = "Llimphi pinta vector preciso sobre el silicio: \
43geometrías exactas, sin cajas negras. شكراً 你好 こんにちは — el shaping \
44de parley maneja kerning, ligaduras y fallback CJK/Árabe en la misma \
45línea, resuelto por fontique sobre las fuentes Noto de Android.";
46
47const TECNICO: &str = "stack: wgpu(Vulkan) → llimphi-hal → vello compute → \
48parley shaping → fontique fallback. APK firmado v2, ~7 MB stripped.";
49
50struct State {
51 window: Arc<Window>,
52 hal: Hal,
53 surface: WinitSurface,
54 renderer: llimphi_raster::Renderer,
55 scene: vello::Scene,
56 typesetter: Typesetter,
57}
58
59struct App {
60 state: Option<State>,
61 frames: u64,
62 last_report: Instant,
63 /// `None` antes del primer present; al loguearse pasa a `Some` para
64 /// no spamear. Mide el tiempo "tiempo en pantalla" real del usuario.
65 first_paint: Option<Instant>,
66 started: Instant,
67}
68
69impl App {
70 fn new() -> Self {
71 Self {
72 state: None,
73 frames: 0,
74 last_report: Instant::now(),
75 first_paint: None,
76 started: Instant::now(),
77 }
78 }
79
80 fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
81 // Timings paso a paso — Android tarda 3-5s en el cold-start,
82 // queremos saber si es vello shader compile, fontique scan,
83 // request_device, o el primer render. `step` toma el delta
84 // desde la marca anterior y lo loguea.
85 let t0 = Instant::now();
86 let mut tprev = t0;
87 let mut step = |name: &str| {
88 let now = Instant::now();
89 let dt = now.duration_since(tprev);
90 let total = now.duration_since(t0);
91 log::info!(
92 "[boot+{:>5}ms] {} (+{}ms)",
93 total.as_millis(),
94 name,
95 dt.as_millis()
96 );
97 tprev = now;
98 };
99
100 step("0/9 START");
101 let window = event_loop
102 .create_window(WindowAttributes::default().with_title("llimphi · vello-text"))
103 .map_err(|e| format!("create_window: {e}"))?;
104 let window = Arc::new(window);
105 let size = window.inner_size();
106 step(&format!("1/9 Window {}x{}", size.width, size.height));
107
108 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
109 backends: wgpu::Backends::all(),
110 ..Default::default()
111 });
112 step("2/9 wgpu::Instance");
113
114 let surface = instance
115 .create_surface(window.clone())
116 .map_err(|e| format!("create_surface: {e}"))?;
117 step("3/9 Surface");
118
119 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
120 power_preference: wgpu::PowerPreference::HighPerformance,
121 force_fallback_adapter: false,
122 compatible_surface: Some(&surface),
123 }))
124 .ok_or_else(|| "request_adapter → None".to_string())?;
125 let info = adapter.get_info();
126 step(&format!("4/9 Adapter {:?} {}", info.backend, info.name));
127
128 let limits = wgpu::Limits::default().using_resolution(adapter.limits());
129 let (device, queue) = pollster::block_on(adapter.request_device(
130 &wgpu::DeviceDescriptor {
131 label: Some("vello-text-device"),
132 required_features: wgpu::Features::empty(),
133 required_limits: limits,
134 memory_hints: wgpu::MemoryHints::Performance,
135 },
136 None,
137 ))
138 .map_err(|e| format!("request_device: {e}"))?;
139 step("5/9 Device + Queue");
140
141 let hal = Hal {
142 instance,
143 adapter,
144 device,
145 queue,
146 };
147 step("6/9 Hal armado");
148
149 let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
150 .map_err(|e| format!("WinitSurface: {e}"))?;
151 step("7/9 WinitSurface::from_surface");
152
153 // Sospechoso #1: vello compila ~20 shaders WGSL + crea pipelines
154 // de compute. En desktop ~150ms; en Adreno entry-level estimamos
155 // 1-3s. Si es esto, la solución es pipeline_cache persistente.
156 let renderer =
157 llimphi_raster::Renderer::new(&hal).map_err(|e| format!("Renderer: {e}"))?;
158 step("8/9 vello Renderer (shaders + pipelines)");
159
160 // Sospechoso #2: fontique escanea /system/fonts y parsea cada
161 // TTF/OTF para indexar metadata (family, style, scripts).
162 // Android tiene ~50-80 fuentes Noto + Roboto.
163 let typesetter = Typesetter::new();
164 step("9/9 Typesetter (fontique scan /system/fonts)");
165
166 log::info!(
167 "[boot ✓ total {}ms] stack texto listo",
168 t0.elapsed().as_millis()
169 );
170
171 window.request_redraw();
172 Ok(State {
173 window,
174 hal,
175 surface,
176 renderer,
177 scene: vello::Scene::new(),
178 typesetter,
179 })
180 }
181}
182
183impl ApplicationHandler for App {
184 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
185 log::info!("Resumed");
186 match self.boot(event_loop) {
187 Ok(s) => self.state = Some(s),
188 Err(e) => log::error!("BOOT FAILED: {e}"),
189 }
190 }
191
192 fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
193 log::info!("Suspended");
194 self.state = None;
195 }
196
197 fn window_event(
198 &mut self,
199 event_loop: &ActiveEventLoop,
200 _id: WindowId,
201 event: WindowEvent,
202 ) {
203 let Some(state) = self.state.as_mut() else {
204 return;
205 };
206 match event {
207 WindowEvent::CloseRequested => event_loop.exit(),
208 WindowEvent::Resized(size) => {
209 state.surface.resize(size.width, size.height);
210 state.window.request_redraw();
211 }
212 WindowEvent::RedrawRequested => {
213 let frame = match state.surface.acquire() {
214 Ok(f) => f,
215 Err(e) => {
216 log::warn!("acquire {e}");
217 let (w, h) = state.surface.size();
218 state.surface.resize(w, h);
219 state.window.request_redraw();
220 return;
221 }
222 };
223 let (w, h) = frame.size();
224 state.scene.reset();
225 paint_page(&mut state.scene, &mut state.typesetter, w, h);
226 if let Err(e) = state.renderer.render(
227 &state.hal,
228 &state.scene,
229 &frame,
230 COSMOS_NIGHT,
231 ) {
232 log::error!("render: {e}");
233 }
234 state.surface.present(frame, &state.hal);
235
236 if self.first_paint.is_none() {
237 let elapsed = self.started.elapsed();
238 log::info!(
239 "[FIRST PAINT] {}ms desde android_main START",
240 elapsed.as_millis()
241 );
242 self.first_paint = Some(Instant::now());
243 }
244
245 self.frames += 1;
246 if self.last_report.elapsed().as_secs() >= 2 {
247 let fps = self.frames as f64 / self.last_report.elapsed().as_secs_f64();
248 log::info!("{fps:.1} fps · {w}x{h}");
249 self.frames = 0;
250 self.last_report = Instant::now();
251 }
252 // No request_redraw: el texto es estático, evita drenar batería.
253 }
254 _ => {}
255 }
256 }
257}
258
259/// Pinta la página completa de texto. Escala las fuentes proporcionales al
260/// ancho del viewport: en mobile (1080+ px) el texto queda ~1.4× más
261/// grande que en desktop (960 px) — lectura cómoda con device a 30 cm.
262fn paint_page(scene: &mut vello::Scene, ts: &mut Typesetter, w: u32, h: u32) {
263 // Escala lineal sobre el ancho del viewport. base = 1080 px → factor 1.0.
264 let scale = (w as f32 / 1080.0).clamp(0.6, 2.4);
265 let margin_x = (w as f64 * 0.06).max(24.0);
266 let margin_y = (h as f64 * 0.08).max(32.0);
267 let inner_w = (w as f32 - 2.0 * margin_x as f32).max(160.0);
268
269 // Título grande
270 draw_block(
271 scene,
272 ts,
273 &TextBlock {
274 text: "Llimphi",
275 size_px: 96.0 * scale,
276 color: FG_TEXT,
277 origin: (margin_x, margin_y),
278 max_width: Some(inner_w),
279 alignment: Alignment::Center,
280 line_height: 1.0,
281
282 italic: false,
283 font_family: None,
284 },
285 );
286
287 // Subtítulo en accent
288 draw_block(
289 scene,
290 ts,
291 &TextBlock {
292 text: "texto multi-script sobre Android",
293 size_px: 22.0 * scale,
294 color: ACCENT,
295 origin: (margin_x, margin_y + (110.0 * scale as f64)),
296 max_width: Some(inner_w),
297 alignment: Alignment::Center,
298 line_height: 1.0,
299
300 italic: false,
301 font_family: None,
302 },
303 );
304
305 // Línea separadora dorada (un guion largo en amber)
306 draw_block(
307 scene,
308 ts,
309 &TextBlock {
310 text: "—",
311 size_px: 32.0 * scale,
312 color: AMBER,
313 origin: (margin_x, margin_y + (155.0 * scale as f64)),
314 max_width: Some(inner_w),
315 alignment: Alignment::Center,
316 line_height: 1.0,
317
318 italic: false,
319 font_family: None,
320 },
321 );
322
323 // Párrafo justificado con scripts mixtos
324 draw_block(
325 scene,
326 ts,
327 &TextBlock {
328 text: PARRAFO,
329 size_px: 22.0 * scale,
330 color: FG_TEXT,
331 origin: (margin_x, margin_y + (220.0 * scale as f64)),
332 max_width: Some(inner_w),
333 alignment: Alignment::Justify,
334 line_height: 1.5,
335
336 italic: false,
337 font_family: None,
338 },
339 );
340
341 // Pie técnico mute
342 draw_block(
343 scene,
344 ts,
345 &TextBlock {
346 text: TECNICO,
347 size_px: 16.0 * scale,
348 color: FG_MUTED,
349 origin: (margin_x, h as f64 - margin_y - (50.0 * scale as f64)),
350 max_width: Some(inner_w),
351 alignment: Alignment::Start,
352 line_height: 1.3,
353
354 italic: false,
355 font_family: None,
356 },
357 );
358}
359
360#[cfg(target_os = "android")]
361fn install_panic_logger() {
362 std::panic::set_hook(Box::new(|info| {
363 let payload = info
364 .payload()
365 .downcast_ref::<&str>()
366 .copied()
367 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
368 .unwrap_or("<unknown>");
369 let loc = info
370 .location()
371 .map(|l| format!("{}:{}", l.file(), l.line()))
372 .unwrap_or_else(|| "<?>".into());
373 log::error!("PANIC at {loc} — {payload}");
374 }));
375}
376
377#[cfg(target_os = "android")]
378#[no_mangle]
379fn android_main(app: android_activity::AndroidApp) {
380 android_logger::init_once(
381 android_logger::Config::default()
382 .with_max_level(log::LevelFilter::Info)
383 .with_tag(TAG),
384 );
385 install_panic_logger();
386 log::info!("android_main START");
387
388 use llimphi_hal::winit::event_loop::EventLoopBuilder;
389 use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
390
391 let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
392 {
393 Ok(el) => el,
394 Err(e) => {
395 log::error!("EventLoop: {e}");
396 return;
397 }
398 };
399 // Wait (no Poll): el texto es estático, el redraw lo dispara
400 // Resized/Resumed. Ahorra batería vs vello-hello que anima.
401 event_loop.set_control_flow(ControlFlow::Wait);
402 let mut handler = App::new();
403 if let Err(e) = event_loop.run_app(&mut handler) {
404 log::error!("run_app: {e}");
405 }
406}