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:
@@ -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"
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user