tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1//! **La vidriera que HACE algo, corriendo en su propio proceso.**
2//!
3//! `SDD-CARAS.md` §5.2, fase C6. La vidriera de nivel 1 es un documento firmado
4//! que la plaza pinta (C2); ésta es la de nivel 2: cuando la página tiene que
5//! **responder** —un catálogo que filtra, un turno que se pide— la forma de la
6//! casa no es un navegador, es **una jaula**.
7//!
8//! Lo que este huésped demuestra, y es todo el punto:
9//!
10//! 1. **Recibe la vidriera por argumento y la verifica él mismo.** No confía en
11//! quien lo lanzó: si la firma no cierra, lo dice y no pinta nada más.
12//! 2. **Pinta y recibe clics** sin que la plaza sepa qué hay en cada píxel — el
13//! hit-test es de este lado, contra su propio layout.
14//! 3. **No puede hacer nada más.** No tiene red, ni disco, ni la identidad de
15//! nadie: le llegaron bytes por `argv` y un socket que sólo habla el cable.
16//! Eso no es una promesa de este archivo — es lo que hay.
17//!
18//! ```bash
19//! cargo build --manifest-path 02_ruway/llimphi/llimphi-jaula/Cargo.toml \
20//! --no-default-features --features std --example vidriera_activa
21//! ```
22//!
23//! Normalmente no se corre a mano: lo lanza la plaza (`agora-app`, diente
24//! Vidriera, «abrirla como programa»).
25//!
26//! **Los textos que se ven son los de la vidriera**, que son datos del que la
27//! publicó. Lo único propio del huésped es una línea de números —clics, medidas—
28//! que es el instrumento, no prosa: por eso no pasa por `rimay-localize`, igual
29//! que su hermano `huesped_demo`. Lo que la plaza dice **sobre** este huésped sí
30//! está en los tres idiomas, y vive del lado de la plaza.
31
32use std::cell::RefCell;
33use std::rc::Rc;
34
35use agora_vidriera::{Donde, Paquete};
36use llimphi_cable::{DelAnfitrion, Huesped};
37use llimphi_jaula::taffy::prelude::{length, percent, FlexDirection, Rect, Size, Style};
38use llimphi_jaula::taffy::AlignItems;
39use llimphi_jaula::{medidor, peniko::Color, Jaula, View};
40use llimphi_text::Alignment;
41
42const FONDO: Color = Color::from_rgba8(22, 24, 30, 255);
43const TINTA: Color = Color::from_rgba8(228, 231, 238, 255);
44const TENUE: Color = Color::from_rgba8(150, 156, 170, 255);
45const ELEGIDA: Color = Color::from_rgba8(52, 84, 140, 255);
46const FILA: Color = Color::from_rgba8(32, 35, 43, 255);
47
48struct Modelo {
49 /// La vidriera que se está mostrando, o el motivo por el que no hay ninguna.
50 /// Se guarda **ya verificada**: `Paquete::de_texto` no devuelve otra cosa.
51 que_hay: Result<Paquete, String>,
52 /// Qué línea de «lo que dice» está elegida. Es el catálogo que filtra, en su
53 /// forma más chica: el huésped tiene estado propio y la plaza no se entera.
54 elegida: usize,
55 clics: u32,
56 medidas: u64,
57 ancho: f32,
58 alto: f32,
59}
60
61#[derive(Clone, Debug)]
62enum Msg {
63 Elegir(usize),
64}
65
66/// Las líneas de «lo que dice». Un párrafo suelto es una sola; varios renglones
67/// son varios. No se inventa una estructura que el documento no tiene.
68fn lineas(p: &Paquete) -> Vec<String> {
69 let dice = &p.vidriera.core.dice;
70 let ls: Vec<String> = dice
71 .lines()
72 .map(str::trim)
73 .filter(|l| !l.is_empty())
74 .map(str::to_string)
75 .collect();
76 if ls.is_empty() {
77 vec![dice.clone()]
78 } else {
79 ls
80 }
81}
82
83fn vista(m: &Modelo) -> View<Msg> {
84 let mut hijos: Vec<View<Msg>> = Vec::new();
85 match &m.que_hay {
86 Err(motivo) => {
87 hijos.push(texto(motivo.clone(), 13.0, Color::from_rgba8(220, 140, 120, 255), 20.0));
88 }
89 Ok(p) => {
90 hijos.push(texto(p.vidriera.core.nombre.clone(), 19.0, TINTA, 26.0));
91 let donde = match (&p.vidriera.core.donde, &p.reservada) {
92 (_, Some(r)) => r.core.direccion.clone(),
93 (Donde::Zona(z), None) => z.clone(),
94 (Donde::NoDice, None) => String::new(),
95 };
96 if !donde.is_empty() {
97 hijos.push(texto(donde, 12.0, TENUE, 18.0));
98 }
99 for (i, l) in lineas(p).into_iter().enumerate() {
100 hijos.push(fila(i, l, i == m.elegida));
101 }
102 }
103 }
104 hijos.push(instrumento(m));
105
106 View::new(Style {
107 flex_direction: FlexDirection::Column,
108 size: Size {
109 width: percent(1.0_f32),
110 height: percent(1.0_f32),
111 },
112 padding: Rect {
113 left: length(12.0),
114 right: length(12.0),
115 top: length(10.0),
116 bottom: length(8.0),
117 },
118 gap: Size {
119 width: length(0.0),
120 height: length(5.0),
121 },
122 ..Default::default()
123 })
124 .fill(FONDO)
125 .children(hijos)
126}
127
128fn texto(t: String, tam: f32, color: Color, alto: f32) -> View<Msg> {
129 View::new(Style {
130 size: Size {
131 width: percent(1.0_f32),
132 height: length(alto),
133 },
134 flex_shrink: 0.0,
135 align_items: Some(AlignItems::CENTER),
136 ..Default::default()
137 })
138 .text_aligned(t, tam, color, Alignment::Start)
139}
140
141fn fila(i: usize, t: String, elegida: bool) -> View<Msg> {
142 View::new(Style {
143 size: Size {
144 width: percent(1.0_f32),
145 height: length(22.0),
146 },
147 flex_shrink: 0.0,
148 align_items: Some(AlignItems::CENTER),
149 padding: Rect {
150 left: length(8.0),
151 right: length(8.0),
152 top: length(0.0),
153 bottom: length(0.0),
154 },
155 ..Default::default()
156 })
157 .fill(if elegida { ELEGIDA } else { FILA })
158 .on_click(Msg::Elegir(i))
159 .text_aligned(t, 12.0, TINTA, Alignment::Start)
160}
161
162/// La línea de números. Cambia con cada clic: si el cable se congelara, la
163/// pantalla lo diría sola en vez de parecer una interfaz quieta.
164fn instrumento(m: &Modelo) -> View<Msg> {
165 texto(
166 format!(
167 "{} clics · {} medidas · {}×{}",
168 m.clics, m.medidas, m.ancho as i32, m.alto as i32
169 ),
170 10.5,
171 TENUE,
172 16.0,
173 )
174}
175
176fn main() {
177 let ruta = std::env::args().nth(1).unwrap_or_else(|| {
178 eprintln!("uso: vidriera_activa <ruta-del-socket> [vidriera-en-hex]");
179 std::process::exit(2);
180 });
181 // La vidriera llega por argumento. **Se verifica acá**: el huésped no confía
182 // en quien lo lanzó, igual que no confiaría en un servidor.
183 let que_hay = match std::env::args().nth(2) {
184 Some(hex) => Paquete::de_texto(&hex)
185 .map_err(|e| format!("esta vidriera no entra: {e:?}")),
186 None => Err("no me pasaron ninguna vidriera".to_string()),
187 };
188
189 let flujo = match std::os::unix::net::UnixStream::connect(&ruta) {
190 Ok(f) => f,
191 Err(e) => {
192 eprintln!("vidriera_activa: no pude conectarme a {ruta}: {e}");
193 std::process::exit(1);
194 }
195 };
196
197 let cable = Rc::new(RefCell::new(Huesped::new(flujo)));
198 let medidor_cable = Rc::clone(&cable);
199 let mut jaula = Jaula::new(560.0, 360.0, move |p| {
200 medidor::medir_con(p, |peticion, salida| {
201 let pedido = match postcard::from_bytes(peticion) {
202 Ok(p) => p,
203 Err(_) => return -1,
204 };
205 match medidor_cable.borrow_mut().medir(&pedido) {
206 Ok(m) => {
207 salida.copy_from_slice(&m.a_bytes());
208 0
209 }
210 Err(_) => -1,
211 }
212 })
213 .unwrap_or(llimphi_text::wire::Medida {
214 width: 0.0,
215 full_width: 0.0,
216 height: 0.0,
217 })
218 });
219
220 let mut modelo = Modelo {
221 que_hay,
222 elegida: usize::MAX,
223 clics: 0,
224 medidas: 0,
225 ancho: 560.0,
226 alto: 360.0,
227 };
228
229 loop {
230 let montado = jaula.fotograma(vista(&modelo));
231 modelo.medidas = cable.borrow().medidas_pedidas();
232 if cable.borrow_mut().enviar_cuadro(jaula.escena()).is_err() {
233 return;
234 }
235 let evento = match cable.borrow_mut().esperar_evento() {
236 Ok(e) => e,
237 Err(_) => return,
238 };
239 match evento {
240 DelAnfitrion::Puntero { x, y, apretado } if apretado => {
241 if let Some(Msg::Elegir(i)) = jaula.click(&montado, x, y) {
242 modelo.elegida = i;
243 modelo.clics += 1;
244 }
245 }
246 DelAnfitrion::Tamano { ancho, alto } => {
247 jaula.redimensionar(ancho, alto);
248 modelo.ancho = ancho;
249 modelo.alto = alto;
250 }
251 DelAnfitrion::Adios => return,
252 _ => {}
253 }
254 }
255}