tawasuyu

tawasuyu/llimphi

Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.

Rust 614Markdown 274TOML 149RON 310606 Sutichaykuna

⎇ main android/vello-text-android/src/lib.rs Rust · 406 siq'ikuna · 13.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

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

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}