tawasuyu

tawasuyu/llimphi

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

Rust 660Markdown 302TOML 157RON 312382 Sutichaykuna

⎇ c5a232cf24788ab75a850ffe81eca5a7af50e2f8 llimphi-icons/src/app_icons.rs Rust · 906 siq'ikuna · 27.4 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: refresco desde el monorepo — 27 crates nuevos (anim, lottie, svg, mesh, image, layer, test, wire-view, widgets rag-sidebar/router/lazy-list/rive-button) y sync de los 95 existentes 93253cc22 · Sergio ·

1//! `app_icons` — iconos de marca, uno por dominio/app de tawasuyu.
2//!
3//! A diferencia del set canónico de [`crate::Icon`] (glifos genéricos de
4//! acción: file, save, search…), aquí vive **un glifo distintivo por app**.
5//! Cada app tiene su símbolo y su **color de marca** propios, pero todos
6//! comparten el mismo lenguaje visual:
7//!
8//! - **Mismo grid lógico 24×24**, origen top-left, eje Y hacia abajo.
9//! - **Stroke-based, sin fill**: trazos con `Join::Round` + `Cap::Round`.
10//! - **Geometría minimal**: reconocible al primer vistazo aún en 16×16.
11//! - **Aire de ~3 unidades** en los bordes para que respire dentro de un chip.
12//!
13//! La idea es que un dock/spotlight/menú pinte `app_icon_view(AppIcon::Pluma)`
14//! y obtenga el glifo de la pluma en su color de tinta, sin que la app tenga
15//! que cargar un PNG ni declarar su propia geometría.
16//!
17//! ```ignore
18//! use llimphi_icons::app_icons::{AppIcon, app_icon_view};
19//!
20//! // Resuelve desde el id del registro de apps:
21//! if let Some(icon) = AppIcon::from_app_id("cosmos") {
22//!     let chip = View::new(style).children(vec![app_icon_view(icon, 1.8)]);
23//! }
24//! ```
25
26use llimphi_ui::llimphi_layout::taffy::{
27    prelude::{percent, Size, Style},
28    Position,
29};
30use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Cap, Join, Stroke};
31use llimphi_ui::llimphi_raster::peniko::Color;
32use llimphi_ui::View;
33
34/// Una app de tawasuyu con icono de marca. El identificador (`name`) coincide
35/// con el `id` del `AppEntry` en `app-bus`.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum AppIcon {
38    // --- 00_unanchay · PERCIBIR ---
39    Chaka,
40    Khipu,
41    Pineal,
42    Pluma,
43    Puriy,
44    Rimay,
45    // --- 01_yachay · CONOCER ---
46    Cosmos,
47    Dominium,
48    Iniy,
49    Nakui,
50    Tinkuy,
51    // --- 02_ruway · HACER ---
52    Suilad,
53    Cards,
54    Chasqui,
55    Llimphi,
56    Media,
57    Mirada,
58    Nada,
59    Nahual,
60    Shuma,
61    Supay,
62    Takiy,
63    Tullpu,
64    Wawa,
65    Hapiy,
66    Raymi,
67    // --- 03_ukupacha · RAÍZ ---
68    Agora,
69    Arje,
70    Minga,
71    Sandokan,
72    WawaExplorer,
73}
74
75/// Las 29 apps, en orden de cuadrante. Útil para iterar (galerías, tests).
76pub const ALL: [AppIcon; 31] = [
77    AppIcon::Chaka,
78    AppIcon::Khipu,
79    AppIcon::Pineal,
80    AppIcon::Pluma,
81    AppIcon::Puriy,
82    AppIcon::Rimay,
83    AppIcon::Cosmos,
84    AppIcon::Dominium,
85    AppIcon::Iniy,
86    AppIcon::Nakui,
87    AppIcon::Tinkuy,
88    AppIcon::Suilad,
89    AppIcon::Cards,
90    AppIcon::Chasqui,
91    AppIcon::Llimphi,
92    AppIcon::Media,
93    AppIcon::Mirada,
94    AppIcon::Nada,
95    AppIcon::Nahual,
96    AppIcon::Shuma,
97    AppIcon::Supay,
98    AppIcon::Takiy,
99    AppIcon::Tullpu,
100    AppIcon::Wawa,
101    AppIcon::Hapiy,
102    AppIcon::Raymi,
103    AppIcon::Agora,
104    AppIcon::Arje,
105    AppIcon::Minga,
106    AppIcon::Sandokan,
107    AppIcon::WawaExplorer,
108];
109
110impl AppIcon {
111    /// Id estable de la app (coincide con `AppEntry.id` / nombre del dominio).
112    pub const fn name(self) -> &'static str {
113        match self {
114            AppIcon::Chaka => "chaka",
115            AppIcon::Khipu => "khipu",
116            AppIcon::Pineal => "pineal",
117            AppIcon::Pluma => "pluma",
118            AppIcon::Puriy => "puriy",
119            AppIcon::Rimay => "rimay",
120            AppIcon::Cosmos => "cosmos",
121            AppIcon::Dominium => "dominium",
122            AppIcon::Iniy => "iniy",
123            AppIcon::Nakui => "nakui",
124            AppIcon::Tinkuy => "tinkuy",
125            AppIcon::Suilad => "suilad",
126            AppIcon::Cards => "cards",
127            AppIcon::Chasqui => "chasqui",
128            AppIcon::Llimphi => "llimphi",
129            AppIcon::Media => "media",
130            AppIcon::Mirada => "mirada",
131            AppIcon::Nada => "nada",
132            AppIcon::Nahual => "nahual",
133            AppIcon::Shuma => "shuma",
134            AppIcon::Supay => "supay",
135            AppIcon::Takiy => "takiy",
136            AppIcon::Tullpu => "tullpu",
137            AppIcon::Wawa => "wawa",
138            AppIcon::Hapiy => "hapiy",
139            AppIcon::Raymi => "raymi",
140            AppIcon::Agora => "agora",
141            AppIcon::Arje => "arje",
142            AppIcon::Minga => "minga",
143            AppIcon::Sandokan => "sandokan",
144            AppIcon::WawaExplorer => "wawa-explorer",
145        }
146    }
147
148    /// Resuelve una app desde su `id` del registro. Acepta tanto
149    /// `"wawa-explorer"` como `"wawa_explorer"`.
150    pub fn from_app_id(id: &str) -> Option<AppIcon> {
151        let id = id.trim().to_ascii_lowercase();
152        let id = id.replace('_', "-");
153        ALL.into_iter().find(|a| a.name() == id)
154    }
155
156    /// Color de marca de la app — el que el dock/menú debería usar para
157    /// pintar el glifo por default.
158    pub const fn brand(self) -> Color {
159        let (r, g, b) = match self {
160            AppIcon::Chaka => (43, 166, 164),
161            AppIcon::Khipu => (181, 101, 29),
162            AppIcon::Pineal => (108, 79, 216),
163            AppIcon::Pluma => (61, 59, 142),
164            AppIcon::Puriy => (63, 163, 77),
165            AppIcon::Rimay => (232, 131, 58),
166            AppIcon::Cosmos => (230, 184, 0),
167            AppIcon::Dominium => (74, 111, 165),
168            AppIcon::Iniy => (124, 179, 66),
169            AppIcon::Nakui => (194, 84, 157),
170            AppIcon::Tinkuy => (217, 83, 79),
171            AppIcon::Suilad => (42, 168, 196),
172            AppIcon::Cards => (142, 99, 206),
173            AppIcon::Chasqui => (52, 179, 106),
174            AppIcon::Llimphi => (229, 91, 122),
175            AppIcon::Media => (226, 62, 87),
176            AppIcon::Mirada => (45, 125, 210),
177            AppIcon::Nada => (136, 147, 160),
178            AppIcon::Nahual => (124, 77, 191),
179            AppIcon::Shuma => (224, 165, 38),
180            AppIcon::Supay => (155, 63, 181),
181            AppIcon::Takiy => (229, 99, 155),
182            AppIcon::Tullpu => (224, 96, 58),
183            AppIcon::Wawa => (91, 141, 239),
184            AppIcon::Hapiy => (38, 184, 156),
185            AppIcon::Raymi => (216, 88, 120),
186            AppIcon::Agora => (47, 158, 143),
187            AppIcon::Arje => (176, 141, 87),
188            AppIcon::Minga => (224, 123, 57),
189            AppIcon::Sandokan => (192, 57, 43),
190            AppIcon::WawaExplorer => (110, 160, 240),
191        };
192        Color::from_rgb8(r, g, b)
193    }
194
195    /// `BezPath` del glifo en coords del grid 24×24.
196    pub fn path(self) -> BezPath {
197        match self {
198            AppIcon::Chaka => path_chaka(),
199            AppIcon::Khipu => path_khipu(),
200            AppIcon::Pineal => path_pineal(),
201            AppIcon::Pluma => path_pluma(),
202            AppIcon::Puriy => path_puriy(),
203            AppIcon::Rimay => path_rimay(),
204            AppIcon::Cosmos => path_cosmos(),
205            AppIcon::Dominium => path_dominium(),
206            AppIcon::Iniy => path_iniy(),
207            AppIcon::Nakui => path_nakui(),
208            AppIcon::Tinkuy => path_tinkuy(),
209            AppIcon::Suilad => path_suilad(),
210            AppIcon::Cards => path_cards(),
211            AppIcon::Chasqui => path_chasqui(),
212            AppIcon::Llimphi => path_llimphi(),
213            AppIcon::Media => path_media(),
214            AppIcon::Mirada => path_mirada(),
215            AppIcon::Nada => path_nada(),
216            AppIcon::Nahual => path_nahual(),
217            AppIcon::Shuma => path_shuma(),
218            AppIcon::Supay => path_supay(),
219            AppIcon::Takiy => path_takiy(),
220            AppIcon::Tullpu => path_tullpu(),
221            AppIcon::Wawa => path_wawa(),
222            AppIcon::Hapiy => path_hapiy(),
223            AppIcon::Raymi => path_raymi(),
224            AppIcon::Agora => path_agora(),
225            AppIcon::Arje => path_arje(),
226            AppIcon::Minga => path_minga(),
227            AppIcon::Sandokan => path_sandokan(),
228            AppIcon::WawaExplorer => path_wawa_explorer(),
229        }
230    }
231}
232
233/// `View` que pinta el icono de app en su **color de marca**, ocupando todo
234/// el rect del padre, escalado uniforme y centrado.
235///
236/// - `stroke_width` en unidades del grid 24×24 (típico de marca: `1.8`).
237pub fn app_icon_view<Msg: Clone + 'static>(icon: AppIcon, stroke_width: f32) -> View<Msg> {
238    app_icon_view_colored(icon, icon.brand(), stroke_width)
239}
240
241/// Igual que [`app_icon_view`] pero forzando un color (p.ej. monocromo
242/// `theme.fg_text` para un menú denso donde el color distrae).
243pub fn app_icon_view_colored<Msg: Clone + 'static>(
244    icon: AppIcon,
245    color: Color,
246    stroke_width: f32,
247) -> View<Msg> {
248    View::new(Style {
249        position: Position::Absolute,
250        size: Size {
251            width: percent(1.0_f32),
252            height: percent(1.0_f32),
253        },
254        ..Default::default()
255    })
256    .paint_with(move |scene, _ts, rect| {
257        paint_app_icon(scene, rect, icon, color, stroke_width);
258    })
259}
260
261/// Exporta el icono de app como **SVG** (string), en su color de marca.
262/// Mismo dibujo que [`app_icon_view`] pero en un formato de archivo: sirve para
263/// los `.desktop` (freedesktop `scalable/apps/<id>.svg`), la web, o cualquier
264/// consumidor que no renderice con Llimphi. El path es stroke-only con remates
265/// redondos, igual que el pintor vectorial. `viewBox 0 0 24 24` (la grilla).
266pub fn app_icon_svg(icon: AppIcon, stroke_width: f32) -> String {
267    let d = icon.path().to_svg();
268    let [r, g, b, _] = icon.brand().components;
269    let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
270    let hex = format!("#{:02x}{:02x}{:02x}", q(r), q(g), q(b));
271    format!(
272        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" \
273fill=\"none\" stroke=\"{hex}\" stroke-width=\"{sw}\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\
274<path d=\"{d}\"/></svg>\n",
275        sw = fmt_num(stroke_width),
276    )
277}
278
279/// Formatea un f32 sin ceros de cola (`1.8` no `1.8000`).
280fn fmt_num(v: f32) -> String {
281    let s = format!("{v:.3}");
282    let s = s.trim_end_matches('0').trim_end_matches('.');
283    s.to_string()
284}
285
286/// Pintor crudo — para stampear varios iconos de app dentro del mismo
287/// `paint_with` (una grilla de launcher, por ejemplo).
288pub fn paint_app_icon(
289    scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
290    rect: llimphi_ui::PaintRect,
291    icon: AppIcon,
292    color: Color,
293    stroke_width: f32,
294) {
295    let side = rect.w.min(rect.h) as f64;
296    if side <= 0.0 {
297        return;
298    }
299    let scale = side / 24.0;
300    let tx = rect.x as f64 + (rect.w as f64 - side) * 0.5;
301    let ty = rect.y as f64 + (rect.h as f64 - side) * 0.5;
302    let xform = Affine::translate((tx, ty)) * Affine::scale(scale);
303
304    let stroke = Stroke::new(stroke_width as f64)
305        .with_join(Join::Round)
306        .with_caps(Cap::Round);
307    let path = icon.path();
308    scene.stroke(&stroke, xform, color, None, &path);
309}
310
311// =====================================================================
312// Helpers
313// =====================================================================
314
315/// Círculo aproximado con `segments` lados rectos (liso por el Cap::Round).
316fn circle(cx: f64, cy: f64, r: f64, segments: usize) -> BezPath {
317    let mut p = BezPath::new();
318    for i in 0..=segments {
319        let theta = std::f64::consts::TAU * (i as f64) / (segments as f64);
320        let x = cx + r * theta.cos();
321        let y = cy + r * theta.sin();
322        if i == 0 {
323            p.move_to((x, y));
324        } else {
325            p.line_to((x, y));
326        }
327    }
328    p
329}
330
331/// Empuja todos los elementos de `src` dentro de `dst` (para componer
332/// glifos hechos de varias subformas).
333fn push_all(dst: &mut BezPath, src: BezPath) {
334    for el in src.elements() {
335        dst.push(*el);
336    }
337}
338
339// =====================================================================
340// Glifos — uno por app. Grid 24×24, margen ~3.
341// =====================================================================
342
343// --- 00_unanchay · PERCIBIR ---
344
345fn path_chaka() -> BezPath {
346    // chaka = puente: tablero recto + arco + dos pilotes.
347    let mut p = BezPath::new();
348    // Tablero.
349    p.move_to((3.0, 9.0));
350    p.line_to((21.0, 9.0));
351    // Arco bajo el tablero.
352    p.move_to((5.0, 18.0));
353    p.curve_to((5.0, 11.0), (19.0, 11.0), (19.0, 18.0));
354    // Pilotes que conectan tablero y arco.
355    p.move_to((9.0, 9.0));
356    p.line_to((9.0, 12.5));
357    p.move_to((15.0, 9.0));
358    p.line_to((15.0, 12.5));
359    p
360}
361
362fn path_khipu() -> BezPath {
363    // khipu: cordón principal + tres ramales con nudos (puntos).
364    let mut p = BezPath::new();
365    // Cordón superior.
366    p.move_to((4.0, 6.0));
367    p.line_to((20.0, 6.0));
368    // Ramales.
369    p.move_to((7.0, 6.0));
370    p.line_to((7.0, 19.0));
371    p.move_to((12.0, 6.0));
372    p.line_to((12.0, 20.0));
373    p.move_to((17.0, 6.0));
374    p.line_to((17.0, 18.0));
375    // Nudos.
376    push_all(&mut p, circle(7.0, 12.0, 1.3, 10));
377    push_all(&mut p, circle(12.0, 10.0, 1.3, 10));
378    push_all(&mut p, circle(12.0, 16.0, 1.3, 10));
379    push_all(&mut p, circle(17.0, 11.0, 1.3, 10));
380    p
381}
382
383fn path_pineal() -> BezPath {
384    // pineal = tercer ojo: párpado almendrado + iris + antena/rayo arriba.
385    let mut p = BezPath::new();
386    p.move_to((4.0, 12.0));
387    p.curve_to((8.0, 7.0), (16.0, 7.0), (20.0, 12.0));
388    p.curve_to((16.0, 17.0), (8.0, 17.0), (4.0, 12.0));
389    push_all(&mut p, circle(12.0, 12.0, 2.6, 14));
390    p.move_to((12.0, 3.0));
391    p.line_to((12.0, 5.5));
392    p
393}
394
395fn path_pluma() -> BezPath {
396    // pluma = plumín: rombo apuntando abajo + ranura + ojal.
397    let mut p = BezPath::new();
398    p.move_to((12.0, 3.0));
399    p.line_to((16.0, 9.0));
400    p.line_to((13.5, 20.0));
401    p.line_to((10.5, 20.0));
402    p.line_to((8.0, 9.0));
403    p.close_path();
404    // Ranura.
405    p.move_to((12.0, 11.5));
406    p.line_to((12.0, 19.0));
407    // Ojal.
408    push_all(&mut p, circle(12.0, 9.5, 1.2, 10));
409    p
410}
411
412fn path_puriy() -> BezPath {
413    // puriy = caminar/recorrido: senda curva ascendente con flecha.
414    let mut p = BezPath::new();
415    p.move_to((6.0, 20.0));
416    p.curve_to((6.0, 12.0), (18.0, 12.0), (18.0, 4.0));
417    // Cabeza de flecha.
418    p.move_to((15.0, 6.0));
419    p.line_to((18.0, 4.0));
420    p.line_to((20.5, 6.5));
421    p
422}
423
424fn path_rimay() -> BezPath {
425    // rimay = palabra/habla: globo de diálogo con cola + dos renglones.
426    let mut p = BezPath::new();
427    p.move_to((4.0, 6.0));
428    p.line_to((20.0, 6.0));
429    p.line_to((20.0, 15.0));
430    p.line_to((11.0, 15.0));
431    p.line_to((8.0, 19.0));
432    p.line_to((8.0, 15.0));
433    p.line_to((4.0, 15.0));
434    p.close_path();
435    // Renglones.
436    p.move_to((8.0, 9.5));
437    p.line_to((16.0, 9.5));
438    p.move_to((8.0, 12.0));
439    p.line_to((13.0, 12.0));
440    p
441}
442
443// --- 01_yachay · CONOCER ---
444
445fn path_cosmos() -> BezPath {
446    // cosmos = destello de 4 puntas + dos estrellas pequeñas.
447    let mut p = BezPath::new();
448    p.move_to((12.0, 4.0));
449    p.line_to((13.4, 10.6));
450    p.line_to((20.0, 12.0));
451    p.line_to((13.4, 13.4));
452    p.line_to((12.0, 20.0));
453    p.line_to((10.6, 13.4));
454    p.line_to((4.0, 12.0));
455    p.line_to((10.6, 10.6));
456    p.close_path();
457    // Estrellas chicas.
458    push_all(&mut p, circle(19.0, 6.0, 0.8, 8));
459    push_all(&mut p, circle(5.5, 18.0, 0.8, 8));
460    p
461}
462
463fn path_dominium() -> BezPath {
464    // dominium = ERP/libro mayor: barras de distinta altura sobre una base.
465    let mut p = BezPath::new();
466    // Base.
467    p.move_to((3.0, 20.0));
468    p.line_to((21.0, 20.0));
469    // Columnas.
470    p.move_to((6.0, 14.0));
471    p.line_to((9.0, 14.0));
472    p.line_to((9.0, 20.0));
473    p.line_to((6.0, 20.0));
474    p.close_path();
475    p.move_to((10.5, 8.0));
476    p.line_to((13.5, 8.0));
477    p.line_to((13.5, 20.0));
478    p.line_to((10.5, 20.0));
479    p.close_path();
480    p.move_to((15.0, 11.0));
481    p.line_to((18.0, 11.0));
482    p.line_to((18.0, 20.0));
483    p.line_to((15.0, 20.0));
484    p.close_path();
485    p
486}
487
488fn path_iniy() -> BezPath {
489    // iniy = aliento/creer: brote con tallo y dos hojas.
490    let mut p = BezPath::new();
491    // Tallo.
492    p.move_to((12.0, 20.0));
493    p.line_to((12.0, 10.0));
494    // Hoja izquierda.
495    p.move_to((12.0, 14.0));
496    p.curve_to((8.0, 14.0), (6.0, 11.0), (7.0, 8.0));
497    p.curve_to((10.0, 9.0), (12.0, 11.0), (12.0, 14.0));
498    // Hoja derecha.
499    p.move_to((12.0, 12.0));
500    p.curve_to((15.5, 12.0), (17.0, 9.0), (16.5, 6.0));
501    p.curve_to((14.0, 7.0), (12.0, 9.0), (12.0, 12.0));
502    p
503}
504
505fn path_nakui() -> BezPath {
506    // nakui = grafo de morfismos: tres nodos + aristas.
507    let mut p = BezPath::new();
508    // Aristas (primero, para que queden bajo los nodos).
509    p.move_to((7.5, 9.0));
510    p.line_to((16.5, 9.0));
511    p.move_to((7.5, 9.8));
512    p.line_to((10.8, 16.0));
513    p.move_to((16.5, 9.8));
514    p.line_to((13.2, 16.0));
515    // Nodos.
516    push_all(&mut p, circle(6.0, 8.0, 2.2, 14));
517    push_all(&mut p, circle(18.0, 8.0, 2.2, 14));
518    push_all(&mut p, circle(12.0, 18.0, 2.2, 14));
519    p
520}
521
522fn path_tinkuy() -> BezPath {
523    // tinkuy = encuentro/choque: dos flechas que convergen + chispa.
524    let mut p = BezPath::new();
525    // Flecha izquierda →
526    p.move_to((3.0, 12.0));
527    p.line_to((9.5, 12.0));
528    p.move_to((7.5, 10.0));
529    p.line_to((9.5, 12.0));
530    p.line_to((7.5, 14.0));
531    // Flecha derecha ←
532    p.move_to((21.0, 12.0));
533    p.line_to((14.5, 12.0));
534    p.move_to((16.5, 10.0));
535    p.line_to((14.5, 12.0));
536    p.line_to((16.5, 14.0));
537    // Chispa central.
538    push_all(&mut p, circle(12.0, 12.0, 1.6, 10));
539    p
540}
541
542// --- 02_ruway · HACER ---
543
544fn path_suilad() -> BezPath {
545    // suilad = saludo mutuo (élfico); ciclo de reciprocidad: dos flechas curvas en ciclo.
546    let mut p = BezPath::new();
547    // Arco superior, flecha hacia la derecha-abajo.
548    p.move_to((6.0, 8.0));
549    p.curve_to((9.0, 4.0), (15.0, 4.0), (18.0, 8.5));
550    p.move_to((15.5, 8.0));
551    p.line_to((18.0, 8.5));
552    p.line_to((18.5, 5.8));
553    // Arco inferior, flecha hacia la izquierda-arriba.
554    p.move_to((18.0, 16.0));
555    p.curve_to((15.0, 20.0), (9.0, 20.0), (6.0, 15.5));
556    p.move_to((8.5, 16.0));
557    p.line_to((6.0, 15.5));
558    p.line_to((5.5, 18.2));
559    p
560}
561
562fn path_cards() -> BezPath {
563    // cards = naipes apilados: carta frontal + borde de la de atrás.
564    let mut p = BezPath::new();
565    // Carta de atrás (asoma arriba y a la derecha).
566    p.move_to((8.0, 5.0));
567    p.line_to((19.0, 5.0));
568    p.line_to((19.0, 16.0));
569    // Carta frontal.
570    p.move_to((5.0, 9.0));
571    p.line_to((15.0, 9.0));
572    p.line_to((15.0, 20.0));
573    p.line_to((5.0, 20.0));
574    p.close_path();
575    p
576}
577
578fn path_chasqui() -> BezPath {
579    // chasqui = mensajero: avión de papel.
580    let mut p = BezPath::new();
581    p.move_to((4.0, 11.0));
582    p.line_to((20.0, 4.0));
583    p.line_to((13.0, 20.0));
584    p.line_to((11.0, 13.0));
585    p.close_path();
586    // Pliegue central.
587    p.move_to((11.0, 13.0));
588    p.line_to((20.0, 4.0));
589    p
590}
591
592fn path_llimphi() -> BezPath {
593    // llimphi = pintura/color: paleta con apoyo para el pulgar + 3 gotas.
594    let mut p = BezPath::new();
595    p.move_to((4.0, 12.0));
596    p.curve_to((4.0, 6.0), (11.0, 4.0), (15.0, 5.0));
597    p.curve_to((20.0, 6.5), (21.0, 12.0), (18.0, 15.0));
598    p.curve_to((16.0, 16.5), (16.5, 13.5), (14.0, 14.0));
599    p.curve_to((11.5, 14.5), (12.5, 18.0), (9.0, 18.0));
600    p.curve_to((5.5, 18.0), (4.0, 15.0), (4.0, 12.0));
601    p.close_path();
602    // Gotas de pintura.
603    push_all(&mut p, circle(8.0, 9.0, 1.1, 10));
604    push_all(&mut p, circle(12.0, 8.0, 1.1, 10));
605    push_all(&mut p, circle(15.5, 10.0, 1.1, 10));
606    p
607}
608
609fn path_media() -> BezPath {
610    // media = reproducción: marco + triángulo de play.
611    let mut p = BezPath::new();
612    p.move_to((4.0, 6.0));
613    p.line_to((20.0, 6.0));
614    p.line_to((20.0, 18.0));
615    p.line_to((4.0, 18.0));
616    p.close_path();
617    // Play.
618    p.move_to((10.0, 9.0));
619    p.line_to((10.0, 15.0));
620    p.line_to((16.0, 12.0));
621    p.close_path();
622    p
623}
624
625fn path_mirada() -> BezPath {
626    // mirada = ojo: párpado + iris + pupila.
627    let mut p = BezPath::new();
628    p.move_to((3.0, 12.0));
629    p.curve_to((8.0, 6.0), (16.0, 6.0), (21.0, 12.0));
630    p.curve_to((16.0, 18.0), (8.0, 18.0), (3.0, 12.0));
631    p.close_path();
632    push_all(&mut p, circle(12.0, 12.0, 3.4, 18));
633    push_all(&mut p, circle(12.0, 12.0, 1.0, 8));
634    p
635}
636
637fn path_nada() -> BezPath {
638    // nada = vacío: conjunto vacío ∅ (anillo + diagonal).
639    let mut p = circle(12.0, 12.0, 8.0, 28);
640    p.move_to((6.5, 17.5));
641    p.line_to((17.5, 6.5));
642    p
643}
644
645fn path_nahual() -> BezPath {
646    // nahual = máscara/mutación de forma: antifaz con dos ojos.
647    let mut p = BezPath::new();
648    p.move_to((4.0, 9.0));
649    p.curve_to((4.0, 6.5), (8.0, 6.0), (10.0, 7.5));
650    p.curve_to((11.0, 8.2), (13.0, 8.2), (14.0, 7.5));
651    p.curve_to((16.0, 6.0), (20.0, 6.5), (20.0, 9.0));
652    p.curve_to((20.0, 13.5), (16.0, 16.5), (12.0, 15.5));
653    p.curve_to((8.0, 16.5), (4.0, 13.5), (4.0, 9.0));
654    p.close_path();
655    push_all(&mut p, circle(9.0, 10.0, 1.3, 10));
656    push_all(&mut p, circle(15.0, 10.0, 1.3, 10));
657    p
658}
659
660fn path_shuma() -> BezPath {
661    // shuma = discernir: embudo/filtro.
662    let mut p = BezPath::new();
663    p.move_to((4.0, 6.0));
664    p.line_to((20.0, 6.0));
665    p.line_to((13.0, 14.0));
666    p.line_to((13.0, 19.0));
667    p.line_to((11.0, 20.0));
668    p.line_to((11.0, 14.0));
669    p.close_path();
670    p
671}
672
673fn path_supay() -> BezPath {
674    // supay = espíritu del ukhupacha: llama doble.
675    let mut p = BezPath::new();
676    // Llama exterior.
677    p.move_to((12.0, 3.0));
678    p.curve_to((17.0, 9.0), (16.0, 14.0), (12.0, 21.0));
679    p.curve_to((8.0, 14.0), (7.0, 9.0), (12.0, 3.0));
680    p.close_path();
681    // Llama interior.
682    p.move_to((12.0, 9.0));
683    p.curve_to((14.0, 12.0), (13.0, 16.0), (12.0, 18.0));
684    p.curve_to((11.0, 16.0), (10.0, 12.0), (12.0, 9.0));
685    p.close_path();
686    p
687}
688
689fn path_takiy() -> BezPath {
690    // takiy = cantar: corchea + ondas de sonido.
691    let mut p = BezPath::new();
692    // Cabeza de nota.
693    push_all(&mut p, circle(8.0, 18.0, 2.4, 16));
694    // Plica.
695    p.move_to((10.4, 18.0));
696    p.line_to((10.4, 6.0));
697    // Banderola.
698    p.move_to((10.4, 6.0));
699    p.curve_to((13.5, 7.0), (14.5, 9.0), (13.5, 11.0));
700    // Ondas.
701    p.move_to((16.0, 9.0));
702    p.curve_to((18.0, 11.0), (18.0, 13.0), (16.0, 15.0));
703    p
704}
705
706fn path_tullpu() -> BezPath {
707    // tullpu = tinte/color: tres gotas.
708    let mut p = BezPath::new();
709    // Gota 1.
710    p.move_to((8.0, 5.0));
711    p.curve_to((11.0, 9.0), (11.0, 11.0), (8.0, 12.0));
712    p.curve_to((5.0, 11.0), (5.0, 9.0), (8.0, 5.0));
713    p.close_path();
714    // Gota 2.
715    p.move_to((16.0, 6.0));
716    p.curve_to((19.0, 10.0), (19.0, 12.0), (16.0, 13.0));
717    p.curve_to((13.0, 12.0), (13.0, 10.0), (16.0, 6.0));
718    p.close_path();
719    // Gota 3.
720    p.move_to((12.0, 13.0));
721    p.curve_to((15.0, 17.0), (15.0, 19.0), (12.0, 20.0));
722    p.curve_to((9.0, 19.0), (9.0, 17.0), (12.0, 13.0));
723    p.close_path();
724    p
725}
726
727fn path_wawa() -> BezPath {
728    // wawa = célula/semilla (el SO en gestación): membrana + núcleo.
729    let mut p = circle(12.0, 12.0, 8.0, 28);
730    push_all(&mut p, circle(12.0, 12.0, 3.0, 16));
731    p
732}
733
734// --- 03_ukupacha · RAÍZ ---
735
736fn path_agora() -> BezPath {
737    // agora = firma/confianza: escudo con check.
738    let mut p = BezPath::new();
739    p.move_to((12.0, 3.0));
740    p.line_to((20.0, 6.0));
741    p.line_to((20.0, 12.0));
742    p.curve_to((20.0, 17.0), (16.0, 20.0), (12.0, 21.0));
743    p.curve_to((8.0, 20.0), (4.0, 17.0), (4.0, 12.0));
744    p.line_to((4.0, 6.0));
745    p.close_path();
746    // Check.
747    p.move_to((8.5, 12.0));
748    p.line_to((11.0, 14.5));
749    p.line_to((16.0, 8.5));
750    p
751}
752
753fn path_arje() -> BezPath {
754    // arje = arché/raíz de confianza: ancla.
755    let mut p = BezPath::new();
756    // Anillo.
757    push_all(&mut p, circle(12.0, 5.0, 2.2, 14));
758    // Caña.
759    p.move_to((12.0, 7.2));
760    p.line_to((12.0, 19.0));
761    // Travesaño.
762    p.move_to((8.0, 10.0));
763    p.line_to((16.0, 10.0));
764    // Uñas/brazos.
765    p.move_to((6.0, 14.0));
766    p.curve_to((6.0, 18.5), (9.0, 20.0), (12.0, 20.0));
767    p.move_to((18.0, 14.0));
768    p.curve_to((18.0, 18.5), (15.0, 20.0), (12.0, 20.0));
769    p
770}
771
772fn path_minga() -> BezPath {
773    // minga = trabajo comunal: tres figuras.
774    let mut p = BezPath::new();
775    // Figura central.
776    push_all(&mut p, circle(12.0, 7.0, 2.2, 14));
777    p.move_to((8.0, 18.0));
778    p.curve_to((8.0, 13.0), (16.0, 13.0), (16.0, 18.0));
779    // Figura izquierda.
780    push_all(&mut p, circle(5.5, 10.0, 1.6, 12));
781    p.move_to((2.5, 18.0));
782    p.curve_to((2.5, 14.5), (6.0, 13.5), (7.5, 15.0));
783    // Figura derecha.
784    push_all(&mut p, circle(18.5, 10.0, 1.6, 12));
785    p.move_to((21.5, 18.0));
786    p.curve_to((21.5, 14.5), (18.0, 13.5), (16.5, 15.0));
787    p
788}
789
790fn path_sandokan() -> BezPath {
791    // sandokan = caja/contenedor aislado: cubo isométrico.
792    let mut p = BezPath::new();
793    // Cara frontal.
794    p.move_to((5.0, 8.0));
795    p.line_to((14.0, 8.0));
796    p.line_to((14.0, 18.0));
797    p.line_to((5.0, 18.0));
798    p.close_path();
799    // Tapa.
800    p.move_to((5.0, 8.0));
801    p.line_to((9.0, 4.0));
802    p.line_to((18.0, 4.0));
803    p.line_to((14.0, 8.0));
804    // Cara lateral.
805    p.move_to((14.0, 8.0));
806    p.line_to((18.0, 4.0));
807    p.line_to((18.0, 14.0));
808    p.line_to((14.0, 18.0));
809    p
810}
811
812fn path_hapiy() -> BezPath {
813    // hapiy = asir/capturar: marco de cuatro esquinas (encuadre de captura).
814    let mut p = BezPath::new();
815    p.move_to((4.0, 8.0));
816    p.line_to((4.0, 4.0));
817    p.line_to((8.0, 4.0)); // sup-izq
818    p.move_to((16.0, 4.0));
819    p.line_to((20.0, 4.0));
820    p.line_to((20.0, 8.0)); // sup-der
821    p.move_to((20.0, 16.0));
822    p.line_to((20.0, 20.0));
823    p.line_to((16.0, 20.0)); // inf-der
824    p.move_to((8.0, 20.0));
825    p.line_to((4.0, 20.0));
826    p.line_to((4.0, 16.0)); // inf-izq
827    p
828}
829
830fn path_raymi() -> BezPath {
831    // raymi = fiesta/calendario: hoja con anillas, cabecera y día marcado.
832    let mut p = BezPath::new();
833    p.move_to((4.0, 6.0));
834    p.line_to((20.0, 6.0));
835    p.line_to((20.0, 20.0));
836    p.line_to((4.0, 20.0));
837    p.close_path();
838    p.move_to((4.0, 10.0));
839    p.line_to((20.0, 10.0)); // cabecera
840    p.move_to((8.0, 4.0));
841    p.line_to((8.0, 7.5)); // anilla izq
842    p.move_to((16.0, 4.0));
843    p.line_to((16.0, 7.5)); // anilla der
844    push_all(&mut p, circle(12.0, 15.0, 1.8, 12)); // día marcado
845    p
846}
847
848fn path_wawa_explorer() -> BezPath {
849    // wawa-explorer = launchpad: grilla 2×2.
850    let mut p = BezPath::new();
851    for (x, y) in &[(5.0, 5.0), (13.0, 5.0), (5.0, 13.0), (13.0, 13.0)] {
852        p.move_to((*x, *y));
853        p.line_to((*x + 6.0, *y));
854        p.line_to((*x + 6.0, *y + 6.0));
855        p.line_to((*x, *y + 6.0));
856        p.close_path();
857    }
858    p
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864
865    #[test]
866    fn all_app_icons_have_nonempty_path() {
867        for icon in ALL {
868            let p = icon.path();
869            assert!(
870                p.elements().len() > 0,
871                "icono de app {} produjo path vacío",
872                icon.name()
873            );
874        }
875    }
876
877    #[test]
878    fn svg_export_es_valido() {
879        for icon in ALL {
880            let svg = app_icon_svg(icon, 1.8);
881            assert!(svg.starts_with("<svg"), "{} svg sin cabecera", icon.name());
882            assert!(svg.contains("<path d=\"M"), "{} svg sin path", icon.name());
883            assert!(svg.contains("stroke=\"#"), "{} svg sin color de marca", icon.name());
884            assert!(svg.trim_end().ends_with("</svg>"), "{} svg sin cierre", icon.name());
885        }
886    }
887
888    #[test]
889    fn app_names_are_unique() {
890        let mut names: Vec<&str> = ALL.iter().map(|i| i.name()).collect();
891        let n = names.len();
892        names.sort();
893        names.dedup();
894        assert_eq!(names.len(), n, "nombres duplicados en AppIcon::name()");
895    }
896
897    #[test]
898    fn from_app_id_roundtrips() {
899        for icon in ALL {
900            assert_eq!(AppIcon::from_app_id(icon.name()), Some(icon));
901        }
902        // Tolera underscores y mayúsculas.
903        assert_eq!(AppIcon::from_app_id("WAWA_EXPLORER"), Some(AppIcon::WawaExplorer));
904        assert_eq!(AppIcon::from_app_id("desconocida"), None);
905    }
906}