⎇ 48e5a861a0ccd73f813828603c35346697befb9d
00_unanchay/khipu/khipu-app/src/cuerdas.rs
Historia
Quién tocó qué
crudo
chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0)
1//! `cuerdas` — el lienzo raíz de khipu: un khipu literal.
2//!
3//! Una **viga** horizontal (la cuerda madre) cruza la parte alta; de ella
4//! cuelgan las **cuerdas** (ideas recurrentes), ordenadas por masa — la
5//! más gorda a la izquierda. A lo largo de cada cuerda, sus **nudos** en
6//! orden cronológico (arriba lo primero que se pensó); el **tejido** (la
7//! síntesis) se pinta como rombo. El grosor viene de cuántos nudos tiene
8//! la cuerda; el brillo, de cuán reciente fue su último crecimiento
9//! (`khipu_gravity::{masa_cuerda, frescura}`) — lo que crece arde, lo
10//! quieto reposa, nada desaparece.
11//!
12//! La geometría es una proyección pura del store (nada se persiste): el
13//! layout se deriva en [`layout_cuerdas`], el painter lo pinta y el
14//! hit-test ([`pick`]) lo consulta. Pan horizontal con arrastre o rueda.
15
16use khipu_core::{CuerdaId, EstadoNudo, NoteId};
17use khipu_gravity::{frescura, masa_cuerda, FRESCURA_MEDIA_VIDA_SECS};
18use llimphi_theme::Theme;
19use llimphi_ui::llimphi_layout::taffy::{
20 prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
21 AlignItems, Dimension, JustifyContent,
22};
23use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
24use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
25use llimphi_ui::llimphi_text::{draw_block, TextBlock};
26use llimphi_ui::{DragPhase, View};
27
28use crate::estado::now_secs;
29use crate::modelo::{Model, Msg};
30use crate::panels::button;
31
32// =====================================================================
33// Geometría
34// =====================================================================
35
36/// Margen izquierdo de la primera cuerda.
37const MARGEN_X: f32 = 90.0;
38/// Separación horizontal entre cuerdas.
39pub(crate) const ESPACIO_CUERDA: f32 = 150.0;
40/// Altura de la viga (cuerda madre) dentro del lienzo.
41const VIGA_Y: f32 = 34.0;
42/// Donde empiezan los nudos (deja lugar al nombre bajo la viga).
43const NUDOS_TOP: f32 = 84.0;
44/// Espacio que se reserva al pie del lienzo.
45const FONDO_PAD: f32 = 30.0;
46/// Separación vertical máxima entre nudos (se comprime si no entran).
47const ESPACIO_NUDO_MAX: f32 = 40.0;
48/// Radio de un nudo.
49const NUDO_R: f32 = 5.0;
50/// Tolerancia de click sobre un nudo (px).
51const TOL_NUDO: f32 = 14.0;
52
53/// Un nudo ya proyectado a coordenadas locales del lienzo.
54pub(crate) struct NudoVis {
55 pub(crate) id: NoteId,
56 pub(crate) y: f32,
57 pub(crate) estado: EstadoNudo,
58 pub(crate) es_tejido: bool,
59 pub(crate) titulo: String,
60}
61
62/// Una cuerda proyectada: su columna, su físico y sus nudos.
63pub(crate) struct CuerdaVis {
64 pub(crate) id: CuerdaId,
65 pub(crate) x: f32,
66 pub(crate) nombre: String,
67 /// Grosor de trazo (deriva de la masa — cantidad de nudos).
68 pub(crate) grosor: f32,
69 /// Alpha base (deriva de la frescura del último crecimiento).
70 pub(crate) brillo: f32,
71 pub(crate) color: Color,
72 pub(crate) nudos: Vec<NudoVis>,
73}
74
75/// Proyecta el store al lienzo: cuerdas ordenadas por masa descendente
76/// (la más gorda a la izquierda, desempate por id — estable), nudos en
77/// cronología descendente hacia abajo, tejido al final. Determinista.
78pub(crate) fn layout_cuerdas(model: &Model, h: f32, now: u64) -> Vec<CuerdaVis> {
79 let theme = &model.theme;
80 let mut cuerdas: Vec<(CuerdaId, usize, u64)> = model
81 .store
82 .cuerdas()
83 .map(|c| {
84 let nudos = model.store.nudos_de(c.id);
85 let ultimo = nudos.iter().map(|n| n.created_at).max().unwrap_or(c.created_at);
86 (c.id, nudos.len(), ultimo)
87 })
88 .collect();
89 cuerdas.sort_by(|a, b| {
90 masa_cuerda(b.1)
91 .partial_cmp(&masa_cuerda(a.1))
92 .unwrap_or(core::cmp::Ordering::Equal)
93 .then(a.0.cmp(&b.0))
94 });
95
96 cuerdas
97 .into_iter()
98 .enumerate()
99 .map(|(i, (cid, n_nudos, ultimo))| {
100 let x = MARGEN_X + i as f32 * ESPACIO_CUERDA - model.pan_x;
101 let cuerda = model.store.cuerda(cid).expect("recién listada");
102 let nudos = model.store.nudos_de(cid);
103 // El tejido se pinta al final aunque su fecha sea vieja:
104 // es el remate de la cuerda, no un nudo más de la cronología.
105 let (mut cuerpo, tejido): (Vec<_>, Vec<_>) =
106 nudos.into_iter().partition(|n| cuerda.tejido != Some(n.id));
107 cuerpo.extend(tejido);
108 let total = cuerpo.len();
109 let espacio = if total <= 1 {
110 ESPACIO_NUDO_MAX
111 } else {
112 ((h - NUDOS_TOP - FONDO_PAD) / (total as f32 - 1.0)).clamp(14.0, ESPACIO_NUDO_MAX)
113 };
114 let nudos_vis: Vec<NudoVis> = cuerpo
115 .iter()
116 .enumerate()
117 .map(|(j, n)| NudoVis {
118 id: n.id,
119 y: NUDOS_TOP + j as f32 * espacio,
120 estado: n.estado,
121 es_tejido: cuerda.tejido == Some(n.id),
122 titulo: short_label(&n.title),
123 })
124 .collect();
125 CuerdaVis {
126 id: cid,
127 x,
128 nombre: cuerda.nombre.clone(),
129 grosor: 1.4 + masa_cuerda(n_nudos) * 1.5,
130 brillo: 0.45 + 0.55 * frescura(ultimo, now, FRESCURA_MEDIA_VIDA_SECS),
131 color: color_cuerda(cid, *theme),
132 nudos: nudos_vis,
133 }
134 })
135 .collect()
136}
137
138/// Qué hay bajo un click en el lienzo, en coordenadas locales.
139pub(crate) enum Pick {
140 Nudo(NoteId, CuerdaId),
141 Cabeza(CuerdaId),
142 Nada,
143}
144
145/// Resuelve el click: primero nudos (radio de tolerancia), después la
146/// cabeza de la cuerda (nombre + tramo sobre los nudos), después nada.
147pub(crate) fn pick(model: &Model, lx: f32, ly: f32, h: f32) -> Pick {
148 let now = now_secs();
149 let layout = layout_cuerdas(model, h, now);
150 for c in &layout {
151 for n in &c.nudos {
152 let d2 = (lx - c.x).powi(2) + (ly - n.y).powi(2);
153 if d2 <= TOL_NUDO * TOL_NUDO {
154 return Pick::Nudo(n.id, c.id);
155 }
156 }
157 }
158 for c in &layout {
159 if (lx - c.x).abs() < ESPACIO_CUERDA * 0.45 && ly > VIGA_Y - 12.0 && ly < NUDOS_TOP {
160 return Pick::Cabeza(c.id);
161 }
162 }
163 Pick::Nada
164}
165
166/// Máximo pan horizontal: que la última cuerda pueda acercarse al margen.
167pub(crate) fn max_pan(model: &Model, w: f32) -> f32 {
168 let n = model.store.cuerdas_len() as f32;
169 (MARGEN_X + n * ESPACIO_CUERDA - w * 0.6).max(0.0)
170}
171
172// =====================================================================
173// El panel del lienzo
174// =====================================================================
175
176/// El lienzo de cuerdas: canvas `paint_with` + pan por arrastre/rueda +
177/// click con hit-test. `injected` son overlays hijos (tarjeta de cuerda,
178/// etc.) que viajan encima del pintado.
179pub(crate) fn cuerdas_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
180 let theme = model.theme;
181 let now = now_secs();
182 // La proyección se materializa aquí (h con el último tamaño conocido)
183 // para viajar plana dentro de la closure de pintura.
184 let layout = layout_cuerdas(model, model.canvas_size.1, now);
185 let sel_nota = model.selected;
186 let sel_cuerda = model.cuerda_sel;
187 let vacio = model.store.cuerdas_len() == 0;
188
189 let canvas = View::new(Style {
190 size: Size {
191 width: percent(1.0_f32),
192 height: percent(1.0_f32),
193 },
194 ..Default::default()
195 })
196 .fill(theme.bg_panel_alt)
197 .paint_with(move |scene, ts, rect| {
198 paint_cuerdas(scene, ts, rect, &layout, sel_nota, sel_cuerda, vacio, theme);
199 })
200 .draggable_at(|phase, dx, _dy, _lx, _ly| match phase {
201 DragPhase::Move => Some(Msg::CuerdasPan(-dx)),
202 DragPhase::End => None,
203 })
204 .on_scroll(|_dx, dy| Some(Msg::CuerdasPan(dy * 36.0)))
205 .on_click_at(|lx, ly, w, h| Some(Msg::CuerdasClick(lx, ly, w, h)))
206 .children(injected);
207
208 View::new(Style {
209 size: Size {
210 width: Dimension::auto(),
211 height: percent(1.0_f32),
212 },
213 flex_grow: 1.0,
214 flex_basis: length(0.0_f32),
215 min_size: Size {
216 width: length(0.0_f32),
217 height: length(0.0_f32),
218 },
219 padding: Rect {
220 left: length(4.0_f32),
221 right: length(4.0_f32),
222 top: length(4.0_f32),
223 bottom: length(4.0_f32),
224 },
225 ..Default::default()
226 })
227 .fill(theme.bg_panel)
228 .children(vec![canvas])
229}
230
231#[allow(clippy::too_many_arguments)]
232fn paint_cuerdas(
233 scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
234 ts: &mut llimphi_ui::llimphi_text::Typesetter,
235 rect: llimphi_ui::PaintRect,
236 layout: &[CuerdaVis],
237 sel_nota: Option<NoteId>,
238 sel_cuerda: Option<CuerdaId>,
239 vacio: bool,
240 theme: Theme,
241) {
242 if rect.w <= 0.0 || rect.h <= 0.0 {
243 return;
244 }
245 let sx = |x: f32| (rect.x + x) as f64;
246 let sy = |y: f32| (rect.y + y) as f64;
247
248 // La viga: la cuerda madre de la que cuelga todo.
249 let mut viga = BezPath::new();
250 viga.move_to((sx(0.0), sy(VIGA_Y)));
251 viga.line_to((sx(rect.w), sy(VIGA_Y)));
252 scene.stroke(
253 &Stroke::new(3.0),
254 Affine::IDENTITY,
255 with_alpha(theme.fg_muted, 0.45),
256 None,
257 &viga,
258 );
259
260 if vacio {
261 draw_block(
262 scene,
263 ts,
264 &TextBlock::simple(
265 "las cuerdas nacen cuando dos nudos resuenan — bota una idea arriba",
266 13.0,
267 with_alpha(theme.fg_muted, 0.8),
268 (sx(rect.w * 0.5 - 240.0), sy(rect.h * 0.42)),
269 ),
270 );
271 return;
272 }
273
274 for c in layout {
275 // Culling: fuera del lienzo no se pinta.
276 if c.x < -ESPACIO_CUERDA || c.x > rect.w + ESPACIO_CUERDA {
277 continue;
278 }
279 let seleccionada = sel_cuerda == Some(c.id);
280 let brillo = if seleccionada { 1.0 } else { c.brillo };
281 let col = with_alpha(c.color, brillo);
282
283 // Amarre a la viga.
284 let amarre = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 3.6);
285 scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &amarre);
286 if seleccionada {
287 let ring = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 6.5);
288 scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, theme.accent, None, &ring);
289 }
290
291 // El nombre, colgado bajo la viga.
292 let size = 11.5_f32;
293 let est_w = c.nombre.chars().count() as f64 * size as f64 * 0.52;
294 let name_col = if seleccionada {
295 theme.accent
296 } else {
297 with_alpha(theme.fg_text, (brillo + 0.15).clamp(0.0, 1.0))
298 };
299 draw_block(
300 scene,
301 ts,
302 &TextBlock::simple(
303 &c.nombre,
304 size,
305 name_col,
306 (sx(c.x) - est_w * 0.5, sy(VIGA_Y + 12.0)),
307 ),
308 );
309
310 // La cuerda: de la viga al último nudo (o un colgajo corto si
311 // está vacía).
312 let y_fin = c.nudos.last().map(|n| n.y + 14.0).unwrap_or(NUDOS_TOP);
313 let mut linea = BezPath::new();
314 linea.move_to((sx(c.x), sy(VIGA_Y)));
315 linea.line_to((sx(c.x), sy(y_fin)));
316 scene.stroke(
317 &Stroke::new(c.grosor as f64),
318 Affine::IDENTITY,
319 with_alpha(c.color, brillo * 0.8),
320 None,
321 &linea,
322 );
323
324 // Los nudos.
325 for n in &c.nudos {
326 let (px, py) = (sx(c.x), sy(n.y));
327 if n.es_tejido {
328 // El tejido: rombo lleno, el remate brillante de la cuerda.
329 let r = (NUDO_R + 2.5) as f64;
330 let mut rombo = BezPath::new();
331 rombo.move_to((px, py - r));
332 rombo.line_to((px + r, py));
333 rombo.line_to((px, py + r));
334 rombo.line_to((px - r, py));
335 rombo.close_path();
336 scene.fill(Fill::NonZero, Affine::IDENTITY, theme.accent, None, &rombo);
337 } else {
338 let circle = KurboCircle::new((px, py), NUDO_R as f64);
339 match n.estado {
340 EstadoNudo::Normal => {
341 scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
342 }
343 EstadoNudo::Pendiente => {
344 // Anillo hueco: un nudo por hacer.
345 scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, col, None, &circle);
346 }
347 EstadoNudo::Hecho => {
348 scene.fill(
349 Fill::NonZero,
350 Affine::IDENTITY,
351 with_alpha(c.color, brillo * 0.35),
352 None,
353 &circle,
354 );
355 }
356 }
357 }
358 if sel_nota == Some(n.id) {
359 let ring = KurboCircle::new((px, py), (NUDO_R + 4.0) as f64);
360 scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, theme.accent, None, &ring);
361 }
362 // Etiquetas sólo en la cuerda seleccionada (o el nudo elegido):
363 // de lejos el khipu se lee por forma, de cerca por texto.
364 if seleccionada || sel_nota == Some(n.id) {
365 let lbl_col = with_alpha(theme.fg_text, (brillo + 0.2).clamp(0.0, 1.0));
366 draw_block(
367 scene,
368 ts,
369 &TextBlock::simple(&n.titulo, 10.0, lbl_col, (px + 10.0, py - 6.0)),
370 );
371 }
372 }
373 }
374}
375
376// =====================================================================
377// Overlays de flujo (compartidos con app.rs)
378// =====================================================================
379
380/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
381/// completo con un margen. El lienzo de fondo sigue recibiendo eventos en
382/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
383pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
384 View::new(Style {
385 position: Position::Absolute,
386 inset: Rect {
387 left: length(8.0_f32),
388 top: length(8.0_f32),
389 bottom: length(8.0_f32),
390 right: auto(),
391 },
392 size: Size {
393 width: length(width),
394 height: auto(),
395 },
396 flex_direction: FlexDirection::Column,
397 ..Default::default()
398 })
399 .children(vec![child])
400}
401
402/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
403/// el editor abajo, sobre `bg_panel`.
404pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
405 let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
406 let close_row = View::new(Style {
407 flex_direction: FlexDirection::Row,
408 size: Size {
409 width: percent(1.0_f32),
410 height: length(28.0_f32),
411 },
412 flex_shrink: 0.0,
413 justify_content: Some(JustifyContent::End),
414 align_items: Some(AlignItems::Center),
415 padding: Rect {
416 left: length(6.0_f32),
417 right: length(6.0_f32),
418 top: length(2.0_f32),
419 bottom: length(2.0_f32),
420 },
421 ..Default::default()
422 })
423 .fill(theme.bg_panel_alt)
424 .children(vec![close]);
425
426 View::new(Style {
427 flex_direction: FlexDirection::Column,
428 size: Size {
429 width: percent(1.0_f32),
430 height: percent(1.0_f32),
431 },
432 ..Default::default()
433 })
434 .fill(theme.bg_panel)
435 .children(vec![close_row, child])
436}
437
438/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
439/// completo, con barra de cierre — el editor del nudo abierto.
440pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
441 View::new(Style {
442 position: Position::Absolute,
443 inset: Rect {
444 left: auto(),
445 top: length(8.0_f32),
446 bottom: length(8.0_f32),
447 right: length(8.0_f32),
448 },
449 size: Size {
450 width: length(width),
451 height: auto(),
452 },
453 flex_direction: FlexDirection::Column,
454 ..Default::default()
455 })
456 .children(vec![editor_shell(child, theme)])
457}
458
459// =====================================================================
460// Color y texto
461// =====================================================================
462
463pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
464 let [r, g, b, _] = c.components;
465 Color::new([r, g, b, alpha])
466}
467
468/// Color estable de una cuerda: matices por ángulo áureo sobre el hue del
469/// accent — determinista por id, sin paleta que se agote.
470pub(crate) fn color_cuerda(id: CuerdaId, theme: Theme) -> Color {
471 rotate_hue(theme.accent, (id as f32 * 0.381_966) % 1.0)
472}
473
474pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
475 // RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
476 let [r, g, b, a] = c.components;
477 let max = r.max(g).max(b);
478 let min = r.min(g).min(b);
479 let v = max;
480 let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
481 let h = if (max - min).abs() < 1e-6 {
482 0.0
483 } else if max == r {
484 ((g - b) / (max - min)) % 6.0
485 } else if max == g {
486 (b - r) / (max - min) + 2.0
487 } else {
488 (r - g) / (max - min) + 4.0
489 };
490 let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
491 let c2 = v * s;
492 let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
493 let (r2, g2, b2) = match h2 as i32 {
494 0 => (c2, x, 0.0),
495 1 => (x, c2, 0.0),
496 2 => (0.0, c2, x),
497 3 => (0.0, x, c2),
498 4 => (x, 0.0, c2),
499 _ => (c2, 0.0, x),
500 };
501 let m = v - c2;
502 Color::new([r2 + m, g2 + m, b2 + m, a])
503}
504
505pub(crate) fn short_label(s: &str) -> String {
506 let s = if s.trim().is_empty() { "(sin título)" } else { s };
507 let mut out: String = s.chars().take(26).collect();
508 if s.chars().count() > 26 {
509 out.push('…');
510 }
511 out
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517 use crate::modelo::{Embedder, Model};
518
519 /// Modelo con dos cuerdas: «gorda» (3 nudos, vieja) y «flaca»
520 /// (1 nudo, recién crecida), más un suelto.
521 fn modelo() -> (Model, CuerdaId, CuerdaId) {
522 let mut m = Model::blank(Embedder::Local);
523 let gorda = m.store.crear_cuerda("gorda", 0);
524 for (i, t) in ["a", "b", "c"].iter().enumerate() {
525 let id = m.store.create(*t, "", vec![], 100 + i as u64);
526 m.order.push(id);
527 m.store.anudar(id, gorda);
528 }
529 let flaca = m.store.crear_cuerda("flaca", 0);
530 let id = m.store.create("d", "", vec![], 900);
531 m.order.push(id);
532 m.store.anudar(id, flaca);
533 let suelto = m.store.create("suelto", "", vec![], 950);
534 m.order.push(suelto);
535 (m, gorda, flaca)
536 }
537
538 #[test]
539 fn layout_ordena_por_masa_y_cronologia() {
540 let (m, gorda, flaca) = modelo();
541 let l = layout_cuerdas(&m, 600.0, 1_000);
542 assert_eq!(l.len(), 2);
543 // La gorda a la izquierda (más masa), la flaca después.
544 assert_eq!(l[0].id, gorda);
545 assert_eq!(l[1].id, flaca);
546 assert!(l[0].x < l[1].x);
547 assert!(l[0].grosor > l[1].grosor);
548 // La flaca creció más recién → arde más.
549 assert!(l[1].brillo > l[0].brillo);
550 // Nudos en cronología descendente hacia abajo.
551 let ys: Vec<f32> = l[0].nudos.iter().map(|n| n.y).collect();
552 assert_eq!(l[0].nudos.len(), 3);
553 assert!(ys.windows(2).all(|w| w[0] < w[1]));
554 // Los sueltos no cuelgan de la viga.
555 assert!(l.iter().all(|c| c.nudos.iter().all(|n| n.titulo != "suelto")));
556 }
557
558 #[test]
559 fn pick_encuentra_nudo_cabeza_y_nada() {
560 let (m, gorda, _) = modelo();
561 let l = layout_cuerdas(&m, 600.0, 1_000);
562 let (cx, ny) = (l[0].x, l[0].nudos[0].y);
563 assert!(matches!(pick(&m, cx, ny, 600.0), Pick::Nudo(_, c) if c == gorda));
564 // Sobre el nombre, entre viga y nudos: la cabeza.
565 assert!(matches!(pick(&m, cx, 50.0, 600.0), Pick::Cabeza(c) if c == gorda));
566 // Lejos de todo: nada.
567 assert!(matches!(pick(&m, 5_000.0, 300.0, 600.0), Pick::Nada));
568 }
569
570 #[test]
571 fn max_pan_crece_con_las_cuerdas() {
572 let (m, ..) = modelo();
573 // Dos cuerdas entran holgadas en 1280 px: sin pan.
574 assert_eq!(max_pan(&m, 1280.0), 0.0);
575 // En una ventana angosta sí hay tela que correr.
576 assert!(max_pan(&m, 200.0) > 0.0);
577 }
578}