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.5 Android: chacana animada con vello + llimphi-raster.
2//!
3//! Smoke test del stack raster completo en device móvil:
4//! wgpu (Vulkan/Adreno) → llimphi-hal (intermediate Rgba8) →
5//! vello::Scene (kurbo paths + peniko brushes) →
6//! llimphi_raster::Renderer (compute pipeline AA) →
7//! blit a swapchain.
8//!
9//! El bootstrap es el mismo orden estricto que `clear-screen-android`:
10//! create_surface antes que request_adapter (compatible_surface=Some),
11//! WinitSurface::from_surface (no `new`), panic hook al logcat.
12//!
13//! Si esta app pinta y mantiene fps en device, todas las apps Llimphi
14//! basadas en vello están listas para portar mecánicamente — solo hay
15//! que envolver su `build_scene` con este shell.
16
17use std::sync::Arc;
18use std::time::Instant;
19
20use llimphi_hal::winit::application::ApplicationHandler;
21use llimphi_hal::winit::event::WindowEvent;
22use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
23use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
24use llimphi_hal::{wgpu, Hal, Surface, WinitSurface};
25use llimphi_raster::kurbo::{Affine, BezPath, Circle, Stroke};
26use llimphi_raster::peniko::{Color, Fill};
27use llimphi_raster::{vello, Renderer};
28
29const TAG: &str = "llimphi-vello";
30
31// Paleta gioser (mismos hex que la web/Llimphi-theme).
32const COSMOS_NIGHT: Color = Color::from_rgba8(0x0E, 0x10, 0x16, 255);
33const ACCENT_CYAN: Color = Color::from_rgba8(0xA6, 0xD8, 0xFF, 255);
34const ACCENT_AMBER: Color = Color::from_rgba8(0xE8, 0xC9, 0x7A, 255);
35const ACCENT_BLUE: Color = Color::from_rgba8(0x6E, 0x8C, 0xDC, 255);
36const ACCENT_VIOLET: Color = Color::from_rgba8(0xC3, 0x9C, 0xE8, 255);
37
38struct State {
39 window: Arc<Window>,
40 hal: Hal,
41 surface: WinitSurface,
42 renderer: Renderer,
43 scene: vello::Scene,
44}
45
46struct App {
47 state: Option<State>,
48 started: Instant,
49 frames: u64,
50 last_report: Instant,
51}
52
53impl App {
54 fn new() -> Self {
55 let now = Instant::now();
56 Self {
57 state: None,
58 started: now,
59 frames: 0,
60 last_report: now,
61 }
62 }
63
64 fn boot(&self, event_loop: &ActiveEventLoop) -> Result<State, String> {
65 log::info!("[boot] 1/8 Window");
66 let window = event_loop
67 .create_window(WindowAttributes::default().with_title("llimphi · vello-hello"))
68 .map_err(|e| format!("create_window: {e}"))?;
69 let window = Arc::new(window);
70
71 log::info!("[boot] 2/8 wgpu::Instance");
72 let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
73 backends: wgpu::Backends::all(),
74 ..Default::default()
75 });
76
77 log::info!("[boot] 3/8 Surface (única create_surface en este boot)");
78 let surface = instance
79 .create_surface(window.clone())
80 .map_err(|e| format!("create_surface: {e}"))?;
81
82 log::info!("[boot] 4/8 Adapter compatible con surface");
83 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
84 power_preference: wgpu::PowerPreference::HighPerformance,
85 force_fallback_adapter: false,
86 compatible_surface: Some(&surface),
87 }))
88 .ok_or_else(|| "request_adapter → None".to_string())?;
89 let info = adapter.get_info();
90 log::info!(
91 "[boot] adapter ok · {:?} · {} · {:?}",
92 info.backend,
93 info.name,
94 info.driver_info
95 );
96
97 log::info!("[boot] 5/8 Device + Queue");
98 let limits = wgpu::Limits::default().using_resolution(adapter.limits());
99 let (device, queue) = pollster::block_on(adapter.request_device(
100 &wgpu::DeviceDescriptor {
101 label: Some("vello-hello-device"),
102 required_features: wgpu::Features::empty(),
103 required_limits: limits,
104 memory_hints: wgpu::MemoryHints::Performance,
105 },
106 None,
107 ))
108 .map_err(|e| format!("request_device: {e}"))?;
109
110 log::info!("[boot] 6/8 Hal");
111 let hal = Hal {
112 instance,
113 adapter,
114 device,
115 queue,
116 };
117
118 log::info!("[boot] 7/8 WinitSurface::from_surface");
119 let surface = WinitSurface::from_surface(&hal, window.clone(), surface)
120 .map_err(|e| format!("WinitSurface: {e}"))?;
121
122 log::info!("[boot] 8/8 vello Renderer");
123 let renderer =
124 Renderer::new(&hal).map_err(|e| format!("Renderer::new: {e}"))?;
125
126 log::info!("[boot] ✓ stack raster listo, primer redraw");
127 window.request_redraw();
128
129 Ok(State {
130 window,
131 hal,
132 surface,
133 renderer,
134 scene: vello::Scene::new(),
135 })
136 }
137}
138
139impl ApplicationHandler for App {
140 fn resumed(&mut self, event_loop: &ActiveEventLoop) {
141 log::info!("Resumed");
142 match self.boot(event_loop) {
143 Ok(s) => self.state = Some(s),
144 Err(e) => log::error!("BOOT FAILED: {e}"),
145 }
146 }
147
148 fn suspended(&mut self, _event_loop: &ActiveEventLoop) {
149 log::info!("Suspended — liberando state");
150 self.state = None;
151 }
152
153 fn window_event(
154 &mut self,
155 event_loop: &ActiveEventLoop,
156 _id: WindowId,
157 event: WindowEvent,
158 ) {
159 let Some(state) = self.state.as_mut() else {
160 return;
161 };
162 match event {
163 WindowEvent::CloseRequested => event_loop.exit(),
164 WindowEvent::Resized(size) => {
165 log::info!("Resized → {}x{}", size.width, size.height);
166 state.surface.resize(size.width, size.height);
167 state.window.request_redraw();
168 }
169 WindowEvent::RedrawRequested => {
170 let frame = match state.surface.acquire() {
171 Ok(f) => f,
172 Err(e) => {
173 log::warn!("acquire {e}, reconfig");
174 let (w, h) = state.surface.size();
175 state.surface.resize(w, h);
176 state.window.request_redraw();
177 return;
178 }
179 };
180 let (w, h) = frame.size();
181 let t = self.started.elapsed().as_secs_f64();
182 state.scene.reset();
183 build_chacana(&mut state.scene, w as f64, h as f64, t);
184 if let Err(e) = state.renderer.render(
185 &state.hal,
186 &state.scene,
187 &frame,
188 COSMOS_NIGHT,
189 ) {
190 log::error!("render: {e}");
191 }
192 state.surface.present(frame, &state.hal);
193
194 self.frames += 1;
195 let elapsed = self.last_report.elapsed();
196 if elapsed.as_secs() >= 1 {
197 let fps = self.frames as f64 / elapsed.as_secs_f64();
198 log::info!("{fps:.1} fps · {w}x{h}");
199 self.frames = 0;
200 self.last_report = Instant::now();
201 }
202 state.window.request_redraw();
203 }
204 _ => {}
205 }
206 }
207}
208
209/// Construye la chacana (cruz andina escalonada) animada, centrada en el
210/// viewport. El sol central late con sin(t); cuatro rayos cardinales
211/// rotan en una vuelta cada 12 s; halo cyan constante.
212fn build_chacana(scene: &mut vello::Scene, w: f64, h: f64, t: f64) {
213 let cx = w * 0.5;
214 let cy = h * 0.5;
215 let unit = (w.min(h)) * 0.06; // tamaño de la escala de la cruz
216
217 // Halo radial (anillo cyan suave)
218 scene.stroke(
219 &Stroke::new(2.0),
220 Affine::IDENTITY,
221 Color::from_rgba8(0xA6, 0xD8, 0xFF, 80),
222 None,
223 &Circle::new((cx, cy), unit * 4.6),
224 );
225 scene.stroke(
226 &Stroke::new(1.0),
227 Affine::IDENTITY,
228 Color::from_rgba8(0xA6, 0xD8, 0xFF, 140),
229 None,
230 &Circle::new((cx, cy), unit * 4.0),
231 );
232
233 // Rayos cardinales rotantes (4 trazos a 90°)
234 let theta = t * (std::f64::consts::TAU / 12.0); // 1 vuelta cada 12 s
235 let rotate = Affine::translate((cx, cy)) * Affine::rotate(theta);
236 for i in 0..4 {
237 let angle = i as f64 * std::f64::consts::FRAC_PI_2;
238 let dir = (angle.cos(), angle.sin());
239 let mut p = BezPath::new();
240 p.move_to((dir.0 * unit * 3.2, dir.1 * unit * 3.2));
241 p.line_to((dir.0 * unit * 4.4, dir.1 * unit * 4.4));
242 scene.stroke(
243 &Stroke::new(1.5),
244 rotate,
245 ACCENT_BLUE,
246 None,
247 &p,
248 );
249 }
250
251 // Chacana: cruz escalonada de 12 puntas. Construida como BezPath.
252 // La forma clásica: cuadrado central + escalones en 4 direcciones.
253 let chacana = chacana_path(unit);
254 let center = Affine::translate((cx, cy));
255
256 // Glow ambar exterior
257 scene.stroke(
258 &Stroke::new(6.0),
259 center,
260 Color::from_rgba8(0xE8, 0xC9, 0x7A, 110),
261 None,
262 &chacana,
263 );
264 // Outline cyan
265 scene.stroke(
266 &Stroke::new(2.0),
267 center,
268 ACCENT_CYAN,
269 None,
270 &chacana,
271 );
272 // Relleno violeta tenue
273 scene.fill(
274 Fill::NonZero,
275 center,
276 Color::from_rgba8(0xC3, 0x9C, 0xE8, 40),
277 None,
278 &chacana,
279 );
280
281 // Sol central que late
282 let pulse = 1.0 + 0.18 * (t * 1.8).sin();
283 let r_sun = unit * 0.7 * pulse;
284 scene.fill(
285 Fill::NonZero,
286 Affine::IDENTITY,
287 ACCENT_AMBER,
288 None,
289 &Circle::new((cx, cy), r_sun),
290 );
291 // Corona
292 scene.stroke(
293 &Stroke::new(1.0),
294 Affine::IDENTITY,
295 Color::from_rgba8(0xE8, 0xC9, 0x7A, 120),
296 None,
297 &Circle::new((cx, cy), r_sun * 1.7),
298 );
299 // Punto interior violeta para contraste
300 scene.fill(
301 Fill::NonZero,
302 Affine::IDENTITY,
303 ACCENT_VIOLET,
304 None,
305 &Circle::new((cx, cy), r_sun * 0.35),
306 );
307}
308
309/// Path de la chacana centrada en el origen, con `u` como ancho de cada
310/// escalón. Reconstruye la forma clásica de 12 esquinas escalonadas
311/// (3 escalones por cada brazo cardinal).
312fn chacana_path(u: f64) -> BezPath {
313 let mut p = BezPath::new();
314 // Empezamos en la esquina superior-derecha del brazo norte y vamos
315 // en sentido horario alrededor de toda la cruz.
316 p.move_to((u, 3.0 * u));
317 p.line_to((u, u));
318 p.line_to((3.0 * u, u));
319 p.line_to((3.0 * u, -u));
320 p.line_to((u, -u));
321 p.line_to((u, -3.0 * u));
322 p.line_to((-u, -3.0 * u));
323 p.line_to((-u, -u));
324 p.line_to((-3.0 * u, -u));
325 p.line_to((-3.0 * u, u));
326 p.line_to((-u, u));
327 p.line_to((-u, 3.0 * u));
328 p.close_path();
329 p
330}
331
332#[cfg(target_os = "android")]
333fn install_panic_logger() {
334 std::panic::set_hook(Box::new(|info| {
335 let payload = info
336 .payload()
337 .downcast_ref::<&str>()
338 .copied()
339 .or_else(|| info.payload().downcast_ref::<String>().map(|s| s.as_str()))
340 .unwrap_or("<unknown>");
341 let loc = info
342 .location()
343 .map(|l| format!("{}:{}", l.file(), l.line()))
344 .unwrap_or_else(|| "<?>".into());
345 log::error!("PANIC at {loc} — {payload}");
346 }));
347}
348
349#[cfg(target_os = "android")]
350#[no_mangle]
351fn android_main(app: android_activity::AndroidApp) {
352 android_logger::init_once(
353 android_logger::Config::default()
354 .with_max_level(log::LevelFilter::Info)
355 .with_tag(TAG),
356 );
357 install_panic_logger();
358 log::info!("android_main START");
359
360 use llimphi_hal::winit::event_loop::EventLoopBuilder;
361 use llimphi_hal::winit::platform::android::EventLoopBuilderExtAndroid;
362
363 let event_loop: EventLoop<()> = match EventLoopBuilder::default().with_android_app(app).build()
364 {
365 Ok(el) => el,
366 Err(e) => {
367 log::error!("EventLoop: {e}");
368 return;
369 }
370 };
371 event_loop.set_control_flow(ControlFlow::Poll);
372 let mut handler = App::new();
373 if let Err(e) = event_loop.run_app(&mut handler) {
374 log::error!("run_app: {e}");
375 }
376}