tawasuyu

tawasuyu/khipu

Sovereign local-first P2P notes, content-addressed, in Rust.

Rust 31Markdown 18TOML 8YAML 1517 Definiciones

⎇ 48e5a861a0ccd73f813828603c35346697befb9d 00_unanchay/khipu/khipu-app/src/cuerdas.rs Rust · 578 líneas · 20.3 KB Historia Quién tocó qué crudo

chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0) 48e5a861a · Sergio ·

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}