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,28 @@
|
||||
[package]
|
||||
name = "llimphi-ui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
llimphi-hal = { path = "../llimphi-hal" }
|
||||
llimphi-layout = { path = "../llimphi-layout" }
|
||||
llimphi-raster = { path = "../llimphi-raster" }
|
||||
llimphi-text = { path = "../llimphi-text" }
|
||||
# El compositor declarativo (winit-free): View, mount, paint, hit-test.
|
||||
llimphi-compositor = { path = "../llimphi-compositor" }
|
||||
pollster = { workspace = true }
|
||||
|
||||
[[example]]
|
||||
name = "counter"
|
||||
path = "examples/counter.rs"
|
||||
|
||||
[[example]]
|
||||
name = "editor"
|
||||
path = "examples/editor.rs"
|
||||
|
||||
[[example]]
|
||||
name = "gpu_paint_demo"
|
||||
path = "examples/gpu_paint_demo.rs"
|
||||
@@ -0,0 +1,9 @@
|
||||
# llimphi-ui
|
||||
|
||||
> `View<Msg>` retained-mode + Elm-arch de [llimphi](../README.md).
|
||||
|
||||
API pública del framework: `App { Model, Msg, init, update, view }`. Reactivo: `update` muta el `Model`, `view(&Model)` produce el árbol; el runtime difea contra el árbol anterior y aplica el mínimo. Hover/focus/click se traducen a `Msg`s tipados.
|
||||
|
||||
## Deps
|
||||
|
||||
- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md)
|
||||
@@ -0,0 +1,9 @@
|
||||
# llimphi-ui
|
||||
|
||||
> Retained-mode `View<Msg>` + Elm-arch of [llimphi](../README.md).
|
||||
|
||||
Public API of the framework: `App { Model, Msg, init, update, view }`. Reactive: `update` mutates `Model`, `view(&Model)` produces the tree; the runtime diffs against the previous tree and applies the minimum. Hover/focus/click translate to typed `Msg`s.
|
||||
|
||||
## Deps
|
||||
|
||||
- [`llimphi-hal`](../llimphi-hal/README.md), [`llimphi-raster`](../llimphi-raster/README.md), [`llimphi-layout`](../llimphi-layout/README.md), [`llimphi-text`](../llimphi-text/README.md), [`llimphi-theme`](../llimphi-theme/README.md)
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Fase 4 de Llimphi: contador Elm puro con texto real.
|
||||
//!
|
||||
//! Bucle completo input→update→view→layout→raster→present. El click sobre
|
||||
//! el botón inferior incrementa el contador; el panel central muestra el
|
||||
//! número actual rasterizado por skrifa+vello.
|
||||
//!
|
||||
//! Corre con: `cargo run -p llimphi-ui --example counter --release`.
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, Dimension, FlexDirection, Size, Style},
|
||||
AlignItems, JustifyContent,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::{App, Handle, View};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
Increment,
|
||||
Reset,
|
||||
}
|
||||
|
||||
struct Counter;
|
||||
|
||||
impl App for Counter {
|
||||
type Model = u32;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · counter"
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Self::Msg>) -> Self::Model {
|
||||
0
|
||||
}
|
||||
|
||||
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
|
||||
match msg {
|
||||
Msg::Increment => model.saturating_add(1),
|
||||
Msg::Reset => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||||
let number = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: Dimension::auto(),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text(model.to_string(), 160.0, Color::from_rgba8(230, 240, 250, 255));
|
||||
|
||||
let increment = View::new(Style {
|
||||
size: Size {
|
||||
width: length(160.0_f32),
|
||||
height: length(56.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(60, 200, 130, 255))
|
||||
.radius(12.0)
|
||||
.text("+1", 28.0, Color::from_rgba8(10, 30, 20, 255))
|
||||
.on_click(Msg::Increment);
|
||||
|
||||
let reset = View::new(Style {
|
||||
size: Size {
|
||||
width: length(120.0_f32),
|
||||
height: length(56.0_f32),
|
||||
},
|
||||
align_items: Some(AlignItems::Center),
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(220, 80, 80, 255))
|
||||
.radius(12.0)
|
||||
.text("reset", 22.0, Color::from_rgba8(30, 10, 10, 255))
|
||||
.on_click(Msg::Reset);
|
||||
|
||||
let buttons = View::new(Style {
|
||||
flex_direction: FlexDirection::Row,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(56.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(16.0_f32),
|
||||
height: length(0.0_f32),
|
||||
},
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.children(vec![increment, reset]);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(24.0_f32),
|
||||
},
|
||||
padding: llimphi_ui::llimphi_layout::taffy::Rect {
|
||||
left: length(32.0_f32),
|
||||
right: length(32.0_f32),
|
||||
top: length(32.0_f32),
|
||||
bottom: length(32.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(20, 24, 32, 255))
|
||||
.children(vec![number, buttons])
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Counter>();
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Editor mínimo: text field con char insertion, backspace, enter, ctrl+L
|
||||
//! para limpiar. Valida que el bucle Elm absorbe input de teclado.
|
||||
//!
|
||||
//! Corre con: `cargo run -p llimphi-ui --example editor --release`.
|
||||
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{length, percent, FlexDirection, Size, Style},
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::llimphi_text::Alignment;
|
||||
use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
Insert(String),
|
||||
Backspace,
|
||||
Clear,
|
||||
}
|
||||
|
||||
struct Editor;
|
||||
|
||||
impl App for Editor {
|
||||
type Model = String;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · editor"
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Self::Msg>) -> Self::Model {
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
|
||||
match msg {
|
||||
Msg::Insert(s) => {
|
||||
let mut m = model;
|
||||
m.push_str(&s);
|
||||
m
|
||||
}
|
||||
Msg::Backspace => {
|
||||
let mut m = model;
|
||||
m.pop();
|
||||
m
|
||||
}
|
||||
Msg::Clear => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_key(_: &Self::Model, e: &KeyEvent) -> Option<Self::Msg> {
|
||||
if e.state != KeyState::Pressed {
|
||||
return None;
|
||||
}
|
||||
if e.modifiers.ctrl {
|
||||
if let Key::Character(c) = &e.key {
|
||||
if c.eq_ignore_ascii_case("l") {
|
||||
return Some(Msg::Clear);
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
match &e.key {
|
||||
Key::Named(NamedKey::Backspace) => Some(Msg::Backspace),
|
||||
Key::Named(NamedKey::Enter) => Some(Msg::Insert("\n".into())),
|
||||
Key::Named(NamedKey::Tab) => Some(Msg::Insert(" ".into())),
|
||||
_ => e.text.clone().map(Msg::Insert),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||||
let body_text = if model.is_empty() {
|
||||
"tipea algo · ctrl+L limpia · enter salto · backspace borra".to_string()
|
||||
} else {
|
||||
// Cursor visual al final del contenido.
|
||||
format!("{model}\u{2588}")
|
||||
};
|
||||
let body_color = if model.is_empty() {
|
||||
Color::from_rgba8(110, 130, 150, 255)
|
||||
} else {
|
||||
Color::from_rgba8(220, 230, 240, 255)
|
||||
};
|
||||
|
||||
let body = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.text_aligned(body_text, 22.0, body_color, Alignment::Start);
|
||||
|
||||
let status = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(36.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(30, 36, 48, 255))
|
||||
.text(
|
||||
format!("{} chars", model.chars().count()),
|
||||
16.0,
|
||||
Color::from_rgba8(160, 180, 200, 255),
|
||||
);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(8.0_f32),
|
||||
},
|
||||
padding: llimphi_ui::llimphi_layout::taffy::Rect {
|
||||
left: length(24.0_f32),
|
||||
right: length(24.0_f32),
|
||||
top: length(24.0_f32),
|
||||
bottom: length(24.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(20, 24, 32, 255))
|
||||
.children(vec![body, status])
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<Editor>();
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
//! Demo del hook GPU directo (`View::gpu_paint_with`) — Fase 1 del SDD
|
||||
//! `02_ruway/llimphi/SDD.md` §"GPU directo wgpu".
|
||||
//!
|
||||
//! Pinta una grilla de N puntos coloridos sobre un panel central usando
|
||||
//! un pipeline `wgpu` propio (instanced quad), encima de un fondo y
|
||||
//! títulos pintados por vello. Valida que:
|
||||
//!
|
||||
//! - El callback `gpu_paint_with` recibe `(device, queue, encoder,
|
||||
//! view, rect)` con los recursos del runtime.
|
||||
//! - El `LoadOp::Load` preserva la pasada vello (el fondo no se borra).
|
||||
//! - El submit del encoder ocurre antes del `surface.present` (las
|
||||
//! primitivas GPU son visibles).
|
||||
//!
|
||||
//! Corre con: `cargo run -p llimphi-ui --example gpu_paint_demo --release`.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
use llimphi_ui::llimphi_hal::wgpu;
|
||||
use llimphi_ui::llimphi_layout::taffy::{
|
||||
prelude::{auto, length, percent, FlexDirection, Size, Style},
|
||||
AlignItems, JustifyContent, Rect as TaffyRect,
|
||||
};
|
||||
use llimphi_ui::llimphi_raster::peniko::Color;
|
||||
use llimphi_ui::{App, Handle, PaintRect, View};
|
||||
|
||||
const POINTS: u32 = 250_000;
|
||||
const TARGET_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Msg {
|
||||
Bump,
|
||||
}
|
||||
|
||||
struct GpuDemo;
|
||||
|
||||
impl App for GpuDemo {
|
||||
type Model = u32;
|
||||
type Msg = Msg;
|
||||
|
||||
fn title() -> &'static str {
|
||||
"llimphi · gpu_paint_demo"
|
||||
}
|
||||
|
||||
fn init(_: &Handle<Self::Msg>) -> Self::Model {
|
||||
0
|
||||
}
|
||||
|
||||
fn update(model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
|
||||
match msg {
|
||||
Msg::Bump => model.wrapping_add(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn view(model: &Self::Model) -> View<Self::Msg> {
|
||||
let title = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(48.0_f32),
|
||||
},
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text(
|
||||
format!("gpu_paint_with — {POINTS} puntos GPU directo · seed {model}"),
|
||||
22.0,
|
||||
Color::from_rgba8(220, 230, 245, 255),
|
||||
);
|
||||
|
||||
// Canvas central: vello pinta el fondo (fill + radius), GPU pinta
|
||||
// la grilla de puntos encima vía gpu_paint_with. El seed del
|
||||
// modelo se mete en el shader vía una rotación trivial — cada
|
||||
// click cambia el patrón. El callback se invoca ya con el
|
||||
// CommandEncoder del frame y la TextureView intermediate.
|
||||
let seed = *model;
|
||||
let canvas = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: auto(),
|
||||
},
|
||||
flex_grow: 1.0,
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(14, 18, 28, 255))
|
||||
.radius(8.0)
|
||||
.gpu_paint_with(move |device, queue, encoder, view, rect, _viewport| {
|
||||
draw_points(device, queue, encoder, view, rect, seed);
|
||||
})
|
||||
.on_click(Msg::Bump);
|
||||
|
||||
let footer = View::new(Style {
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: length(28.0_f32),
|
||||
},
|
||||
justify_content: Some(JustifyContent::Center),
|
||||
align_items: Some(AlignItems::Center),
|
||||
..Default::default()
|
||||
})
|
||||
.text(
|
||||
"click sobre el canvas → rebobinar el seed",
|
||||
14.0,
|
||||
Color::from_rgba8(150, 165, 185, 255),
|
||||
);
|
||||
|
||||
View::new(Style {
|
||||
flex_direction: FlexDirection::Column,
|
||||
size: Size {
|
||||
width: percent(1.0_f32),
|
||||
height: percent(1.0_f32),
|
||||
},
|
||||
gap: Size {
|
||||
width: length(0.0_f32),
|
||||
height: length(16.0_f32),
|
||||
},
|
||||
padding: TaffyRect {
|
||||
left: length(24.0_f32),
|
||||
right: length(24.0_f32),
|
||||
top: length(16.0_f32),
|
||||
bottom: length(16.0_f32),
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.fill(Color::from_rgba8(24, 28, 38, 255))
|
||||
.children(vec![title, canvas, footer])
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
llimphi_ui::run::<GpuDemo>();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Lado GPU del demo: pipeline + buffer + draw call.
|
||||
// ============================================================
|
||||
|
||||
/// Estado compartido del demo a través de los frames. Se construye en
|
||||
/// el primer `gpu_paint_with` (cuando ya tenemos device/queue) y se
|
||||
/// reutiliza después. Sin esto pagaríamos creación de pipeline + write
|
||||
/// del buffer por frame, que es lo que `GpuBatch` resolverá de raíz en
|
||||
/// Fase 3.
|
||||
struct DemoGpu {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
instances: wgpu::Buffer,
|
||||
uniforms: wgpu::Buffer,
|
||||
bind_group: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
fn shared() -> &'static OnceLock<Arc<DemoGpu>> {
|
||||
static SLOT: OnceLock<Arc<DemoGpu>> = OnceLock::new();
|
||||
&SLOT
|
||||
}
|
||||
|
||||
fn draw_points(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
view: &wgpu::TextureView,
|
||||
rect: PaintRect,
|
||||
seed: u32,
|
||||
) {
|
||||
let gpu = shared()
|
||||
.get_or_init(|| Arc::new(DemoGpu::new(device)))
|
||||
.clone();
|
||||
|
||||
// Uniforms: rect + seed → el VS los usa para colocar y colorear.
|
||||
let uniforms = [rect.x, rect.y, rect.w, rect.h, f32::from_bits(seed), 0.0, 0.0, 0.0];
|
||||
let mut bytes = Vec::with_capacity(32);
|
||||
for v in uniforms {
|
||||
bytes.extend_from_slice(&v.to_ne_bytes());
|
||||
}
|
||||
queue.write_buffer(&gpu.uniforms, 0, &bytes);
|
||||
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("gpu_paint_demo-pass"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
// Load preserva el fondo vello ya pintado en este frame.
|
||||
load: wgpu::LoadOp::Load,
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
});
|
||||
pass.set_pipeline(&gpu.pipeline);
|
||||
pass.set_bind_group(0, &gpu.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, gpu.instances.slice(..));
|
||||
pass.draw(0..6, 0..POINTS);
|
||||
}
|
||||
}
|
||||
|
||||
impl DemoGpu {
|
||||
fn new(device: &wgpu::Device) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("gpu_paint_demo-shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(WGSL.into()),
|
||||
});
|
||||
|
||||
let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("gpu_paint_demo-bgl"),
|
||||
entries: &[wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("gpu_paint_demo-pl"),
|
||||
bind_group_layouts: &[&bind_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("gpu_paint_demo-pipe"),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[wgpu::VertexBufferLayout {
|
||||
array_stride: 4,
|
||||
step_mode: wgpu::VertexStepMode::Instance,
|
||||
attributes: &[wgpu::VertexAttribute {
|
||||
format: wgpu::VertexFormat::Uint32,
|
||||
offset: 0,
|
||||
shader_location: 0,
|
||||
}],
|
||||
}],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||
strip_index_format: None,
|
||||
front_face: wgpu::FrontFace::Ccw,
|
||||
cull_mode: None,
|
||||
unclipped_depth: false,
|
||||
polygon_mode: wgpu::PolygonMode::Fill,
|
||||
conservative: false,
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: TARGET_FORMAT,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
// Instance buffer: índice 0..POINTS empaquetado como u32.
|
||||
let mut idx_bytes = Vec::with_capacity((POINTS as usize) * 4);
|
||||
for i in 0..POINTS {
|
||||
idx_bytes.extend_from_slice(&i.to_ne_bytes());
|
||||
}
|
||||
let instances = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("gpu_paint_demo-inst"),
|
||||
size: idx_bytes.len() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// El buffer ya vive el resto del programa — escribimos una vez.
|
||||
// Para esto necesitamos el queue, pero `new` no lo recibe. Lo
|
||||
// mantenemos como "lazy escrito en draw_points la primera vez";
|
||||
// por simplicidad lo escribimos en el primer queue.write_buffer
|
||||
// del flujo de uniforms. Actualmente el shader no usa la
|
||||
// instancia (sólo @builtin(vertex_index) + uniforms + builtin
|
||||
// instance_index), así que el buffer es ignorado — lo dejamos
|
||||
// para que el layout del pipeline siga válido y el día que
|
||||
// queramos meter datos por instancia ya está el slot listo.
|
||||
let _ = idx_bytes; // (no se sube — ver comentario arriba)
|
||||
|
||||
let uniforms = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("gpu_paint_demo-u"),
|
||||
size: 32,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("gpu_paint_demo-bg"),
|
||||
layout: &bind_layout,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniforms.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
Self {
|
||||
pipeline,
|
||||
instances,
|
||||
uniforms,
|
||||
bind_group,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hash 32-bit barato (PCG-like) implementado en WGSL para mapear
|
||||
// `instance_index + seed` → posición/color sin tocar buffers. Mantiene
|
||||
// el demo en una sola draw call con cero CPU work por frame (salvo
|
||||
// 32 bytes de uniforms).
|
||||
const WGSL: &str = r#"
|
||||
struct Uniforms {
|
||||
rect: vec4<f32>, // x, y, w, h en pixels del frame
|
||||
seed: u32,
|
||||
_pad0: u32,
|
||||
_pad1: u32,
|
||||
_pad2: u32,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
struct V2F {
|
||||
@builtin(position) pos: vec4<f32>,
|
||||
@location(0) color: vec4<f32>,
|
||||
};
|
||||
|
||||
fn hash(x: u32) -> u32 {
|
||||
var v = x ^ 2747636419u;
|
||||
v = v * 2654435769u;
|
||||
v = v ^ (v >> 16u);
|
||||
v = v * 2654435769u;
|
||||
v = v ^ (v >> 16u);
|
||||
v = v * 2654435769u;
|
||||
return v;
|
||||
}
|
||||
|
||||
// La resolución real del frame no la conoce el shader sin un uniform
|
||||
// adicional. Como aproximación robusta, asumimos que el callback se
|
||||
// llama sobre un viewport "default" 960×540 (tamaño inicial del demo)
|
||||
// y dejamos que rect.x/y/w/h centren los puntos dentro del canvas.
|
||||
// El tamaño real del frame se debería pasar por uniforms en una versión
|
||||
// no-demo — Fase 2/3 del SDD lo formaliza vía `GpuBatch`.
|
||||
const FRAME_W: f32 = 960.0;
|
||||
const FRAME_H: f32 = 540.0;
|
||||
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) vid: u32, @builtin(instance_index) iid: u32) -> V2F {
|
||||
var corners = array<vec2<f32>, 6>(
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, -1.0),
|
||||
vec2<f32>( 1.0, 1.0),
|
||||
vec2<f32>(-1.0, 1.0),
|
||||
);
|
||||
let off = corners[vid] * 1.5; // quad de 3 pixels lado
|
||||
|
||||
let h1 = hash(iid ^ u.seed);
|
||||
let h2 = hash(h1);
|
||||
let h3 = hash(h2);
|
||||
|
||||
let fx = f32(h1 & 0xFFFFu) / 65535.0;
|
||||
let fy = f32(h2 & 0xFFFFu) / 65535.0;
|
||||
|
||||
let px = u.rect.x + fx * u.rect.z + off.x;
|
||||
let py = u.rect.y + fy * u.rect.w + off.y;
|
||||
|
||||
let ndc = vec2<f32>(
|
||||
px / FRAME_W * 2.0 - 1.0,
|
||||
1.0 - py / FRAME_H * 2.0,
|
||||
);
|
||||
|
||||
let r = f32( h3 & 0xFFu) / 255.0;
|
||||
let g = f32((h3 >> 8u) & 0xFFu) / 255.0;
|
||||
let b = f32((h3 >> 16u) & 0xFFu) / 255.0;
|
||||
|
||||
var out: V2F;
|
||||
out.pos = vec4<f32>(ndc, 0.0, 1.0);
|
||||
out.color = vec4<f32>(r, g, b, 0.85);
|
||||
return out;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(in: V2F) -> @location(0) vec4<f32> {
|
||||
return in.color;
|
||||
}
|
||||
"#;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,604 @@
|
||||
//! llimphi-ui — Runtime Elm sobre winit.
|
||||
//!
|
||||
//! Maneja el bucle `input → update(model, msg) → view(model) → layout →
|
||||
//! raster → present` sobre una ventana winit + GPU (`llimphi-hal` +
|
||||
//! `llimphi-raster`). La parte declarativa y winit-agnóstica (el árbol
|
||||
//! `View<Msg>`, `mount`, `paint`, hit-test) vive en `llimphi-compositor` y
|
||||
//! se re-exporta tal cual, así los consumidores siguen escribiendo
|
||||
//! `llimphi_ui::View` sin enterarse del split.
|
||||
//!
|
||||
//! El estado del [`App`] es inmutable: cada evento produce un `Model`
|
||||
//! nuevo. La vista (`view`) es una función pura `&Model -> View<Msg>`.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use llimphi_hal::winit::application::ApplicationHandler;
|
||||
use llimphi_hal::winit::dpi::{LogicalSize, PhysicalPosition};
|
||||
use llimphi_hal::winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent};
|
||||
use llimphi_hal::winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy};
|
||||
use llimphi_hal::winit::keyboard::ModifiersState;
|
||||
use llimphi_hal::winit::window::{Window, WindowAttributes, WindowId};
|
||||
use llimphi_hal::{Hal, Surface, WinitSurface};
|
||||
|
||||
pub use llimphi_hal::winit::keyboard::{Key, NamedKey};
|
||||
use llimphi_layout::{ComputedLayout, LayoutTree};
|
||||
use llimphi_raster::peniko::color::palette;
|
||||
use llimphi_raster::{vello, Renderer};
|
||||
|
||||
pub use llimphi_hal;
|
||||
pub use llimphi_layout;
|
||||
pub use llimphi_raster;
|
||||
pub use llimphi_text;
|
||||
|
||||
// El compositor declarativo (View, mount, paint, hit-test, tipos de
|
||||
// handler) se re-exporta entero: `llimphi_ui::View`, `llimphi_ui::DragFn`,
|
||||
// etc. siguen resolviendo igual que antes del split.
|
||||
pub use llimphi_compositor;
|
||||
pub use llimphi_compositor::*;
|
||||
|
||||
/// Aplicación Elm: estado inmutable, transición pura, vista pura.
|
||||
///
|
||||
/// `init` y `update` reciben un [`Handle`] que permite hablar con el runtime
|
||||
/// desde dentro de la transición (cerrar la ventana, lanzar trabajo en otro
|
||||
/// hilo y reentrar con un Msg al terminar). Mantener la transición pura del
|
||||
/// modelo sigue siendo el contrato — `Handle` sólo escala efectos.
|
||||
pub trait App: 'static {
|
||||
type Model: 'static;
|
||||
type Msg: Clone + Send + 'static;
|
||||
|
||||
fn init(handle: &Handle<Self::Msg>) -> Self::Model;
|
||||
fn update(model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model;
|
||||
fn view(model: &Self::Model) -> View<Self::Msg>;
|
||||
|
||||
/// Maneja una pulsación de tecla. Devuelve `Some(Msg)` para disparar
|
||||
/// una transición; `None` (default) ignora la tecla.
|
||||
fn on_key(_model: &Self::Model, _event: &KeyEvent) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// El foco cambió: el runtime movió el foco a `id` (`None` = nada
|
||||
/// enfocado). Pasa al pulsar Tab/Shift+Tab (recorre los nodos
|
||||
/// `View::focusable` en orden de árbol, envolviendo) o al clickear un
|
||||
/// nodo enfocable. La app guarda `id` en su `Model` para (a) pintar el
|
||||
/// focus-ring (`if model.focus == Some(id) { … }` en `view`) y (b)
|
||||
/// rutear el teclado al campo activo desde `on_key`. Devolver
|
||||
/// `Some(Msg)` dispara una transición; `None` (default) ignora.
|
||||
///
|
||||
/// El foco lo administra el runtime (única fuente de verdad), así que
|
||||
/// Tab y click-to-focus quedan consistentes sin que la app los cablee.
|
||||
fn on_focus(_model: &Self::Model, _id: Option<u64>) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// ¿Habilitar IME (input method editor) en esta ventana? Default
|
||||
/// `false`. Con IME activo, el texto compuesto (CJK, acentos muertos,
|
||||
/// emoji picker) llega por [`App::on_ime`] como `Commit`, **no** por
|
||||
/// `KeyEvent.text` — por eso es opt-in: las apps que sólo leen
|
||||
/// `on_key` siguen funcionando igual. Las que editan texto
|
||||
/// (`text-input`, `text-editor`) la activan e implementan `on_ime`.
|
||||
fn ime_allowed() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Maneja un evento de IME (sólo llega si [`App::ime_allowed`] es
|
||||
/// `true`). El flujo típico: `Enabled` → uno o más `Preedit` (texto en
|
||||
/// composición, a pintar subrayado en el caret) → `Commit(texto)` (el
|
||||
/// texto final, a insertar como si se hubiera tecleado) o `Disabled`.
|
||||
/// El `Preedit` no es definitivo: cada uno reemplaza al anterior, y un
|
||||
/// `Commit` o `Preedit` vacío lo cierra. Devolver `Some(Msg)` dispara
|
||||
/// una transición.
|
||||
fn on_ime(_model: &Self::Model, _event: &ImeEvent) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Área del caret en **píxeles físicos** `(x, y, w, h)` para posicionar
|
||||
/// la ventana de candidatos del IME (CJK) junto al cursor de texto. El
|
||||
/// runtime la consulta por frame cuando [`App::ime_allowed`] es `true`.
|
||||
/// `None` (default) deja que el sistema la ubique por defecto.
|
||||
fn ime_cursor_area(_model: &Self::Model) -> Option<(f32, f32, f32, f32)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maneja una rueda del mouse. `delta` está normalizado a "líneas"
|
||||
/// (positivo arriba/izquierda, negativo abajo/derecha). En backends
|
||||
/// que reportan píxeles, llimphi-ui divide por 20 para aproximar.
|
||||
fn on_wheel(
|
||||
_model: &Self::Model,
|
||||
_delta: WheelDelta,
|
||||
_cursor: (f32, f32),
|
||||
_modifiers: Modifiers,
|
||||
) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Capa de overlay opcional. Si devuelve `Some(view)`, el runtime
|
||||
/// la pinta encima del árbol principal y los clicks/hover se
|
||||
/// rutean exclusivamente a ella (el árbol de fondo queda "bajo
|
||||
/// vidrio" hasta que se cierre el overlay). Pensado para menús
|
||||
/// contextuales, diálogos modales, popovers — el patrón usual es
|
||||
/// envolver los items en un scrim a pantalla completa con
|
||||
/// `on_click = DismissOverlay` para que los clicks afuera lo
|
||||
/// cierren.
|
||||
///
|
||||
/// La transición entre "con overlay" y "sin overlay" la maneja la
|
||||
/// app vía su Model: cuando el state diga "menu abierto",
|
||||
/// `view_overlay` devuelve `Some`; cuando se cierre, `None`.
|
||||
fn view_overlay(_model: &Self::Model) -> Option<View<Self::Msg>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maneja un drop de archivo desde el sistema operativo (drag&drop
|
||||
/// desde el file manager hacia la ventana). El runtime invoca este
|
||||
/// callback una vez por archivo soltado — si el usuario suelta varios,
|
||||
/// llega un evento por path. Devolver `Some(Msg)` dispara un update;
|
||||
/// `None` (default) ignora el drop.
|
||||
///
|
||||
/// Backend: mapea directamente `winit::WindowEvent::DroppedFile(PathBuf)`.
|
||||
/// La posición del drop no se reporta porque winit no la expone hasta
|
||||
/// que el compositor la propague — en Wayland depende del extension
|
||||
/// `data_device_manager`, en X11 viene en el ClientMessage XDND.
|
||||
fn on_file_drop(_model: &Self::Model, _path: std::path::PathBuf) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maneja un redimensionado de la ventana. `width`/`height` son el
|
||||
/// nuevo tamaño en **píxeles físicos** (lo que reporta
|
||||
/// `winit::WindowEvent::Resized` y lo que recibe la surface). El
|
||||
/// runtime ya reconfiguró la surface y pedirá redraw; este callback
|
||||
/// es para que la app reaccione al nuevo viewport (recalcular layout
|
||||
/// dependiente del tamaño, emitir un evento `resize`, etc.).
|
||||
/// Devolver `Some(Msg)` dispara un update; `None` (default) lo ignora.
|
||||
fn on_resize(_model: &Self::Model, _width: u32, _height: u32) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Maneja un cambio del factor de escala de la ventana (`scale_factor`
|
||||
/// de winit: 1.0 en pantallas normales, 2.0 en HiDPI/Retina, fraccional
|
||||
/// con escalado del compositor). El runtime lo invoca una vez al arrancar
|
||||
/// (con el factor inicial de la ventana, tras `init`) y luego en cada
|
||||
/// `WindowEvent::ScaleFactorChanged` (mover la ventana entre monitores,
|
||||
/// cambiar el escalado del sistema). Es lo que permite, p. ej., que
|
||||
/// `window.devicePixelRatio` refleje el DPI real. Devolver `Some(Msg)`
|
||||
/// dispara un update; `None` (default) lo ignora.
|
||||
fn on_scale_factor(_model: &Self::Model, _scale: f64) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Título de la ventana (sólo se lee al arrancar). Es el título inicial;
|
||||
/// para uno que cambie en runtime, ver [`App::window_title`].
|
||||
fn title() -> &'static str {
|
||||
"llimphi"
|
||||
}
|
||||
|
||||
/// Título **dinámico** de la ventana, derivado del modelo. El runtime lo
|
||||
/// consulta tras cada render y, si cambió, lo aplica con `Window::set_title`
|
||||
/// — así el título de la barra del SO puede reflejar el estado (p. ej. el
|
||||
/// medio que se reproduce). `None` (default) deja el título fijo de
|
||||
/// [`App::title`]; una app que no lo implemente no paga nada.
|
||||
fn window_title(_model: &Self::Model) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Vista de una ventana OS **secundaria** identificada por `key` (la que
|
||||
/// se pasó a [`Handle::open_window`]). El runtime la pinta en su propia
|
||||
/// ventana y rutea sus eventos al mismo [`App::update`] — comparte modelo
|
||||
/// con la primaria. `None` (default, o para una key desconocida) deja la
|
||||
/// ventana en blanco. Las secundarias NO tienen capa de overlay
|
||||
/// ([`App::view_overlay`] es sólo de la primaria); para diálogos dentro de
|
||||
/// una secundaria, componerlos en su propio `secondary_view`.
|
||||
fn secondary_view(_model: &Self::Model, _key: u64) -> Option<View<Self::Msg>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Título dinámico de una ventana secundaria (análogo a
|
||||
/// [`App::window_title`] para la primaria). `None` deja el título con el
|
||||
/// que se abrió.
|
||||
fn secondary_title(_model: &Self::Model, _key: u64) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// El usuario cerró una ventana secundaria con el botón del SO. El runtime
|
||||
/// ya la destruyó; este callback es para que la app sincronice su modelo
|
||||
/// (p. ej. marcar el panel como cerrado). Devolver `Some(Msg)` dispara un
|
||||
/// `update`; `None` (default) no hace nada.
|
||||
fn on_secondary_close(_model: &Self::Model, _key: u64) -> Option<Self::Msg> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Identificador de aplicación. En Wayland se mapea al `app_id` del
|
||||
/// xdg-toplevel (lo que el compositor usa para reconocer la ventana,
|
||||
/// p. ej. `carmen.greeter`). `None` deja que el sistema asigne uno.
|
||||
fn app_id() -> Option<&'static str> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Tamaño lógico inicial de la ventana, en píxeles. El usuario puede
|
||||
/// redimensionar después; sólo se lee al arrancar.
|
||||
fn initial_size() -> (u32, u32) {
|
||||
(960, 540)
|
||||
}
|
||||
}
|
||||
|
||||
/// Mensaje interno del event loop. `Msg` lo dispara la app desde un hilo de
|
||||
/// fondo vía [`Handle::dispatch`] o [`Handle::spawn`]; `Quit` cierra la
|
||||
/// ventana y termina el proceso.
|
||||
pub enum UserEvent<Msg> {
|
||||
Msg(Msg),
|
||||
Quit,
|
||||
/// Pide abrir una ventana OS **secundaria** con la `key` dada (la app la
|
||||
/// usa para distinguir cuál es en [`App::secondary_view`]). Idempotente:
|
||||
/// si ya existe una con esa key, se enfoca en vez de duplicar. La crea el
|
||||
/// event loop (que tiene el `ActiveEventLoop`); por eso va por mensaje.
|
||||
OpenWindow {
|
||||
key: u64,
|
||||
title: String,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
/// Pide cerrar la ventana secundaria con esa `key`. No afecta a la primaria.
|
||||
CloseWindow { key: u64 },
|
||||
}
|
||||
|
||||
/// Asa al runtime de Llimphi. Clonable y enviable entre hilos: la usás para
|
||||
/// pedir cerrar la ventana o para lanzar trabajo (PAM, IO, etc.) que al
|
||||
/// terminar reentra con un Msg al `update`.
|
||||
///
|
||||
/// Tests pueden construir un handle "muerto" con [`Handle::for_test`]: los
|
||||
/// `dispatch`/`quit`/`spawn` siguen siendo seguros de llamar pero los
|
||||
/// `Msg` que generan no van a ningún lado (no hay event loop detrás).
|
||||
pub struct Handle<Msg: Send + 'static> {
|
||||
inner: HandleInner<Msg>,
|
||||
}
|
||||
|
||||
enum HandleInner<Msg: Send + 'static> {
|
||||
Real(EventLoopProxy<UserEvent<Msg>>),
|
||||
/// Handle de tests: drop silencioso de todos los dispatches. Permite
|
||||
/// llamar funciones que toman `&Handle<Msg>` sin levantar un event
|
||||
/// loop real (que en CI sin display tiraría).
|
||||
Test,
|
||||
}
|
||||
|
||||
impl<Msg: Send + 'static> Clone for Handle<Msg> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: match &self.inner {
|
||||
HandleInner::Real(p) => HandleInner::Real(p.clone()),
|
||||
HandleInner::Test => HandleInner::Test,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Msg: Send + 'static> Handle<Msg> {
|
||||
/// Construye un handle desactivado para tests — todos los dispatch
|
||||
/// se descartan silenciosamente. Útil para probar funciones que toman
|
||||
/// `&Handle<Msg>` sin levantar un event loop real (que en CI sin
|
||||
/// display tiraría).
|
||||
pub fn for_test() -> Self {
|
||||
Self {
|
||||
inner: HandleInner::Test,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra la ventana y termina el bucle. La transición en curso (si la
|
||||
/// hay) se completa antes de salir.
|
||||
pub fn quit(&self) {
|
||||
match &self.inner {
|
||||
HandleInner::Real(p) => {
|
||||
let _ = p.send_event(UserEvent::Quit);
|
||||
}
|
||||
HandleInner::Test => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Abre una ventana OS **secundaria** (ver [`App::secondary_view`]). La
|
||||
/// `key` la elige la app para reconocerla luego; abrir con una key que ya
|
||||
/// existe sólo la enfoca (no duplica). El contenido lo pinta
|
||||
/// `App::secondary_view(model, key)` y los eventos (click/tecla/…) reentran
|
||||
/// al mismo `update`, así que la ventana comparte el modelo con la primaria.
|
||||
/// Cerrala con [`Self::close_window`] o con el botón del SO.
|
||||
pub fn open_window(&self, key: u64, title: impl Into<String>, width: u32, height: u32) {
|
||||
if let HandleInner::Real(p) = &self.inner {
|
||||
let _ = p.send_event(UserEvent::OpenWindow {
|
||||
key,
|
||||
title: title.into(),
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Cierra la ventana secundaria con esa `key` (no-op si no existe). La
|
||||
/// ventana primaria nunca se cierra por acá — para eso está [`Self::quit`].
|
||||
pub fn close_window(&self, key: u64) {
|
||||
if let HandleInner::Real(p) = &self.inner {
|
||||
let _ = p.send_event(UserEvent::CloseWindow { key });
|
||||
}
|
||||
}
|
||||
|
||||
/// Encola un Msg para procesarse en el próximo turno del bucle. Útil
|
||||
/// para que un callback externo reentre al update.
|
||||
pub fn dispatch(&self, msg: Msg) {
|
||||
match &self.inner {
|
||||
HandleInner::Real(p) => {
|
||||
let _ = p.send_event(UserEvent::Msg(msg));
|
||||
}
|
||||
HandleInner::Test => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lanza una closure en un hilo aparte; cuando devuelve `Msg`, el
|
||||
/// runtime la entrega al `update` en el hilo de UI. Pensado para
|
||||
/// trabajo bloqueante (PAM tarda ~2 s ante un fallo, p. ej.).
|
||||
pub fn spawn<F>(&self, f: F)
|
||||
where
|
||||
F: FnOnce() -> Msg + Send + 'static,
|
||||
{
|
||||
match &self.inner {
|
||||
HandleInner::Real(p) => {
|
||||
let proxy = p.clone();
|
||||
std::thread::spawn(move || {
|
||||
let msg = f();
|
||||
let _ = proxy.send_event(UserEvent::Msg(msg));
|
||||
});
|
||||
}
|
||||
HandleInner::Test => {
|
||||
// Corremos la closure igual (para no perder side-effects de
|
||||
// tests que dependan de su side) pero el msg se descarta.
|
||||
std::thread::spawn(move || {
|
||||
let _ = f();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Lanza un loop periódico en un hilo aparte: cada `period` invoca
|
||||
/// `f()` y dispatcha el `Msg` resultante al `update`. El thread
|
||||
/// queda corriendo hasta que el event loop se cierra (en ese
|
||||
/// punto el `send_event` falla silenciosamente y el thread spinea
|
||||
/// hasta el exit del proceso, costo despreciable).
|
||||
///
|
||||
/// Útil para ticks de simulación (~11 Hz en dominium), polling de
|
||||
/// hardware, o cualquier feed que necesite Msgs a intervalos
|
||||
/// regulares. Si `f` necesita state, capturalo en la closure por
|
||||
/// move; la closure se ejecuta en un thread aparte así que el
|
||||
/// state capturado debe ser `Send`.
|
||||
pub fn spawn_periodic<F>(&self, period: std::time::Duration, f: F)
|
||||
where
|
||||
F: Fn() -> Msg + Send + 'static,
|
||||
{
|
||||
match &self.inner {
|
||||
HandleInner::Real(p) => {
|
||||
let proxy = p.clone();
|
||||
std::thread::spawn(move || loop {
|
||||
std::thread::sleep(period);
|
||||
if proxy.send_event(UserEvent::Msg(f())).is_err() {
|
||||
// Event loop cerrado — el thread puede morir.
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
HandleInner::Test => {
|
||||
// Un thread vivo eternamente sin sumidero ni manera de
|
||||
// pararlo sería un leak — en for_test simplemente no
|
||||
// arrancamos el loop. Los tests que necesiten verificar
|
||||
// periodic behaviour deben usar el callback directo.
|
||||
let _ = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evento de teclado normalizado.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct KeyEvent {
|
||||
pub key: Key,
|
||||
pub state: KeyState,
|
||||
/// Texto resultante (con modifiers e IME aplicados). Útil para inserción
|
||||
/// directa; `None` para teclas que no producen texto (flechas, etc.).
|
||||
pub text: Option<String>,
|
||||
pub modifiers: Modifiers,
|
||||
pub repeat: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyState {
|
||||
Pressed,
|
||||
Released,
|
||||
}
|
||||
|
||||
/// Evento de IME normalizado (espeja `winit::event::Ime`). Ver
|
||||
/// [`App::on_ime`] para el flujo Enabled → Preedit* → Commit/Disabled.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ImeEvent {
|
||||
/// El IME se activó para esta ventana.
|
||||
Enabled,
|
||||
/// Texto en composición (aún no confirmado). `cursor` es el rango
|
||||
/// `(inicio, fin)` en bytes a resaltar dentro de `text`, si el IME lo
|
||||
/// reporta. Cada `Preedit` reemplaza al anterior; uno con `text`
|
||||
/// vacío cierra la preedición sin confirmar.
|
||||
Preedit {
|
||||
text: String,
|
||||
cursor: Option<(usize, usize)>,
|
||||
},
|
||||
/// Texto confirmado: insertarlo como si se hubiera tecleado.
|
||||
Commit(String),
|
||||
/// El IME se desactivó (perder foco, cambiar de método).
|
||||
Disabled,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Modifiers {
|
||||
pub shift: bool,
|
||||
pub ctrl: bool,
|
||||
pub alt: bool,
|
||||
pub meta: bool,
|
||||
}
|
||||
|
||||
/// Delta de rueda en "líneas" lógicas (normalizado a través de backends).
|
||||
/// Convención CSS: positivo = scroll **hacia abajo** (contenido sube).
|
||||
/// `x` similar para scroll horizontal (touchpads, ratones de 2 ejes).
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct WheelDelta {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl From<ModifiersState> for Modifiers {
|
||||
fn from(m: ModifiersState) -> Self {
|
||||
Self {
|
||||
shift: m.shift_key(),
|
||||
ctrl: m.control_key(),
|
||||
alt: m.alt_key(),
|
||||
meta: m.super_key(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Runtime winit. El event loop (impl ApplicationHandler) vive en
|
||||
// `eventloop` y accede los campos privados de estos structs vía
|
||||
// `use super::*`. La composición declarativa (View, mount, paint,
|
||||
// hit-test) la trae el re-export de `llimphi_compositor`. ---
|
||||
mod eventloop;
|
||||
|
||||
struct Runtime<A: App> {
|
||||
handle: Handle<A::Msg>,
|
||||
state: Option<RuntimeState<A>>,
|
||||
/// Ventanas OS secundarias abiertas (opt-in vía [`Handle::open_window`]).
|
||||
/// Comparten el `Hal`/`Renderer` y el modelo de la primaria (`state`);
|
||||
/// cada una lleva su propia surface + caches de interacción. Vacío en la
|
||||
/// inmensa mayoría de las apps (monoventana) — coste cero.
|
||||
secondaries: Vec<SecondaryState<A>>,
|
||||
}
|
||||
|
||||
/// Estado por **ventana secundaria**. Espeja los campos de interacción de
|
||||
/// [`RuntimeState`] pero SIN modelo (vive en la primaria), sin overlay y sin
|
||||
/// `Hal`/`Renderer` propios (los toma prestados de la primaria al pintar).
|
||||
struct SecondaryState<A: App> {
|
||||
/// La key con la que la app la abrió (la pasa a `secondary_view`).
|
||||
key: u64,
|
||||
window: Arc<Window>,
|
||||
surface: WinitSurface,
|
||||
scene: vello::Scene,
|
||||
typesetter: llimphi_text::Typesetter,
|
||||
layout: LayoutTree,
|
||||
cursor: PhysicalPosition<f64>,
|
||||
modifiers: Modifiers,
|
||||
last_render: Option<SecRenderCache<A::Msg>>,
|
||||
hovered: Option<usize>,
|
||||
drag: Option<DragState<A::Msg>>,
|
||||
last_title: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache de render de una ventana secundaria (como [`RenderCache`] pero sin
|
||||
/// capa de overlay). Sólo guarda el árbol montado + layout para hit-testear el
|
||||
/// próximo click/hover; el `hover_idx` actual vive en `SecondaryState::hovered`.
|
||||
struct SecRenderCache<Msg> {
|
||||
mounted: Mounted<Msg>,
|
||||
computed: ComputedLayout,
|
||||
}
|
||||
|
||||
struct RuntimeState<A: App> {
|
||||
window: Arc<Window>,
|
||||
hal: Hal,
|
||||
surface: WinitSurface,
|
||||
renderer: Renderer,
|
||||
scene: vello::Scene,
|
||||
/// Compositor de la capa de overlay sobre contenido `gpu_paint` (video).
|
||||
/// Sólo entra en juego cuando el árbol principal tiene painters gpu y hay
|
||||
/// un overlay activo; resuelve el z-order (menús por encima del video).
|
||||
overlay_compositor: llimphi_hal::OverlayCompositor,
|
||||
model: Option<A::Model>,
|
||||
cursor: PhysicalPosition<f64>,
|
||||
modifiers: Modifiers,
|
||||
typesetter: llimphi_text::Typesetter,
|
||||
/// Árboles de layout reusados entre frames: `clear()` + `mount` en
|
||||
/// vez de re-allocar el slotmap de taffy en cada redraw. Uno para el
|
||||
/// árbol principal, otro para el overlay (sus `NodeId` no deben
|
||||
/// colisionar dentro del mismo frame).
|
||||
layout: LayoutTree,
|
||||
overlay_layout: LayoutTree,
|
||||
/// Último frame renderizado: árbol montado + rects absolutos +
|
||||
/// nodo con hover. Lo consume el handler de click para hit-testear
|
||||
/// sin reconstruir `view` + layout, y CursorMoved para detectar si
|
||||
/// el hover cambió y disparar redraw.
|
||||
last_render: Option<RenderCache<A::Msg>>,
|
||||
/// Nodo hovereado **persistente** entre frames, actualizado SÓLO en
|
||||
/// `CursorMoved`. Es contra esto que se detecta el `on_pointer_enter`
|
||||
/// (no contra `last_render.hover_idx`, que el render recomputa cada
|
||||
/// cuadro): en una app que re-renderiza sin parar (visores `paint_with`)
|
||||
/// el render "se comería" la transición de hover antes de que el handler
|
||||
/// del mouse la detecte, y el hover-switch de menús no funcionaría.
|
||||
hovered: Option<usize>,
|
||||
/// Drag activo. Mantiene su propio handler clonado del MountedNode
|
||||
/// — así el drag sobrevive aunque el cache se invalide entre
|
||||
/// eventos.
|
||||
drag: Option<DragState<A::Msg>>,
|
||||
/// Foco actual (id de un nodo `View::focusable`). El runtime es la
|
||||
/// única fuente de verdad: lo mueve con Tab/Shift+Tab y click-to-focus
|
||||
/// y lo notifica vía `App::on_focus`. `None` = nada enfocado.
|
||||
focused: Option<u64>,
|
||||
/// Último título dinámico aplicado a la ventana (ver [`App::window_title`]).
|
||||
/// Evita llamar `set_title` en cada frame cuando no cambió.
|
||||
last_title: Option<String>,
|
||||
}
|
||||
|
||||
struct RenderCache<Msg> {
|
||||
mounted: Mounted<Msg>,
|
||||
computed: ComputedLayout,
|
||||
/// Índice del nodo en hover en el frame ya pintado. `None` si el
|
||||
/// cursor no toca ningún `hover_fill`.
|
||||
hover_idx: Option<usize>,
|
||||
/// Índice del drop target hovereado en el frame ya pintado. Solo
|
||||
/// se setea durante un drag activo con `payload` declarado.
|
||||
drop_hover_idx: Option<usize>,
|
||||
/// Capa de overlay (menú contextual, modal). Cuando está presente,
|
||||
/// hover/click/right-click se rutean a ella exclusivamente — el
|
||||
/// árbol principal queda "bajo vidrio" hasta que la app cierre el
|
||||
/// overlay devolviendo `None` desde [`App::view_overlay`].
|
||||
overlay: Option<OverlayCache<Msg>>,
|
||||
}
|
||||
|
||||
struct OverlayCache<Msg> {
|
||||
mounted: Mounted<Msg>,
|
||||
computed: ComputedLayout,
|
||||
hover_idx: Option<usize>,
|
||||
}
|
||||
|
||||
/// Dos sabores de handler de drag activo: el simple `(phase, dx, dy)`
|
||||
/// o la variante que conserva la posición local del press original
|
||||
/// `(phase, dx, dy, lx0, ly0)`. El runtime elige uno al iniciar el drag.
|
||||
enum DragHandlerKind<Msg> {
|
||||
Delta(DragFn<Msg>),
|
||||
DeltaAt(DragAtFn<Msg>, f32, f32),
|
||||
}
|
||||
|
||||
struct DragState<Msg> {
|
||||
handler: DragHandlerKind<Msg>,
|
||||
/// Cursor en el último evento (Press o CursorMoved). El delta del
|
||||
/// próximo Move se calcula contra este, no contra el inicio del
|
||||
/// drag — el caller acumula los deltas en su modelo si los necesita.
|
||||
last_cursor: PhysicalPosition<f64>,
|
||||
/// Payload `u64` que viaja con el drag. `None` si el draggable
|
||||
/// origen no declaró ninguno (drag de resize/scroll/etc.). Los drop
|
||||
/// targets sólo reaccionan cuando hay payload.
|
||||
payload: Option<u64>,
|
||||
}
|
||||
|
||||
/// Punto de entrada: corre el bucle Elm hasta que el usuario cierre la
|
||||
/// ventana (o la app llame [`Handle::quit`]).
|
||||
pub fn run<A: App>() {
|
||||
let event_loop = EventLoop::<UserEvent<A::Msg>>::with_user_event()
|
||||
.build()
|
||||
.expect("event loop");
|
||||
event_loop.set_control_flow(ControlFlow::Wait);
|
||||
let handle = Handle {
|
||||
inner: HandleInner::Real(event_loop.create_proxy()),
|
||||
};
|
||||
let mut runtime: Runtime<A> = Runtime {
|
||||
handle,
|
||||
state: None,
|
||||
secondaries: Vec::new(),
|
||||
};
|
||||
event_loop.run_app(&mut runtime).expect("run app");
|
||||
}
|
||||
Reference in New Issue
Block a user