chore: refresco desde el monorepo (pin tawasuyu.git v0.2.0)
Regenerado con scripts/actualizar-standalone.py: código del dominio al día, git-deps repineadas a v0.2.0 y doble-fuente resuelta con [patch] al source git. cargo check --workspace verde.
Delta semántico · 304
00_unanchay/khipu/khipu-app/src/app.rs
- nueva def INITIAL_H
- nueva def INITIAL_W
- nueva def KhipuApp
- nueva type Model
- nueva type Msg
- nueva fn app_id
- nueva fn apply_edit_menu_action
- nueva fn handle_menu_command
- nueva fn init
- nueva fn initial_size
- nueva fn on_key
- nueva fn split_captura
- nueva fn title
- nueva fn update
- nueva fn view
- nueva fn view_overlay
00_unanchay/khipu/khipu-app/src/cuerdas.rs
- nueva def CuerdaVis
- nueva def ESPACIO_CUERDA
- nueva def ESPACIO_NUDO_MAX
- nueva def FONDO_PAD
- nueva def MARGEN_X
- nueva def NUDOS_TOP
- nueva def NUDO_R
- nueva def NudoVis
- nueva def Pick
- nueva def TOL_NUDO
- nueva def VIGA_Y
- nueva def color_cuerda
- nueva def cuerdas_panel
- nueva def editor_shell
- nueva def layout_cuerdas
- nueva fn layout_ordena_por_masa_y_cronologia
- nueva def max_pan
- nueva fn max_pan_crece_con_las_cuerdas
- nueva fn modelo
- nueva def overlay_left
- nueva def overlay_right
- nueva fn paint_cuerdas
- nueva def pick
- nueva fn pick_encuentra_nudo_cabeza_y_nada
- nueva def rotate_hue
- nueva def short_label
- nueva mod tests
- nueva def with_alpha
00_unanchay/khipu/khipu-app/src/estado.rs
- nueva struct NoteStoreViejo
- nueva struct NoteStoreViejoW
- nueva struct NoteVieja
- nueva struct NoteViejaW
- nueva def PersistedState
- nueva struct PersistedV1
- nueva struct PersistedV2
- nueva struct PersistedV3
- nueva struct PersistedV3W
- nueva struct RegionVieja
- nueva struct RegionViejaW
- nueva def commit_edits
- nueva def data_file_path
- nueva def deselect
- nueva fn el_formato_v4_roundtripea_con_cuerdas
- nueva def embed_now
- nueva def emitir_checkpoint_nota
- nueva def es_sedimento
- nueva fn es_sedimento_respeta_el_umbral_y_las_cuerdas
- nueva def from_state
- nueva def khipu_dir
- nueva def load_state
- nueva fn migra_el_cuaderno_real_si_esta
- nueva fn migra_v3_conservando_ids_y_contenido_con_backup
- nueva fn migrar_store
- nueva def nombre_propuesto
- nueva fn nota_vieja
- nueva def now_secs
- nueva def parse_tags
- nueva def persist
- nueva def resonancia_de
- nueva fn respaldar_una_vez
- nueva def schedule_embedding
- nueva def seeded_model
- nueva def select
- nueva def start_unlock
- nueva mod tests
- nueva def unlock_identity
00_unanchay/khipu/khipu-app/src/main.rs
- borrada def CLUSTER_THRESHOLD
- borrada def EDITOR_OVERLAY_W
- borrada def EDITOR_VISIBLE_LINES
- borrada def EMBED_DIM
- borrada enum Embedder
- borrada def FIELD_LABEL_SIZE
- borrada enum Focus
- borrada def HEADER_H
- borrada struct KhipuApp
- borrada def LIST_WIDTH
- borrada struct Model
- borrada type Model
- borrada enum Msg
- borrada type Msg
- borrada struct P2p
- borrada struct PeerInfo
- borrada struct PersistedState
- borrada struct PersistedStateV1
- borrada struct PersistedStateV2
- borrada def ROW_H
- borrada struct Region
- borrada def ZOOM_INJECT
- nueva mod app
- borrada fn app_id
- borrada fn app_menu
- borrada fn apply_edit_menu_action
- borrada fn commit_edits
- borrada fn connect
- nueva mod cuerdas
- borrada fn current_mass
- borrada fn data_file_path
- borrada fn deselect
- borrada fn embed
- borrada fn embed_blocking
- borrada fn embed_now
- nueva mod estado
- borrada fn first_visible
- borrada fn focused_edit_flags
- borrada fn focused_editor
- borrada fn from_state
- borrada fn handle_menu_command
- borrada fn init
- borrada fn initial_size
- borrada fn khipu_dir
- borrada fn label
- borrada fn load_state
- borrada mod map
- nueva mod menu
- borrada fn menubar_spec
- nueva mod modelo
- borrada fn now_secs
- borrada fn on_key
- borrada fn parse_tags
- borrada fn persist
- borrada fn reinforce_and_touch
- borrada fn schedule_embedding
- borrada fn seeded_model
- borrada fn select
- borrada fn start_unlock
- borrada fn status_bar
- nueva mod tejido
- borrada fn title
- borrada fn unlock_identity
- borrada fn unlock_view
- borrada fn update
- borrada fn view
- borrada fn view_overlay
00_unanchay/khipu/khipu-app/src/map.rs
- borrada def GOLDEN_ANGLE
- borrada def MAP_MIN_SEP
- borrada def MapNode
- borrada def REGION_MATCH_DIST
- borrada def REGION_MIN_MEMBERS
- borrada def cluster_color
- borrada def editor_shell
- borrada def gravity_panel
- borrada def local_to_world
- borrada def name_region_chip
- borrada def naming_input
- borrada def node_card
- borrada def node_screen_pos
- borrada def overlay_left
- borrada def overlay_right
- borrada def paint_map
- borrada def pick_note
- borrada def pinned
- borrada def place_note
- borrada def rotate_hue
- borrada def short_label
- borrada def unnamed_cluster_centroids
- borrada def with_alpha
- borrada def world_screen
- borrada def world_to_local
00_unanchay/khipu/khipu-app/src/menu.rs
- nueva def app_menu
- nueva def focused_edit_flags
- nueva def focused_editor
- nueva def menubar_spec
00_unanchay/khipu/khipu-app/src/modelo.rs
- nueva def EDITOR_OVERLAY_W
- nueva def EDITOR_VISIBLE_LINES
- nueva def EMBED_DIM
- nueva def Embedder
- nueva def FIELD_LABEL_SIZE
- nueva def Focus
- nueva def HEADER_H
- nueva def LIST_WIDTH
- nueva def Model
- nueva def Msg
- nueva def P2p
- nueva def PeerInfo
- nueva def RESONANCIA_MAX
- nueva def ROW_H
- nueva def Resonancia
- nueva def SEDIMENTO_SECS
- nueva def SUELTOS_MAX
- nueva def UMBRAL_RESONANCIA_LOCAL
- nueva def UMBRAL_RESONANCIA_REMOTO
- nueva def blank
- nueva def connect
- nueva def embed_blocking
- nueva def label
- nueva def umbral_resonancia
00_unanchay/khipu/khipu-app/src/panels.rs
- nueva fn acciones_nudo
- cambió por dentro def body_field_view
- cambió por dentro def button
- nueva def captura_bar
- cambió por dentro def editor_panel
- cambió por dentro def header_view
- cambió por dentro def list_panel
- nueva def nombre_overlay
- cambió por dentro def receive_panel
- nueva def resonancia_panel
- cambió de firma def row_label
- cambió por dentro def stats_view
- nueva def status_bar
- nueva def sueltos_strip
- nueva def tarjeta_cuerda
- nueva def unlock_view
00_unanchay/khipu/khipu-app/src/tejido.rs
- nueva def MAX_TOKENS
- nueva def SYSTEM
- nueva fn fecha_corta
- nueva fn fecha_corta_maps_known_dates
- nueva def prompt_de
- nueva fn prompt_lists_nudos_chronologically_without_tejido
- nueva def tejer
- nueva mod tests
00_unanchay/khipu/khipu-core/src/lib.rs
- nueva mod nombre pub
00_unanchay/khipu/khipu-core/src/nombre.rs
- nueva def STOP
- nueva fn capitalize
- nueva fn is_stopword
- nueva fn note
- nueva fn note_tokens
- nueva fn propose_cuerda_name pub
- nueva fn propose_ignores_stopwords_and_short_tokens
- nueva fn propose_is_deterministic_on_ties
- nueva fn propose_picks_the_shared_term
- nueva fn propose_prefers_shared_over_merely_frequent
- nueva mod tests
00_unanchay/khipu/khipu-core/src/note.rs
- nueva struct Cuerda pub
- nueva type CuerdaId pub
- nueva enum EstadoNudo pub
- cambió de firma struct Note pub
- nueva struct Origen pub
- nueva fn new_notes_are_loose_by_default
- cambió por dentro fn note
- nueva fn suelto pub
00_unanchay/khipu/khipu-core/src/store.rs
- cambió de firma struct NoteStore pub
- nueva fn anudar pub
- nueva fn anudar_moves_between_cuerdas
- nueva fn anudar_rejects_missing_note_or_cuerda
- nueva fn borrar_cuerda pub
- nueva fn borrar_cuerda_frees_its_nudos
- nueva fn coherente pub
- nueva fn coherente_detects_dangling_cuerda
- nueva fn crear_cuerda pub
- nueva fn crear_cuerda_and_anudar
- cambió por dentro fn create pub
- cambió por dentro fn create_initializes_mass_and_last_access
- nueva fn cuerda pub
- nueva fn cuerda_mut pub
- nueva fn cuerdas pub
- nueva fn cuerdas_len pub
- nueva fn desanudar pub
- nueva fn desanudar_frees_the_note
- nueva fn desanudar_tejido_clears_the_pointer
- nueva fn insert_raw pub
- nueva fn insert_raw_preserves_ids_and_bumps_counter
- cambió por dentro fn new pub
- nueva fn nudos_de pub
- nueva fn nudos_de_sorts_chronologically
- nueva fn one
- cambió por dentro fn remove pub
- nueva fn removing_tejido_clears_the_cuerda_pointer
- borrada fn set_mass_persists_the_value
- borrada fn set_pos pub
- borrada fn set_pos_anchors_the_note_once
- nueva fn sueltos pub
- nueva fn sueltos_lists_newest_first
- borrada fn update_body_also_marks_last_access
00_unanchay/khipu/khipu-gravity/src/lib.rs
- nueva def ANCHOR_GOLDEN_ANGLE
- nueva def ANCHOR_MIN_SEP pub
- nueva def FIELD_MODEL_NAME
- nueva def FRESCURA_MEDIA_VIDA_SECS pub
- cambió de firma struct SemanticField pub
- nueva fn anchor_new pub
- cambió por dentro fn clusters pub
- nueva mod cuerda_tests
- nueva fn etiqueta_distinta_no_mezcla_espacios
- nueva fn frescura pub
- nueva fn frescura_handles_clock_skew_and_bad_half_life
- nueva fn frescura_is_full_at_growth_and_halves_at_half_life
- nueva fn frescura_stays_in_unit_range_long_term
- cambió por dentro fn gravity_layout pub
- cambió por dentro fn insert pub
- nueva fn insert_etiquetado pub
- cambió por dentro fn is_empty pub
- cambió por dentro fn iter pub
- cambió por dentro fn len pub
- nueva fn local_embed pub
- nueva fn masa_cuerda pub
- nueva fn masa_cuerda_grows_with_diminishing_returns
- cambió por dentro fn nearest pub
- cambió por dentro fn remove pub
- cambió por dentro fn vector_of
35 archivos · +7150 −3772
- 00_unanchay/khipu/LEEME.md +43 −45
- 00_unanchay/khipu/README.md +57 −11
- 00_unanchay/khipu/README.qu.md +10 −9
- 00_unanchay/khipu/khipu-app/Cargo.toml +9 −2
- 00_unanchay/khipu/khipu-app/LEEME.md +11 −5
- 00_unanchay/khipu/khipu-app/README.md +9 −5
- 00_unanchay/khipu/khipu-app/src/app.rs +1025 −0
- 00_unanchay/khipu/khipu-app/src/cuerdas.rs +578 −0
- 00_unanchay/khipu/khipu-app/src/estado.rs +664 −0
- 00_unanchay/khipu/khipu-app/src/main.rs +37 −1915
- 00_unanchay/khipu/khipu-app/src/map.rs +0 −655
- 00_unanchay/khipu/khipu-app/src/menu.rs +149 −0
- 00_unanchay/khipu/khipu-app/src/modelo.rs +441 −0
- 00_unanchay/khipu/khipu-app/src/net.rs +3 −2
- 00_unanchay/khipu/khipu-app/src/panels.rs +830 −124
- 00_unanchay/khipu/khipu-app/src/tejido.rs +139 −0
- 00_unanchay/khipu/khipu-brahman/Cargo.toml +0 −1
- 00_unanchay/khipu/khipu-brahman/LEEME.md +28 −0
- 00_unanchay/khipu/khipu-brahman/README.md +12 −0
- 00_unanchay/khipu/khipu-core/LEEME.md +17 −9
- 00_unanchay/khipu/khipu-core/README.md +15 −9
- 00_unanchay/khipu/khipu-core/src/lib.rs +16 −10
- 00_unanchay/khipu/khipu-core/src/nombre.rs +143 −0
- 00_unanchay/khipu/khipu-core/src/note.rs +84 −19
- 00_unanchay/khipu/khipu-core/src/store.rs +317 −44
- 00_unanchay/khipu/khipu-gravity/Cargo.toml +7 −2
- 00_unanchay/khipu/khipu-gravity/LEEME.md +11 −8
- 00_unanchay/khipu/khipu-gravity/README.md +9 −8
- 00_unanchay/khipu/khipu-gravity/src/lib.rs +300 −38
- 00_unanchay/khipu/khipu-share/LEEME.md +37 −0
- 00_unanchay/khipu/khipu-share/README.md +11 −0
- 00_unanchay/khipu/khipu-share/src/lib.rs +1 −1
- 00_unanchay/khipu/pantallazo.png +— −—
- Cargo.lock +1811 −722
- Cargo.toml +326 −128
diff --git a/00_unanchay/khipu/LEEME.md b/00_unanchay/khipu/LEEME.md
index ebc6d31..8d73d00 100644
--- a/00_unanchay/khipu/LEEME.md
+++ b/00_unanchay/khipu/LEEME.md
@@ -1,78 +1,76 @@
# khipu
-> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
+> `khipu` (quechua: nudos de cuerdas para registrar memoria). El cuaderno de resonancia: las ideas se refuerzan entre sí, no se olvidan.
-Captura de notas rápidas donde el olvido es parte del modelo: cada nota tiene una masa que decae con el tiempo y se refuerza con cada acceso. Lo recurrente queda visible; lo que no se vuelve a tocar se va difuminando hasta caer del horizonte.
+Un lugar donde botar ideas sueltas en cualquier estado mental — pensamientos, TODOs, citas — y que la estructura **emerja de la recurrencia** en vez de imponerse por adelantado. La inversa de un gestor de notas con carpetas y la inversa del khipu viejo (que olvidaba por decay): aquí cada idea que vuelve **refuerza** a las que ya estaban. El objetivo es cerrar las fugas cerebrales: la idea que se te va y vuelves a pensar desde cero.
-## Instalación
-
-```sh
-cargo run --release -p khipu-app
-```
+## El modelo: nudos, cuerdas, tejido
-## Compatibilidad
+- **Nudo** — la unidad de captura. Escribes, Enter, listo: cero organización en el momento. Un nudo puede marcarse pendiente/hecho (TODO sin due-dates) y lleva estampa de origen si vino de otra app (fase 2: chasqui, pata, pacha).
+- **Resonancia** — al capturar, khipu busca los nudos previos afines (embeddings) y te los muestra ahí mismo: *«esto resuena con…»*. Un click anuda el nudo nuevo a la cuerda del vecino, o crea una cuerda nueva con ambos. Ese instante es el cierre de la fuga.
+- **Cuerda** — una idea recurrente. No se crea por adelantado: nace cuando dos nudos resuenan. Se bautiza con un nombre propuesto del contenido (editable). Su **masa** crece con cada nudo; su **brillo**, con la frescura del último crecimiento. Nada decae: lo que no resuena **sedimenta** (queda abajo, buscable, sin estorbar).
+- **Tejido** — cuando una cuerda engorda pero la idea sigue difusa, «tejer» le pide al LLM un encuadre de sus nudos: *qué se repite, qué ya está claro, qué falta decidir*. El resultado es un nudo-síntesis **editable** al final de la cuerda (rombo ◈); re-tejer lo reemplaza. Es la función para la que khipu existe: el caso canónico fue la idea de khipu mismo.
-- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
-- Persistencia local en `$XDG_DATA_HOME/khipu/`.
+## La ventana es un khipu
-## Crates
+Captura siempre lista arriba; franja de sueltos frescos; y el lienzo: una viga de la que cuelgan las cuerdas ordenadas por masa (la más gorda a la izquierda), con sus nudos en cronología. Pendiente = anillo hueco, hecho = relleno tenue, tejido = rombo. Click en un nudo abre el editor (overlay derecho); click en la cabeza de una cuerda abre su tarjeta (tejer / renombrar / desatar). `☰ nudos` abre el cajón con búsqueda; el sedimento vive ahí. `Esc` cierra en cascada. Pan con arrastre o rueda.
-| Crate | Rol |
-|---|---|
-| [`khipu-core`](khipu-core/README.md) | Modelo de nota + store; sin UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Algoritmo de masa/decay; refuerzo por acceso. |
-| `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
-| `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
-| [`khipu-app`](khipu-app/README.md) | UI Llimphi sobre el core. |
+## Instalación
-## Gravedad semántica (embeddings)
+```sh
+cargo run --release -p khipu-app
+```
-El canvas de la derecha agrupa las notas por afinidad. Los vectores salen del `verbo-daemon` si está corriendo; si no, de un hash-trigram local.
+### Embeddings reales (resonancia semántica de verdad)
```sh
-# Embeddings reales (clústeres y vecinos semánticos de verdad):
cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # escucha en $XDG_RUNTIME_DIR/verbo.sock
cargo run --release -p khipu-app # lo detecta solo al arrancar
```
-Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline, idéntico al comportamiento histórico. El cálculo nunca bloquea la UI: viaja a un worker y reentra al bucle cuando termina. Si el espacio vectorial cambia entre dos arranques (arrancó/cayó el daemon, otro modelo), los vectores se recalculan automáticamente.
+Sin daemon, khipu cae al embebedor trigram de 16d — determinista y offline. Si el espacio vectorial cambia entre dos arranques, los vectores se recalculan solos.
-## Compartir (agora)
-
-`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
+### Tejido (LLM)
-La identidad vive **cifrada** (Argon2id + ChaCha20-Poly1305, vía `agora-keystore`) en `<datos>/keys/` — la semilla privada nunca queda en claro en disco. Al primer intento de compartir, khipu pide una passphrase: la primera vez la crea, después la usa para descifrar. `KHIPU_PASSPHRASE` en el entorno desbloquea sin prompt (headless). Una `identidad.seed` en claro de versiones viejas se migra al keystore (y se borra el claro) automáticamente. Comparte **lo que el buscador esté filtrando** (vacío = todo el cuaderno), así que escribir en la búsqueda y exportar manda sólo ese subconjunto. `importar` verifica firma + hash y, si cuadra, ingiere las notas, marcándolas con una etiqueta de procedencia `de:<autor>` (visible en el editor como «✎ de: …»).
+El backend sale de `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, o `PLUMA_LLM_BACKEND` explícito. **Sin credenciales cae a Mock** — el botón siempre responde, aunque sea un eco de prueba.
-Lo que viaja es el **contenido** (título, cuerpo, etiquetas), nunca la física temporal: al importar, cada nota nace fresca (masa plena, acceso = ahora) — su gravedad arranca en el cuaderno que la recibe. Los wiki-links `[[Título]]` se rearman solos porque khipu resuelve enlaces por título. Reimportar el mismo sobre no duplica (se omiten títulos ya presentes). Un sobre alterado o con firma ajena se rechaza entero, sin autoridad central.
+## Persistencia y migración
-Para compartir en vivo sin copiar archivos: `publicar` levanta un servidor TCP que sirve el cuaderno (puerto `KHIPU_BIND`, default `127.0.0.1:7700`) **y anuncia una baliza UDP** para que lo descubran en la LAN. `recibir` abre un panel con un **campo de dirección** (`host:puerto`, prellenado y editable) y, debajo, los **pares descubiertos en la LAN** (nombre · autor · dirección): click en uno para jalarle el cuaderno, o escribí una dirección y «jalar». El transporte es `std::net` puro y **no necesita ser confiable** — el receptor verifica firma + hash antes de ingerir; la baliza sólo dice *dónde*, no *qué*.
+`$XDG_DATA_HOME/khipu/notes.bin` (postcard, formato V4: nudos + cuerdas). Los cuadernos del khipu viejo (V1–V3, el del mapa gravitatorio) **migran solos** al abrir: las notas conservan id y contenido, nacen sueltas (las cuerdas se anudan de ahí en más), y el archivo original queda respaldado en `notes.v3.bak`.
-**WAN / libp2p**: el campo de dirección de `recibir` acepta dos formas, autodetectadas: `host:puerto` (TCP directo) o una **multiaddr libp2p** —directa `/ip4/…/p2p/<id>` o de circuito `/ip4/…/p2p/<relay>/p2p-circuit/p2p/<id>`. Al `publicar`, khipu sirve por libp2p (stream cifrado Noise sobre `BrahmanNet`, protocolo `/khipu/sobre/1.0.0`, vía `khipu-brahman`) y muestra tu dirección de marcado.
+## Crates
-**NAT traversal**: `BrahmanNet` ahora trae **Circuit Relay v2 + DCUtR** (`card-net`). Un nodo alcanzable hace de relay; uno detrás de NAT reserva un circuito ahí y queda accesible vía la dirección de circuito. Configurá `KHIPU_RELAY=/ip4/…/tcp/…/p2p/<relay-id>` antes de `publicar` y khipu reserva el circuito y muestra la dirección para compartir. Las direcciones externas no se confían a ciegas: **AutoNAT** las confirma pidiendo dial-backs a otros peers, y sólo las confirmadas se anuncian (y entran en las reservas de relay). En la malla Brahman AutoNAT corre con `only_global_ips=false` para confirmar también en LAN/loopback.
+| Crate | Rol |
+|---|---|
+| [`khipu-core`](khipu-core/README.md) | Nudos + cuerdas + grafo de enlaces + propuesta de nombre; sin UI. |
+| [`khipu-gravity`](khipu-gravity/README.md) | Las físicas puras: campo semántico (resonancia), masa/frescura de cuerda, y el decay temporal (que ya no usa la app — lo consumen pata e iniy-derive). |
+| `khipu-share` | Sobres de notas firmados (Ed25519) y direccionados por contenido (BLAKE3) sobre agora; transporte TCP/LAN + descubrimiento UDP + identidad cifrada. |
+| `khipu-brahman` | Transporte de sobres sobre libp2p (BrahmanNet): stream cifrado + descubrimiento por DHT. |
+| [`khipu-app`](khipu-app/README.md) | UI Llimphi: captura, lienzo de cuerdas, resonancia, tejido. |
-**Descubrimiento por DHT**: con `KHIPU_BOOTSTRAP=/ip4/…/p2p/<id>` (un nodo de la malla), khipu se une a la DHT Kademlia al arrancar; `publicar` se anuncia bajo la clave khipu y `recibir` lista —además de los pares LAN— los pares hallados por DHT (filas `DHT · …<id>`), que se jalan por peer-id. Así dos khipu se encuentran sin compartir IP ni multiaddr a mano, sólo conociendo un bootstrap común. Verificado end-to-end en localhost (rendezvous + publicador + receptor; 4 tests en `khipu-brahman`).
+## Compartir (agora)
-La lógica vive en `khipu-share`: `net` (transporte TCP) y `discovery` (baliza UDP). 15 tests + un test de integración que recorre la cadena completa descubrir→jalar→verificar en loopback.
+Sin cambios de fondo respecto del khipu viejo — viaja el **contenido** (título, cuerpo, etiquetas), nunca la estructura local: al importar, cada nota nace suelta y fresca; las cuerdas son atención de quien las anudó y no se transfieren. `exportar` sella un sobre firmado Ed25519 direccionado por BLAKE3 (filtrado por la búsqueda si hay texto); `importar` verifica firma + hash, deduplica por título y marca procedencia `de:<autor>`. `publicar`/`recibir`: TCP + baliza UDP en LAN, y libp2p (Noise sobre BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, DHT Kademlia) para WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` como siempre. La identidad vive cifrada (Argon2id + ChaCha20-Poly1305) en `<datos>/keys/`.
-## Estado (2026-05-31)
+## Estado (2026-07-13)
### Hecho
-- `khipu-core` (modelo de nota + store) + `khipu-gravity` (masa/decay con refuerzo por acceso).
-- `khipu-app`: UI Llimphi sobre el core, con menú principal y contextual.
-- Gravedad semántica: clustering por embeddings del `verbo-daemon` (rimay), con fallback trigram 16d offline; cálculo en worker que no bloquea la UI.
-- Compartir vía agora (`khipu-share`): sobres firmados Ed25519 + direccionados BLAKE3, identidad cifrada (Argon2id + ChaCha20-Poly1305 en keystore), compartir selectivo + procedencia del autor; transporte TCP/LAN + descubrimiento por baliza UDP (15 tests + integración loopback).
-- WAN/P2P (`khipu-brahman` sobre libp2p/BrahmanNet): stream cifrado Noise, NAT traversal (Circuit Relay v2 + DCUtR), AutoNAT, descubrimiento por DHT Kademlia (4 tests e2e localhost).
+- **Rediseño completo**: se invirtió la física (resonancia en vez de olvido) y murió el mapa gravitatorio como interfaz. Vista raíz = captura + cuerdas.
+- `khipu-core`: modelo nudo/cuerda (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store con anudar/desanudar/sueltos, propuesta de nombre.
+- `khipu-gravity`: `masa_cuerda` + `frescura` (puras); el decay temporal sigue para sus otros consumidores.
+- `khipu-app`: lienzo de cuerdas (painter vello + hit-test + pan), panel de resonancia al capturar, sedimentación, tejido LLM vía `pluma-llm`, migración V1–V3 → V4 con backup.
+- Compartir agora/libp2p intacto (19 tests + 4 e2e).
-### Pendiente
+### Pendiente (fase 2)
-- Sincronización bidireccional / resolución de conflictos entre cuadernos (hoy es import unidireccional de sobres).
-- Transferir física temporal opcional al compartir (hoy el contenido nace fresco en el receptor — decisión de diseño, no bug).
-- Endurecimiento de la malla DHT en WAN real (probado en localhost/LAN).
+- **Captura global**: hotkey de mirada → popup de captura; puente chasqui (correo → nudo con `origen`); estampa de pacha/workspace. El modelo ya trae el campo `origen`.
+- Unificar la captura rápida de la barra de pata (hoy store propio `pata-quick.json`) con el cuaderno de khipu.
+- Resonancia entre cuerdas (sugerir fusión de cuerdas gemelas).
+- Sincronización bidireccional / conflictos entre cuadernos (hoy import unidireccional).
## Consideraciones
-- **No es un sistema de "todo"** — no hay due-dates ni recordatorios; es un cuaderno con física propia.
-- El decay es transparente: cada nota expone su masa actual; el usuario decide si la salva.
-- La gravedad es local y no transferible: compartir mueve el contenido, no la atención.
+- **Los TODOs son nudos** — expresión mental como cualquier otra; sin due-dates ni recordatorios.
+- **Nada se borra solo**: el sedimento es reposo, no papelera. Borrar es siempre un gesto explícito.
+- La cuerda es estructura, no contenido: desatarla suelta los nudos, no los pierde.
diff --git a/00_unanchay/khipu/README.md b/00_unanchay/khipu/README.md
index f85f02e..b2283d6 100644
--- a/00_unanchay/khipu/README.md
+++ b/00_unanchay/khipu/README.md
@@ -1,8 +1,19 @@
# khipu
-> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
+> `khipu` (Quechua: knotted cord recordkeeping). The resonance notebook: ideas reinforce each other instead of being forgotten.
-Quick-note capture where forgetting is part of the model: each note has a mass that decays with time and reinforces with each access. What's recurrent stays visible; what isn't touched fades until it falls off the horizon.
+A place to dump loose ideas in any mental state — thoughts, TODOs, quotes — where structure **emerges from recurrence** instead of being imposed up front. The inverse of a folder-based note manager, and the inverse of the old khipu (which forgot by decay): here every idea that comes back **reinforces** the ones already there. The goal is closing brain leaks: the idea that slips away and gets re-thought from scratch.
+
+## The model: knots, cords, weaving
+
+- **Knot (nudo)** — the capture unit. Type, Enter, done: zero organizing in the moment. A knot can be marked pending/done (TODOs without due-dates) and carries an origin stamp when it comes from another app (phase 2: chasqui, pata, pacha).
+- **Resonance** — on capture, khipu finds affine previous knots (embeddings) and shows them right there: *"this resonates with…"*. One click ties the new knot to the neighbor's cord, or spins a new cord from both. That instant is the leak being closed.
+- **Cord (cuerda)** — a recurrent idea. Never created ahead of time: it's born when two knots resonate. It's christened with a name proposed from the content (editable). Its **mass** grows with each knot; its **glow**, with how recently it last grew. Nothing decays: what never resonates **settles as sediment** (down below, searchable, out of the way).
+- **Weave (tejido)** — when a cord fattens but the idea stays diffuse, «tejer» asks the LLM for a framing of its knots: *what keeps repeating, what's already clear, what's left to decide*. The result is an **editable** synthesis-knot at the end of the cord (diamond ◈); re-weaving replaces it. This is the function khipu exists for — the canonical case was the idea of khipu itself.
+
+## The window is a khipu
+
+Capture always ready on top; a strip of fresh loose knots; and the canvas: a beam from which the cords hang ordered by mass (fattest on the left), knots in chronology. Pending = hollow ring, done = faint fill, weave = diamond. Click a knot to open the editor (right overlay); click a cord's head to open its card (weave / rename / untie). `☰ nudos` opens the searchable drawer; sediment lives there. `Esc` closes in cascade. Pan by drag or wheel.
## Install
@@ -10,21 +21,56 @@ Quick-note capture where forgetting is part of the model: each note has a mass t
cargo run --release -p khipu-app
```
-## Compatibility
+### Real embeddings (true semantic resonance)
+
+```sh
+cargo run -p rimay-verbo-daemon-bin -- --provider fastembed # listens on $XDG_RUNTIME_DIR/verbo.sock
+cargo run --release -p khipu-app # auto-detects it on startup
+```
+
+Without the daemon, khipu falls back to the 16d trigram embedder — deterministic and offline. If the vector space changes between two launches, vectors are recomputed automatically.
+
+### Weaving (LLM)
-- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
-- Local persistence in `$XDG_DATA_HOME/khipu/`.
+The backend comes from `pluma-llm::from_env()`: `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` / `COHERE_API_KEY` / Ollama, or an explicit `PLUMA_LLM_BACKEND`. **Without credentials it falls back to Mock** — the button always answers, even if it's a test echo.
+
+## Persistence and migration
+
+`$XDG_DATA_HOME/khipu/notes.bin` (postcard, V4 format: knots + cords). Notebooks from the old khipu (V1–V3, the gravity map) **migrate automatically** on open: notes keep id and content, are born loose (cords get tied from then on), and the original file is backed up as `notes.v3.bak`.
## Crates
| Crate | Role |
|---|---|
-| [`khipu-core`](khipu-core/README.md) | Note model + store; no UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Mass/decay algorithm; reinforcement on access. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI over the core. |
+| [`khipu-core`](khipu-core/README.md) | Knots + cords + link graph + name proposal; no UI. |
+| [`khipu-gravity`](khipu-gravity/README.md) | The pure physics: semantic field (resonance), cord mass/freshness, and the temporal decay (no longer used by the app — consumed by pata and iniy-derive). |
+| `khipu-share` | Note envelopes, Ed25519-signed and content-addressed (BLAKE3) over agora; TCP/LAN transport + UDP discovery + encrypted identity. |
+| `khipu-brahman` | Envelope transport over libp2p (BrahmanNet): encrypted stream + DHT discovery. |
+| [`khipu-app`](khipu-app/README.md) | Llimphi UI: capture, cord canvas, resonance, weaving. |
+
+## Sharing (agora)
+
+Unchanged at its core from the old khipu — what travels is the **content** (title, body, tags), never the local structure: imported notes are born loose and fresh; cords are the attention of whoever tied them and don't transfer. `export` seals a signed envelope (filtered by the search box if non-empty); `import` verifies signature + hash, dedups by title and marks provenance `de:<author>`. `publicar`/`recibir`: TCP + UDP beacon on LAN, and libp2p (Noise over BrahmanNet, Circuit Relay v2 + DCUtR + AutoNAT, Kademlia DHT) for WAN — `KHIPU_BIND`, `KHIPU_RELAY`, `KHIPU_BOOTSTRAP`, `KHIPU_PASSPHRASE` as always. The identity lives encrypted (Argon2id + ChaCha20-Poly1305) in `<data>/keys/`.
+
+## Status (2026-07-13)
+
+### Done
+
+- **Full redesign**: the physics was inverted (resonance instead of forgetting) and the gravity map died as the interface. Root view = capture + cords.
+- `khipu-core`: knot/cord model (`Note.cuerda/estado/origen`, `Cuerda.tejido`), store with tie/untie/loose, name proposal.
+- `khipu-gravity`: `masa_cuerda` + `frescura` (pure); temporal decay stays for its other consumers.
+- `khipu-app`: cord canvas (vello painter + hit-test + pan), resonance panel on capture, sedimentation, LLM weaving via `pluma-llm`, V1–V3 → V4 migration with backup.
+- Sharing via agora/libp2p intact (19 tests + 4 e2e).
+
+### Pending (phase 2)
+
+- **Global capture**: mirada hotkey → capture popup; chasqui bridge (mail → knot with `origen`); pacha/workspace stamp. The model already carries the `origen` field.
+- Unify pata's quick-capture bar (today its own `pata-quick.json` store) with the khipu notebook.
+- Cord-to-cord resonance (suggest merging twin cords).
+- Bidirectional sync / conflict resolution between notebooks (today import is unidirectional).
## Considerations
-- **It's not a "todo" system** — no due dates, no reminders; it's a notebook with its own physics.
-- Decay is transparent: each note shows its current mass; the user decides whether to save it.
-- Plays well with the [agora](../../03_ukupacha/agora/README.md) network: notes can be shared without losing their local gravity.
+- **TODOs are knots** — mental expression like any other; no due dates, no reminders.
+- **Nothing deletes itself**: sediment is rest, not a trash can. Deleting is always an explicit gesture.
+- The cord is structure, not content: untying it frees the knots, it doesn't lose them.
diff --git a/00_unanchay/khipu/README.qu.md b/00_unanchay/khipu/README.qu.md
index 0f2f69a..411863f 100644
--- a/00_unanchay/khipu/README.qu.md
+++ b/00_unanchay/khipu/README.qu.md
@@ -2,9 +2,9 @@
# khipu
-> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
+> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Yuyaykunaq tinkunan qillqana: yuyaykuna purapura kallpachanakun, manan qunqakunchu.
-Usqhay nota hap'iy. Qunqaytaqa modelopa ukhunpi: sapanka nota llasayuq, pachawan miran, sapa kuti rikuptin kallpachayuq. Sapa kuti rikuq nota qhipanqa; mana takyaqkuna chinkanqaku, ñawi hawamanta wikch'urunqakama.
+Yuyaykunata usqhaylla wikch'uy, ima sunqu-kaypipas. Musuq yuyay ñawpaq yuyaykunawan tinkuptin, khipu rikuchisunki — chaymanta **wask'a** (cuerda) paqarin: kutimuq yuyay chayta wiñachin, llasachan. Manan imapas chinkanchu: mana tinkuq yuyaykuna allpaman tiyaykun (maskhaypi tarikullanqapuni). Wask'a wirayaptin, «away» (tejido) LLM-wan huk qhawariyta awan: imas kutimun, imas sut'iña, imas faltashan.
## Churay
@@ -15,18 +15,19 @@ cargo run --release -p khipu-app
## Tinkuy
- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 `winit` patapi).
-- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi.
+- Allpa-yana waqaychay `$XDG_DATA_HOME/khipu/`-pi (ñawpaq khipuq qillqankuna kikinmanta migrasqa, `notes.v3.bak` copiayuq).
## Crateskuna
| Crate | Ima ruwan |
|---|---|
-| [`khipu-core`](khipu-core/README.md) | Nota modelo + waqaychay; mana UI. |
-| [`khipu-gravity`](khipu-gravity/README.md) | Llasay/chinkay algoritmo, rikuq kallpachay. |
-| [`khipu-app`](khipu-app/README.md) | Llimphi UI core patapi. |
+| [`khipu-core`](khipu-core/README.md) | Nudo + wask'a modelo, waqaychay; mana UI. |
+| [`khipu-gravity`](khipu-gravity/README.md) | Físicakuna: tinkuy (resonancia), wask'a llasay/musuq-kay. |
+| `khipu-share` / `khipu-brahman` | Firmasqa sobrekuna agora-wan, libp2p-wan willanakuy. |
+| [`khipu-app`](khipu-app/README.md) | Llimphi UI: hap'iy, wask'a lienzo, tinkuy, away. |
## Yuyaykunaq
-- **Manan "todo" sistemachu** — mana fechakuna, mana willaqkuna; pi-físikayuq qillqana kanmi.
-- Chinkayqa rikukullan: sapanka nota llasayninta rikuchin; runa salvanaqa rikun.
-- [agora](../../03_ukupacha/agora/README.md) ayllu-redwan tinkuy atin: nota willanakuyqa mana lokal llasayninmanta hark'aqasqa.
+- **TODOkunapas nudokunam** — mana fechakuna, mana willaqkuna.
+- Manan imapas sapallanmanta chinkanchu: allpaqa samaymi, manan wikch'unachu.
+- Wask'aqa awasqa kaymi, manan qillqachu: paskaptiyki nudokunaqa qhipakullanku.
diff --git a/00_unanchay/khipu/khipu-app/Cargo.toml b/00_unanchay/khipu/khipu-app/Cargo.toml
index 7d2580b..de09504 100644
--- a/00_unanchay/khipu/khipu-app/Cargo.toml
+++ b/00_unanchay/khipu/khipu-app/Cargo.toml
@@ -6,7 +6,7 @@ rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
-description = "khipu-app — cuaderno de notas sobre Llimphi: lista editable in-situ + canvas de gravedad semántica con persistencia en $XDG_DATA_HOME/khipu/."
+description = "khipu-app — cuaderno de resonancia sobre Llimphi: captura + lienzo de cuerdas (ideas recurrentes con nudos cronológicos) + tejido LLM, con persistencia en $XDG_DATA_HOME/khipu/."
[[bin]]
name = "khipu-app"
@@ -23,13 +23,15 @@ khipu-share = { path = "../khipu-share" }
khipu-brahman = { path = "../khipu-brahman" }
agora-core = { workspace = true }
rimay-verbo = { workspace = true }
+# El tejido: la síntesis de una cuerda la redacta el LLM que haya en el
+# entorno (fachada transparente; Mock sin credenciales).
+pluma-llm = { workspace = true }
tokio = { workspace = true }
llimphi-ui = { workspace = true }
llimphi-theme = { workspace = true }
llimphi-widget-list = { workspace = true }
llimphi-widget-text-editor = { workspace = true }
llimphi-widget-text-input = { workspace = true }
-llimphi-widget-tiled = { workspace = true }
llimphi-widget-menubar = { workspace = true }
llimphi-widget-edit-menu = { workspace = true }
llimphi-widget-context-menu = { workspace = true }
@@ -39,3 +41,8 @@ app-bus = { workspace = true }
directories = { workspace = true }
postcard = { workspace = true }
serde = { workspace = true }
+# Timeline universal (#6): al capturar un nudo se emite un checkpoint cuyo raíz
+# es el BLAKE3 del contenido serializado de la nota.
+blake3 = { workspace = true }
+willay-checkpoint = { workspace = true }
+willay-emit = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-app/LEEME.md b/00_unanchay/khipu/khipu-app/LEEME.md
index 0cf0ecf..c5affec 100644
--- a/00_unanchay/khipu/khipu-app/LEEME.md
+++ b/00_unanchay/khipu/khipu-app/LEEME.md
@@ -1,17 +1,23 @@
# khipu-app
-> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
+*Read this in English: [README.md](README.md).*
-App de escritorio: lista de notas ordenadas por masa actual, editor inline (Markdown ligero), captura rápida (`Ctrl+N`), búsqueda fuzzy. Cada redibujo recalcula masa con [`khipu-gravity`](../khipu-gravity/README.md) y muestra notas con `mass > umbral`; las que cayeron se acceden por menú "archivo".
+> UI Llimphi del cuaderno de resonancia [khipu](../LEEME.md). Binario del usuario.
+
+La ventana es un khipu: **captura** siempre lista arriba (Enter bota el nudo), franja de **sueltos** frescos, y el lienzo de **cuerdas** colgadas de la viga — grosor por masa, brillo por frescura, nudos en cronología, tejido ◈ al final. Al capturar, el panel de **resonancia** ofrece anudar con lo que ya pensaste; **tejer** (tarjeta de cuerda o menú Cuerda) pide al LLM el encuadre de la idea. Lista y editor son overlays (`☰ nudos`, click en un nudo); `Esc` cierra en cascada; `Ctrl+N` captura, `Ctrl+F` busca.
## Uso
```sh
cargo run --release -p khipu-app
+# resonancia semántica real: cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
+# tejido con LLM real: ANTHROPIC_API_KEY=… (o Gemini/DeepSeek/Cohere/Ollama; sin nada → Mock)
```
+Persistencia: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); los cuadernos del khipu viejo migran solos con backup `notes.v3.bak`.
+
## Deps
-- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
-- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
-- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) para preferencias compartidas
+- [`khipu-core`](../khipu-core/LEEME.md), [`khipu-gravity`](../khipu-gravity/LEEME.md), `khipu-share`, `khipu-brahman`
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
+- `pluma-llm` (el tejido), `rimay-verbo` (embeddings), `agora-core` (identidad), `willay-*` (timeline)
diff --git a/00_unanchay/khipu/khipu-app/README.md b/00_unanchay/khipu/khipu-app/README.md
index afb78d4..3d5b905 100644
--- a/00_unanchay/khipu/khipu-app/README.md
+++ b/00_unanchay/khipu/khipu-app/README.md
@@ -1,17 +1,21 @@
# khipu-app
-> Llimphi UI over the core of [khipu](../README.md). The user binary.
+> Llimphi UI of the [khipu](../README.md) resonance notebook. The user binary.
-Desktop app: list of notes sorted by current mass, inline editor (lightweight Markdown), quick capture (`Ctrl+N`), fuzzy search. Each redraw recomputes mass via [`khipu-gravity`](../khipu-gravity/README.md) and shows notes with `mass > threshold`; fallen ones are accessed via the "archive" menu.
+The window is a khipu: **capture** always ready on top (Enter drops the knot), a strip of fresh **loose knots**, and the **cord canvas** hanging from the beam — thickness by mass, glow by freshness, knots in chronology, weave ◈ at the end. On capture, the **resonance** panel offers to tie with what you already thought; **weave** (cord card or the Cuerda menu) asks the LLM for the idea's framing. List and editor are overlays (`☰ nudos`, click a knot); `Esc` closes in cascade; `Ctrl+N` captures, `Ctrl+F` searches.
## Usage
```sh
cargo run --release -p khipu-app
+# real semantic resonance: cargo run -p rimay-verbo-daemon-bin -- --provider fastembed
+# real LLM weaving: ANTHROPIC_API_KEY=… (or Gemini/DeepSeek/Cohere/Ollama; nothing → Mock)
```
+Persistence: `$XDG_DATA_HOME/khipu/notes.bin` (postcard V4); old-khipu notebooks migrate automatically with a `notes.v3.bak` backup.
+
## Deps
-- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md)
-- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`
-- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/) for shared prefs
+- [`khipu-core`](../khipu-core/README.md), [`khipu-gravity`](../khipu-gravity/README.md), `khipu-share`, `khipu-brahman`
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `text-input`, `list`, `menubar`, `edit-menu`
+- `pluma-llm` (the weave), `rimay-verbo` (embeddings), `agora-core` (identity), `willay-*` (timeline)
diff --git a/00_unanchay/khipu/khipu-app/src/app.rs b/00_unanchay/khipu/khipu-app/src/app.rs
new file mode 100644
index 0000000..521dc18
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/app.rs
@@ -0,0 +1,1025 @@
+//! `app` — implementación de [`llimphi_ui::App`] para khipu.
+//!
+//! Contiene la estructura `KhipuApp` y los métodos del ciclo Elm:
+//! `init`, `update`, `view`, `view_overlay` y `on_key`.
+//! La lógica de negocio se delega a los submódulos `estado`, `menu`,
+//! `cuerdas`, `panels`, `tejido` y `net`.
+
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, FlexDirection, Size, Style},
+ Dimension,
+};
+use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
+use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
+use llimphi_widget_edit_menu::{self as editmenu, EditAction};
+use llimphi_widget_list::ListPalette;
+use llimphi_widget_menubar::{
+ menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view,
+};
+use llimphi_widget_text_editor::{EditorMetrics, EditorPalette, PointerEvent};
+use llimphi_widget_text_input::{TextInputEvent, TextInputPalette};
+
+use crate::cuerdas::{cuerdas_panel, max_pan, overlay_left, overlay_right, pick, Pick};
+use crate::estado::{
+ commit_edits, data_file_path, deselect, emitir_checkpoint_nota, from_state, load_state,
+ nombre_propuesto, now_secs, persist, resonancia_de, schedule_embedding, seeded_model, select,
+ start_unlock, unlock_identity,
+};
+use crate::menu::{app_menu, focused_edit_flags, menubar_spec};
+use crate::modelo::{
+ Embedder, Focus, Model, Msg, Resonancia, EDITOR_OVERLAY_W, LIST_WIDTH,
+};
+use crate::net::{ensure_p2p, export_notebook, import_notebook, peer_addr, start_publishing};
+use crate::panels::{
+ captura_bar, editor_panel, header_view, list_panel, nombre_overlay, receive_panel,
+ resonancia_panel, status_bar, sueltos_strip, tarjeta_cuerda, unlock_view,
+};
+use crate::tejido;
+
+/// Tamaño inicial de la ventana. Compartido por `menubar_spec` y el App trait.
+const INITIAL_W: u32 = 1280;
+const INITIAL_H: u32 = 760;
+
+/// La aplicación Llimphi de khipu. Sin estado propio: todo vive en `Model`.
+pub(crate) struct KhipuApp;
+
+// =====================================================================
+// Helpers locales de menú (no pueden vivir en menu.rs por ciclo de módulos)
+// =====================================================================
+
+/// Traduce el `command` del menú principal al `Msg` real y lo redespacha
+/// por el `update`. Cierra el menú antes de actuar.
+fn handle_menu_command(mut model: Model, command: String, h: &Handle<Msg>) -> Model {
+ model.menu_open = None;
+ let target = match command.as_str() {
+ "note.captura" => Some(Msg::Focus(Focus::Captura)),
+ "note.delete" => Some(Msg::DeleteSelected),
+ "share.export" => Some(Msg::Export),
+ "share.import" => Some(Msg::Import),
+ "share.publish" => Some(Msg::Publish),
+ "share.receive" => Some(Msg::Receive),
+ "view.search" => Some(Msg::Focus(Focus::Search)),
+ "cuerda.tejer" => model.cuerda_sel.map(Msg::Tejer),
+ "cuerda.renombrar" => model.cuerda_sel.map(Msg::RenombrarCuerda),
+ "cuerda.desatar" => model.cuerda_sel.map(Msg::BorrarCuerda),
+ "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
+ "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
+ "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
+ "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
+ "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
+ "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
+ "help.about" => {
+ model.status = Some("khipu · cuaderno de resonancia P2P soberano".into());
+ None
+ }
+ _ => None,
+ };
+ match target {
+ Some(msg) => KhipuApp::update(model, msg, h),
+ None => model,
+ }
+}
+
+/// Aplica una acción del menú de edición al editor del campo focuseado,
+/// usando el portapapeles del sistema. Cierra el menú de edición.
+fn apply_edit_menu_action(mut model: Model, action: EditAction, h: &Handle<Msg>) -> Model {
+ model.edit_menu = None;
+ let focus = model.focus;
+ let clip = &mut model.clipboard;
+ let result = match focus {
+ Focus::Body => Some(editmenu::apply(&mut model.body, action, clip)),
+ Focus::Captura => Some(editmenu::apply(model.captura.editor_mut(), action, clip)),
+ Focus::Title => Some(editmenu::apply(model.title.editor_mut(), action, clip)),
+ Focus::Tags => Some(editmenu::apply(model.tags.editor_mut(), action, clip)),
+ Focus::Search => Some(editmenu::apply(model.search.editor_mut(), action, clip)),
+ Focus::PeerAddr => Some(editmenu::apply(model.peer_input.editor_mut(), action, clip)),
+ Focus::Nombre => Some(editmenu::apply(model.nombre_input.editor_mut(), action, clip)),
+ Focus::Passphrase => Some(editmenu::apply(model.passphrase.editor_mut(), action, clip)),
+ Focus::None => None,
+ };
+ // Si la acción cambió un campo persistente de la nota, corremos el commit.
+ if let Some(r) = result {
+ if r.changed() && matches!(focus, Focus::Body | Focus::Title | Focus::Tags) {
+ commit_edits(&mut model, h);
+ }
+ }
+ model
+}
+
+/// Divide el texto de una captura: la primera línea es el título; el
+/// resto (si lo hay) cae al cuerpo.
+fn split_captura(texto: &str) -> (String, String) {
+ match texto.split_once('\n') {
+ Some((titulo, cuerpo)) => (titulo.trim().to_string(), cuerpo.trim().to_string()),
+ None => (texto.trim().to_string(), String::new()),
+ }
+}
+
+// =====================================================================
+// App impl
+// =====================================================================
+
+impl App for KhipuApp {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ // Conectamos al daemon una sola vez al arrancar.
+ let embedder = Embedder::connect();
+ let data_path = data_file_path();
+ let mut model = match data_path.as_ref().and_then(load_state) {
+ Some(state) => from_state(state, embedder),
+ None => {
+ // Un archivo presente pero ilegible NUNCA se pisa con el
+ // seed sin dejar copia: el cuaderno vale más que la app.
+ if let Some(p) = data_path.as_ref().filter(|p| p.exists()) {
+ let _ = std::fs::copy(p, p.with_extension("ilegible.bak"));
+ }
+ seeded_model(embedder)
+ }
+ };
+ model.data_path = data_path;
+ // Identidad: si `KHIPU_PASSPHRASE` está en el entorno, desbloqueamos
+ // sin prompt — útil headless.
+ model.keypair = std::env::var("KHIPU_PASSPHRASE")
+ .ok()
+ .and_then(|p| unlock_identity(&p));
+ model.theme = llimphi_theme::Theme::dark();
+ // Con bootstrap configurado, arrancamos el nodo libp2p ya.
+ if std::env::var("KHIPU_BOOTSTRAP").is_ok() {
+ ensure_p2p(&mut model);
+ }
+ persist(&model);
+ // Latido lento — la frescura pintada de las cuerdas se actualiza
+ // sola aunque nadie toque nada. (60 s: es brillo, no física.)
+ handle.spawn_periodic(std::time::Duration::from_secs(60), || Msg::Tick);
+ model
+ }
+
+ fn update(mut model: Model, msg: Msg, h: &Handle<Msg>) -> Model {
+ match msg {
+ // ------------------------------------------------ captura
+ Msg::Capturar => {
+ let texto = model.captura.text().trim().to_string();
+ if texto.is_empty() {
+ return model;
+ }
+ let (titulo, cuerpo) = split_captura(&texto);
+ let now = now_secs();
+ let id = model.store.create(titulo, cuerpo, Vec::new(), now);
+ model.order.push(id);
+ model.captura.clear();
+ model.focus = Focus::Captura;
+ // El eco arranca "calculando": el panel aparece apenas el
+ // embedding vuelve del worker (Msg::EmbeddingReady).
+ model.resonancia = Some(Resonancia {
+ nudo: id,
+ vecinos: Vec::new(),
+ calculando: true,
+ });
+ schedule_embedding(&mut model, id, h);
+ persist(&model);
+ // Timeline universal (#6): checkpoint al capturar un nudo
+ // (límite discreto — no en cada tecla).
+ emitir_checkpoint_nota(&model, id);
+ }
+ Msg::EmbeddingReady(id, seq, v) => {
+ if model.embed_latest.get(&id) == Some(&seq) && model.store.get(id).is_some() {
+ model.field.insert_etiquetado(id, v, &model.embedder.label());
+ // ¿Era la captura cuyo eco esperamos? Resolvemos la
+ // resonancia ahora que su vector existe.
+ if model.resonancia.as_ref().map(|r| r.nudo) == Some(id) {
+ let vecinos = resonancia_de(&model, id);
+ if vecinos.is_empty() {
+ model.resonancia = None;
+ model.status =
+ Some("quedó suelto — no resonó con nada todavía".into());
+ } else {
+ model.resonancia = Some(Resonancia {
+ nudo: id,
+ vecinos,
+ calculando: false,
+ });
+ }
+ }
+ persist(&model);
+ }
+ }
+ Msg::Anudar(nota, cuerda) => {
+ if model.store.anudar(nota, cuerda) {
+ if model.resonancia.as_ref().map(|r| r.nudo) == Some(nota) {
+ model.resonancia = None;
+ }
+ model.cuerda_sel = Some(cuerda);
+ let nombre = model
+ .store
+ .cuerda(cuerda)
+ .map(|c| c.nombre.clone())
+ .unwrap_or_default();
+ model.status = Some(format!("anudado a «{nombre}»"));
+ persist(&model);
+ }
+ }
+ Msg::AnudarJuntos(a, b) => {
+ let now = now_secs();
+ let nombre = nombre_propuesto(&model, &[a, b]);
+ let cuerda = model.store.crear_cuerda(&nombre, now);
+ model.store.anudar(a, cuerda);
+ model.store.anudar(b, cuerda);
+ if model.resonancia.as_ref().map(|r| r.nudo) == Some(a) {
+ model.resonancia = None;
+ }
+ model.cuerda_sel = Some(cuerda);
+ model.status = Some(format!(
+ "cuerda nueva «{nombre}» — renombrala si el nombre no te dice nada"
+ ));
+ persist(&model);
+ }
+ Msg::NuevaCuerda(nota) => {
+ let now = now_secs();
+ let nombre = nombre_propuesto(&model, &[nota]);
+ let cuerda = model.store.crear_cuerda(&nombre, now);
+ model.store.anudar(nota, cuerda);
+ if model.resonancia.as_ref().map(|r| r.nudo) == Some(nota) {
+ model.resonancia = None;
+ }
+ model.cuerda_sel = Some(cuerda);
+ model.status = Some(format!("cuerda nueva «{nombre}»"));
+ persist(&model);
+ }
+ Msg::DescartarResonancia => {
+ model.resonancia = None;
+ }
+ Msg::Resonar(id) => {
+ let vecinos = resonancia_de(&model, id);
+ if vecinos.is_empty() {
+ model.status = Some("no resuena con nada todavía".into());
+ } else {
+ model.resonancia = Some(Resonancia {
+ nudo: id,
+ vecinos,
+ calculando: false,
+ });
+ }
+ }
+ Msg::Desanudar(id) => {
+ if model.store.desanudar(id) {
+ model.status = Some("nudo suelto de nuevo".into());
+ persist(&model);
+ }
+ }
+ Msg::CiclarEstado(id) => {
+ use khipu_core::EstadoNudo::*;
+ if let Some(n) = model.store.get_mut(id) {
+ n.estado = match n.estado {
+ Normal => Pendiente,
+ Pendiente => Hecho,
+ Hecho => Normal,
+ };
+ persist(&model);
+ }
+ }
+
+ // ------------------------------------------------ cuerdas
+ Msg::SelectCuerda(sel) => {
+ model.cuerda_sel = sel;
+ }
+ Msg::RenombrarCuerda(cid) => {
+ if let Some(c) = model.store.cuerda(cid) {
+ model.nombre_input.set_text(c.nombre.clone());
+ model.renombrando = Some(cid);
+ model.focus = Focus::Nombre;
+ }
+ }
+ Msg::CommitNombre => {
+ if let Some(cid) = model.renombrando.take() {
+ let nombre = model.nombre_input.text().trim().to_string();
+ if !nombre.is_empty() {
+ if let Some(c) = model.store.cuerda_mut(cid) {
+ c.nombre = nombre;
+ }
+ persist(&model);
+ }
+ }
+ model.nombre_input.clear();
+ model.focus = Focus::None;
+ }
+ Msg::CancelNombre => {
+ model.renombrando = None;
+ model.nombre_input.clear();
+ model.focus = Focus::None;
+ }
+ Msg::BorrarCuerda(cid) => {
+ if model.store.borrar_cuerda(cid).is_some() {
+ if model.cuerda_sel == Some(cid) {
+ model.cuerda_sel = None;
+ }
+ model.status =
+ Some("cuerda desatada — sus nudos quedaron sueltos".into());
+ persist(&model);
+ }
+ }
+ Msg::Tejer(cid) => {
+ if model.tejiendo.is_some() {
+ return model;
+ }
+ let Some(c) = model.store.cuerda(cid) else {
+ return model;
+ };
+ let materia = model
+ .store
+ .nudos_de(cid)
+ .iter()
+ .filter(|n| c.tejido != Some(n.id))
+ .count();
+ if materia < 2 {
+ model.status =
+ Some("una cuerda se teje con al menos dos nudos de materia".into());
+ return model;
+ }
+ model.tejiendo = Some(cid);
+ model.status = Some(format!("tejiendo «{}»…", c.nombre));
+ tejido::tejer(&model, cid, h);
+ }
+ Msg::TejidoListo(cid, resultado) => {
+ model.tejiendo = None;
+ match resultado {
+ Err(e) => model.status = Some(e),
+ Ok(texto) => {
+ let now = now_secs();
+ let Some(c) = model.store.cuerda(cid) else {
+ return model;
+ };
+ let nombre = c.nombre.clone();
+ let tid = match c.tejido {
+ // Re-tejer: el mismo nudo-síntesis, cuerpo nuevo.
+ Some(tid) if model.store.get(tid).is_some() => {
+ model.store.update_body(tid, texto, now);
+ tid
+ }
+ _ => {
+ let tid = model.store.create(
+ format!("tejido · {nombre}"),
+ texto,
+ vec!["tejido".into()],
+ now,
+ );
+ model.order.push(tid);
+ model.store.anudar(tid, cid);
+ if let Some(c) = model.store.cuerda_mut(cid) {
+ c.tejido = Some(tid);
+ }
+ tid
+ }
+ };
+ schedule_embedding(&mut model, tid, h);
+ // El encuadre se abre para leer/editar de una.
+ select(&mut model, tid);
+ model.status = Some(format!(
+ "tejida «{nombre}» — el encuadre es un nudo editable"
+ ));
+ persist(&model);
+ }
+ }
+ }
+ Msg::CuerdasPan(dx) => {
+ let tope = max_pan(&model, model.canvas_size.0);
+ model.pan_x = (model.pan_x + dx).clamp(0.0, tope);
+ }
+ Msg::CuerdasClick(lx, ly, w, h_px) => {
+ model.canvas_size = (w, h_px);
+ match pick(&model, lx, ly, h_px) {
+ Pick::Nudo(nid, cid) => {
+ commit_edits(&mut model, h);
+ select(&mut model, nid);
+ model.cuerda_sel = Some(cid);
+ }
+ Pick::Cabeza(cid) => {
+ model.cuerda_sel = if model.cuerda_sel == Some(cid) {
+ None
+ } else {
+ Some(cid)
+ };
+ }
+ Pick::Nada => {}
+ }
+ }
+
+ // ------------------------------------------------ notas
+ Msg::SelectNote(id) => {
+ commit_edits(&mut model, h);
+ select(&mut model, id);
+ // Elegir un nudo enfoca también a su cuerda (contexto).
+ model.cuerda_sel = model.store.get(id).and_then(|n| n.cuerda);
+ }
+ Msg::DeleteSelected => {
+ if let Some(id) = model.selected {
+ model.store.remove(id);
+ model.order.retain(|x| *x != id);
+ model.field.remove(id);
+ if model.resonancia.as_ref().map(|r| r.nudo) == Some(id) {
+ model.resonancia = None;
+ }
+ deselect(&mut model);
+ persist(&model);
+ }
+ }
+ Msg::Deselect => {
+ commit_edits(&mut model, h);
+ deselect(&mut model);
+ persist(&model);
+ }
+ Msg::ToggleList => {
+ model.show_list = !model.show_list;
+ }
+ Msg::Tick => {
+ // No muta nada: el Tick existe sólo para repintar la
+ // frescura de las cuerdas.
+ }
+ Msg::EscapeMapa => {
+ if model.renombrando.is_some() {
+ model.renombrando = None;
+ model.nombre_input.clear();
+ model.focus = Focus::None;
+ } else if model.resonancia.is_some() {
+ model.resonancia = None;
+ } else if model.selected.is_some() {
+ commit_edits(&mut model, h);
+ deselect(&mut model);
+ persist(&model);
+ } else if model.show_list {
+ model.show_list = false;
+ } else if model.cuerda_sel.is_some() {
+ model.cuerda_sel = None;
+ } else {
+ model.focus = Focus::None;
+ }
+ }
+
+ // ------------------------------------------------ campos
+ Msg::Focus(f) => {
+ commit_edits(&mut model, h);
+ model.focus = f;
+ }
+ Msg::Campo(f, ev) => {
+ // Un `Press` enfoca el campo (y confirma la edición previa);
+ // el arrastre extiende la selección. El teclado sigue por
+ // `Msg::Key`.
+ if matches!(ev, TextInputEvent::Press(_)) && model.focus != f {
+ commit_edits(&mut model, h);
+ model.focus = f;
+ }
+ let clip = &mut model.clipboard;
+ match f {
+ Focus::Captura => {
+ model.captura.handle(ev, clip);
+ }
+ Focus::Title => {
+ model.title.handle(ev, clip);
+ }
+ Focus::Tags => {
+ model.tags.handle(ev, clip);
+ }
+ Focus::Search => {
+ model.search.handle(ev, clip);
+ }
+ Focus::Passphrase => {
+ model.passphrase.handle(ev, clip);
+ }
+ Focus::PeerAddr => {
+ model.peer_input.handle(ev, clip);
+ }
+ Focus::Nombre => {
+ model.nombre_input.handle(ev, clip);
+ }
+ // El cuerpo es un editor multilínea (otro widget) y no hay
+ // campo sin foco: nunca llega un `Campo` con estos.
+ Focus::Body | Focus::None => {}
+ }
+ }
+ Msg::Key(ev) => {
+ // El teclado va al campo enfocado. `handle` cubre copiar/
+ // cortar/pegar (Ctrl+C/X/V) contra el portapapeles del
+ // sistema; el cuerpo es un editor multilínea aparte.
+ let changed = match model.focus {
+ Focus::Captura => {
+ model.captura.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+ false
+ }
+ Focus::Title => model.title.handle(TextInputEvent::Key(ev), &mut model.clipboard),
+ Focus::Body => model.body.apply_key(&ev).touched(),
+ Focus::Tags => model.tags.handle(TextInputEvent::Key(ev), &mut model.clipboard),
+ Focus::Search => {
+ model.search.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+ false
+ }
+ Focus::Passphrase => {
+ model.passphrase.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+ false
+ }
+ Focus::PeerAddr => {
+ model.peer_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+ false
+ }
+ Focus::Nombre => {
+ model.nombre_input.handle(TextInputEvent::Key(ev), &mut model.clipboard);
+ false
+ }
+ Focus::None => false,
+ };
+ if changed {
+ commit_edits(&mut model, h);
+ }
+ }
+ Msg::EditorPointer(ev) => {
+ let metrics = EditorMetrics::for_font_size(13.0);
+ match ev {
+ PointerEvent::Click { x, y } => {
+ let (line, col) = metrics.screen_to_pos(x, y, model.body.scroll_offset);
+ model.body.set_caret_at(line, col);
+ }
+ PointerEvent::Drag { initial_x, initial_y, dx, dy } => {
+ let (l0, c0) = metrics.screen_to_pos(
+ initial_x,
+ initial_y,
+ model.body.scroll_offset,
+ );
+ let (l1, c1) = metrics.screen_to_pos(
+ initial_x + dx,
+ initial_y + dy,
+ model.body.scroll_offset,
+ );
+ model.body.set_caret_at(l0, c0);
+ model.body.extend_selection_to(l1, c1);
+ }
+ }
+ model.focus = Focus::Body;
+ }
+
+ // ------------------------------------------------ compartir
+ Msg::Export => {
+ if model.keypair.is_none() {
+ start_unlock(&mut model, Msg::Export);
+ } else {
+ commit_edits(&mut model, h);
+ model.status = Some(export_notebook(&model));
+ }
+ }
+ Msg::Import => {
+ let report = import_notebook(&mut model, h);
+ persist(&model);
+ model.status = Some(report);
+ }
+ Msg::Publish => {
+ if model.keypair.is_none() {
+ start_unlock(&mut model, Msg::Publish);
+ } else {
+ commit_edits(&mut model, h);
+ let _ = export_notebook(&model);
+ model.status = Some(start_publishing(&mut model, h));
+ }
+ }
+ Msg::Receive => {
+ let my_key = model.keypair.as_ref().map(|k| k.public_key());
+ model.receiving = true;
+ model.peers.clear();
+ if model.peer_input.is_empty() {
+ model.peer_input.set_text(peer_addr());
+ }
+ model.focus = Focus::PeerAddr;
+ model.status = Some("buscando pares (LAN + DHT)… o escribe una dirección".into());
+ let dht = model.p2p.as_ref().map(|p| (p.rt.clone(), p.node.clone()));
+ h.spawn(move || {
+ let mut infos: Vec<crate::modelo::PeerInfo> =
+ khipu_share::discovery::descubrir(std::time::Duration::from_secs(3))
+ .unwrap_or_default()
+ .into_iter()
+ .filter(|p| Some(p.beacon.author) != my_key)
+ .map(|p| crate::modelo::PeerInfo {
+ addr: p.fetch_addr.to_string(),
+ label: format!(
+ "LAN · {} · de:{} · {}",
+ p.beacon.name,
+ khipu_share::hex8(&p.beacon.author),
+ p.fetch_addr
+ ),
+ })
+ .collect();
+ if let Some((rt, node)) = dht {
+ let me = node.peer_id();
+ for pid in rt.block_on(node.descubrir()) {
+ if pid == me {
+ continue;
+ }
+ let s = pid.to_string();
+ let corto: String = s.chars().rev().take(8).collect::<Vec<_>>()
+ .into_iter().rev().collect();
+ infos.push(crate::modelo::PeerInfo {
+ label: format!("DHT · …{corto}"),
+ addr: s,
+ });
+ }
+ }
+ Msg::PeersFound(infos)
+ });
+ }
+ Msg::PeersFound(peers) => {
+ if model.receiving {
+ model.status = Some(if peers.is_empty() {
+ "ningún par en la LAN — escribe una dirección y jala".into()
+ } else {
+ format!("{} pares en la red — elige uno o escribe una dirección", peers.len())
+ });
+ model.peers = peers;
+ }
+ }
+ Msg::FetchManual => {
+ let addr = model.peer_input.text().trim().to_string();
+ if addr.is_empty() {
+ model.status = Some("escribe una dirección host:puerto".into());
+ } else {
+ h.dispatch(Msg::FetchFrom(addr));
+ }
+ }
+ Msg::FetchFrom(addr) => {
+ model.receiving = false;
+ model.peers.clear();
+ model.focus = Focus::None;
+ let destino = addr.trim().to_string();
+ if destino.starts_with('/') || !destino.contains(':') {
+ // Vía libp2p: multiaddr o peer-id pelado.
+ if ensure_p2p(&mut model) {
+ let p = model.p2p.as_ref().expect("p2p recién armado");
+ let (rt, node) = (p.rt.clone(), p.node.clone());
+ let es_multiaddr = destino.starts_with('/');
+ model.status = Some(format!("jalando por libp2p de {destino}…"));
+ h.spawn(move || {
+ let res = if es_multiaddr {
+ rt.block_on(node.fetch_addr_str(&destino))
+ } else {
+ rt.block_on(node.fetch_peer_str(&destino))
+ };
+ match res {
+ Ok(s) => Msg::Received(Ok(s)),
+ Err(e) => Msg::Received(Err(format!("p2p: {e}"))),
+ }
+ });
+ } else {
+ model.status = Some("no se pudo iniciar el nodo libp2p".into());
+ }
+ } else {
+ // Dirección TCP `host:puerto` (LAN/WAN directa).
+ model.status = Some(format!("jalando de {destino}…"));
+ h.spawn(move || match khipu_share::net::fetch(&destino) {
+ Ok(s) => Msg::Received(Ok(s)),
+ Err(e) => Msg::Received(Err(format!("no se pudo recibir de {destino}: {e}"))),
+ });
+ }
+ }
+ Msg::CancelPeers => {
+ model.receiving = false;
+ model.peers.clear();
+ model.focus = Focus::None;
+ model.status = Some("recibir cancelado".into());
+ }
+ Msg::Received(res) => {
+ model.receiving = false;
+ model.peers.clear();
+ model.status = Some(match res {
+ Ok(sobre) => match khipu_share::open(&sobre) {
+ Ok(bundle) => {
+ let now = now_secs();
+ let outcome =
+ khipu_share::import_into(&mut model.store, bundle, now);
+ for id in &outcome.created {
+ model.order.push(*id);
+ schedule_embedding(&mut model, *id, h);
+ }
+ persist(&model);
+ format!(
+ "recibidas {} · omitidas {} (ya existían)",
+ outcome.created.len(),
+ outcome.skipped
+ )
+ }
+ Err(_) => "firma inválida — sobre rechazado".into(),
+ },
+ Err(e) => e,
+ });
+ }
+ Msg::Unlock => {
+ let pass = model.passphrase.text();
+ match unlock_identity(&pass) {
+ Some(kp) => {
+ let id = khipu_share::hex8(&kp.public_key());
+ model.keypair = Some(kp);
+ model.unlocking = false;
+ model.passphrase.clear();
+ model.focus = Focus::None;
+ model.status = Some(format!("identidad desbloqueada · {id}"));
+ if let Some(accion) = model.pending.take() {
+ h.dispatch(*accion);
+ }
+ }
+ None => {
+ model.status =
+ Some("passphrase incorrecta o sin acceso al keystore".into());
+ }
+ }
+ }
+ Msg::CancelUnlock => {
+ model.unlocking = false;
+ model.pending = None;
+ model.passphrase.clear();
+ model.focus = Focus::None;
+ model.status = Some("desbloqueo cancelado".into());
+ }
+ Msg::RelayReady(addr) => {
+ model.status = Some(format!("alcanzable vía relay: {addr}"));
+ }
+
+ // ------------------------------------------------ menús
+ Msg::MenuOpen(idx) => {
+ model.menu_open = idx;
+ model.menu_active = usize::MAX;
+ model.edit_menu = None;
+ if idx.is_some() {
+ model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+ animate(h, motion::FAST, || Msg::MenuTick);
+ }
+ }
+ Msg::MenuCommand(cmd) => {
+ return handle_menu_command(model, cmd, h);
+ }
+ Msg::MenuNav(dir) => {
+ if let Some(mi) = model.menu_open {
+ let menu = app_menu(&model);
+ model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
+ }
+ }
+ Msg::MenuActivate => {
+ if let Some(mi) = model.menu_open {
+ let menu = app_menu(&model);
+ if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
+ return handle_menu_command(model, cmd, h);
+ }
+ }
+ }
+ Msg::MenuTick => {}
+ Msg::EditNav(dir) => {
+ let flags = focused_edit_flags(&model);
+ model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
+ }
+ Msg::EditActivate => {
+ let flags = focused_edit_flags(&model);
+ if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
+ return apply_edit_menu_action(model, action, h);
+ }
+ }
+ Msg::EditMenuOpen(x, y) => {
+ model.edit_menu = Some((x, y));
+ model.edit_active = usize::MAX;
+ model.menu_open = None;
+ model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+ animate(h, motion::FAST, || Msg::MenuTick);
+ }
+ Msg::EditMenuAction(action) => {
+ return apply_edit_menu_action(model, action, h);
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.menu_active = usize::MAX;
+ model.edit_menu = None;
+ model.edit_active = usize::MAX;
+ }
+ }
+ model
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ let palette = ListPalette::from_theme(&model.theme);
+ let input_palette = TextInputPalette::from_theme(&model.theme);
+ let editor_palette = EditorPalette::from_theme(&model.theme);
+ let viewport = (INITIAL_W as f32, INITIAL_H as f32);
+
+ // Prompt de passphrase: ocupa toda la ventana hasta resolverse.
+ if model.unlocking {
+ let mut children = vec![header_view(model), unlock_view(model, &input_palette)];
+ if let Some(bar) = status_bar(model) {
+ children.push(bar);
+ }
+ return View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_app)
+ .children(children);
+ }
+
+ let header = header_view(model);
+ let captura = captura_bar(model, &input_palette);
+ let sueltos = sueltos_strip(model);
+
+ // Overlays que viven encima del lienzo: la tarjeta de la cuerda
+ // seleccionada, el eco de la captura y el input de renombre.
+ let mut injected: Vec<View<Msg>> = Vec::new();
+ if let Some(card) = tarjeta_cuerda(model) {
+ injected.push(card);
+ }
+ if let Some(res) = resonancia_panel(model) {
+ injected.push(res);
+ }
+ if let Some(nombre) = nombre_overlay(model, &input_palette) {
+ injected.push(nombre);
+ }
+ let lienzo = cuerdas_panel(model, injected);
+ let mut layers: Vec<View<Msg>> = vec![lienzo];
+
+ if model.show_list || model.receiving {
+ let drawer = if model.receiving {
+ receive_panel(model, &palette, &input_palette)
+ } else {
+ list_panel(model, &palette, &input_palette)
+ };
+ layers.push(overlay_left(drawer, LIST_WIDTH));
+ }
+
+ if model.selected.is_some() {
+ let editor = editor_panel(model, &input_palette, &editor_palette);
+ layers.push(overlay_right(editor, EDITOR_OVERLAY_W, &model.theme));
+ }
+
+ let body = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ min_size: Size {
+ width: length(0.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(layers);
+
+ let menu = app_menu(model);
+ let menubar = menubar_view(&menubar_spec(&menu, model, &model.theme, viewport));
+
+ let mut children = vec![menubar, header, captura, sueltos, body];
+ if let Some(bar) = status_bar(model) {
+ children.push(bar);
+ }
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
+ .children(children)
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ let viewport = (INITIAL_W as f32, INITIAL_H as f32);
+ if model.unlocking {
+ return None;
+ }
+ if let Some((x, y)) = model.edit_menu {
+ let flags = focused_edit_flags(model);
+ let mut spec = editmenu::edit_context_menu(
+ (x, y),
+ viewport,
+ &model.theme,
+ flags,
+ Msg::EditMenuAction,
+ Msg::CloseMenus,
+ );
+ spec.active = model.edit_active;
+ return Some(context_menu_view_ex(
+ spec,
+ ContextMenuExtras {
+ appear: model.edit_anim.value(),
+ ..Default::default()
+ },
+ ));
+ }
+ let menu = app_menu(model);
+ menubar_overlay_animated(
+ &menubar_spec(&menu, model, &model.theme, viewport),
+ model.menu_active,
+ model.menu_anim.value(),
+ )
+ }
+
+ fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
+ // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
+ if event.state == KeyState::Pressed {
+ if let Some(mi) = model.menu_open {
+ let n = app_menu(model).menus.len().max(1);
+ return match &event.key {
+ Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+ Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
+ Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
+ Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
+ Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
+ Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
+ _ => None,
+ };
+ }
+ if model.edit_menu.is_some() {
+ return match &event.key {
+ Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
+ Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
+ Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
+ Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
+ _ => None,
+ };
+ }
+ }
+ // Con el prompt de passphrase abierto, las teclas son sólo suyas.
+ if model.unlocking {
+ if event.state == KeyState::Pressed && !event.repeat {
+ if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+ return Some(Msg::Unlock);
+ }
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::CancelUnlock);
+ }
+ }
+ return Some(Msg::Key(event.clone()));
+ }
+ // En modo recibir con foco en la dirección: Enter jala, Esc cancela.
+ if model.receiving && model.focus == Focus::PeerAddr {
+ if event.state == KeyState::Pressed && !event.repeat {
+ if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+ return Some(Msg::FetchManual);
+ }
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::CancelPeers);
+ }
+ }
+ return Some(Msg::Key(event.clone()));
+ }
+ // Renombrando una cuerda: Enter confirma, Esc cancela.
+ if model.renombrando.is_some() && model.focus == Focus::Nombre {
+ if event.state == KeyState::Pressed && !event.repeat {
+ if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+ return Some(Msg::CommitNombre);
+ }
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::CancelNombre);
+ }
+ }
+ return Some(Msg::Key(event.clone()));
+ }
+ // En la captura: Enter bota el nudo.
+ if model.focus == Focus::Captura
+ && event.state == KeyState::Pressed
+ && !event.repeat
+ && matches!(&event.key, Key::Named(NamedKey::Enter))
+ {
+ return Some(Msg::Capturar);
+ }
+ // Atajos globales: Ctrl+N va a la captura, Ctrl+F a la búsqueda.
+ // Esc cierra en cascada.
+ if event.state == KeyState::Pressed && !event.repeat {
+ if event.modifiers.ctrl
+ && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
+ {
+ return Some(Msg::Focus(Focus::Captura));
+ }
+ if event.modifiers.ctrl
+ && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("f"))
+ {
+ return Some(Msg::Focus(Focus::Search));
+ }
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::EscapeMapa);
+ }
+ }
+ Some(Msg::Key(event.clone()))
+ }
+
+ fn title() -> &'static str {
+ "khipu"
+ }
+
+ fn app_id() -> Option<&'static str> {
+ Some("tawasuyu.khipu")
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (INITIAL_W, INITIAL_H)
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/cuerdas.rs b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
new file mode 100644
index 0000000..4005f32
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/cuerdas.rs
@@ -0,0 +1,578 @@
+//! `cuerdas` — el lienzo raíz de khipu: un khipu literal.
+//!
+//! Una **viga** horizontal (la cuerda madre) cruza la parte alta; de ella
+//! cuelgan las **cuerdas** (ideas recurrentes), ordenadas por masa — la
+//! más gorda a la izquierda. A lo largo de cada cuerda, sus **nudos** en
+//! orden cronológico (arriba lo primero que se pensó); el **tejido** (la
+//! síntesis) se pinta como rombo. El grosor viene de cuántos nudos tiene
+//! la cuerda; el brillo, de cuán reciente fue su último crecimiento
+//! (`khipu_gravity::{masa_cuerda, frescura}`) — lo que crece arde, lo
+//! quieto reposa, nada desaparece.
+//!
+//! La geometría es una proyección pura del store (nada se persiste): el
+//! layout se deriva en [`layout_cuerdas`], el painter lo pinta y el
+//! hit-test ([`pick`]) lo consulta. Pan horizontal con arrastre o rueda.
+
+use khipu_core::{CuerdaId, EstadoNudo, NoteId};
+use khipu_gravity::{frescura, masa_cuerda, FRESCURA_MEDIA_VIDA_SECS};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
+ AlignItems, Dimension, JustifyContent,
+};
+use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
+use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
+use llimphi_ui::llimphi_text::{draw_block, TextBlock};
+use llimphi_ui::{DragPhase, View};
+
+use crate::estado::now_secs;
+use crate::modelo::{Model, Msg};
+use crate::panels::button;
+
+// =====================================================================
+// Geometría
+// =====================================================================
+
+/// Margen izquierdo de la primera cuerda.
+const MARGEN_X: f32 = 90.0;
+/// Separación horizontal entre cuerdas.
+pub(crate) const ESPACIO_CUERDA: f32 = 150.0;
+/// Altura de la viga (cuerda madre) dentro del lienzo.
+const VIGA_Y: f32 = 34.0;
+/// Donde empiezan los nudos (deja lugar al nombre bajo la viga).
+const NUDOS_TOP: f32 = 84.0;
+/// Espacio que se reserva al pie del lienzo.
+const FONDO_PAD: f32 = 30.0;
+/// Separación vertical máxima entre nudos (se comprime si no entran).
+const ESPACIO_NUDO_MAX: f32 = 40.0;
+/// Radio de un nudo.
+const NUDO_R: f32 = 5.0;
+/// Tolerancia de click sobre un nudo (px).
+const TOL_NUDO: f32 = 14.0;
+
+/// Un nudo ya proyectado a coordenadas locales del lienzo.
+pub(crate) struct NudoVis {
+ pub(crate) id: NoteId,
+ pub(crate) y: f32,
+ pub(crate) estado: EstadoNudo,
+ pub(crate) es_tejido: bool,
+ pub(crate) titulo: String,
+}
+
+/// Una cuerda proyectada: su columna, su físico y sus nudos.
+pub(crate) struct CuerdaVis {
+ pub(crate) id: CuerdaId,
+ pub(crate) x: f32,
+ pub(crate) nombre: String,
+ /// Grosor de trazo (deriva de la masa — cantidad de nudos).
+ pub(crate) grosor: f32,
+ /// Alpha base (deriva de la frescura del último crecimiento).
+ pub(crate) brillo: f32,
+ pub(crate) color: Color,
+ pub(crate) nudos: Vec<NudoVis>,
+}
+
+/// Proyecta el store al lienzo: cuerdas ordenadas por masa descendente
+/// (la más gorda a la izquierda, desempate por id — estable), nudos en
+/// cronología descendente hacia abajo, tejido al final. Determinista.
+pub(crate) fn layout_cuerdas(model: &Model, h: f32, now: u64) -> Vec<CuerdaVis> {
+ let theme = &model.theme;
+ let mut cuerdas: Vec<(CuerdaId, usize, u64)> = model
+ .store
+ .cuerdas()
+ .map(|c| {
+ let nudos = model.store.nudos_de(c.id);
+ let ultimo = nudos.iter().map(|n| n.created_at).max().unwrap_or(c.created_at);
+ (c.id, nudos.len(), ultimo)
+ })
+ .collect();
+ cuerdas.sort_by(|a, b| {
+ masa_cuerda(b.1)
+ .partial_cmp(&masa_cuerda(a.1))
+ .unwrap_or(core::cmp::Ordering::Equal)
+ .then(a.0.cmp(&b.0))
+ });
+
+ cuerdas
+ .into_iter()
+ .enumerate()
+ .map(|(i, (cid, n_nudos, ultimo))| {
+ let x = MARGEN_X + i as f32 * ESPACIO_CUERDA - model.pan_x;
+ let cuerda = model.store.cuerda(cid).expect("recién listada");
+ let nudos = model.store.nudos_de(cid);
+ // El tejido se pinta al final aunque su fecha sea vieja:
+ // es el remate de la cuerda, no un nudo más de la cronología.
+ let (mut cuerpo, tejido): (Vec<_>, Vec<_>) =
+ nudos.into_iter().partition(|n| cuerda.tejido != Some(n.id));
+ cuerpo.extend(tejido);
+ let total = cuerpo.len();
+ let espacio = if total <= 1 {
+ ESPACIO_NUDO_MAX
+ } else {
+ ((h - NUDOS_TOP - FONDO_PAD) / (total as f32 - 1.0)).clamp(14.0, ESPACIO_NUDO_MAX)
+ };
+ let nudos_vis: Vec<NudoVis> = cuerpo
+ .iter()
+ .enumerate()
+ .map(|(j, n)| NudoVis {
+ id: n.id,
+ y: NUDOS_TOP + j as f32 * espacio,
+ estado: n.estado,
+ es_tejido: cuerda.tejido == Some(n.id),
+ titulo: short_label(&n.title),
+ })
+ .collect();
+ CuerdaVis {
+ id: cid,
+ x,
+ nombre: cuerda.nombre.clone(),
+ grosor: 1.4 + masa_cuerda(n_nudos) * 1.5,
+ brillo: 0.45 + 0.55 * frescura(ultimo, now, FRESCURA_MEDIA_VIDA_SECS),
+ color: color_cuerda(cid, *theme),
+ nudos: nudos_vis,
+ }
+ })
+ .collect()
+}
+
+/// Qué hay bajo un click en el lienzo, en coordenadas locales.
+pub(crate) enum Pick {
+ Nudo(NoteId, CuerdaId),
+ Cabeza(CuerdaId),
+ Nada,
+}
+
+/// Resuelve el click: primero nudos (radio de tolerancia), después la
+/// cabeza de la cuerda (nombre + tramo sobre los nudos), después nada.
+pub(crate) fn pick(model: &Model, lx: f32, ly: f32, h: f32) -> Pick {
+ let now = now_secs();
+ let layout = layout_cuerdas(model, h, now);
+ for c in &layout {
+ for n in &c.nudos {
+ let d2 = (lx - c.x).powi(2) + (ly - n.y).powi(2);
+ if d2 <= TOL_NUDO * TOL_NUDO {
+ return Pick::Nudo(n.id, c.id);
+ }
+ }
+ }
+ for c in &layout {
+ if (lx - c.x).abs() < ESPACIO_CUERDA * 0.45 && ly > VIGA_Y - 12.0 && ly < NUDOS_TOP {
+ return Pick::Cabeza(c.id);
+ }
+ }
+ Pick::Nada
+}
+
+/// Máximo pan horizontal: que la última cuerda pueda acercarse al margen.
+pub(crate) fn max_pan(model: &Model, w: f32) -> f32 {
+ let n = model.store.cuerdas_len() as f32;
+ (MARGEN_X + n * ESPACIO_CUERDA - w * 0.6).max(0.0)
+}
+
+// =====================================================================
+// El panel del lienzo
+// =====================================================================
+
+/// El lienzo de cuerdas: canvas `paint_with` + pan por arrastre/rueda +
+/// click con hit-test. `injected` son overlays hijos (tarjeta de cuerda,
+/// etc.) que viajan encima del pintado.
+pub(crate) fn cuerdas_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
+ let theme = model.theme;
+ let now = now_secs();
+ // La proyección se materializa aquí (h con el último tamaño conocido)
+ // para viajar plana dentro de la closure de pintura.
+ let layout = layout_cuerdas(model, model.canvas_size.1, now);
+ let sel_nota = model.selected;
+ let sel_cuerda = model.cuerda_sel;
+ let vacio = model.store.cuerdas_len() == 0;
+
+ let canvas = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel_alt)
+ .paint_with(move |scene, ts, rect| {
+ paint_cuerdas(scene, ts, rect, &layout, sel_nota, sel_cuerda, vacio, theme);
+ })
+ .draggable_at(|phase, dx, _dy, _lx, _ly| match phase {
+ DragPhase::Move => Some(Msg::CuerdasPan(-dx)),
+ DragPhase::End => None,
+ })
+ .on_scroll(|_dx, dy| Some(Msg::CuerdasPan(dy * 36.0)))
+ .on_click_at(|lx, ly, w, h| Some(Msg::CuerdasClick(lx, ly, w, h)))
+ .children(injected);
+
+ View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: percent(1.0_f32),
+ },
+ flex_grow: 1.0,
+ flex_basis: length(0.0_f32),
+ min_size: Size {
+ width: length(0.0_f32),
+ height: length(0.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(4.0_f32),
+ bottom: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .children(vec![canvas])
+}
+
+#[allow(clippy::too_many_arguments)]
+fn paint_cuerdas(
+ scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
+ ts: &mut llimphi_ui::llimphi_text::Typesetter,
+ rect: llimphi_ui::PaintRect,
+ layout: &[CuerdaVis],
+ sel_nota: Option<NoteId>,
+ sel_cuerda: Option<CuerdaId>,
+ vacio: bool,
+ theme: Theme,
+) {
+ if rect.w <= 0.0 || rect.h <= 0.0 {
+ return;
+ }
+ let sx = |x: f32| (rect.x + x) as f64;
+ let sy = |y: f32| (rect.y + y) as f64;
+
+ // La viga: la cuerda madre de la que cuelga todo.
+ let mut viga = BezPath::new();
+ viga.move_to((sx(0.0), sy(VIGA_Y)));
+ viga.line_to((sx(rect.w), sy(VIGA_Y)));
+ scene.stroke(
+ &Stroke::new(3.0),
+ Affine::IDENTITY,
+ with_alpha(theme.fg_muted, 0.45),
+ None,
+ &viga,
+ );
+
+ if vacio {
+ draw_block(
+ scene,
+ ts,
+ &TextBlock::simple(
+ "las cuerdas nacen cuando dos nudos resuenan — bota una idea arriba",
+ 13.0,
+ with_alpha(theme.fg_muted, 0.8),
+ (sx(rect.w * 0.5 - 240.0), sy(rect.h * 0.42)),
+ ),
+ );
+ return;
+ }
+
+ for c in layout {
+ // Culling: fuera del lienzo no se pinta.
+ if c.x < -ESPACIO_CUERDA || c.x > rect.w + ESPACIO_CUERDA {
+ continue;
+ }
+ let seleccionada = sel_cuerda == Some(c.id);
+ let brillo = if seleccionada { 1.0 } else { c.brillo };
+ let col = with_alpha(c.color, brillo);
+
+ // Amarre a la viga.
+ let amarre = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 3.6);
+ scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &amarre);
+ if seleccionada {
+ let ring = KurboCircle::new((sx(c.x), sy(VIGA_Y)), 6.5);
+ scene.stroke(&Stroke::new(1.6), Affine::IDENTITY, theme.accent, None, &ring);
+ }
+
+ // El nombre, colgado bajo la viga.
+ let size = 11.5_f32;
+ let est_w = c.nombre.chars().count() as f64 * size as f64 * 0.52;
+ let name_col = if seleccionada {
+ theme.accent
+ } else {
+ with_alpha(theme.fg_text, (brillo + 0.15).clamp(0.0, 1.0))
+ };
+ draw_block(
+ scene,
+ ts,
+ &TextBlock::simple(
+ &c.nombre,
+ size,
+ name_col,
+ (sx(c.x) - est_w * 0.5, sy(VIGA_Y + 12.0)),
+ ),
+ );
+
+ // La cuerda: de la viga al último nudo (o un colgajo corto si
+ // está vacía).
+ let y_fin = c.nudos.last().map(|n| n.y + 14.0).unwrap_or(NUDOS_TOP);
+ let mut linea = BezPath::new();
+ linea.move_to((sx(c.x), sy(VIGA_Y)));
+ linea.line_to((sx(c.x), sy(y_fin)));
+ scene.stroke(
+ &Stroke::new(c.grosor as f64),
+ Affine::IDENTITY,
+ with_alpha(c.color, brillo * 0.8),
+ None,
+ &linea,
+ );
+
+ // Los nudos.
+ for n in &c.nudos {
+ let (px, py) = (sx(c.x), sy(n.y));
+ if n.es_tejido {
+ // El tejido: rombo lleno, el remate brillante de la cuerda.
+ let r = (NUDO_R + 2.5) as f64;
+ let mut rombo = BezPath::new();
+ rombo.move_to((px, py - r));
+ rombo.line_to((px + r, py));
+ rombo.line_to((px, py + r));
+ rombo.line_to((px - r, py));
+ rombo.close_path();
+ scene.fill(Fill::NonZero, Affine::IDENTITY, theme.accent, None, &rombo);
+ } else {
+ let circle = KurboCircle::new((px, py), NUDO_R as f64);
+ match n.estado {
+ EstadoNudo::Normal => {
+ scene.fill(Fill::NonZero, Affine::IDENTITY, col, None, &circle);
+ }
+ EstadoNudo::Pendiente => {
+ // Anillo hueco: un nudo por hacer.
+ scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, col, None, &circle);
+ }
+ EstadoNudo::Hecho => {
+ scene.fill(
+ Fill::NonZero,
+ Affine::IDENTITY,
+ with_alpha(c.color, brillo * 0.35),
+ None,
+ &circle,
+ );
+ }
+ }
+ }
+ if sel_nota == Some(n.id) {
+ let ring = KurboCircle::new((px, py), (NUDO_R + 4.0) as f64);
+ scene.stroke(&Stroke::new(1.8), Affine::IDENTITY, theme.accent, None, &ring);
+ }
+ // Etiquetas sólo en la cuerda seleccionada (o el nudo elegido):
+ // de lejos el khipu se lee por forma, de cerca por texto.
+ if seleccionada || sel_nota == Some(n.id) {
+ let lbl_col = with_alpha(theme.fg_text, (brillo + 0.2).clamp(0.0, 1.0));
+ draw_block(
+ scene,
+ ts,
+ &TextBlock::simple(&n.titulo, 10.0, lbl_col, (px + 10.0, py - 6.0)),
+ );
+ }
+ }
+ }
+}
+
+// =====================================================================
+// Overlays de flujo (compartidos con app.rs)
+// =====================================================================
+
+/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
+/// completo con un margen. El lienzo de fondo sigue recibiendo eventos en
+/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
+pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(8.0_f32),
+ top: length(8.0_f32),
+ bottom: length(8.0_f32),
+ right: auto(),
+ },
+ size: Size {
+ width: length(width),
+ height: auto(),
+ },
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .children(vec![child])
+}
+
+/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
+/// el editor abajo, sobre `bg_panel`.
+pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
+ let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
+ let close_row = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(28.0_f32),
+ },
+ flex_shrink: 0.0,
+ justify_content: Some(JustifyContent::End),
+ align_items: Some(AlignItems::Center),
+ padding: Rect {
+ left: length(6.0_f32),
+ right: length(6.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel_alt)
+ .children(vec![close]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .children(vec![close_row, child])
+}
+
+/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
+/// completo, con barra de cierre — el editor del nudo abierto.
+pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: auto(),
+ top: length(8.0_f32),
+ bottom: length(8.0_f32),
+ right: length(8.0_f32),
+ },
+ size: Size {
+ width: length(width),
+ height: auto(),
+ },
+ flex_direction: FlexDirection::Column,
+ ..Default::default()
+ })
+ .children(vec![editor_shell(child, theme)])
+}
+
+// =====================================================================
+// Color y texto
+// =====================================================================
+
+pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
+ let [r, g, b, _] = c.components;
+ Color::new([r, g, b, alpha])
+}
+
+/// Color estable de una cuerda: matices por ángulo áureo sobre el hue del
+/// accent — determinista por id, sin paleta que se agote.
+pub(crate) fn color_cuerda(id: CuerdaId, theme: Theme) -> Color {
+ rotate_hue(theme.accent, (id as f32 * 0.381_966) % 1.0)
+}
+
+pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
+ // RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
+ let [r, g, b, a] = c.components;
+ let max = r.max(g).max(b);
+ let min = r.min(g).min(b);
+ let v = max;
+ let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
+ let h = if (max - min).abs() < 1e-6 {
+ 0.0
+ } else if max == r {
+ ((g - b) / (max - min)) % 6.0
+ } else if max == g {
+ (b - r) / (max - min) + 2.0
+ } else {
+ (r - g) / (max - min) + 4.0
+ };
+ let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
+ let c2 = v * s;
+ let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
+ let (r2, g2, b2) = match h2 as i32 {
+ 0 => (c2, x, 0.0),
+ 1 => (x, c2, 0.0),
+ 2 => (0.0, c2, x),
+ 3 => (0.0, x, c2),
+ 4 => (x, 0.0, c2),
+ _ => (c2, 0.0, x),
+ };
+ let m = v - c2;
+ Color::new([r2 + m, g2 + m, b2 + m, a])
+}
+
+pub(crate) fn short_label(s: &str) -> String {
+ let s = if s.trim().is_empty() { "(sin título)" } else { s };
+ let mut out: String = s.chars().take(26).collect();
+ if s.chars().count() > 26 {
+ out.push('…');
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::modelo::{Embedder, Model};
+
+ /// Modelo con dos cuerdas: «gorda» (3 nudos, vieja) y «flaca»
+ /// (1 nudo, recién crecida), más un suelto.
+ fn modelo() -> (Model, CuerdaId, CuerdaId) {
+ let mut m = Model::blank(Embedder::Local);
+ let gorda = m.store.crear_cuerda("gorda", 0);
+ for (i, t) in ["a", "b", "c"].iter().enumerate() {
+ let id = m.store.create(*t, "", vec![], 100 + i as u64);
+ m.order.push(id);
+ m.store.anudar(id, gorda);
+ }
+ let flaca = m.store.crear_cuerda("flaca", 0);
+ let id = m.store.create("d", "", vec![], 900);
+ m.order.push(id);
+ m.store.anudar(id, flaca);
+ let suelto = m.store.create("suelto", "", vec![], 950);
+ m.order.push(suelto);
+ (m, gorda, flaca)
+ }
+
+ #[test]
+ fn layout_ordena_por_masa_y_cronologia() {
+ let (m, gorda, flaca) = modelo();
+ let l = layout_cuerdas(&m, 600.0, 1_000);
+ assert_eq!(l.len(), 2);
+ // La gorda a la izquierda (más masa), la flaca después.
+ assert_eq!(l[0].id, gorda);
+ assert_eq!(l[1].id, flaca);
+ assert!(l[0].x < l[1].x);
+ assert!(l[0].grosor > l[1].grosor);
+ // La flaca creció más recién → arde más.
+ assert!(l[1].brillo > l[0].brillo);
+ // Nudos en cronología descendente hacia abajo.
+ let ys: Vec<f32> = l[0].nudos.iter().map(|n| n.y).collect();
+ assert_eq!(l[0].nudos.len(), 3);
+ assert!(ys.windows(2).all(|w| w[0] < w[1]));
+ // Los sueltos no cuelgan de la viga.
+ assert!(l.iter().all(|c| c.nudos.iter().all(|n| n.titulo != "suelto")));
+ }
+
+ #[test]
+ fn pick_encuentra_nudo_cabeza_y_nada() {
+ let (m, gorda, _) = modelo();
+ let l = layout_cuerdas(&m, 600.0, 1_000);
+ let (cx, ny) = (l[0].x, l[0].nudos[0].y);
+ assert!(matches!(pick(&m, cx, ny, 600.0), Pick::Nudo(_, c) if c == gorda));
+ // Sobre el nombre, entre viga y nudos: la cabeza.
+ assert!(matches!(pick(&m, cx, 50.0, 600.0), Pick::Cabeza(c) if c == gorda));
+ // Lejos de todo: nada.
+ assert!(matches!(pick(&m, 5_000.0, 300.0, 600.0), Pick::Nada));
+ }
+
+ #[test]
+ fn max_pan_crece_con_las_cuerdas() {
+ let (m, ..) = modelo();
+ // Dos cuerdas entran holgadas en 1280 px: sin pan.
+ assert_eq!(max_pan(&m, 1280.0), 0.0);
+ // En una ventana angosta sí hay tela que correr.
+ assert!(max_pan(&m, 200.0) > 0.0);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/estado.rs b/00_unanchay/khipu/khipu-app/src/estado.rs
new file mode 100644
index 0000000..4ba66c1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/estado.rs
@@ -0,0 +1,664 @@
+//! `estado` — persistencia, embeddings y helpers de modelo.
+//!
+//! Cubre:
+//! - Formatos de serialización (`PersistedState` V4 + sombras de los
+//! formatos viejos) y su migración.
+//! - `load_state` / `persist`: I/O de disco con postcard.
+//! - `from_state` / `seeded_model`: inicialización del `Model`.
+//! - `embed_now` / `schedule_embedding`: vectores semánticos.
+//! - Helpers de modelo: `select`, `deselect`, `commit_edits`,
+//! `resonancia_de`, `es_sedimento`, `now_secs`, `parse_tags`,
+//! `unlock_identity`, `start_unlock`, `khipu_dir`, `data_file_path`.
+
+use std::collections::BTreeMap;
+use std::path::{Path, PathBuf};
+
+use agora_core::Keypair;
+use directories::ProjectDirs;
+use khipu_core::{Note, NoteId, NoteStore};
+use llimphi_widget_text_editor::EditorState;
+use serde::{Deserialize, Serialize};
+
+use crate::modelo::{Embedder, Focus, Model, Msg, RESONANCIA_MAX, SEDIMENTO_SECS};
+use llimphi_ui::Handle;
+
+// =====================================================================
+// Formatos de persistencia
+// =====================================================================
+
+/// Formato vigente (V4): el store trae nudos **y cuerdas**; las notas ya
+/// no tienen posición de mapa (el lienzo de cuerdas se deriva, no se
+/// persiste) ni las regiones existen.
+#[derive(Serialize, Deserialize)]
+pub(crate) struct PersistedState {
+ pub(crate) store: NoteStore,
+ pub(crate) embeddings: Vec<(NoteId, Vec<f32>)>,
+ pub(crate) order: Vec<NoteId>,
+ /// Etiqueta del espacio vectorial con que se guardaron los embeddings.
+ pub(crate) model: String,
+}
+
+// --- Sombras de los formatos viejos (postcard es posicional: hay que
+// --- replicar la forma exacta con que se escribieron). El `Note` viejo
+// --- tenía `pos` y no tenía cuerda/estado/origen; el store no tenía
+// --- cuerdas; el V3 arrastraba las regiones del mapa.
+
+#[derive(Deserialize)]
+struct NoteVieja {
+ id: NoteId,
+ title: String,
+ body: String,
+ tags: Vec<String>,
+ created_at: u64,
+ updated_at: u64,
+ last_access: u64,
+ mass: f32,
+ /// La posición ancla del mapa muerto. Se descarta al migrar.
+ #[allow(dead_code)]
+ pos: Option<(f32, f32)>,
+}
+
+#[derive(Deserialize)]
+struct NoteStoreViejo {
+ notes: BTreeMap<NoteId, NoteVieja>,
+ #[allow(dead_code)]
+ next_id: NoteId,
+}
+
+#[derive(Deserialize)]
+#[allow(dead_code)]
+struct RegionVieja {
+ name: String,
+ x: f32,
+ y: f32,
+}
+
+/// Formato V3: con `model` y `regions` (los topónimos del mapa muerto).
+#[derive(Deserialize)]
+struct PersistedV3 {
+ store: NoteStoreViejo,
+ embeddings: Vec<(NoteId, Vec<f32>)>,
+ order: Vec<NoteId>,
+ model: String,
+ #[allow(dead_code)]
+ regions: Vec<RegionVieja>,
+}
+
+/// Formato V2: como V3 sin regiones.
+#[derive(Deserialize)]
+struct PersistedV2 {
+ store: NoteStoreViejo,
+ embeddings: Vec<(NoteId, Vec<f32>)>,
+ order: Vec<NoteId>,
+ model: String,
+}
+
+/// Formato V1 histórico, sin `model` (previo a enchufar `verbo`).
+#[derive(Deserialize)]
+struct PersistedV1 {
+ store: NoteStoreViejo,
+ embeddings: Vec<(NoteId, Vec<f32>)>,
+ order: Vec<NoteId>,
+}
+
+/// Migra un store viejo al modelo actual: mismas identidades, sin
+/// posición; todo nace suelto (las cuerdas se anudan de aquí en más).
+fn migrar_store(viejo: NoteStoreViejo) -> NoteStore {
+ let mut store = NoteStore::new();
+ for (_, n) in viejo.notes {
+ store.insert_raw(Note {
+ id: n.id,
+ title: n.title,
+ body: n.body,
+ tags: n.tags,
+ created_at: n.created_at,
+ updated_at: n.updated_at,
+ last_access: n.last_access,
+ mass: n.mass,
+ cuerda: None,
+ estado: Default::default(),
+ origen: None,
+ });
+ }
+ store
+}
+
+// =====================================================================
+// I/O de disco
+// =====================================================================
+
+/// Directorio de datos de khipu (`$XDG_DATA_HOME/khipu/`), creándolo si
+/// hace falta. Raíz de `notes.bin`, `keys/` y `compartido.khipu`.
+pub(crate) fn khipu_dir() -> Option<PathBuf> {
+ let dirs = ProjectDirs::from("org", "tawasuyu", "khipu")?;
+ let dir = dirs.data_dir().to_path_buf();
+ std::fs::create_dir_all(&dir).ok()?;
+ Some(dir)
+}
+
+pub(crate) fn data_file_path() -> Option<PathBuf> {
+ Some(khipu_dir()?.join("notes.bin"))
+}
+
+/// Copia de respaldo única antes de migrar/pisar un formato viejo. Si el
+/// backup ya existe no se toca (conserva el estado pre-migración).
+fn respaldar_una_vez(path: &Path, sufijo: &str) {
+ let bak = path.with_extension(sufijo);
+ if !bak.exists() {
+ let _ = std::fs::copy(path, &bak);
+ }
+}
+
+pub(crate) fn load_state(path: &PathBuf) -> Option<PersistedState> {
+ let bytes = std::fs::read(path).ok()?;
+ // Formato vigente primero. postcard es posicional (no self-describing),
+ // así que un archivo viejo puede "parsear" con basura — el chequeo de
+ // coherencia del store descarta esos falsos positivos.
+ if let Ok(state) = postcard::from_bytes::<PersistedState>(&bytes) {
+ if state.store.coherente() {
+ return Some(state);
+ }
+ }
+ // Formatos del mapa muerto: migran con backup (notes.v3.bak) para que
+ // ninguna migración fallida pueda costar el cuaderno.
+ if let Ok(v3) = postcard::from_bytes::<PersistedV3>(&bytes) {
+ respaldar_una_vez(path, "v3.bak");
+ return Some(PersistedState {
+ store: migrar_store(v3.store),
+ embeddings: v3.embeddings,
+ order: v3.order,
+ model: v3.model,
+ });
+ }
+ if let Ok(v2) = postcard::from_bytes::<PersistedV2>(&bytes) {
+ respaldar_una_vez(path, "v3.bak");
+ return Some(PersistedState {
+ store: migrar_store(v2.store),
+ embeddings: v2.embeddings,
+ order: v2.order,
+ model: v2.model,
+ });
+ }
+ let v1: PersistedV1 = postcard::from_bytes(&bytes).ok()?;
+ respaldar_una_vez(path, "v3.bak");
+ Some(PersistedState {
+ store: migrar_store(v1.store),
+ embeddings: v1.embeddings,
+ order: v1.order,
+ model: String::new(),
+ })
+}
+
+pub(crate) fn persist(model: &Model) {
+ let Some(path) = model.data_path.as_ref() else {
+ return;
+ };
+ let state = PersistedState {
+ store: model.store.clone(),
+ embeddings: model
+ .field
+ .iter()
+ .map(|(id, v)| (id, v.to_vec()))
+ .collect(),
+ order: model.order.clone(),
+ model: model.embedder.label(),
+ };
+ if let Ok(bytes) = postcard::to_allocvec(&state) {
+ let tmp = path.with_extension("bin.tmp");
+ if std::fs::write(&tmp, &bytes).is_ok() {
+ let _ = std::fs::rename(&tmp, path);
+ }
+ }
+}
+
+/// Emite al centro de eventos (willay) un checkpoint de una nota — la entrada de
+/// khipu en la línea de tiempo universal (PLAN-FRONTERA #6). La raíz es el BLAKE3
+/// del contenido serializado de la nota (`postcard` determinista sobre un `Note`,
+/// que es un `BTreeMap`-free struct estable). Se llama en límites discretos
+/// (capturar un nudo), **no** en `persist` — que corre por tecla. **Nunca falla:**
+/// `emitir_silencioso` no-opea sin el daemon willay.
+pub(crate) fn emitir_checkpoint_nota(model: &Model, id: NoteId) {
+ let Some(note) = model.store.get(id) else {
+ return;
+ };
+ let Ok(bytes) = postcard::to_allocvec(note) else {
+ return;
+ };
+ let etiqueta = if note.title.trim().is_empty() {
+ "nudo sin título".to_string()
+ } else {
+ note.title.clone()
+ };
+ let cp = willay_checkpoint::Checkpoint {
+ app: "khipu".to_string(),
+ cosa: format!("nota:{id}"),
+ etiqueta,
+ raiz: *blake3::hash(&bytes).as_bytes(),
+ padre: None,
+ ts_usec: willay_emit::ahora_usec(),
+ autor: willay_checkpoint::Autor::Humano,
+ };
+ willay_emit::emitir_silencioso(&cp.a_evento());
+}
+
+// =====================================================================
+// Inicialización del Model
+// =====================================================================
+
+pub(crate) fn from_state(state: PersistedState, embedder: Embedder) -> Model {
+ // ¿Los vectores guardados son del mismo espacio que el embebedor
+ // activo? Si cambió el modelo o la dimensión, son incomparables:
+ // se descartan y se recalcula todo el cuaderno.
+ let same_space = !state.model.is_empty() && state.model == embedder.label();
+ let mut model = Model::blank(embedder);
+ model.store = state.store;
+ model.order = state.order;
+
+ if same_space {
+ let restored: std::collections::HashSet<NoteId> =
+ state.embeddings.iter().map(|(id, _)| *id).collect();
+ for (id, v) in &state.embeddings {
+ if !v.is_empty() {
+ model.field.insert_etiquetado(*id, v.clone(), &model.embedder.label());
+ }
+ }
+ // Notas sin vector persistido: recalcular sólo esas.
+ let missing: Vec<NoteId> = model
+ .order
+ .iter()
+ .copied()
+ .filter(|id| !restored.contains(id))
+ .collect();
+ for id in missing {
+ embed_now(&mut model, id);
+ }
+ } else {
+ let ids: Vec<NoteId> = model.order.clone();
+ for id in ids {
+ embed_now(&mut model, id);
+ }
+ }
+ model
+}
+
+/// El cuaderno demo de un khipu recién nacido: la cuerda «khipu» — la
+/// idea que originó la app, contada en sus propios nudos — más dos
+/// sueltos esperando resonar. Meta y didáctico a la vez: se ve una
+/// cuerda con masa, un pendiente, y material listo para tejer.
+pub(crate) fn seeded_model(embedder: Embedder) -> Model {
+ let mut model = Model::blank(embedder);
+ let now = now_secs();
+ let dia = 24 * 3600;
+
+ let cuerda = model.store.crear_cuerda("khipu", now.saturating_sub(9 * dia));
+ let nudos_cuerda: [(&str, u64); 3] = [
+ (
+ "quiero un lugar donde botar mis ideas sueltas, en cualquier estado mental",
+ now.saturating_sub(9 * dia),
+ ),
+ (
+ "que las ideas se refuercen entre sí al volver, en vez de decaer por no leerlas",
+ now.saturating_sub(5 * dia),
+ ),
+ (
+ "no otro keep u obsidian cuadrado: la estructura tiene que emerger de la recurrencia",
+ now.saturating_sub(2 * dia),
+ ),
+ ];
+ for (texto, ts) in nudos_cuerda {
+ let id = model.store.create(texto, "", vec![], ts);
+ model.order.push(id);
+ model.store.anudar(id, cuerda);
+ embed_now(&mut model, id);
+ }
+
+ let sueltos: [(&str, u64); 2] = [
+ ("probar el tejido de una cuerda con el daemon de verbo corriendo", now.saturating_sub(dia)),
+ ("la barra de captura de pata podría botar nudos aquí (fase 2)", now),
+ ];
+ for (texto, ts) in sueltos {
+ let id = model.store.create(texto, "", vec![], ts);
+ model.order.push(id);
+ embed_now(&mut model, id);
+ }
+ // El primero de los sueltos es un pendiente — muestra el TODO.
+ if let Some(primero) = model.store.sueltos().last().map(|n| n.id) {
+ if let Some(n) = model.store.get_mut(primero) {
+ n.estado = khipu_core::EstadoNudo::Pendiente;
+ }
+ }
+ model
+}
+
+// =====================================================================
+// Embeddings
+// =====================================================================
+
+// El embedder local de fallback (hash trigram → vector) vive en el core
+// `khipu-gravity` como `local_embed`, junto a los vectores que alimenta (Regla
+// 2). `modelo::Embedder::Local` lo llama directamente desde ahí.
+
+/// Versión síncrona para el arranque (seed y migración de formato):
+/// calcula el vector en línea y lo inserta. En init todavía no hay nada
+/// que repintar, así que bloquear un instante es lo correcto.
+pub(crate) fn embed_now(model: &mut Model, id: NoteId) {
+ let Some(note) = model.store.get(id) else {
+ return;
+ };
+ let combined = format!("{} {}", note.title, note.body);
+ let v = model.embedder.embed_blocking(&combined);
+ model.field.insert_etiquetado(id, v, &model.embedder.label());
+}
+
+/// Pide el embedding de `id` en segundo plano. Asigna una secuencia
+/// nueva, la marca como vigente, y dispara un worker (`Handle::spawn`)
+/// que al terminar reentra al `update` con [`Msg::EmbeddingReady`]. Así
+/// el `block_on` del arm remoto nunca corre en el hilo de UI.
+pub(crate) fn schedule_embedding(model: &mut Model, id: NoteId, h: &Handle<Msg>) {
+ let Some(note) = model.store.get(id) else {
+ return;
+ };
+ let combined = format!("{} {}", note.title, note.body);
+ model.embed_seq += 1;
+ let seq = model.embed_seq;
+ model.embed_latest.insert(id, seq);
+ let embedder = model.embedder.clone();
+ h.spawn(move || {
+ let v = embedder.embed_blocking(&combined);
+ Msg::EmbeddingReady(id, seq, v)
+ });
+}
+
+// =====================================================================
+// Resonancia y sedimento
+// =====================================================================
+
+/// Los vecinos que un nudo toca: afinidad coseno sobre el umbral del
+/// espacio vectorial activo, de mayor a menor, a lo sumo
+/// [`RESONANCIA_MAX`]. Excluye al propio nudo (lo hace `nearest`) y al
+/// tejido de las cuerdas (una síntesis siempre "resuena" con su materia —
+/// no informa).
+pub(crate) fn resonancia_de(model: &Model, id: NoteId) -> Vec<(NoteId, f32)> {
+ let umbral = model.embedder.umbral_resonancia();
+ let tejidos: std::collections::BTreeSet<NoteId> =
+ model.store.cuerdas().filter_map(|c| c.tejido).collect();
+ model
+ .field
+ .nearest(id, RESONANCIA_MAX + tejidos.len())
+ .into_iter()
+ .filter(|(nid, aff)| *aff >= umbral && !tejidos.contains(nid))
+ .take(RESONANCIA_MAX)
+ .collect()
+}
+
+/// `true` si un nudo suelto ya sedimentó: sin cuerda y sin toques desde
+/// hace [`SEDIMENTO_SECS`]. El sedimento no se borra ni se esconde del
+/// buscador — sólo deja la franja de sueltos frescos.
+pub(crate) fn es_sedimento(n: &Note, now: u64) -> bool {
+ n.suelto() && now.saturating_sub(n.updated_at.max(n.created_at)) > SEDIMENTO_SECS
+}
+
+/// Nombre propuesto para una cuerda que nace de estos nudos, con
+/// fallback genérico si no hay señal en los títulos/etiquetas.
+pub(crate) fn nombre_propuesto(model: &Model, ids: &[NoteId]) -> String {
+ let notas: Vec<&Note> = ids.iter().filter_map(|id| model.store.get(*id)).collect();
+ khipu_core::propose_cuerda_name(¬as).unwrap_or_else(|| "idea".to_string())
+}
+
+// =====================================================================
+// Helpers de modelo
+// =====================================================================
+
+pub(crate) fn now_secs() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+pub(crate) fn parse_tags(raw: &str) -> Vec<String> {
+ raw.split(',')
+ .map(|t| t.trim().to_string())
+ .filter(|t| !t.is_empty())
+ .collect()
+}
+
+/// Carga la nota `id` en los campos del editor y foca el cuerpo.
+pub(crate) fn select(model: &mut Model, id: NoteId) {
+ let Some(note) = model.store.get(id) else {
+ return;
+ };
+ model.selected = Some(id);
+ model.title.set_text(note.title.clone());
+ model.body = EditorState::default();
+ model.body.set_text(¬e.body);
+ model.tags.set_text(note.tags.join(", "));
+ model.focus = Focus::Body;
+}
+
+/// Suelta la nota seleccionada y limpia los campos del editor.
+pub(crate) fn deselect(model: &mut Model) {
+ model.selected = None;
+ model.title.set_text(String::new());
+ model.body = EditorState::default();
+ model.tags.set_text(String::new());
+ model.focus = Focus::None;
+}
+
+/// Sincroniza inputs/editor → store/field + persiste si cambió algo.
+pub(crate) fn commit_edits(model: &mut Model, h: &Handle<Msg>) {
+ let Some(id) = model.selected else {
+ return;
+ };
+ let mut changed = false;
+ let new_title = model.title.text();
+ let new_body = model.body.text();
+ let new_tags = parse_tags(&model.tags.text());
+ let now = now_secs();
+ if let Some(note) = model.store.get_mut(id) {
+ if note.title != new_title {
+ note.title = new_title;
+ note.updated_at = now;
+ changed = true;
+ }
+ if note.body != new_body {
+ note.body = new_body;
+ note.updated_at = now;
+ changed = true;
+ }
+ if note.tags != new_tags {
+ note.tags = new_tags;
+ note.updated_at = now;
+ changed = true;
+ }
+ }
+ if changed {
+ // El texto ya está en el store: persistimos de inmediato para no
+ // perderlo. El embedding viaja a un worker y persistirá de nuevo
+ // cuando llegue (`Msg::EmbeddingReady`).
+ persist(model);
+ schedule_embedding(model, id, h);
+ }
+}
+
+/// Desbloquea (o crea, o migra) la identidad del cuaderno con `passphrase`,
+/// vía [`khipu_share::identity::unlock`]. La semilla vive cifrada en
+/// `<datos>/keys/`; si existe un `identidad.seed` en claro de versiones
+/// viejas, se migra al keystore y se borra el claro.
+pub(crate) fn unlock_identity(passphrase: &str) -> Option<Keypair> {
+ let dir = khipu_dir()?;
+ let legacy = dir.join("identidad.seed");
+ khipu_share::identity::unlock(&dir.join("keys"), Some(&legacy), passphrase).ok()
+}
+
+/// Arranca el prompt de passphrase y memoriza la acción a reanudar.
+pub(crate) fn start_unlock(model: &mut Model, accion: Msg) {
+ model.unlocking = true;
+ model.pending = Some(Box::new(accion));
+ model.focus = Focus::Passphrase;
+ model.passphrase.clear();
+ model.status = Some("ingresa tu passphrase para desbloquear la identidad".into());
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Twins serializables de los formatos viejos, con la MISMA forma
+ // posicional con que el khipu del mapa escribía notes.bin. Si estos
+ // structs divergen de los `Deserialize` de arriba, la migración es
+ // mentira — por eso el test escribe bytes de verdad y los carga.
+ #[derive(serde::Serialize)]
+ struct NoteViejaW {
+ id: u64,
+ title: String,
+ body: String,
+ tags: Vec<String>,
+ created_at: u64,
+ updated_at: u64,
+ last_access: u64,
+ mass: f32,
+ pos: Option<(f32, f32)>,
+ }
+ #[derive(serde::Serialize)]
+ struct NoteStoreViejoW {
+ notes: BTreeMap<u64, NoteViejaW>,
+ next_id: u64,
+ }
+ #[derive(serde::Serialize)]
+ struct RegionViejaW {
+ name: String,
+ x: f32,
+ y: f32,
+ }
+ #[derive(serde::Serialize)]
+ struct PersistedV3W {
+ store: NoteStoreViejoW,
+ embeddings: Vec<(u64, Vec<f32>)>,
+ order: Vec<u64>,
+ model: String,
+ regions: Vec<RegionViejaW>,
+ }
+
+ fn nota_vieja(id: u64, title: &str) -> NoteViejaW {
+ NoteViejaW {
+ id,
+ title: title.into(),
+ body: format!("cuerpo de {title}"),
+ tags: vec!["vieja".into()],
+ created_at: 100 + id,
+ updated_at: 200 + id,
+ last_access: 300 + id,
+ mass: 0.7,
+ pos: Some((10.0 * id as f32, -5.0)),
+ }
+ }
+
+ #[test]
+ fn migra_v3_conservando_ids_y_contenido_con_backup() {
+ let mut notes = BTreeMap::new();
+ // Ids con hueco (el 2 fue borrado en su día) — deben conservarse.
+ notes.insert(1, nota_vieja(1, "Primera"));
+ notes.insert(3, nota_vieja(3, "Tercera"));
+ let v3 = PersistedV3W {
+ store: NoteStoreViejoW { notes, next_id: 4 },
+ embeddings: vec![(1, vec![0.5, 0.5]), (3, vec![0.1, 0.9])],
+ order: vec![1, 3],
+ model: "khipu-trigram-16d".into(),
+ regions: vec![RegionViejaW { name: "Zona".into(), x: 1.0, y: 2.0 }],
+ };
+ let bytes = postcard::to_allocvec(&v3).unwrap();
+ let dir = std::env::temp_dir().join(format!("khipu-migra-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("notes.bin");
+ std::fs::write(&path, &bytes).unwrap();
+
+ let state = load_state(&path).expect("el V3 migra");
+ assert!(state.store.coherente());
+ assert_eq!(state.store.len(), 2);
+ let n1 = state.store.get(1).unwrap();
+ assert_eq!(n1.title, "Primera");
+ assert_eq!(n1.body, "cuerpo de Primera");
+ assert_eq!(n1.created_at, 101);
+ assert!(n1.suelto()); // todo migra suelto: las cuerdas se anudan de aquí en más
+ assert!(state.store.get(3).is_some());
+ assert_eq!(state.order, vec![1, 3]);
+ assert_eq!(state.model, "khipu-trigram-16d");
+ assert_eq!(state.embeddings.len(), 2);
+ // El hueco no se reusa: el próximo create no pisa nada.
+ let mut store = state.store.clone();
+ let nuevo = store.create("nueva", "", vec![], 0);
+ assert!(nuevo >= 4);
+ // Backup pre-migración en su lugar.
+ assert!(path.with_extension("v3.bak").exists());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn el_formato_v4_roundtripea_con_cuerdas() {
+ let mut store = NoteStore::new();
+ let a = store.create("nudo a", "", vec![], 10);
+ let b = store.create("nudo b", "", vec![], 20);
+ let c = store.crear_cuerda("idea", 20);
+ store.anudar(a, c);
+ store.anudar(b, c);
+ let state = PersistedState {
+ store,
+ embeddings: vec![(a, vec![1.0]), (b, vec![0.9])],
+ order: vec![a, b],
+ model: "x".into(),
+ };
+ let bytes = postcard::to_allocvec(&state).unwrap();
+ let dir = std::env::temp_dir().join(format!("khipu-v4-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("notes.bin");
+ std::fs::write(&path, &bytes).unwrap();
+
+ let cargado = load_state(&path).expect("el V4 carga directo");
+ assert!(cargado.store.coherente());
+ assert_eq!(cargado.store.cuerdas_len(), 1);
+ assert_eq!(cargado.store.nudos_de(c).len(), 2);
+ // Sin migración no hay backup.
+ assert!(!path.with_extension("v3.bak").exists());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// Prueba de fuego opcional: migrar una COPIA del cuaderno real.
+ /// `KHIPU_MIGRA_REAL=/ruta/a/notes.bin cargo test -p khipu-app migra_el_cuaderno_real`
+ /// No corre en CI (sin la env var es un no-op).
+ #[test]
+ fn migra_el_cuaderno_real_si_esta() {
+ let Ok(origen) = std::env::var("KHIPU_MIGRA_REAL") else {
+ return;
+ };
+ let dir = std::env::temp_dir().join(format!("khipu-real-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let path = dir.join("notes.bin");
+ std::fs::copy(&origen, &path).expect("copiar el cuaderno real");
+ let state = load_state(&path).expect("el cuaderno real carga o migra");
+ assert!(state.store.coherente());
+ assert!(state.store.len() > 0, "un cuaderno real trae notas");
+ println!(
+ "cuaderno real: {} notas · {} cuerdas · modelo «{}»",
+ state.store.len(),
+ state.store.cuerdas_len(),
+ state.model
+ );
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn es_sedimento_respeta_el_umbral_y_las_cuerdas() {
+ let mut store = NoteStore::new();
+ let viejo = store.create("viejo", "", vec![], 0);
+ let now = SEDIMENTO_SECS + 10;
+ assert!(es_sedimento(store.get(viejo).unwrap(), now));
+ // Un nudo anudado nunca sedimenta.
+ let c = store.crear_cuerda("idea", 0);
+ store.anudar(viejo, c);
+ assert!(!es_sedimento(store.get(viejo).unwrap(), now));
+ // Uno fresco tampoco.
+ let fresco = store.create("fresco", "", vec![], now);
+ assert!(!es_sedimento(store.get(fresco).unwrap(), now));
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/main.rs b/00_unanchay/khipu/khipu-app/src/main.rs
index 19366fd..c5bc266 100644
--- a/00_unanchay/khipu/khipu-app/src/main.rs
+++ b/00_unanchay/khipu/khipu-app/src/main.rs
@@ -1,1927 +1,49 @@
-//! `khipu-app` — cuaderno de notas sobre Llimphi.
+//! `khipu-app` — el cuaderno de resonancia sobre Llimphi.
//!
-//! Tres regiones, todas en la misma ventana, sin modal:
-//! - **Lista** (izquierda, 240 px): notas en orden de creación.
-//! Click selecciona. Botón `+ nueva` arriba.
-//! - **Editor** (centro): título (input), cuerpo (text-editor con
-//! wiki-links `[[...]]`), etiquetas (input). Edición directa — la
-//! nota seleccionada se modifica al teclear, sin botón guardar.
-//! - **Gravedad** (derecha): canvas vello que pinta las posiciones
-//! 2D del [`SemanticField::gravity_layout`]. Color por clúster
-//! (umbral 0.55), la seleccionada va resaltada con borde acento.
+//! La ventana es un khipu: captura arriba (siempre lista), franja de
+//! nudos sueltos, y el lienzo de cuerdas — las ideas recurrentes colgadas
+//! de la viga, con sus nudos en cronología y su tejido (síntesis LLM) al
+//! final. Lista y editor son overlays que aparecen sólo cuando hacen
+//! falta.
+//!
+//! **El ciclo**: botás una idea (Enter) → la resonancia muestra qué nudos
+//! previos toca (embeddings) → un click la anuda a una cuerda (o nace una
+//! cuerda nueva) → cuando la cuerda engorda, «tejer» le pide al LLM el
+//! encuadre: qué se repite, qué está claro, qué falta. Nada se olvida:
+//! lo que no resuena sedimenta (buscable, sin estorbar).
//!
//! **Embeddings**: si hay un `verbo-daemon` corriendo en el socket por
//! defecto (`$XDG_RUNTIME_DIR/verbo.sock`) los vectores son reales
-//! (fastembed e5, etc.) — clústeres y vecinos se vuelven semánticos de
-//! verdad. Sin daemon caemos al hash trigram → R^16 local (random
-//! projection 1-bit signed, normalizado): determinista, offline,
-//! idéntico al comportamiento histórico. Ver [`Embedder`]. El cálculo
-//! es async, así que viaja a un worker (`Handle::spawn`) y reentra al
-//! `update` con [`Msg::EmbeddingReady`] — la UI nunca se bloquea.
+//! (fastembed e5, etc.). Sin daemon caemos al hash trigram → R^16 local:
+//! determinista, offline.
//!
//! **Persistencia**: cada mutación graba `$XDG_DATA_HOME/khipu/notes.bin`
-//! con postcard, anotando la etiqueta del espacio vectorial usado. Al
-//! arrancar, si el archivo existe se carga; si el espacio cambió (otro
-//! modelo o dimensión) los vectores se recalculan. Sin archivo se
-//! siembra el cuaderno demo (siete notas en español).
-
-use std::collections::BTreeMap;
-use std::path::PathBuf;
-use std::sync::Arc;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-use agora_core::Keypair;
-use directories::ProjectDirs;
-use khipu_share::SignedBundle;
-use rimay_verbo::Provider;
-use khipu_core::{Note, NoteId, NoteStore};
-use khipu_gravity::{Gravity, Params, SemanticField};
-use llimphi_theme::Theme;
-use llimphi_ui::llimphi_hal::winit::keyboard::{Key, NamedKey};
-use llimphi_ui::llimphi_layout::taffy::{
- prelude::{length, percent, FlexDirection, Rect, Size, Style},
- AlignItems, Dimension, JustifyContent,
-};
-use llimphi_ui::llimphi_text::Alignment;
-use llimphi_ui::{App, Handle, KeyEvent, KeyState, View};
-use llimphi_widget_list::ListPalette;
-use llimphi_widget_text_editor::{EditorMetrics, EditorPalette, EditorState, PointerEvent};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette, TextInputState};
-use llimphi_widget_menubar::{
- menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
- DEFAULT_HEIGHT as MENU_H,
-};
-use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
-use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
-use llimphi_motion::{animate, motion, Tween};
-use llimphi_clipboard::SystemClipboard;
-use serde::{Deserialize, Serialize};
-
-mod map;
+//! con postcard (formato V4: nudos + cuerdas). Los cuadernos del mapa
+//! viejo (V1–V3) migran solos, con backup en `notes.v3.bak`.
+//!
+//! ## Organización de módulos
+//!
+//! | módulo | contenido |
+//! |-----------|-------------------------------------------------------------|
+//! | `modelo` | tipos (`Embedder`, `Focus`, `Msg`, `Model`, constantes) |
+//! | `estado` | persistencia + migración, embeddings, resonancia, helpers |
+//! | `menu` | menú principal + menú de edición contextual |
+//! | `app` | `KhipuApp` + implementación `App` (init/update/view/on_key) |
+//! | `cuerdas` | el lienzo del khipu: layout, painter, hit-test, pan |
+//! | `panels` | captura, sueltos, resonancia, tarjeta de cuerda, cajón, editor |
+//! | `tejido` | la síntesis LLM de una cuerda (pluma-llm) |
+//! | `net` | lado P2P: exportar/importar/publicar/recibir |
+
+mod app;
+mod cuerdas;
+mod estado;
+mod menu;
+mod modelo;
mod net;
mod panels;
-use map::*;
-use net::*;
-use panels::*;
-
-/// Dimensión del embebedor local (fallback sin daemon).
-const EMBED_DIM: usize = 16;
-const CLUSTER_THRESHOLD: f32 = 0.55;
-const EDITOR_VISIBLE_LINES: usize = 24;
-const LIST_WIDTH: f32 = 240.0;
-/// Ancho del editor flotante (overlay derecho sobre el mapa).
-const EDITOR_OVERLAY_W: f32 = 420.0;
-/// Zoom a partir del cual el nodo seleccionado deja de editarse en el panel
-/// lateral y pasa a abrirse como tarjeta anclada a su coordenada en el mapa
-/// (zoom semántico). Por debajo, el editor vuelve al overlay derecho.
-const ZOOM_INJECT: f32 = 1.6;
-const HEADER_H: f32 = 36.0;
-const ROW_H: f32 = 26.0;
-const FIELD_LABEL_SIZE: f32 = 10.0;
-
-/// Fuente de vectores semánticos. Con un `verbo-daemon` en el socket por
-/// defecto usa embeddings reales; si no hay daemon cae al hash-trigram
-/// local de 16d — determinista, offline, sin runtime.
-///
-/// El arm remoto guarda el `Runtime` de tokio para resolver las llamadas
-/// async del `Provider` con `block_on` desde el hilo worker que las
-/// dispara (nunca el de UI). Es `Clone` (todo tras `Arc`) para viajar
-/// barato dentro de la closure de `Handle::spawn`.
-#[derive(Clone)]
-enum Embedder {
- /// Daemon `rimay-verbo` por socket Unix.
- Remote {
- provider: Arc<dyn Provider>,
- rt: Arc<tokio::runtime::Runtime>,
- dim: usize,
- label: String,
- },
- /// Fallback local: hash-trigram → R^EMBED_DIM, sin red ni runtime.
- Local,
-}
-
-impl Embedder {
- /// Conecta al `verbo-daemon` en el socket por defecto. Si no hay
- /// ninguno (o no se pudo armar el runtime), devuelve el embebedor
- /// local — los demos arrancan igual, sin red.
- fn connect() -> Self {
- let rt = match tokio::runtime::Builder::new_multi_thread()
- .worker_threads(2)
- .enable_all()
- .build()
- {
- Ok(rt) => rt,
- Err(_) => return Embedder::Local,
- };
- match rt.block_on(rimay_verbo::conectar()) {
- Ok(client) => {
- let id = client.model_id();
- let dim = id.dimension;
- let label = id.to_string();
- Embedder::Remote {
- provider: Arc::new(client),
- rt: Arc::new(rt),
- dim,
- label,
- }
- }
- Err(_) => Embedder::Local,
- }
- }
-
- /// Etiqueta del espacio vectorial. Si cambia entre dos arranques, los
- /// vectores persistidos son incomparables (otro modelo o dimensión) y
- /// hay que recalcularlos — ver [`from_state`].
- fn label(&self) -> String {
- match self {
- Embedder::Remote { label, .. } => label.clone(),
- Embedder::Local => format!("khipu-trigram-{EMBED_DIM}d"),
- }
- }
-
- /// Embebe `text` de forma bloqueante. En el arm remoto resuelve el
- /// future con `block_on`; ante un error del backend devuelve un
- /// vector de ceros (afinidad nula con todo, nunca panic).
- fn embed_blocking(&self, text: &str) -> Vec<f32> {
- match self {
- Embedder::Local => embed(text, EMBED_DIM),
- Embedder::Remote { provider, rt, dim, .. } => rt
- .block_on(provider.embed(text))
- .map(|v| v.values)
- .unwrap_or_else(|_| vec![0.0; *dim]),
- }
- }
-}
-
-/// Foco activo del teclado. Cualquier `KeyEvent` se rutea al input
-/// correspondiente; sin foco las teclas se ignoran.
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum Focus {
- None,
- Search,
- Title,
- Body,
- Tags,
- Passphrase,
- PeerAddr,
- /// Input del nombre de una región emergente (bautizo de un clúster).
- Region,
-}
-
-/// Una región del mapa: un nombre pinchado en una coordenada de mundo. No
-/// es una carpeta — es un topónimo. Nace cuando el usuario bautiza un
-/// clúster denso que el mapa detectó, y de ahí queda como landmark fijo;
-/// los pensamientos cercanos "pertenecen" a esa zona por vecindad, no por
-/// asignación. Las placas tectónicas emergen del caos, no se imponen.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-struct Region {
- name: String,
- x: f32,
- y: f32,
-}
-
-/// Nodo libp2p del cuaderno + su runtime tokio (que lo mantiene vivo). Se
-/// arma perezosamente la primera vez que se usa P2P (`ensure_p2p`).
-struct P2p {
- rt: Arc<tokio::runtime::Runtime>,
- node: Arc<khipu_brahman::KhipuNode>,
- /// Nuestra dirección para compartir (`/ip4/.../tcp/.../p2p/<id>`).
- dial_addr: String,
- /// `true` cuando ya estamos sirviendo el cuaderno por libp2p.
- serving: bool,
-}
-
-/// Un par descubierto en la red, en forma lista para la UI: dónde jalarle
-/// el cuaderno y una etiqueta legible. Datos planos (no `PeerVisto`) para
-/// viajar dentro de un `Msg`.
-#[derive(Clone)]
-struct PeerInfo {
- /// Dirección TCP de fetch, como string (`ip:puerto`).
- addr: String,
- /// Etiqueta para la fila: nombre · de:autor · dirección.
- label: String,
-}
-
-#[derive(Clone)]
-enum Msg {
- SelectNote(NoteId),
- NewNote,
- DeleteSelected,
- ToggleArchive,
- Focus(Focus),
- Key(KeyEvent),
- EditorPointer(PointerEvent),
- /// Latido — fuerza el rerender para que la masa decaiga
- /// visiblemente aunque el usuario no esté tocando nada.
- Tick,
- /// Resultado async de un embed: `(nota, secuencia, vector)`. Se
- /// aplica sólo si `secuencia` sigue siendo la más reciente para esa
- /// nota — descarta cálculos que ediciones posteriores dejaron viejos.
- EmbeddingReady(NoteId, u64, Vec<f32>),
- /// Sella todo el cuaderno en un sobre firmado (`compartido.khipu`).
- Export,
- /// Verifica e ingiere `compartido.khipu` como notas nuevas.
- Import,
- /// Empieza a servir el cuaderno por TCP para que un par lo jale.
- Publish,
- /// Busca pares en la LAN para jalarles el cuaderno.
- Receive,
- /// Resultado del descubrimiento: los pares vistos (ya sin uno mismo).
- PeersFound(Vec<PeerInfo>),
- /// Jala el cuaderno del par en esta dirección TCP.
- FetchFrom(String),
- /// Jala de la dirección escrita a mano (input) — habilita WAN: cualquier
- /// `host:puerto` alcanzable, no sólo pares descubiertos en la LAN.
- FetchManual,
- /// Cierra el panel de recibir sin jalar nada.
- CancelPeers,
- /// Resultado async de un fetch: el sobre recibido o un error.
- Received(Result<SignedBundle, String>),
- /// Intenta desbloquear la identidad con la passphrase tipeada.
- Unlock,
- /// Cierra el prompt de passphrase sin desbloquear.
- CancelUnlock,
- /// Resultado async de reservar un circuito en un relay: la dirección de
- /// marcado vía circuito (o un mensaje de error) para mostrar.
- RelayReady(String),
- /// Abre/cierra un dropdown de la barra de menú principal (índice del menú).
- MenuOpen(Option<usize>),
- /// Comando elegido en la barra de menú (`command` de cada `MenuItem`).
- MenuCommand(String),
- /// Navegación por teclado en el menú principal (`+1` baja, `-1` sube).
- MenuNav(i32),
- /// Enter en el menú principal: ejecuta la fila activa.
- MenuActivate,
- /// Tick de animación de menús (sólo re-render).
- MenuTick,
- /// Navegación por teclado en el menú de edición.
- EditNav(i32),
- /// Enter en el menú de edición: ejecuta la fila activa.
- EditActivate,
- /// Abre el menú de edición contextual (right-click) en coords de ventana.
- EditMenuOpen(f32, f32),
- /// Acción de edición (undo/redo/cut/copy/paste/delete/selectall) sobre
- /// el campo focuseado.
- EditMenuAction(EditAction),
- /// Cierra cualquier menú abierto (principal o de edición).
- CloseMenus,
- /// Pan del mapa: delta de arrastre en pixels de pantalla.
- MapPan(f32, f32),
- /// Zoom del mapa: delta de rueda (líneas, signo winit). Acerca/aleja.
- MapZoom(f32),
- /// Click en el lienzo en coords locales `(lx, ly)` sobre un rect
- /// `(w, h)`: selecciona la nota más cercana bajo el cursor, si hay.
- MapClick(f32, f32, f32, f32),
- /// Abre/cierra el cajón de notas (overlay izquierdo).
- ToggleList,
- /// Cierra el editor flotante: deselecciona y vuelve al mapa limpio.
- Deselect,
- /// Escape en el mapa: cierra lo de más arriba (editor → cajón → foco).
- EscapeMap,
- /// Empieza a bautizar una región en la coordenada de mundo `(x, y)`
- /// (el centroide del clúster denso que se ofreció nombrar).
- BeginNaming(f32, f32),
- /// Confirma el bautizo: crea la región con el texto tipeado.
- CommitNaming,
- /// Cancela el bautizo sin crear región.
- CancelNaming,
-}
-
-struct Model {
- store: NoteStore,
- field: SemanticField,
- /// Orden de inserción (estable). La presentación se reordena por
- /// masa decreciente al renderizar.
- order: Vec<NoteId>,
- selected: Option<NoteId>,
- title: TextInputState,
- body: EditorState,
- tags: TextInputState,
- search: TextInputState,
- focus: Focus,
- theme: Theme,
- data_path: Option<PathBuf>,
- /// Física temporal: vida media + boost + horizonte.
- gravity: Gravity,
- /// `true` cuando el usuario quiere ver también las notas que
- /// cayeron del horizonte. Default `false`.
- show_archive: bool,
- /// Fuente de embeddings: daemon `verbo` o fallback trigram local.
- embedder: Embedder,
- /// Última secuencia de embedding pedida por nota. Un resultado async
- /// (`Msg::EmbeddingReady`) sólo se aplica si su secuencia coincide
- /// con la vigente aquí; así una edición rápida invalida el cálculo
- /// de la anterior sin condición de carrera.
- embed_latest: BTreeMap<NoteId, u64>,
- /// Contador monótono de pedidos de embedding.
- embed_seq: u64,
- /// Identidad Ed25519 del cuaderno, para firmar/exportar sobres
- /// (`khipu-share`). `None` si no hay directorio de datos.
- keypair: Option<Keypair>,
- /// Última línea de estado (export/import/red). Se pinta en una barra
- /// al pie cuando es `Some`.
- status: Option<String>,
- /// `true` cuando ya hay un servidor TCP sirviendo el cuaderno. Evita
- /// rebindear el puerto si se pulsa «publicar» dos veces.
- publishing: bool,
- /// `true` mientras el panel izquierdo está en modo "recibir": input de
- /// dirección + lista de pares descubiertos.
- receiving: bool,
- /// Pares descubiertos en la última búsqueda (filas clickeables).
- peers: Vec<PeerInfo>,
- /// Dirección manual del par para recibir (habilita WAN). Prellenada
- /// con `KHIPU_PEER` o el default; editable.
- peer_input: TextInputState,
- /// Input de la passphrase para desbloquear la identidad.
- passphrase: TextInputState,
- /// `true` mientras se muestra el prompt de passphrase (modal).
- unlocking: bool,
- /// Acción a reanudar tras desbloquear (lo que el usuario quiso hacer
- /// y disparó el prompt). Se redispatcha al lograr el unlock.
- pending: Option<Box<Msg>>,
- /// Nodo libp2p (perezoso): `Some` una vez que se usó P2P.
- p2p: Option<P2p>,
- /// Dropdown abierto de la barra de menú (índice), o `None` si cerrada.
- menu_open: Option<usize>,
- /// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
- menu_active: usize,
- /// Animación de aparición/swap del dropdown del menú principal (0→1).
- menu_anim: Tween<f32>,
- /// Posición (coords de ventana) del menú de edición contextual, si abierto.
- edit_menu: Option<(f32, f32)>,
- /// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
- edit_active: usize,
- /// Animación de aparición del menú de edición (0→1).
- edit_anim: Tween<f32>,
- /// Portapapeles del sistema, compartido por todas las acciones de edición.
- clipboard: SystemClipboard,
- /// Desplazamiento de la cámara del mapa, en coordenadas de mundo. El
- /// lienzo es infinito; arrastrar el fondo desplaza este vector.
- cam_pan: (f32, f32),
- /// Escala de la cámara del mapa (1.0 = mundo:pantalla). La rueda la
- /// cambia; el zoom semántico futuro decidirá qué se inyecta según ella.
- cam_zoom: f32,
- /// `true` mientras el cajón de notas (overlay izquierdo) está abierto.
- /// El mapa es la interfaz; la lista es un cajón invocable, no un panel
- /// permanente. Default `true` para no perder al usuario en el primer
- /// arranque.
- show_list: bool,
- /// Último tamaño conocido del lienzo `(w, h)` en pixels. Lo aprende de
- /// cada click (`on_click_at` lo trae) y sirve para anclar la tarjeta
- /// del nodo en su coordenada de pantalla durante el zoom semántico,
- /// que se calcula en `view()` antes de que corra el layout. Se corrige
- /// solo en el siguiente click tras un resize.
- canvas_size: (f32, f32),
- /// Topónimos del mapa: regiones bautizadas (landmarks persistidos).
- regions: Vec<Region>,
- /// Coordenada de mundo de la región que se está bautizando ahora mismo
- /// (input abierto), o `None`.
- naming: Option<(f32, f32)>,
- /// Input del nombre de la región en curso.
- region_input: TextInputState,
-}
-
-struct KhipuApp;
-
-impl App for KhipuApp {
- type Model = Model;
- type Msg = Msg;
-
- fn init(handle: &Handle<Msg>) -> Model {
- // Conectamos al daemon una sola vez al arrancar; el embebedor
- // resultante (remoto o local) se clona barato a cada worker.
- let embedder = Embedder::connect();
- let data_path = data_file_path();
- let mut model = match data_path.as_ref().and_then(load_state) {
- Some(state) => from_state(state, embedder),
- None => seeded_model(embedder),
- };
- model.data_path = data_path;
- // Identidad: si `KHIPU_PASSPHRASE` está en el entorno, desbloqueamos
- // (o creamos/migramos) sin prompt — útil headless. Si no, queda
- // bloqueada y se pide la passphrase al primer intento de compartir.
- model.keypair = std::env::var("KHIPU_PASSPHRASE")
- .ok()
- .and_then(|p| unlock_identity(&p));
- model.theme = Theme::dark();
- // Con bootstrap configurado, arrancamos el nodo libp2p ya, para que
- // la malla DHT esté caliente cuando el usuario quiera descubrir.
- if std::env::var("KHIPU_BOOTSTRAP").is_ok() {
- ensure_p2p(&mut model);
- }
- // Elegimos la primera nota más pesada (decayendo on-the-fly);
- // si todo el cuaderno está en archivo, caemos al orden de
- // inserción para no abrir vacío.
- let first = first_visible(&model).or_else(|| model.order.first().copied());
- if let Some(id) = first {
- reinforce_and_touch(&mut model, id);
- select(&mut model, id);
- }
- persist(&model);
- // Latido cada 30 s — la masa decae en disco como en pantalla.
- handle.spawn_periodic(std::time::Duration::from_secs(30), || Msg::Tick);
- model
- }
-
- fn update(mut model: Model, msg: Msg, h: &Handle<Msg>) -> Model {
- match msg {
- Msg::SelectNote(id) => {
- commit_edits(&mut model, h);
- reinforce_and_touch(&mut model, id);
- select(&mut model, id);
- persist(&model);
- }
- Msg::NewNote => {
- commit_edits(&mut model, h);
- let now = now_secs();
- let id = model.store.create("Nota nueva", "", Vec::new(), now);
- model.order.push(id);
- schedule_embedding(&mut model, id, h);
- select(&mut model, id);
- persist(&model);
- }
- Msg::ToggleArchive => {
- model.show_archive = !model.show_archive;
- }
- Msg::Tick => {
- // No muta nada: la masa vive en `current_mass` (decay
- // contra `last_access`). El Tick existe sólo para
- // pedirle al event loop un redraw.
- }
- Msg::EmbeddingReady(id, seq, v) => {
- // Aplicamos el vector sólo si sigue siendo el cálculo más
- // reciente para esa nota y la nota no fue borrada entre
- // medio. Tras insertarlo, persistimos para que el campo
- // semántico en disco quede al día.
- if model.embed_latest.get(&id) == Some(&seq)
- && model.store.get(id).is_some()
- {
- model.field.insert(id, v);
- // Recién ahora la nota tiene vector: le damos domicilio
- // en el mapa una sola vez, cerca de sus parientes. Si ya
- // tenía posición (re-embed por edición), no se mueve.
- place_note(&mut model, id);
- persist(&model);
- }
- }
- Msg::Export => {
- // Firmar requiere identidad: si está bloqueada, pedimos la
- // passphrase y reanudamos el export al desbloquear.
- if model.keypair.is_none() {
- start_unlock(&mut model, Msg::Export);
- } else {
- commit_edits(&mut model, h);
- model.status = Some(export_notebook(&model));
- }
- }
- Msg::Import => {
- let report = import_notebook(&mut model, h);
- persist(&model);
- model.status = Some(report);
- }
- Msg::Publish => {
- if model.keypair.is_none() {
- start_unlock(&mut model, Msg::Publish);
- } else {
- // Asegura que el sobre en disco refleje lo editado, luego
- // levanta (una vez) el servidor TCP que lo sirve.
- commit_edits(&mut model, h);
- let _ = export_notebook(&model);
- model.status = Some(start_publishing(&mut model, h));
- }
- }
- Msg::Receive => {
- let my_key = model.keypair.as_ref().map(|k| k.public_key());
- // Abrimos el panel de recibir ya: input de dirección
- // (prellenado, editable para WAN) + lista que se irá
- // poblando con lo que aparezca en la LAN.
- model.receiving = true;
- model.peers.clear();
- if model.peer_input.is_empty() {
- model.peer_input.set_text(peer_addr());
- }
- model.focus = Focus::PeerAddr;
- model.status = Some("buscando pares (LAN + DHT)… o escribí una dirección".into());
- // Si hay nodo libp2p (bootstrap configurado), también
- // consultamos la DHT; lo capturamos para el worker.
- let dht = model.p2p.as_ref().map(|p| (p.rt.clone(), p.node.clone()));
- // El descubrimiento bloquea: va a un worker y reentra con la
- // lista de pares (LAN por UDP + DHT por libp2p, sin uno mismo).
- h.spawn(move || {
- let mut infos: Vec<PeerInfo> =
- khipu_share::discovery::descubrir(std::time::Duration::from_secs(3))
- .unwrap_or_default()
- .into_iter()
- .filter(|p| Some(p.beacon.author) != my_key)
- .map(|p| PeerInfo {
- addr: p.fetch_addr.to_string(),
- label: format!(
- "LAN · {} · de:{} · {}",
- p.beacon.name,
- khipu_share::hex8(&p.beacon.author),
- p.fetch_addr
- ),
- })
- .collect();
- if let Some((rt, node)) = dht {
- let me = node.peer_id();
- for pid in rt.block_on(node.descubrir()) {
- if pid == me {
- continue;
- }
- let s = pid.to_string();
- let corto: String = s.chars().rev().take(8).collect::<Vec<_>>()
- .into_iter().rev().collect();
- infos.push(PeerInfo {
- label: format!("DHT · …{corto}"),
- addr: s,
- });
- }
- }
- Msg::PeersFound(infos)
- });
- }
- Msg::PeersFound(peers) => {
- // Sólo aplica si seguimos en modo recibir (no cancelado).
- if model.receiving {
- model.status = Some(if peers.is_empty() {
- "ningún par en la LAN — escribí una dirección y jalá".into()
- } else {
- format!("{} pares en la red — elegí uno o escribí una dirección", peers.len())
- });
- model.peers = peers;
- }
- }
- Msg::FetchManual => {
- let addr = model.peer_input.text().trim().to_string();
- if addr.is_empty() {
- model.status = Some("escribí una dirección host:puerto".into());
- } else {
- h.dispatch(Msg::FetchFrom(addr));
- }
- }
- Msg::FetchFrom(addr) => {
- model.receiving = false;
- model.peers.clear();
- model.focus = Focus::None;
- let destino = addr.trim().to_string();
- if destino.starts_with('/') || !destino.contains(':') {
- // Vía libp2p: multiaddr (`/ip4/…/p2p/<id>`, incluido
- // circuito) o un peer-id pelado (descubierto por DHT).
- // Arma el nodo si hace falta.
- if ensure_p2p(&mut model) {
- let p = model.p2p.as_ref().expect("p2p recién armado");
- let (rt, node) = (p.rt.clone(), p.node.clone());
- let es_multiaddr = destino.starts_with('/');
- model.status = Some(format!("jalando por libp2p de {destino}…"));
- h.spawn(move || {
- let res = if es_multiaddr {
- rt.block_on(node.fetch_addr_str(&destino))
- } else {
- rt.block_on(node.fetch_peer_str(&destino))
- };
- match res {
- Ok(s) => Msg::Received(Ok(s)),
- Err(e) => Msg::Received(Err(format!("p2p: {e}"))),
- }
- });
- } else {
- model.status = Some("no se pudo iniciar el nodo libp2p".into());
- }
- } else {
- // Dirección TCP `host:puerto` (LAN/WAN directa).
- model.status = Some(format!("jalando de {destino}…"));
- h.spawn(move || match khipu_share::net::fetch(&destino) {
- Ok(s) => Msg::Received(Ok(s)),
- Err(e) => {
- Msg::Received(Err(format!("no se pudo recibir de {destino}: {e}")))
- }
- });
- }
- }
- Msg::CancelPeers => {
- model.receiving = false;
- model.peers.clear();
- model.focus = Focus::None;
- model.status = Some("recibir cancelado".into());
- }
- Msg::Received(res) => {
- model.receiving = false;
- model.peers.clear();
- model.status = Some(match res {
- Ok(sobre) => match khipu_share::open(&sobre) {
- Ok(bundle) => {
- let now = now_secs();
- let outcome =
- khipu_share::import_into(&mut model.store, bundle, now);
- for id in &outcome.created {
- model.order.push(*id);
- schedule_embedding(&mut model, *id, h);
- }
- persist(&model);
- format!(
- "recibidas {} · omitidas {} (ya existían)",
- outcome.created.len(),
- outcome.skipped
- )
- }
- Err(_) => "firma inválida — sobre rechazado".into(),
- },
- Err(e) => e,
- });
- }
- Msg::Unlock => {
- let pass = model.passphrase.text();
- match unlock_identity(&pass) {
- Some(kp) => {
- let id = khipu_share::hex8(&kp.public_key());
- model.keypair = Some(kp);
- model.unlocking = false;
- model.passphrase.clear();
- model.focus = Focus::None;
- model.status = Some(format!("identidad desbloqueada · {id}"));
- // Reanudar lo que el usuario quería hacer.
- if let Some(accion) = model.pending.take() {
- h.dispatch(*accion);
- }
- }
- None => {
- model.status =
- Some("passphrase incorrecta o sin acceso al keystore".into());
- }
- }
- }
- Msg::CancelUnlock => {
- model.unlocking = false;
- model.pending = None;
- model.passphrase.clear();
- model.focus = Focus::None;
- model.status = Some("desbloqueo cancelado".into());
- }
- Msg::RelayReady(addr) => {
- model.status = Some(format!("alcanzable vía relay: {addr}"));
- }
- Msg::DeleteSelected => {
- if let Some(id) = model.selected {
- model.store.remove(id);
- model.order.retain(|x| *x != id);
- model.field.remove(id);
- let next = model.order.first().copied();
- model.selected = None;
- model.title.clear();
- model.body = EditorState::default();
- model.tags.clear();
- if let Some(n) = next {
- select(&mut model, n);
- }
- persist(&model);
- }
- }
- Msg::Focus(f) => {
- commit_edits(&mut model, h);
- model.focus = f;
- }
- Msg::Key(ev) => {
- let changed = match model.focus {
- Focus::Title => model.title.apply_key(&ev),
- Focus::Body => model.body.apply_key(&ev).touched(),
- Focus::Tags => model.tags.apply_key(&ev),
- Focus::Search => {
- // El search no muta el store: filtramos al
- // renderizar. Sólo consumimos el evento.
- let _ = model.search.apply_key(&ev);
- false
- }
- Focus::Passphrase => {
- // La passphrase no toca el store; sólo el input.
- let _ = model.passphrase.apply_key(&ev);
- false
- }
- Focus::PeerAddr => {
- let _ = model.peer_input.apply_key(&ev);
- false
- }
- Focus::Region => {
- let _ = model.region_input.apply_key(&ev);
- false
- }
- Focus::None => false,
- };
- if changed {
- commit_edits(&mut model, h);
- }
- }
- Msg::EditorPointer(ev) => {
- let metrics = EditorMetrics::for_font_size(13.0);
- match ev {
- PointerEvent::Click { x, y } => {
- let (line, col) = metrics.screen_to_pos(x, y, model.body.scroll_offset);
- model.body.set_caret_at(line, col);
- }
- PointerEvent::Drag { initial_x, initial_y, dx, dy } => {
- let (l0, c0) = metrics.screen_to_pos(
- initial_x,
- initial_y,
- model.body.scroll_offset,
- );
- let (l1, c1) = metrics.screen_to_pos(
- initial_x + dx,
- initial_y + dy,
- model.body.scroll_offset,
- );
- model.body.set_caret_at(l0, c0);
- model.body.extend_selection_to(l1, c1);
- }
- }
- model.focus = Focus::Body;
- }
- Msg::MenuOpen(idx) => {
- model.menu_open = idx;
- model.menu_active = usize::MAX;
- model.edit_menu = None;
- if idx.is_some() {
- model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
- animate(h, motion::FAST, || Msg::MenuTick);
- }
- }
- Msg::MenuCommand(cmd) => {
- return handle_menu_command(model, cmd, h);
- }
- Msg::MenuNav(dir) => {
- if let Some(mi) = model.menu_open {
- let menu = app_menu(&model);
- model.menu_active = menubar_nav(&menu, mi, model.menu_active, dir);
- }
- }
- Msg::MenuActivate => {
- if let Some(mi) = model.menu_open {
- let menu = app_menu(&model);
- if let Some(cmd) = menubar_command_at(&menu, mi, model.menu_active) {
- return handle_menu_command(model, cmd, h);
- }
- }
- }
- Msg::MenuTick => {}
- Msg::EditNav(dir) => {
- let flags = focused_edit_flags(&model);
- model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
- }
- Msg::EditActivate => {
- let flags = focused_edit_flags(&model);
- if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
- return apply_edit_menu_action(model, action, h);
- }
- }
- Msg::EditMenuOpen(x, y) => {
- model.edit_menu = Some((x, y));
- model.edit_active = usize::MAX;
- model.menu_open = None;
- model.edit_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
- animate(h, motion::FAST, || Msg::MenuTick);
- }
- Msg::EditMenuAction(action) => {
- return apply_edit_menu_action(model, action, h);
- }
- Msg::CloseMenus => {
- model.menu_open = None;
- model.menu_active = usize::MAX;
- model.edit_menu = None;
- model.edit_active = usize::MAX;
- }
- Msg::MapPan(dx, dy) => {
- // El delta viene en pixels de pantalla; lo llevamos a
- // mundo dividiendo por el zoom para que el arrastre se
- // sienta 1:1 con el cursor a cualquier escala.
- let z = model.cam_zoom.max(0.01);
- model.cam_pan.0 += dx / z;
- model.cam_pan.1 += dy / z;
- }
- Msg::MapZoom(dy) => {
- // dy>0 = rueda hacia el usuario (winit invierte el signo en
- // el event loop) → alejar. Factor multiplicativo, clamp para
- // no perder el mapa.
- let factor = (1.0 - dy * 0.12).clamp(0.5, 2.0);
- model.cam_zoom = (model.cam_zoom * factor).clamp(0.15, 6.0);
- }
- Msg::MapClick(lx, ly, rw, rh) => {
- model.canvas_size = (rw, rh);
- if let Some(id) = pick_note(&model, lx, ly, rw, rh) {
- commit_edits(&mut model, h);
- reinforce_and_touch(&mut model, id);
- select(&mut model, id);
- persist(&model);
- }
- }
- Msg::ToggleList => {
- model.show_list = !model.show_list;
- }
- Msg::Deselect => {
- commit_edits(&mut model, h);
- deselect(&mut model);
- persist(&model);
- }
- Msg::BeginNaming(x, y) => {
- model.naming = Some((x, y));
- model.region_input.clear();
- model.focus = Focus::Region;
- }
- Msg::CommitNaming => {
- if let Some((x, y)) = model.naming.take() {
- let name = model.region_input.text().trim().to_string();
- if !name.is_empty() {
- model.regions.push(Region { name, x, y });
- persist(&model);
- }
- }
- model.region_input.clear();
- model.focus = Focus::None;
- }
- Msg::CancelNaming => {
- model.naming = None;
- model.region_input.clear();
- model.focus = Focus::None;
- }
- Msg::EscapeMap => {
- // Cierra la capa más cercana al usuario, en orden.
- if model.naming.is_some() {
- model.naming = None;
- model.region_input.clear();
- model.focus = Focus::None;
- } else if model.selected.is_some() {
- commit_edits(&mut model, h);
- deselect(&mut model);
- persist(&model);
- } else if model.show_list {
- model.show_list = false;
- } else {
- model.focus = Focus::None;
- }
- }
- }
- model
- }
-
- fn view(model: &Model) -> View<Msg> {
- let palette = ListPalette::from_theme(&model.theme);
- let input_palette = TextInputPalette::from_theme(&model.theme);
- let editor_palette = EditorPalette::from_theme(&model.theme);
-
- // Prompt de passphrase: ocupa toda la ventana hasta resolverse.
- if model.unlocking {
- let mut children = vec![header_view(model), unlock_view(model, &input_palette)];
- if let Some(bar) = status_bar(model) {
- children.push(bar);
- }
- return View::new(Style {
- flex_direction: FlexDirection::Column,
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- ..Default::default()
- })
- .fill(model.theme.bg_app)
- .children(children);
- }
-
- let header = header_view(model);
-
- // Zoom semántico: con una nota seleccionada y el mapa lo bastante
- // cerca, el nodo se "abre" como tarjeta anclada a su coordenada
- // (in-situ). Lejos, el editor cae al panel lateral — un fallback
- // para editar sin tener que acercarse.
- let inplace = model
- .selected
- .filter(|_| model.cam_zoom >= ZOOM_INJECT)
- .and_then(|id| node_screen_pos(model, id).map(|p| (id, p)));
-
- // El mapa es la interfaz: ocupa todo el cuerpo como capa de fondo.
- // Sobre él viajan, como hijos del canvas: la tarjeta del nodo
- // abierto (zoom semántico), los chips para bautizar clústeres
- // densos, y el input del bautizo en curso.
- let mut injected: Vec<View<Msg>> = Vec::new();
- if let Some((_, (nx, ny))) = inplace {
- let editor = editor_panel(model, &input_palette, &editor_palette);
- injected.push(node_card(editor, nx, ny, model.canvas_size, &model.theme));
- }
- // Sugerencias de bautizo (sólo si no estamos editando in-situ, para
- // no encimar la tarjeta).
- if inplace.is_none() {
- for (wx, wy) in unnamed_cluster_centroids(model) {
- let (sx, sy) = world_screen(model, wx, wy);
- injected.push(pinned(
- name_region_chip(wx, wy, &model.theme),
- sx,
- sy,
- 132.0,
- 24.0,
- model.canvas_size,
- ));
- }
- }
- // Input del bautizo en curso, anclado al centroide elegido.
- if let Some((wx, wy)) = model.naming {
- let (sx, sy) = world_screen(model, wx, wy);
- injected.push(pinned(
- naming_input(model, &input_palette),
- sx,
- sy,
- 220.0,
- 34.0,
- model.canvas_size,
- ));
- }
- let map = gravity_panel(model, injected);
- let mut layers: Vec<View<Msg>> = vec![map];
-
- // Cajón de notas (izquierda): abierto a pedido, o forzado en modo
- // recibir (muestra los pares en vez de las notas).
- if model.show_list || model.receiving {
- let drawer = if model.receiving {
- receive_panel(model, &palette, &input_palette)
- } else {
- list_panel(model, &palette, &input_palette)
- };
- layers.push(overlay_left(drawer, LIST_WIDTH));
- }
-
- // Editor lateral: sólo si hay selección y NO se abrió in-situ.
- if model.selected.is_some() && inplace.is_none() {
- let editor = editor_panel(model, &input_palette, &editor_palette);
- layers.push(overlay_right(editor, EDITOR_OVERLAY_W, &model.theme));
- }
-
- let body = View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: Dimension::auto(),
- },
- flex_grow: 1.0,
- min_size: Size {
- width: length(0.0_f32),
- height: length(0.0_f32),
- },
- ..Default::default()
- })
- .children(layers);
-
- // Barra de menú principal: primer hijo del column raíz.
- let menu = app_menu(model);
- let menubar = menubar_view(&menubar_spec(&menu, model, &model.theme));
-
- let mut children = vec![menubar, header, body];
- if let Some(bar) = status_bar(model) {
- children.push(bar);
- }
-
- // El right-click se engancha en la raíz (origen 0,0 → coords
- // locales == coords de ventana) y abre el menú de edición sobre
- // el campo focuseado.
- View::new(Style {
- flex_direction: FlexDirection::Column,
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- ..Default::default()
- })
- .fill(model.theme.bg_app)
- .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
- .children(children)
- }
-
- fn view_overlay(model: &Model) -> Option<View<Msg>> {
- // El prompt modal de passphrase no convive con menús.
- if model.unlocking {
- return None;
- }
- // Prioridad: menú de edición contextual sobre el menú principal.
- if let Some((x, y)) = model.edit_menu {
- let flags = focused_edit_flags(model);
- let (w, h) = Self::initial_size();
- let mut spec = editmenu::edit_context_menu(
- (x, y),
- (w as f32, h as f32),
- &model.theme,
- flags,
- Msg::EditMenuAction,
- Msg::CloseMenus,
- );
- spec.active = model.edit_active;
- return Some(context_menu_view_ex(
- spec,
- ContextMenuExtras {
- appear: model.edit_anim.value(),
- ..Default::default()
- },
- ));
- }
- // Si no, el dropdown del menú principal.
- let menu = app_menu(model);
- menubar_overlay_animated(
- &menubar_spec(&menu, model, &model.theme),
- model.menu_active,
- model.menu_anim.value(),
- )
- }
-
- fn on_key(model: &Model, event: &KeyEvent) -> Option<Msg> {
- // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
- if event.state == KeyState::Pressed {
- if let Some(mi) = model.menu_open {
- let n = app_menu(model).menus.len().max(1);
- return match &event.key {
- Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
- Key::Named(NamedKey::ArrowLeft) => Some(Msg::MenuOpen(Some((mi + n - 1) % n))),
- Key::Named(NamedKey::ArrowRight) => Some(Msg::MenuOpen(Some((mi + 1) % n))),
- Key::Named(NamedKey::ArrowDown) => Some(Msg::MenuNav(1)),
- Key::Named(NamedKey::ArrowUp) => Some(Msg::MenuNav(-1)),
- Key::Named(NamedKey::Enter) => Some(Msg::MenuActivate),
- _ => None,
- };
- }
- if model.edit_menu.is_some() {
- return match &event.key {
- Key::Named(NamedKey::Escape) => Some(Msg::CloseMenus),
- Key::Named(NamedKey::ArrowDown) => Some(Msg::EditNav(1)),
- Key::Named(NamedKey::ArrowUp) => Some(Msg::EditNav(-1)),
- Key::Named(NamedKey::Enter) => Some(Msg::EditActivate),
- _ => None,
- };
- }
- }
- // Con el prompt de passphrase abierto, las teclas son sólo suyas:
- // Enter desbloquea, Esc cancela, el resto va al input.
- if model.unlocking {
- if event.state == KeyState::Pressed && !event.repeat {
- if matches!(&event.key, Key::Named(NamedKey::Enter)) {
- return Some(Msg::Unlock);
- }
- if matches!(&event.key, Key::Named(NamedKey::Escape)) {
- return Some(Msg::CancelUnlock);
- }
- }
- return Some(Msg::Key(event.clone()));
- }
- // En modo recibir con foco en la dirección: Enter jala, Esc cancela.
- if model.receiving && model.focus == Focus::PeerAddr {
- if event.state == KeyState::Pressed && !event.repeat {
- if matches!(&event.key, Key::Named(NamedKey::Enter)) {
- return Some(Msg::FetchManual);
- }
- if matches!(&event.key, Key::Named(NamedKey::Escape)) {
- return Some(Msg::CancelPeers);
- }
- }
- return Some(Msg::Key(event.clone()));
- }
- // Bautizando una región: Enter confirma, Esc cancela, resto al input.
- if model.naming.is_some() && model.focus == Focus::Region {
- if event.state == KeyState::Pressed && !event.repeat {
- if matches!(&event.key, Key::Named(NamedKey::Enter)) {
- return Some(Msg::CommitNaming);
- }
- if matches!(&event.key, Key::Named(NamedKey::Escape)) {
- return Some(Msg::CancelNaming);
- }
- }
- return Some(Msg::Key(event.clone()));
- }
- // Atajo global: Ctrl+N (sin foco en input necesario) crea
- // nota. Esc libera el foco. Cualquier otra tecla la dispatcha
- // como `Key` al input/editor focado.
- if event.state == KeyState::Pressed && !event.repeat {
- if event.modifiers.ctrl
- && matches!(&event.key, Key::Character(s) if s.eq_ignore_ascii_case("n"))
- {
- return Some(Msg::NewNote);
- }
- if matches!(&event.key, Key::Named(NamedKey::Escape)) {
- return Some(Msg::EscapeMap);
- }
- }
- Some(Msg::Key(event.clone()))
- }
-
- fn title() -> &'static str {
- "khipu"
- }
-
- fn app_id() -> Option<&'static str> {
- Some("gioser.khipu")
- }
-
- fn initial_size() -> (u32, u32) {
- (1280, 760)
- }
-}
-
-
-
-/// Sincroniza inputs/editor → store/field + persiste si cambió algo.
-fn commit_edits(model: &mut Model, h: &Handle<Msg>) {
- let Some(id) = model.selected else {
- return;
- };
- let mut changed = false;
- let new_title = model.title.text();
- let new_body = model.body.text();
- let new_tags = parse_tags(&model.tags.text());
- let now = now_secs();
- if let Some(note) = model.store.get_mut(id) {
- if note.title != new_title {
- note.title = new_title;
- note.updated_at = now;
- changed = true;
- }
- if note.body != new_body {
- note.body = new_body;
- note.updated_at = now;
- changed = true;
- }
- if note.tags != new_tags {
- note.tags = new_tags;
- note.updated_at = now;
- changed = true;
- }
- }
- if changed {
- // El texto ya está en el store: persistimos de inmediato para no
- // perderlo. El embedding viaja a un worker y persistirá de nuevo
- // cuando llegue (`Msg::EmbeddingReady`).
- persist(model);
- schedule_embedding(model, id, h);
- }
-}
-
-// =====================================================================
-// Menú principal + menú de edición contextual
-// =====================================================================
-
-/// Devuelve el `EditorState` del campo focuseado (referencia inmutable) y
-/// si está enmascarado (passphrase). Search/PeerAddr/Title/Tags son
-/// `TextInputState` (su `.editor()`); Body es el `EditorState` directo.
-/// Sin foco editable devuelve `None`.
-fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
- match model.focus {
- Focus::Body => (Some(&model.body), false),
- Focus::Title => (Some(model.title.editor()), false),
- Focus::Tags => (Some(model.tags.editor()), false),
- Focus::Search => (Some(model.search.editor()), false),
- Focus::PeerAddr => (Some(model.peer_input.editor()), false),
- Focus::Region => (Some(model.region_input.editor()), false),
- Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
- Focus::None => (None, false),
- }
-}
-
-/// `EditFlags` del campo focuseado, para nav/ejecución por teclado del
-/// menú de edición. Sin campo focuseado, flags vacíos (todo gris).
-fn focused_edit_flags(model: &Model) -> EditFlags {
- let (editor, masked) = focused_editor(model);
- match editor {
- Some(ed) => EditFlags::from_editor(ed, masked),
- None => EditFlags::default(),
- }
-}
-
-/// Construye el menú principal de khipu reflejando el estado del campo
-/// focuseado (ítems de Editar grises sin selección / historial).
-fn app_menu(model: &Model) -> app_bus::AppMenu {
- use app_bus::{AppMenu, Menu, MenuItem};
- let (editor, _masked) = focused_editor(model);
- let has_sel = editor.map(|e| e.has_selection()).unwrap_or(false);
- let can_undo = editor.map(|e| e.can_undo()).unwrap_or(false);
- let can_redo = editor.map(|e| e.can_redo()).unwrap_or(false);
- let has_field = editor.is_some();
- let has_sel_note = model.selected.is_some();
-
- let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
- if !can_undo {
- undo = undo.disabled();
- }
- let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
- if !can_redo {
- redo = redo.disabled();
- }
- let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
- let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
- if !has_sel {
- cut = cut.disabled();
- copy = copy.disabled();
- }
- let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
- if !has_field {
- paste = paste.disabled();
- }
- let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
- .shortcut("Ctrl+A")
- .separated();
- if !has_field {
- sel_all = sel_all.disabled();
- }
-
- let mut delete_note = MenuItem::new("Borrar nota", "note.delete");
- if !has_sel_note {
- delete_note = delete_note.disabled();
- }
- let archive_label = if model.show_archive {
- "Ocultar archivadas"
- } else {
- "Ver archivadas"
- };
+mod tejido;
- AppMenu::new()
- .menu(
- Menu::new("Archivo")
- .item(MenuItem::new("Nueva nota", "note.new").shortcut("Ctrl+N"))
- .item(delete_note)
- .item(MenuItem::new(archive_label, "note.archive").separated())
- .item(MenuItem::new("Exportar sobre…", "share.export"))
- .item(MenuItem::new("Importar sobre…", "share.import")),
- )
- .menu(
- Menu::new("Editar")
- .item(undo)
- .item(redo)
- .item(cut)
- .item(copy)
- .item(paste)
- .item(sel_all),
- )
- .menu(
- Menu::new("Compartir")
- .item(MenuItem::new("Publicar (P2P)", "share.publish"))
- .item(MenuItem::new("Recibir de un par…", "share.receive")),
- )
- .menu(
- Menu::new("Ayuda")
- .item(MenuItem::new("Buscar (foco)", "view.search").shortcut("Ctrl+F"))
- .item(MenuItem::new("Acerca de khipu", "help.about")),
- )
-}
-
-/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
-fn menubar_spec<'a>(
- menu: &'a app_bus::AppMenu,
- model: &Model,
- theme: &'a Theme,
-) -> MenuBarSpec<'a, Msg> {
- let (w, h) = KhipuApp::initial_size();
- MenuBarSpec {
- menu,
- open: model.menu_open,
- theme,
- viewport: (w as f32, h as f32),
- height: MENU_H,
- on_open: std::sync::Arc::new(Msg::MenuOpen),
- on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
- }
-}
-
-/// Traduce el `command` del menú principal al `Msg` real y lo redespacha
-/// por el `update`. Cierra el menú antes de actuar.
-fn handle_menu_command(mut model: Model, command: String, h: &Handle<Msg>) -> Model {
- model.menu_open = None;
- let target = match command.as_str() {
- "note.new" => Some(Msg::NewNote),
- "note.delete" => Some(Msg::DeleteSelected),
- "note.archive" => Some(Msg::ToggleArchive),
- "share.export" => Some(Msg::Export),
- "share.import" => Some(Msg::Import),
- "share.publish" => Some(Msg::Publish),
- "share.receive" => Some(Msg::Receive),
- "view.search" => Some(Msg::Focus(Focus::Search)),
- "edit.undo" => Some(Msg::EditMenuAction(EditAction::Undo)),
- "edit.redo" => Some(Msg::EditMenuAction(EditAction::Redo)),
- "edit.cut" => Some(Msg::EditMenuAction(EditAction::Cut)),
- "edit.copy" => Some(Msg::EditMenuAction(EditAction::Copy)),
- "edit.paste" => Some(Msg::EditMenuAction(EditAction::Paste)),
- "edit.selectall" => Some(Msg::EditMenuAction(EditAction::SelectAll)),
- "help.about" => {
- model.status = Some("khipu · cuaderno de notas P2P soberano".into());
- None
- }
- _ => None,
- };
- match target {
- Some(msg) => KhipuApp::update(model, msg, h),
- None => model,
- }
-}
-
-/// Aplica una acción del menú de edición al editor del campo focuseado,
-/// usando el portapapeles del sistema, y replica el bookkeeping que khipu
-/// hace tras editar (commit al store + embedding si cambió un campo que
-/// vive en la nota). Cierra el menú de edición.
-fn apply_edit_menu_action(mut model: Model, action: EditAction, h: &Handle<Msg>) -> Model {
- model.edit_menu = None;
- let focus = model.focus;
- let clip = &mut model.clipboard;
- let result = match focus {
- Focus::Body => Some(editmenu::apply(&mut model.body, action, clip)),
- Focus::Title => Some(editmenu::apply(model.title.editor_mut(), action, clip)),
- Focus::Tags => Some(editmenu::apply(model.tags.editor_mut(), action, clip)),
- Focus::Search => Some(editmenu::apply(model.search.editor_mut(), action, clip)),
- Focus::PeerAddr => Some(editmenu::apply(model.peer_input.editor_mut(), action, clip)),
- Focus::Region => Some(editmenu::apply(model.region_input.editor_mut(), action, clip)),
- Focus::Passphrase => Some(editmenu::apply(model.passphrase.editor_mut(), action, clip)),
- Focus::None => None,
- };
- // Si la acción cambió un campo persistente de la nota (título, cuerpo o
- // tags), corremos el mismo commit que las teclas. Search/PeerAddr/
- // Passphrase no tocan el store.
- if let Some(r) = result {
- if r.changed() && matches!(focus, Focus::Body | Focus::Title | Focus::Tags) {
- commit_edits(&mut model, h);
- }
- }
- model
-}
-
-fn parse_tags(raw: &str) -> Vec<String> {
- raw.split(',')
- .map(|t| t.trim().to_string())
- .filter(|t| !t.is_empty())
- .collect()
-}
-
-fn select(model: &mut Model, id: NoteId) {
- let Some(note) = model.store.get(id) else {
- return;
- };
- model.selected = Some(id);
- model.title.set_text(note.title.clone());
- model.body = EditorState::default();
- model.body.set_text(¬e.body);
- model.tags.set_text(note.tags.join(", "));
- model.focus = Focus::Body;
-}
-
-/// Suelta la nota seleccionada y limpia los campos del editor. El editor
-/// flotante desaparece y el mapa queda libre — el equivalente a alejarse
-/// del nodo (precursor del zoom semántico).
-fn deselect(model: &mut Model) {
- model.selected = None;
- model.title.set_text(String::new());
- model.body = EditorState::default();
- model.tags.set_text(String::new());
- model.focus = Focus::None;
-}
-
-/// Pide el embedding de `id` en segundo plano. Asigna una secuencia
-/// nueva, la marca como vigente, y dispara un worker (`Handle::spawn`)
-/// que al terminar reentra al `update` con [`Msg::EmbeddingReady`]. Así
-/// el `block_on` del arm remoto nunca corre en el hilo de UI.
-fn schedule_embedding(model: &mut Model, id: NoteId, h: &Handle<Msg>) {
- let Some(note) = model.store.get(id) else {
- return;
- };
- let combined = format!("{} {}", note.title, note.body);
- model.embed_seq += 1;
- let seq = model.embed_seq;
- model.embed_latest.insert(id, seq);
- let embedder = model.embedder.clone();
- h.spawn(move || {
- let v = embedder.embed_blocking(&combined);
- Msg::EmbeddingReady(id, seq, v)
- });
-}
-
-/// Versión síncrona para el arranque (seed y migración de formato):
-/// calcula el vector en línea y lo inserta. En init todavía no hay nada
-/// que repintar, así que bloquear un instante es lo correcto — y deja el
-/// campo semántico listo antes del primer layout.
-fn embed_now(model: &mut Model, id: NoteId) {
- let Some(note) = model.store.get(id) else {
- return;
- };
- let combined = format!("{} {}", note.title, note.body);
- let v = model.embedder.embed_blocking(&combined);
- model.field.insert(id, v);
-}
-
-/// Hash trigram → R^EMBED_DIM con signos +/-1 (random projection
-/// 1-bit signed), normalizado por L2. Determinista, independiente de
-/// idioma, sin red.
-fn embed(text: &str, dim: usize) -> Vec<f32> {
- let mut v = vec![0.0f32; dim];
- let lower = text.to_lowercase();
- let bytes = lower.as_bytes();
- if bytes.len() < 3 {
- for (i, b) in bytes.iter().enumerate() {
- v[i % dim] += *b as f32 / 255.0;
- }
- } else {
- for w in bytes.windows(3) {
- let mut h: u64 = 0xcbf29ce484222325;
- for b in w {
- h ^= *b as u64;
- h = h.wrapping_mul(0x100000001b3);
- }
- let idx = (h as usize) % dim;
- let sign = if h & 1 == 0 { 1.0 } else { -1.0 };
- v[idx] += sign;
- }
- }
- let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
- if n > 0.0 {
- for x in &mut v {
- *x /= n;
- }
- }
- v
-}
-
-#[derive(Serialize, Deserialize)]
-struct PersistedState {
- store: NoteStore,
- embeddings: Vec<(NoteId, Vec<f32>)>,
- order: Vec<NoteId>,
- /// Etiqueta del espacio vectorial con que se guardaron los
- /// `embeddings` (ver [`Embedder::label`]). Si al cargar no coincide
- /// con el embebedor activo, los vectores se recalculan.
- model: String,
- /// Topónimos bautizados. Trailing → archivos previos a las regiones
- /// no parsean como esta forma y caen al fallback `PersistedStateV2`.
- #[serde(default)]
- regions: Vec<Region>,
-}
-
-/// Formato previo a las regiones (postcard no es self-describing, así que
-/// un campo trailing rompe el parseo y hay que intentar la forma vieja).
-#[derive(Deserialize)]
-struct PersistedStateV2 {
- store: NoteStore,
- embeddings: Vec<(NoteId, Vec<f32>)>,
- order: Vec<NoteId>,
- model: String,
-}
-
-/// Formato histórico, sin `model`. Fallback cuando ni el actual ni el V2
-/// parsean (archivos escritos antes de enchufar `verbo`).
-#[derive(Deserialize)]
-struct PersistedStateV1 {
- store: NoteStore,
- embeddings: Vec<(NoteId, Vec<f32>)>,
- order: Vec<NoteId>,
-}
-
-/// Directorio de datos de khipu (`$XDG_DATA_HOME/khipu/`), creándolo si
-/// hace falta. Raíz de `notes.bin`, `identidad.seed` y `compartido.khipu`.
-fn khipu_dir() -> Option<PathBuf> {
- let dirs = ProjectDirs::from("org", "gioser", "khipu")?;
- let dir = dirs.data_dir().to_path_buf();
- std::fs::create_dir_all(&dir).ok()?;
- Some(dir)
-}
-
-fn data_file_path() -> Option<PathBuf> {
- Some(khipu_dir()?.join("notes.bin"))
-}
-
-/// Desbloquea (o crea, o migra) la identidad del cuaderno con `passphrase`,
-/// vía [`khipu_share::identity::unlock`]. La semilla vive cifrada en
-/// `<datos>/keys/`; si existe un `identidad.seed` en claro de versiones
-/// viejas, se migra al keystore y se borra el claro. `None` si no hay
-/// directorio de datos o la passphrase no descifra.
-fn unlock_identity(passphrase: &str) -> Option<Keypair> {
- let dir = khipu_dir()?;
- let legacy = dir.join("identidad.seed");
- khipu_share::identity::unlock(&dir.join("keys"), Some(&legacy), passphrase).ok()
-}
-
-/// Arranca el prompt de passphrase y memoriza la acción a reanudar.
-fn start_unlock(model: &mut Model, accion: Msg) {
- model.unlocking = true;
- model.pending = Some(Box::new(accion));
- model.focus = Focus::Passphrase;
- model.passphrase.clear();
- model.status = Some("ingresá tu passphrase para desbloquear la identidad".into());
-}
-
-/// Prompt modal de passphrase: tarjeta centrada con el input (enmascarado
-/// con •) y dos botones. Enter desbloquea, Esc cancela (ver `on_key`).
-fn unlock_view(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
- let titulo = View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: length(22.0_f32),
- },
- ..Default::default()
- })
- .text_aligned(
- "Desbloqueá tu identidad para firmar".to_string(),
- 14.0,
- model.theme.fg_text,
- Alignment::Start,
- );
-
- let hint = View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: length(30.0_f32),
- },
- ..Default::default()
- })
- .text_aligned(
- "La semilla vive cifrada (Argon2id). La primera vez, esta passphrase la crea."
- .to_string(),
- 11.0,
- model.theme.fg_muted,
- Alignment::Start,
- );
-
- let input = text_input_view(
- &model.passphrase,
- "passphrase",
- model.focus == Focus::Passphrase,
- input_palette,
- Msg::Focus(Focus::Passphrase),
- );
- let input_row = View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: length(30.0_f32),
- },
- flex_shrink: 0.0,
- ..Default::default()
- })
- .children(vec![input]);
-
- let unlock_btn = button(
- "desbloquear (Enter)",
- model.theme.bg_button,
- model.theme.accent,
- Msg::Unlock,
- );
- let cancel_btn = button(
- "cancelar (Esc)",
- model.theme.bg_button,
- model.theme.fg_muted,
- Msg::CancelUnlock,
- );
- let buttons = View::new(Style {
- flex_direction: FlexDirection::Row,
- size: Size {
- width: percent(1.0_f32),
- height: length(30.0_f32),
- },
- gap: Size {
- width: length(8.0_f32),
- height: length(0.0_f32),
- },
- ..Default::default()
- })
- .children(vec![unlock_btn, cancel_btn]);
-
- let card = View::new(Style {
- flex_direction: FlexDirection::Column,
- size: Size {
- width: length(420.0_f32),
- height: Dimension::auto(),
- },
- padding: Rect {
- left: length(18.0_f32),
- right: length(18.0_f32),
- top: length(16.0_f32),
- bottom: length(16.0_f32),
- },
- gap: Size {
- width: length(0.0_f32),
- height: length(10.0_f32),
- },
- ..Default::default()
- })
- .fill(model.theme.bg_panel)
- .radius(6.0)
- .children(vec![titulo, hint, input_row, buttons]);
-
- 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()
- })
- .fill(model.theme.bg_app)
- .children(vec![card])
-}
-
-/// Barra de estado al pie: muestra el último mensaje de export/import.
-fn status_bar(model: &Model) -> Option<View<Msg>> {
- let text = model.status.as_ref()?;
- Some(
- View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: length(22.0_f32),
- },
- flex_shrink: 0.0,
- padding: Rect {
- left: length(12.0_f32),
- right: length(12.0_f32),
- top: length(2.0_f32),
- bottom: length(2.0_f32),
- },
- align_items: Some(AlignItems::Center),
- ..Default::default()
- })
- .fill(model.theme.bg_panel_alt)
- .text_aligned(text.clone(), 11.0, model.theme.fg_muted, Alignment::Start),
- )
-}
-
-fn load_state(path: &PathBuf) -> Option<PersistedState> {
- let bytes = std::fs::read(path).ok()?;
- // Formato actual primero; si no parsea (payload viejo sin `model`)
- // caemos al V1 y lo migramos con `model` vacío → fuerza recálculo.
- if let Ok(state) = postcard::from_bytes::<PersistedState>(&bytes) {
- return Some(state);
- }
- // Archivo previo a las regiones: misma forma sin el campo trailing.
- if let Ok(v2) = postcard::from_bytes::<PersistedStateV2>(&bytes) {
- return Some(PersistedState {
- store: v2.store,
- embeddings: v2.embeddings,
- order: v2.order,
- model: v2.model,
- regions: Vec::new(),
- });
- }
- let v1: PersistedStateV1 = postcard::from_bytes(&bytes).ok()?;
- Some(PersistedState {
- store: v1.store,
- embeddings: v1.embeddings,
- order: v1.order,
- model: String::new(),
- regions: Vec::new(),
- })
-}
-
-fn persist(model: &Model) {
- let Some(path) = model.data_path.as_ref() else {
- return;
- };
- let state = PersistedState {
- store: model.store.clone(),
- embeddings: model
- .field
- .iter()
- .map(|(id, v)| (id, v.to_vec()))
- .collect(),
- order: model.order.clone(),
- model: model.embedder.label(),
- regions: model.regions.clone(),
- };
- if let Ok(bytes) = postcard::to_allocvec(&state) {
- let tmp = path.with_extension("bin.tmp");
- if std::fs::write(&tmp, &bytes).is_ok() {
- let _ = std::fs::rename(&tmp, path);
- }
- }
-}
-
-fn from_state(state: PersistedState, embedder: Embedder) -> Model {
- // ¿Los vectores guardados son del mismo espacio que el embebedor
- // activo? Si cambió el modelo o la dimensión (p. ej. arrancó el
- // daemon, o se cayó y volvimos al trigram local), son incomparables:
- // se descartan y se recalcula todo el cuaderno.
- let same_space = !state.model.is_empty() && state.model == embedder.label();
- let regions = state.regions;
- let mut model = Model {
- store: state.store,
- field: SemanticField::new(),
- order: state.order,
- selected: None,
- title: TextInputState::new(),
- body: EditorState::default(),
- tags: TextInputState::new(),
- search: TextInputState::new(),
- focus: Focus::None,
- theme: Theme::dark(),
- data_path: None,
- gravity: Gravity::new(Params::default()),
- show_archive: false,
- embedder,
- embed_latest: BTreeMap::new(),
- embed_seq: 0,
- keypair: None,
- status: None,
- publishing: false,
- receiving: false,
- peers: Vec::new(),
- peer_input: TextInputState::new(),
- passphrase: TextInputState::masked(),
- unlocking: false,
- pending: None,
- p2p: None,
- menu_open: None,
- menu_active: usize::MAX,
- menu_anim: Tween::idle(1.0),
- edit_menu: None,
- edit_active: usize::MAX,
- edit_anim: Tween::idle(1.0),
- clipboard: SystemClipboard::new(),
- cam_pan: (0.0, 0.0),
- cam_zoom: 1.0,
- show_list: true,
- canvas_size: (1280.0, 640.0),
- regions,
- naming: None,
- region_input: TextInputState::new(),
- };
- if same_space {
- let restored: std::collections::HashSet<NoteId> =
- state.embeddings.iter().map(|(id, _)| *id).collect();
- for (id, v) in &state.embeddings {
- if !v.is_empty() {
- model.field.insert(*id, v.clone());
- }
- }
- // Notas sin vector persistido (nota nueva que no alcanzó a
- // guardar su embedding async): recalcular sólo esas.
- let missing: Vec<NoteId> = model
- .order
- .iter()
- .copied()
- .filter(|id| !restored.contains(id))
- .collect();
- for id in missing {
- embed_now(&mut model, id);
- place_note(&mut model, id);
- }
- } else {
- let ids: Vec<NoteId> = model.order.clone();
- for id in ids {
- embed_now(&mut model, id);
- }
- }
- // Notas cargadas de disco sin posición (payloads viejos previos al
- // anclaje) reciben domicilio ahora, en orden, contra las ya asentadas.
- let unplaced: Vec<NoteId> = model
- .order
- .iter()
- .copied()
- .filter(|id| model.store.get(*id).map(|n| n.pos.is_none()).unwrap_or(false))
- .collect();
- for id in unplaced {
- place_note(&mut model, id);
- }
- model
-}
-
-fn seeded_model(embedder: Embedder) -> Model {
- let mut model = Model {
- store: NoteStore::new(),
- field: SemanticField::new(),
- order: Vec::new(),
- selected: None,
- title: TextInputState::new(),
- body: EditorState::default(),
- tags: TextInputState::new(),
- search: TextInputState::new(),
- focus: Focus::None,
- theme: Theme::dark(),
- data_path: None,
- gravity: Gravity::new(Params::default()),
- show_archive: false,
- embedder,
- embed_latest: BTreeMap::new(),
- embed_seq: 0,
- keypair: None,
- status: None,
- publishing: false,
- receiving: false,
- peers: Vec::new(),
- peer_input: TextInputState::new(),
- passphrase: TextInputState::masked(),
- unlocking: false,
- pending: None,
- p2p: None,
- menu_open: None,
- menu_active: usize::MAX,
- menu_anim: Tween::idle(1.0),
- edit_menu: None,
- edit_active: usize::MAX,
- edit_anim: Tween::idle(1.0),
- clipboard: SystemClipboard::new(),
- cam_pan: (0.0, 0.0),
- cam_zoom: 1.0,
- show_list: true,
- canvas_size: (1280.0, 640.0),
- regions: Vec::new(),
- naming: None,
- region_input: TextInputState::new(),
- };
- let now = now_secs();
- let seed: [(&str, &str, &[&str]); 7] = [
- (
- "Índice",
- "mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
- &["meta"],
- ),
- (
- "Recetas de la abuela",
- "sopa de auyama; ver también [[Lista del mercado]]",
- &["cocina"],
- ),
- (
- "Lista del mercado",
- "auyama, cilantro, pan; vuelve al [[Índice]]",
- &["cocina"],
- ),
- (
- "Jardín",
- "riego semanal; las [[Semillas de cilantro]] van en marzo",
- &["jardín"],
- ),
- (
- "Semillas de cilantro",
- "germinan en diez días",
- &["jardín"],
- ),
- (
- "Oficina",
- "[[Reunión del lunes]] y pendientes varios",
- &["trabajo"],
- ),
- (
- "Diario sin enlaces",
- "una nota suelta, no la enlaza nadie",
- &["personal"],
- ),
- ];
- for (title, body, tags) in seed {
- let tags: Vec<String> = tags.iter().map(|s| s.to_string()).collect();
- let id = model.store.create(title, body, tags, now);
- model.order.push(id);
- embed_now(&mut model, id);
- place_note(&mut model, id);
- }
- model
-}
-
-fn now_secs() -> u64 {
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .map(|d| d.as_secs())
- .unwrap_or(0)
-}
-
-/// La masa "vivida" de una nota en `now`: la guardada decae contra
-/// el tiempo transcurrido desde `last_access`. Las notas con
-/// `last_access == 0` (payloads viejos sin el campo) toman su `mass`
-/// tal cual — equivale a tratar `now` como su primer acceso.
-fn current_mass(gravity: &Gravity, n: &Note, now: u64) -> f32 {
- if n.last_access == 0 {
- return n.mass;
- }
- let dt = if now > n.last_access {
- (now - n.last_access) as f32
- } else {
- 0.0
- };
- gravity.decay(n.mass, dt)
-}
-
-/// Refuerza la masa de `id` y marca `last_access`. El gesto canónico
-/// cuando el usuario selecciona o abre una nota: primero decaemos el
-/// valor guardado al "ahora" y sobre ese decaído sumamos el boost.
-fn reinforce_and_touch(model: &mut Model, id: NoteId) {
- let now = now_secs();
- let Some(n) = model.store.get(id) else {
- return;
- };
- let lived = current_mass(&model.gravity, n, now);
- let reinforced = model.gravity.reinforce(lived);
- model.store.set_mass(id, reinforced);
- model.store.touch(id, now);
-}
-
-/// Primera nota sobre el horizonte, ordenada por masa "viva".
-fn first_visible(model: &Model) -> Option<NoteId> {
- let now = now_secs();
- let mut visible: Vec<(NoteId, f32)> = model
- .order
- .iter()
- .filter_map(|id| {
- model.store.get(*id).and_then(|n| {
- let m = current_mass(&model.gravity, n, now);
- model.gravity.is_visible(m).then_some((*id, m))
- })
- })
- .collect();
- visible.sort_by(|a, b| {
- b.1.partial_cmp(&a.1)
- .unwrap_or(core::cmp::Ordering::Equal)
- .then(a.0.cmp(&b.0))
- });
- visible.first().map(|(id, _)| *id)
-}
+use app::KhipuApp;
fn main() {
llimphi_ui::run::<KhipuApp>();
diff --git a/00_unanchay/khipu/khipu-app/src/map.rs b/00_unanchay/khipu/khipu-app/src/map.rs
deleted file mode 100644
index a378c83..0000000
--- a/00_unanchay/khipu/khipu-app/src/map.rs
+++ /dev/null
@@ -1,655 +0,0 @@
-//! `map` — el lienzo de pensamientos: geometría de cámara, colocación
-//! determinista de notas (anclaje), detección de regiones emergentes y el
-//! pintado del mapa (nodos que respiran por masa, filamentos, topónimos).
-//!
-//! El resto de la app le pide vistas y consultas; los helpers compartidos
-//! (`current_mass`, `now_secs`, `button`, `CLUSTER_THRESHOLD`) viven en la
-//! raíz y se alcanzan por `crate::`.
-
-use llimphi_theme::Theme;
-use llimphi_ui::llimphi_layout::taffy::{
- prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
- AlignItems, Dimension, JustifyContent,
-};
-use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle as KurboCircle, Stroke};
-use llimphi_ui::llimphi_raster::peniko::{Color, Fill};
-use llimphi_ui::llimphi_text::{draw_block, Alignment, TextBlock};
-use llimphi_ui::{DragPhase, View};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette};
-use khipu_core::NoteId;
-
-use crate::panels::button;
-use crate::{current_mass, now_secs, Focus, Model, Msg, CLUSTER_THRESHOLD};
-
-/// Un nodo del mapa, ya resuelto a coordenadas de mundo + su masa viva.
-/// Datos planos para viajar dentro de la closure de pintura.
-pub(crate) struct MapNode {
- id: NoteId,
- /// Coordenadas de mundo (el domicilio fijo de la nota).
- x: f32,
- y: f32,
- /// Masa "vivida" en el instante del render: enciende el brillo y el
- /// tamaño. Decae con el tiempo → el mapa respira sin que toques nada.
- mass: f32,
- /// `false` si cayó bajo el horizonte (sólo se ve con archivo activo).
- visible: bool,
- color: Color,
- label: String,
-}
-
-/// Mundo → pantalla local (relativa al rect del lienzo). El centro del
-/// rect es el ancla del zoom; `pan` se suma en mundo, luego se escala.
-pub(crate) fn world_to_local(wx: f32, wy: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
- (w * 0.5 + (wx + pan.0) * zoom, h * 0.5 + (wy + pan.1) * zoom)
-}
-
-/// Inversa de [`world_to_local`]: pantalla local → mundo. Para resolver
-/// qué nota cae bajo un click.
-pub(crate) fn local_to_world(lx: f32, ly: f32, w: f32, h: f32, pan: (f32, f32), zoom: f32) -> (f32, f32) {
- let z = zoom.max(1e-3);
- ((lx - w * 0.5) / z - pan.0, (ly - h * 0.5) / z - pan.1)
-}
-
-/// La nota colocada más cercana a un click en coords locales, dentro de un
-/// radio de tolerancia (~18 px de pantalla). `None` si el click cae en el
-/// vacío — así arrastrar el fondo no cambia la selección.
-pub(crate) fn pick_note(model: &Model, lx: f32, ly: f32, w: f32, h: f32) -> Option<NoteId> {
- let (wx, wy) = local_to_world(lx, ly, w, h, model.cam_pan, model.cam_zoom);
- let now = now_secs();
- let mut best: Option<(NoteId, f32)> = None;
- for id in &model.order {
- let Some(n) = model.store.get(*id) else { continue };
- let Some((px, py)) = n.pos else { continue };
- if !model.show_archive {
- let m = current_mass(&model.gravity, n, now);
- if !model.gravity.is_visible(m) {
- continue;
- }
- }
- let d2 = (px - wx).powi(2) + (py - wy).powi(2);
- if best.map(|(_, bd)| d2 < bd).unwrap_or(true) {
- best = Some((*id, d2));
- }
- }
- let tol = (18.0 / model.cam_zoom.max(1e-3)).powi(2);
- best.filter(|(_, d2)| *d2 <= tol).map(|(id, _)| id)
-}
-
-/// Separación mínima entre nodos al colocarlos (coordenadas de mundo).
-pub(crate) const MAP_MIN_SEP: f32 = 30.0;
-/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
-/// parentela semántica sin amontonarlo.
-pub(crate) const GOLDEN_ANGLE: f32 = 2.399_963_2;
-
-/// Le da a `id` un domicilio fijo en el mapa, **una sola vez**: cae en el
-/// baricentro de sus parientes semánticos (ponderado por afinidad) y, si
-/// quedó pegada a otra nota, se separa apenas. Determinista y dependiente
-/// sólo de las notas ya asentadas, así el orden de inserción es estable y
-/// el mapa nunca se reacomoda solo.
-pub(crate) fn place_note(model: &mut Model, id: NoteId) {
- if model.store.get(id).map(|n| n.pos.is_some()).unwrap_or(true) {
- return; // ya tiene domicilio (o no existe): no se mueve.
- }
- // Vecinos ya colocados: su afinidad con la nota nueva y su posición.
- let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
- for other in &model.order {
- if *other == id {
- continue;
- }
- let Some(pos) = model.store.get(*other).and_then(|n| n.pos) else { continue };
- let aff = model.field.affinity(id, *other).unwrap_or(0.0).max(0.0);
- kin.push((aff, pos));
- }
-
- let target = if kin.is_empty() {
- (0.0, 0.0) // primera nota del cuaderno: centro del mundo.
- } else {
- let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
- if wsum > 1e-3 {
- // Cae junto a su parentela: baricentro ponderado por afinidad.
- let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
- for (w, (x, y)) in &kin {
- tx += w * x;
- ty += w * y;
- }
- (tx / wsum, ty / wsum)
- } else {
- // Ortogonal a todo: anillo determinista por id, lejos del núcleo.
- let ang = id as f32 * GOLDEN_ANGLE;
- let rad = 180.0 + 14.0 * (id as f32).sqrt();
- (rad * ang.cos(), rad * ang.sin())
- }
- };
-
- // Separación: empuja el target hasta despegarlo de cada vecino cercano.
- let mut p = target;
- for _ in 0..12 {
- let mut moved = false;
- for (_, q) in &kin {
- let dx = p.0 - q.0;
- let dy = p.1 - q.1;
- let d = (dx * dx + dy * dy).sqrt();
- if d < MAP_MIN_SEP {
- let (ux, uy) = if d > 1e-3 {
- (dx / d, dy / d)
- } else {
- let a = id as f32 * GOLDEN_ANGLE;
- (a.cos(), a.sin())
- };
- let push = MAP_MIN_SEP - d;
- p.0 += ux * push;
- p.1 += uy * push;
- moved = true;
- }
- }
- if !moved {
- break;
- }
- }
-
- model.store.set_pos(id, p.0, p.1);
-}
-
-/// Envuelve `child` como cajón absoluto pegado al borde izquierdo, alto
-/// completo con un margen. El mapa de fondo sigue recibiendo pan/zoom en
-/// el resto de la ventana; sólo los clicks sobre el cajón los come él.
-pub(crate) fn overlay_left(child: View<Msg>, width: f32) -> View<Msg> {
- View::new(Style {
- position: Position::Absolute,
- inset: Rect {
- left: length(8.0_f32),
- top: length(8.0_f32),
- bottom: length(8.0_f32),
- right: auto(),
- },
- size: Size {
- width: length(width),
- height: auto(),
- },
- flex_direction: FlexDirection::Column,
- ..Default::default()
- })
- .children(vec![child])
-}
-
-/// Columna interna del editor: barra de cierre (× ⇒ deselecciona) arriba +
-/// el editor abajo, sobre `bg_panel`. La comparten el overlay lateral y la
-/// tarjeta anclada del zoom semántico.
-pub(crate) fn editor_shell(child: View<Msg>, theme: &Theme) -> View<Msg> {
- let close = button("× cerrar", theme.bg_button, theme.fg_muted, Msg::Deselect);
- let close_row = View::new(Style {
- flex_direction: FlexDirection::Row,
- size: Size {
- width: percent(1.0_f32),
- height: length(28.0_f32),
- },
- flex_shrink: 0.0,
- justify_content: Some(JustifyContent::End),
- align_items: Some(AlignItems::Center),
- padding: Rect {
- left: length(6.0_f32),
- right: length(6.0_f32),
- top: length(2.0_f32),
- bottom: length(2.0_f32),
- },
- ..Default::default()
- })
- .fill(theme.bg_panel_alt)
- .children(vec![close]);
-
- View::new(Style {
- flex_direction: FlexDirection::Column,
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- ..Default::default()
- })
- .fill(theme.bg_panel)
- .children(vec![close_row, child])
-}
-
-/// Envuelve `child` como panel absoluto pegado al borde derecho, alto
-/// completo, con barra de cierre. El editor del nodo abierto cuando se lo
-/// edita de lejos (zoom bajo): un fallback práctico al anclaje in-situ.
-pub(crate) fn overlay_right(child: View<Msg>, width: f32, theme: &Theme) -> View<Msg> {
- View::new(Style {
- position: Position::Absolute,
- inset: Rect {
- left: auto(),
- top: length(8.0_f32),
- bottom: length(8.0_f32),
- right: length(8.0_f32),
- },
- size: Size {
- width: length(width),
- height: auto(),
- },
- flex_direction: FlexDirection::Column,
- ..Default::default()
- })
- .children(vec![editor_shell(child, theme)])
-}
-
-/// Mundo → pantalla local usando el último tamaño de lienzo conocido + la
-/// cámara. La versión de `view()`, donde el rect real aún no se sabe.
-pub(crate) fn world_screen(model: &Model, wx: f32, wy: f32) -> (f32, f32) {
- let (w, h) = model.canvas_size;
- world_to_local(wx, wy, w, h, model.cam_pan, model.cam_zoom)
-}
-
-/// Posición de pantalla (local al lienzo) del nodo `id`. `None` si la nota
-/// no tiene domicilio todavía.
-pub(crate) fn node_screen_pos(model: &Model, id: NoteId) -> Option<(f32, f32)> {
- let (wx, wy) = model.store.get(id).and_then(|n| n.pos)?;
- Some(world_screen(model, wx, wy))
-}
-
-/// Mínimo de notas para que un clúster cuente como región candidata.
-pub(crate) const REGION_MIN_MEMBERS: usize = 3;
-/// Distancia de mundo dentro de la cual una región ya "posee" un clúster:
-/// si hay un topónimo así de cerca del centroide, no se vuelve a ofrecer.
-pub(crate) const REGION_MATCH_DIST: f32 = 140.0;
-
-/// Centroides (mundo) de los clústeres densos que todavía no tienen una
-/// región cerca — los lugares que el mapa ofrece bautizar. Sólo cuentan
-/// miembros colocados y visibles.
-pub(crate) fn unnamed_cluster_centroids(model: &Model) -> Vec<(f32, f32)> {
- let now = now_secs();
- let mut out = Vec::new();
- for cluster in model.field.clusters(CLUSTER_THRESHOLD) {
- let pts: Vec<(f32, f32)> = cluster
- .iter()
- .filter_map(|id| {
- let n = model.store.get(*id)?;
- let p = n.pos?;
- let m = current_mass(&model.gravity, n, now);
- (model.show_archive || model.gravity.is_visible(m)).then_some(p)
- })
- .collect();
- if pts.len() < REGION_MIN_MEMBERS {
- continue;
- }
- let (sx, sy) = pts.iter().fold((0.0, 0.0), |(ax, ay), (x, y)| (ax + x, ay + y));
- let c = (sx / pts.len() as f32, sy / pts.len() as f32);
- let d2 = REGION_MATCH_DIST * REGION_MATCH_DIST;
- let near_named = model
- .regions
- .iter()
- .any(|r| (r.x - c.0).powi(2) + (r.y - c.1).powi(2) <= d2);
- let naming_here = model
- .naming
- .map(|(nx, ny)| (nx - c.0).powi(2) + (ny - c.1).powi(2) <= d2)
- .unwrap_or(false);
- if !near_named && !naming_here {
- out.push(c);
- }
- }
- out
-}
-
-/// Chip clickeable "✛ nombrar zona" que ofrece bautizar el clúster denso
-/// en `(wx, wy)`. Al click abre el input de bautizo en esa coordenada.
-pub(crate) fn name_region_chip(wx: f32, wy: f32, theme: &Theme) -> View<Msg> {
- View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- align_items: Some(AlignItems::Center),
- justify_content: Some(JustifyContent::Center),
- ..Default::default()
- })
- .fill(theme.bg_button)
- .radius(12.0)
- .hover_fill(theme.bg_button_hover)
- .text_aligned("✛ nombrar zona", 11.0, theme.fg_muted, Alignment::Center)
- .on_click(Msg::BeginNaming(wx, wy))
-}
-
-/// Mini-input del bautizo en curso: una tarjeta con el campo de texto
-/// enfocado. Enter confirma, Esc cancela (en `on_key`).
-pub(crate) fn naming_input(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
- let input = text_input_view(
- &model.region_input,
- "nombre de la zona…",
- model.focus == Focus::Region,
- input_palette,
- Msg::Focus(Focus::Region),
- );
- View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- align_items: Some(AlignItems::Center),
- padding: Rect {
- left: length(6.0_f32),
- right: length(6.0_f32),
- top: length(4.0_f32),
- bottom: length(4.0_f32),
- },
- ..Default::default()
- })
- .fill(model.theme.bg_panel)
- .radius(8.0)
- .children(vec![input])
-}
-
-/// Posiciona `child` como vista absoluta de tamaño `(w, h)` centrada en la
-/// pantalla `(sx, sy)`, clampeada al lienzo. Para chips y mini-inputs que
-/// viven en el mapa (sugerencia de bautizo, input de nombre).
-pub(crate) fn pinned(child: View<Msg>, sx: f32, sy: f32, w: f32, h: f32, canvas: (f32, f32)) -> View<Msg> {
- let left = (sx - w * 0.5).clamp(4.0, (canvas.0 - w - 4.0).max(4.0));
- let top = (sy - h * 0.5).clamp(4.0, (canvas.1 - h - 4.0).max(4.0));
- View::new(Style {
- position: Position::Absolute,
- inset: Rect {
- left: length(left),
- top: length(top),
- right: auto(),
- bottom: auto(),
- },
- size: Size {
- width: length(w),
- height: length(h),
- },
- ..Default::default()
- })
- .children(vec![child])
-}
-
-/// La tarjeta del nodo abierto, anclada a su coordenada `(nx, ny)` de
-/// pantalla: el zoom semántico hecho carne — el editor vive EN el lugar del
-/// pensamiento, no en un panel aparte. Se clampea para no salirse del
-/// lienzo. Hija del canvas, así pan/zoom la arrastran con el nodo.
-pub(crate) fn node_card(child: View<Msg>, nx: f32, ny: f32, canvas: (f32, f32), theme: &Theme) -> View<Msg> {
- let (cw_max, ch_max) = canvas;
- let cw = 380.0_f32.min((cw_max - 16.0).max(220.0));
- let ch = 440.0_f32.min((ch_max - 16.0).max(200.0));
- // Anclada bajo el nodo, centrada en X, clampeada a la ventana.
- let left = (nx - cw * 0.5).clamp(8.0, (cw_max - cw - 8.0).max(8.0));
- let top = (ny + 16.0).clamp(8.0, (ch_max - ch - 8.0).max(8.0));
-
- View::new(Style {
- position: Position::Absolute,
- inset: Rect {
- left: length(left),
- top: length(top),
- right: auto(),
- bottom: auto(),
- },
- size: Size {
- width: length(cw),
- height: length(ch),
- },
- flex_direction: FlexDirection::Column,
- ..Default::default()
- })
- .radius(8.0)
- .children(vec![editor_shell(child, theme)])
-}
-
-pub(crate) fn gravity_panel(model: &Model, injected: Vec<View<Msg>>) -> View<Msg> {
- let theme = model.theme;
- let now = now_secs();
- let clusters = model.field.clusters(CLUSTER_THRESHOLD);
- let selected = model.selected;
- let pan = model.cam_pan;
- let zoom = model.cam_zoom;
-
- // Nodos colocados (los que ya tienen domicilio), con su masa viva.
- let mut nodes: Vec<MapNode> = Vec::new();
- for id in &model.order {
- let Some(n) = model.store.get(*id) else { continue };
- let Some((x, y)) = n.pos else { continue };
- let mass = current_mass(&model.gravity, n, now);
- let visible = model.gravity.is_visible(mass);
- if !visible && !model.show_archive {
- continue;
- }
- nodes.push(MapNode {
- id: *id,
- x,
- y,
- mass,
- visible,
- color: cluster_color(*id, &clusters, theme),
- label: short_label(&n.title),
- });
- }
-
- // Topónimos: las regiones bautizadas, para pintarlas como rótulos de
- // continente detrás de los nodos.
- let regions: Vec<(String, f32, f32)> = model
- .regions
- .iter()
- .map(|r| (r.name.clone(), r.x, r.y))
- .collect();
-
- // Filamentos del nodo seleccionado: sus parientes más afines ya
- // colocados. Elegir un pensamiento enciende sus vecinos (activación
- // por difusión) — el motor de serendipia.
- let mut links: Vec<((f32, f32), (f32, f32), f32)> = Vec::new();
- if let Some(sel) = selected {
- if let Some(sp) = model.store.get(sel).and_then(|n| n.pos) {
- for (nid, aff) in model.field.nearest(sel, 6) {
- if aff < 0.20 {
- continue;
- }
- if let Some(np) = model.store.get(nid).and_then(|n| n.pos) {
- links.push((sp, np, aff));
- }
- }
- }
- }
-
- let canvas = View::new(Style {
- size: Size {
- width: percent(1.0_f32),
- height: percent(1.0_f32),
- },
- ..Default::default()
- })
- .fill(theme.bg_panel_alt)
- .paint_with(move |scene, ts, rect| {
- paint_map(scene, ts, rect, &nodes, &links, ®ions, selected, pan, zoom, theme);
- })
- .draggable_at(|phase, dx, dy, _lx, _ly| match phase {
- DragPhase::Move => Some(Msg::MapPan(dx, dy)),
- DragPhase::End => None,
- })
- .on_scroll(|_dx, dy| Some(Msg::MapZoom(dy)))
- .on_click_at(|lx, ly, w, h| Some(Msg::MapClick(lx, ly, w, h)))
- // La tarjeta del nodo abierto (zoom semántico) viaja como hija del
- // canvas: se pinta encima de los nodos y la cámara la arrastra con el
- // pensamiento al que pertenece.
- .children(injected);
-
- View::new(Style {
- size: Size {
- width: Dimension::auto(),
- height: percent(1.0_f32),
- },
- flex_grow: 1.0,
- flex_basis: length(0.0_f32),
- min_size: Size {
- width: length(0.0_f32),
- height: length(0.0_f32),
- },
- padding: Rect {
- left: length(4.0_f32),
- right: length(4.0_f32),
- top: length(4.0_f32),
- bottom: length(4.0_f32),
- },
- ..Default::default()
- })
- .fill(theme.bg_panel)
- .children(vec![canvas])
-}
-
-#[allow(clippy::too_many_arguments)]
-pub(crate) fn paint_map(
- scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
- ts: &mut llimphi_ui::llimphi_text::Typesetter,
- rect: llimphi_ui::PaintRect,
- nodes: &[MapNode],
- links: &[((f32, f32), (f32, f32), f32)],
- regions: &[(String, f32, f32)],
- selected: Option<NoteId>,
- pan: (f32, f32),
- zoom: f32,
- theme: Theme,
-) {
- if rect.w <= 0.0 || rect.h <= 0.0 {
- return;
- }
- // Pantalla absoluta = origen del rect + pantalla local.
- let to_screen = |wx: f32, wy: f32| -> (f64, f64) {
- let (lx, ly) = world_to_local(wx, wy, rect.w, rect.h, pan, zoom);
- ((rect.x + lx) as f64, (rect.y + ly) as f64)
- };
-
- // Topónimos al fondo: el nombre de cada región, grande y tenue, como
- // rótulo de continente; un halo suave insinúa su territorio.
- for (name, rx, ry) in regions {
- let (cx, cy) = to_screen(*rx, *ry);
- let blob = KurboCircle::new((cx, cy), (96.0 * zoom as f64).max(34.0));
- scene.fill(
- Fill::NonZero,
- Affine::IDENTITY,
- with_alpha(theme.accent, 0.05),
- None,
- &blob,
- );
- let size = (15.0 * zoom).clamp(11.0, 28.0);
- // Centrado aproximado: `simple` alinea a la izquierda en (x, y).
- let est_w = name.chars().count() as f64 * size as f64 * 0.52;
- draw_block(
- scene,
- ts,
- &TextBlock::simple(
- name,
- size,
- with_alpha(theme.fg_text, 0.30),
- (cx - est_w * 0.5, cy - size as f64 * 0.6),
- ),
- );
- }
-
- // Filamentos primero (debajo de los nodos). Más opacos cuanto más afín.
- for (a, b, aff) in links {
- let (ax, ay) = to_screen(a.0, a.1);
- let (bx, by) = to_screen(b.0, b.1);
- let mut path = BezPath::new();
- path.move_to((ax, ay));
- path.line_to((bx, by));
- let alpha = (0.18 + aff * 0.55).clamp(0.0, 0.85);
- scene.stroke(
- &Stroke::new((0.8 + *aff as f64 * 1.6).max(0.6)),
- Affine::IDENTITY,
- with_alpha(theme.accent, alpha),
- None,
- &path,
- );
- }
-
- // Nodos: tamaño y brillo crecen con la masa viva (el mapa respira).
- for n in nodes {
- let (px, py) = to_screen(n.x, n.y);
- let m = n.mass.clamp(0.0, 2.0);
- // Radio base por masa, escalado apenas por zoom para no inflarse.
- let r = (3.0 + m * 4.5) * (0.6 + 0.4 * zoom.clamp(0.5, 1.5));
- // Brillo: las notas frescas arden; las que se enfrían se apagan
- // hacia el fondo. Bajo el horizonte (archivo) van casi transparentes.
- let glow = if n.visible {
- (0.35 + m * 0.45).clamp(0.0, 1.0)
- } else {
- 0.18
- };
- let color = with_alpha(n.color, glow);
- // Halo tenue alrededor de las notas más encendidas.
- if n.visible && m > 0.6 {
- let halo = KurboCircle::new((px, py), (r + 5.0) as f64);
- scene.fill(Fill::NonZero, Affine::IDENTITY, with_alpha(n.color, 0.10), None, &halo);
- }
- let circle = KurboCircle::new((px, py), r as f64);
- scene.fill(Fill::NonZero, Affine::IDENTITY, color, None, &circle);
-
- if selected == Some(n.id) {
- let ring = KurboCircle::new((px, py), (r + 3.0) as f64);
- scene.stroke(&Stroke::new(2.0), Affine::IDENTITY, theme.accent, None, &ring);
- }
-
- // Etiqueta: sólo si el zoom da espacio o es la seleccionada — para
- // no saturar el mapa lejano. El texto sale del Typesetter.
- if (zoom >= 0.9 || selected == Some(n.id)) && n.visible {
- let lbl_col = with_alpha(theme.fg_text, (glow + 0.25).clamp(0.0, 1.0));
- draw_block(
- scene,
- ts,
- &TextBlock::simple(&n.label, 10.0, lbl_col, (px + r as f64 + 4.0, py - 7.0)),
- );
- }
- }
-}
-
-pub(crate) fn cluster_color(id: NoteId, clusters: &[Vec<NoteId>], theme: Theme) -> Color {
- let idx = clusters.iter().position(|c| c.contains(&id)).unwrap_or(0);
- // Paleta tomada del theme + matices generados por golden-ratio
- // sobre el hue del accent. Determinista por índice.
- let palette: [Color; 6] = [
- theme.accent,
- with_alpha(rotate_hue(theme.accent, 0.16), 1.0),
- with_alpha(rotate_hue(theme.accent, 0.33), 1.0),
- with_alpha(rotate_hue(theme.accent, 0.50), 1.0),
- with_alpha(rotate_hue(theme.accent, 0.66), 1.0),
- with_alpha(rotate_hue(theme.accent, 0.83), 1.0),
- ];
- palette[idx % palette.len()]
-}
-
-pub(crate) fn with_alpha(c: Color, alpha: f32) -> Color {
- let [r, g, b, _] = c.components;
- Color::new([r, g, b, alpha])
-}
-
-pub(crate) fn rotate_hue(c: Color, dh: f32) -> Color {
- // RGB → HSV → rota H → RGB. Aproximación, alpha fijo.
- let [r, g, b, a] = c.components;
- let max = r.max(g).max(b);
- let min = r.min(g).min(b);
- let v = max;
- let s = if max <= 0.0 { 0.0 } else { (max - min) / max };
- let h = if (max - min).abs() < 1e-6 {
- 0.0
- } else if max == r {
- ((g - b) / (max - min)) % 6.0
- } else if max == g {
- (b - r) / (max - min) + 2.0
- } else {
- (r - g) / (max - min) + 4.0
- };
- let h2 = ((h / 6.0) + dh).rem_euclid(1.0) * 6.0;
- let c2 = v * s;
- let x = c2 * (1.0 - ((h2 % 2.0) - 1.0).abs());
- let (r2, g2, b2) = match h2 as i32 {
- 0 => (c2, x, 0.0),
- 1 => (x, c2, 0.0),
- 2 => (0.0, c2, x),
- 3 => (0.0, x, c2),
- 4 => (x, 0.0, c2),
- _ => (c2, 0.0, x),
- };
- let m = v - c2;
- Color::new([r2 + m, g2 + m, b2 + m, a])
-}
-
-pub(crate) fn short_label(s: &str) -> String {
- let mut out: String = s.chars().take(24).collect();
- if s.chars().count() > 24 {
- out.push('…');
- }
- out
-}
diff --git a/00_unanchay/khipu/khipu-app/src/menu.rs b/00_unanchay/khipu/khipu-app/src/menu.rs
new file mode 100644
index 0000000..ca8af2d
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/menu.rs
@@ -0,0 +1,149 @@
+//! `menu` — menú principal de khipu y menú de edición contextual.
+//!
+//! Construye el `AppMenu` reflejando el estado del campo focuseado e
+//! informa los `EditFlags` del campo activo. El despacho de comandos a
+//! `Msg` reales vive en `app.rs` para evitar un ciclo de módulos.
+
+use llimphi_theme::Theme;
+use llimphi_widget_edit_menu::EditFlags;
+use llimphi_widget_menubar::{MenuBarSpec, DEFAULT_HEIGHT as MENU_H};
+use llimphi_widget_text_editor::EditorState;
+
+use crate::modelo::{Focus, Model, Msg};
+
+// =====================================================================
+// Helpers de foco
+// =====================================================================
+
+/// Devuelve el `EditorState` del campo focuseado (referencia inmutable) y
+/// si está enmascarado (passphrase). Sin foco editable devuelve `None`.
+pub(crate) fn focused_editor(model: &Model) -> (Option<&EditorState>, bool) {
+ match model.focus {
+ Focus::Body => (Some(&model.body), false),
+ Focus::Captura => (Some(model.captura.editor()), false),
+ Focus::Title => (Some(model.title.editor()), false),
+ Focus::Tags => (Some(model.tags.editor()), false),
+ Focus::Search => (Some(model.search.editor()), false),
+ Focus::PeerAddr => (Some(model.peer_input.editor()), false),
+ Focus::Nombre => (Some(model.nombre_input.editor()), false),
+ Focus::Passphrase => (Some(model.passphrase.editor()), model.passphrase.is_masked()),
+ Focus::None => (None, false),
+ }
+}
+
+/// `EditFlags` del campo focuseado, para nav/ejecución por teclado del
+/// menú de edición. Sin campo focuseado, flags vacíos (todo gris).
+pub(crate) fn focused_edit_flags(model: &Model) -> EditFlags {
+ let (editor, masked) = focused_editor(model);
+ match editor {
+ Some(ed) => EditFlags::from_editor(ed, masked),
+ None => EditFlags::default(),
+ }
+}
+
+// =====================================================================
+// Menú principal
+// =====================================================================
+
+/// Construye el menú principal de khipu reflejando el estado del campo
+/// focuseado (ítems de Editar grises sin selección / historial) y de la
+/// cuerda seleccionada (verbos de Cuerda grises sin una).
+pub(crate) fn app_menu(model: &Model) -> app_bus::AppMenu {
+ use app_bus::{AppMenu, Menu, MenuItem};
+ let (editor, _masked) = focused_editor(model);
+ let has_sel = editor.map(|e| e.has_selection()).unwrap_or(false);
+ let can_undo = editor.map(|e| e.can_undo()).unwrap_or(false);
+ let can_redo = editor.map(|e| e.can_redo()).unwrap_or(false);
+ let has_field = editor.is_some();
+ let has_sel_note = model.selected.is_some();
+ let has_sel_cuerda = model.cuerda_sel.is_some();
+
+ let mut undo = MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z");
+ if !can_undo {
+ undo = undo.disabled();
+ }
+ let mut redo = MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Y");
+ if !can_redo {
+ redo = redo.disabled();
+ }
+ let mut cut = MenuItem::new("Cortar", "edit.cut").shortcut("Ctrl+X").separated();
+ let mut copy = MenuItem::new("Copiar", "edit.copy").shortcut("Ctrl+C");
+ if !has_sel {
+ cut = cut.disabled();
+ copy = copy.disabled();
+ }
+ let mut paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
+ if !has_field {
+ paste = paste.disabled();
+ }
+ let mut sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
+ .shortcut("Ctrl+A")
+ .separated();
+ if !has_field {
+ sel_all = sel_all.disabled();
+ }
+
+ let mut delete_note = MenuItem::new("Borrar nudo", "note.delete");
+ if !has_sel_note {
+ delete_note = delete_note.disabled();
+ }
+
+ let mut tejer = MenuItem::new("Tejer (encuadre LLM)", "cuerda.tejer");
+ let mut renombrar = MenuItem::new("Renombrar cuerda…", "cuerda.renombrar");
+ let mut desatar = MenuItem::new("Desatar cuerda", "cuerda.desatar").separated();
+ if !has_sel_cuerda || model.tejiendo.is_some() {
+ tejer = tejer.disabled();
+ }
+ if !has_sel_cuerda {
+ renombrar = renombrar.disabled();
+ desatar = desatar.disabled();
+ }
+
+ AppMenu::new()
+ .menu(
+ Menu::new("Archivo")
+ .item(MenuItem::new("Capturar (foco)", "note.captura").shortcut("Ctrl+N"))
+ .item(delete_note)
+ .item(MenuItem::new("Exportar sobre…", "share.export").separated())
+ .item(MenuItem::new("Importar sobre…", "share.import")),
+ )
+ .menu(
+ Menu::new("Editar")
+ .item(undo)
+ .item(redo)
+ .item(cut)
+ .item(copy)
+ .item(paste)
+ .item(sel_all),
+ )
+ .menu(Menu::new("Cuerda").item(tejer).item(renombrar).item(desatar))
+ .menu(
+ Menu::new("Compartir")
+ .item(MenuItem::new("Publicar (P2P)", "share.publish"))
+ .item(MenuItem::new("Recibir de un par…", "share.receive")),
+ )
+ .menu(
+ Menu::new("Ayuda")
+ .item(MenuItem::new("Buscar (foco)", "view.search").shortcut("Ctrl+F"))
+ .item(MenuItem::new("Acerca de khipu", "help.about")),
+ )
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+/// Recibe el tamaño del viewport para no crear una dependencia de `KhipuApp`.
+pub(crate) fn menubar_spec<'a>(
+ menu: &'a app_bus::AppMenu,
+ model: &Model,
+ theme: &'a Theme,
+ viewport: (f32, f32),
+) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme,
+ viewport,
+ height: MENU_H,
+ on_open: std::sync::Arc::new(Msg::MenuOpen),
+ on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/modelo.rs b/00_unanchay/khipu/khipu-app/src/modelo.rs
new file mode 100644
index 0000000..87b38ba
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/modelo.rs
@@ -0,0 +1,441 @@
+//! `modelo` — tipos centrales de khipu-app: constantes, enums de foco y
+//! mensajes, estructuras de modelo y estado P2P.
+//!
+//! Todo lo que `update`, `view` y los submódulos necesitan conocer para
+//! tipar sus firmas; cero lógica de negocio aquí.
+
+use std::collections::BTreeMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use agora_core::Keypair;
+use khipu_core::{CuerdaId, NoteId, NoteStore};
+use khipu_gravity::SemanticField;
+use khipu_share::SignedBundle;
+use llimphi_clipboard::SystemClipboard;
+use llimphi_motion::Tween;
+use llimphi_theme::Theme;
+use llimphi_widget_text_editor::EditorState;
+use llimphi_widget_text_input::{TextInputEvent, TextInputState};
+use rimay_verbo::Provider;
+
+// =====================================================================
+// Constantes globales
+// =====================================================================
+
+/// Dimensión del embebedor local (fallback sin daemon).
+pub(crate) const EMBED_DIM: usize = 16;
+pub(crate) const EDITOR_VISIBLE_LINES: usize = 24;
+pub(crate) const LIST_WIDTH: f32 = 260.0;
+/// Ancho del editor flotante (overlay derecho sobre el lienzo de cuerdas).
+pub(crate) const EDITOR_OVERLAY_W: f32 = 420.0;
+pub(crate) const HEADER_H: f32 = 36.0;
+pub(crate) const ROW_H: f32 = 26.0;
+pub(crate) const FIELD_LABEL_SIZE: f32 = 10.0;
+/// Cuántos vecinos como máximo ofrece el panel de resonancia.
+pub(crate) const RESONANCIA_MAX: usize = 4;
+/// Umbral de afinidad para que un vecino cuente como resonancia, según
+/// el espacio vectorial: los embeddings reales (e5) viven con baseline
+/// alto; el trigram local es mucho más disperso.
+pub(crate) const UMBRAL_RESONANCIA_REMOTO: f32 = 0.78;
+pub(crate) const UMBRAL_RESONANCIA_LOCAL: f32 = 0.22;
+/// Un nudo suelto sin tocar durante este lapso sedimenta: sigue ahí,
+/// buscable, pero deja la franja de sueltos frescos. 14 días.
+pub(crate) const SEDIMENTO_SECS: u64 = 14 * 24 * 3600;
+/// Máximo de chips en la franja de sueltos frescos.
+pub(crate) const SUELTOS_MAX: usize = 10;
+
+// =====================================================================
+// Embedder
+// =====================================================================
+
+/// Fuente de vectores semánticos. Con un `verbo-daemon` en el socket por
+/// defecto usa embeddings reales; si no hay daemon cae al hash-trigram
+/// local de 16d — determinista, offline, sin runtime.
+///
+/// El arm remoto guarda el `Runtime` de tokio para resolver las llamadas
+/// async del `Provider` con `block_on` desde el hilo worker que las
+/// dispara (nunca el de UI). Es `Clone` (todo tras `Arc`) para viajar
+/// barato dentro de la closure de `Handle::spawn`.
+#[derive(Clone)]
+pub(crate) enum Embedder {
+ /// Daemon `rimay-verbo` por socket Unix.
+ Remote {
+ provider: Arc<dyn Provider>,
+ rt: Arc<tokio::runtime::Runtime>,
+ dim: usize,
+ label: String,
+ },
+ /// Fallback local: hash-trigram → R^EMBED_DIM, sin red ni runtime.
+ Local,
+}
+
+impl Embedder {
+ /// Conecta al `verbo-daemon` en el socket por defecto. Si no hay
+ /// ninguno (o no se pudo armar el runtime), devuelve el embebedor
+ /// local — los demos arrancan igual, sin red.
+ pub(crate) fn connect() -> Self {
+ let rt = match tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(_) => return Embedder::Local,
+ };
+ match rt.block_on(rimay_verbo::conectar()) {
+ Ok(client) => {
+ let id = client.model_id();
+ let dim = id.dimension;
+ let label = id.to_string();
+ Embedder::Remote {
+ provider: Arc::new(client),
+ rt: Arc::new(rt),
+ dim,
+ label,
+ }
+ }
+ Err(_) => Embedder::Local,
+ }
+ }
+
+ /// Etiqueta del espacio vectorial. Si cambia entre dos arranques, los
+ /// vectores persistidos son incomparables y hay que recalcularlos.
+ pub(crate) fn label(&self) -> String {
+ match self {
+ Embedder::Remote { label, .. } => label.clone(),
+ Embedder::Local => format!("khipu-trigram-{EMBED_DIM}d"),
+ }
+ }
+
+ /// Umbral de afinidad para la resonancia en este espacio vectorial.
+ pub(crate) fn umbral_resonancia(&self) -> f32 {
+ match self {
+ Embedder::Remote { .. } => UMBRAL_RESONANCIA_REMOTO,
+ Embedder::Local => UMBRAL_RESONANCIA_LOCAL,
+ }
+ }
+
+ /// Embebe `text` de forma bloqueante. En el arm remoto resuelve el
+ /// future con `block_on`; ante un error devuelve un vector de ceros.
+ pub(crate) fn embed_blocking(&self, text: &str) -> Vec<f32> {
+ match self {
+ Embedder::Local => khipu_gravity::local_embed(text, EMBED_DIM),
+ Embedder::Remote { provider, rt, dim, .. } => rt
+ .block_on(provider.embed(text))
+ .map(|v| v.values)
+ .unwrap_or_else(|_| vec![0.0; *dim]),
+ }
+ }
+}
+
+// =====================================================================
+// Focus
+// =====================================================================
+
+/// Foco activo del teclado. Cualquier `KeyEvent` se rutea al input
+/// correspondiente; sin foco las teclas se ignoran.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum Focus {
+ None,
+ /// La barra de captura — el gesto raíz de khipu.
+ Captura,
+ Search,
+ Title,
+ Body,
+ Tags,
+ Passphrase,
+ PeerAddr,
+ /// Input del nombre de una cuerda (bautizo o renombre).
+ Nombre,
+}
+
+// =====================================================================
+// Resonancia
+// =====================================================================
+
+/// El eco de una captura: el nudo recién botado y los nudos previos que
+/// tocó (afinidad sobre el umbral del espacio vectorial). El panel de
+/// resonancia lo muestra apenas llega el embedding — ese instante es el
+/// cierre de la fuga: anudar en vez de duplicar.
+#[derive(Clone)]
+pub(crate) struct Resonancia {
+ /// El nudo recién capturado.
+ pub(crate) nudo: NoteId,
+ /// Vecinos que resuenan: `(id, afinidad)`, de mayor a menor.
+ /// Vacío mientras el embedding viaja al worker.
+ pub(crate) vecinos: Vec<(NoteId, f32)>,
+ /// `true` hasta que el embedding vuelve del worker.
+ pub(crate) calculando: bool,
+}
+
+// =====================================================================
+// P2p
+// =====================================================================
+
+/// Nodo libp2p del cuaderno + su runtime tokio. Se arma perezosamente la
+/// primera vez que se usa P2P (`ensure_p2p`).
+pub(crate) struct P2p {
+ pub(crate) rt: Arc<tokio::runtime::Runtime>,
+ pub(crate) node: Arc<khipu_brahman::KhipuNode>,
+ /// Nuestra dirección para compartir (`/ip4/.../tcp/.../p2p/<id>`).
+ pub(crate) dial_addr: String,
+ /// `true` cuando ya estamos sirviendo el cuaderno por libp2p.
+ pub(crate) serving: bool,
+}
+
+// =====================================================================
+// PeerInfo
+// =====================================================================
+
+/// Un par descubierto en la red, en forma lista para la UI.
+#[derive(Clone)]
+pub(crate) struct PeerInfo {
+ /// Dirección TCP de fetch, como string (`ip:puerto`).
+ pub(crate) addr: String,
+ /// Etiqueta para la fila: nombre · de:autor · dirección.
+ pub(crate) label: String,
+}
+
+// =====================================================================
+// Msg
+// =====================================================================
+
+#[derive(Clone)]
+pub(crate) enum Msg {
+ // --- captura y resonancia ---
+ /// Enter en la barra de captura: bota el nudo y dispara la resonancia.
+ Capturar,
+ /// Anuda el nudo a una cuerda existente (acción del panel de resonancia
+ /// o del editor).
+ Anudar(NoteId, CuerdaId),
+ /// Anuda dos nudos sueltos en una cuerda nueva (nombre propuesto del
+ /// contenido).
+ AnudarJuntos(NoteId, NoteId),
+ /// Crea una cuerda nueva con este único nudo.
+ NuevaCuerda(NoteId),
+ /// Cierra el panel de resonancia dejando el nudo suelto.
+ DescartarResonancia,
+ /// Reabre la resonancia de un nudo suelto (desde el editor): vuelve a
+ /// preguntar con quién resuena, con su embedding vigente.
+ Resonar(NoteId),
+ /// Suelta un nudo de su cuerda.
+ Desanudar(NoteId),
+ /// Cicla el estado accionable del nudo: Normal → Pendiente → Hecho.
+ CiclarEstado(NoteId),
+
+ // --- cuerdas ---
+ /// Selecciona (o deselecciona con `None`) una cuerda en el lienzo.
+ SelectCuerda(Option<CuerdaId>),
+ /// Abre el input de renombre de una cuerda, precargado.
+ RenombrarCuerda(CuerdaId),
+ /// Confirma el renombre con el texto tipeado.
+ CommitNombre,
+ /// Cancela el renombre.
+ CancelNombre,
+ /// Borra una cuerda: sus nudos vuelven a estar sueltos.
+ BorrarCuerda(CuerdaId),
+ /// Teje la cuerda: pide al LLM el encuadre de sus nudos.
+ Tejer(CuerdaId),
+ /// Resultado async del tejido (`Ok(texto)` o `Err(mensaje)`).
+ TejidoListo(CuerdaId, Result<String, String>),
+ /// Pan horizontal del lienzo de cuerdas (delta de arrastre en px).
+ CuerdasPan(f32),
+ /// Click en el lienzo en coords locales `(lx, ly)` sobre `(w, h)`.
+ CuerdasClick(f32, f32, f32, f32),
+
+ // --- notas / edición ---
+ SelectNote(NoteId),
+ DeleteSelected,
+ Focus(Focus),
+ /// Evento de mouse de un campo de texto (click/arrastre). El `Focus`
+ /// identifica cuál; un `Press` además lo enfoca (y confirma la edición
+ /// previa). Ver [`llimphi_widget_text_input::text_input_view_full`].
+ Campo(Focus, TextInputEvent),
+ Key(llimphi_ui::KeyEvent),
+ EditorPointer(llimphi_widget_text_editor::PointerEvent),
+ /// Latido lento — refresca la frescura pintada de las cuerdas.
+ Tick,
+ /// Resultado async de un embed: `(nota, secuencia, vector)`.
+ EmbeddingReady(NoteId, u64, Vec<f32>),
+ /// Cierra el editor flotante: deselecciona y vuelve al lienzo limpio.
+ Deselect,
+ /// Abre/cierra el cajón de nudos (overlay izquierdo).
+ ToggleList,
+ /// Escape en el lienzo: cierra lo de más arriba.
+ EscapeMapa,
+
+ // --- compartir ---
+ /// Sella todo el cuaderno en un sobre firmado (`compartido.khipu`).
+ Export,
+ /// Verifica e ingiere `compartido.khipu` como notas nuevas.
+ Import,
+ /// Empieza a servir el cuaderno por TCP para que un par lo jale.
+ Publish,
+ /// Busca pares en la LAN para jalarles el cuaderno.
+ Receive,
+ /// Resultado del descubrimiento: los pares vistos (ya sin uno mismo).
+ PeersFound(Vec<PeerInfo>),
+ /// Jala el cuaderno del par en esta dirección TCP.
+ FetchFrom(String),
+ /// Jala de la dirección escrita a mano (input) — habilita WAN.
+ FetchManual,
+ /// Cierra el panel de recibir sin jalar nada.
+ CancelPeers,
+ /// Resultado async de un fetch: el sobre recibido o un error.
+ Received(Result<SignedBundle, String>),
+ /// Intenta desbloquear la identidad con la passphrase tipeada.
+ Unlock,
+ /// Cierra el prompt de passphrase sin desbloquear.
+ CancelUnlock,
+ /// Resultado async de reservar un circuito en un relay.
+ RelayReady(String),
+
+ // --- menús ---
+ /// Abre/cierra un dropdown de la barra de menú principal.
+ MenuOpen(Option<usize>),
+ /// Comando elegido en la barra de menú.
+ MenuCommand(String),
+ /// Navegación por teclado en el menú principal.
+ MenuNav(i32),
+ /// Enter en el menú principal: ejecuta la fila activa.
+ MenuActivate,
+ /// Tick de animación de menús (sólo re-render).
+ MenuTick,
+ /// Navegación por teclado en el menú de edición.
+ EditNav(i32),
+ /// Enter en el menú de edición: ejecuta la fila activa.
+ EditActivate,
+ /// Abre el menú de edición contextual (right-click) en coords de ventana.
+ EditMenuOpen(f32, f32),
+ /// Acción de edición sobre el campo focuseado.
+ EditMenuAction(llimphi_widget_edit_menu::EditAction),
+ /// Cierra cualquier menú abierto (principal o de edición).
+ CloseMenus,
+}
+
+// =====================================================================
+// Model
+// =====================================================================
+
+pub(crate) struct Model {
+ pub(crate) store: NoteStore,
+ pub(crate) field: SemanticField,
+ /// Orden de inserción (estable).
+ pub(crate) order: Vec<NoteId>,
+ pub(crate) selected: Option<NoteId>,
+ /// La barra de captura — siempre lista arriba.
+ pub(crate) captura: TextInputState,
+ /// El eco de la última captura, si el panel sigue abierto.
+ pub(crate) resonancia: Option<Resonancia>,
+ /// Cuerda seleccionada en el lienzo (muestra su tarjeta).
+ pub(crate) cuerda_sel: Option<CuerdaId>,
+ /// Cuerda en renombre, si el input está abierto.
+ pub(crate) renombrando: Option<CuerdaId>,
+ /// Input del nombre de cuerda (bautizo/renombre).
+ pub(crate) nombre_input: TextInputState,
+ /// Cuerda cuyo tejido está viajando al LLM, si hay uno en curso.
+ pub(crate) tejiendo: Option<CuerdaId>,
+ /// Pan horizontal del lienzo de cuerdas (px, ≥ 0).
+ pub(crate) pan_x: f32,
+ pub(crate) title: TextInputState,
+ pub(crate) body: EditorState,
+ pub(crate) tags: TextInputState,
+ pub(crate) search: TextInputState,
+ pub(crate) focus: Focus,
+ pub(crate) theme: Theme,
+ pub(crate) data_path: Option<PathBuf>,
+ /// Fuente de embeddings: daemon `verbo` o fallback trigram local.
+ pub(crate) embedder: Embedder,
+ /// Última secuencia de embedding pedida por nota.
+ pub(crate) embed_latest: BTreeMap<NoteId, u64>,
+ /// Contador monótono de pedidos de embedding.
+ pub(crate) embed_seq: u64,
+ /// Identidad Ed25519 del cuaderno.
+ pub(crate) keypair: Option<Keypair>,
+ /// Última línea de estado (export/import/red/tejido).
+ pub(crate) status: Option<String>,
+ /// `true` cuando ya hay un servidor TCP sirviendo el cuaderno.
+ pub(crate) publishing: bool,
+ /// `true` mientras el panel izquierdo está en modo "recibir".
+ pub(crate) receiving: bool,
+ /// Pares descubiertos en la última búsqueda.
+ pub(crate) peers: Vec<PeerInfo>,
+ /// Dirección manual del par para recibir (habilita WAN).
+ pub(crate) peer_input: TextInputState,
+ /// Input de la passphrase para desbloquear la identidad.
+ pub(crate) passphrase: TextInputState,
+ /// `true` mientras se muestra el prompt de passphrase (modal).
+ pub(crate) unlocking: bool,
+ /// Acción a reanudar tras desbloquear.
+ pub(crate) pending: Option<Box<Msg>>,
+ /// Nodo libp2p (perezoso).
+ pub(crate) p2p: Option<P2p>,
+ /// Dropdown abierto de la barra de menú (índice), o `None`.
+ pub(crate) menu_open: Option<usize>,
+ /// Fila resaltada por teclado en el menú principal.
+ pub(crate) menu_active: usize,
+ /// Animación de aparición/swap del dropdown del menú principal.
+ pub(crate) menu_anim: Tween<f32>,
+ /// Posición del menú de edición contextual, si abierto.
+ pub(crate) edit_menu: Option<(f32, f32)>,
+ /// Fila resaltada por teclado en el menú de edición.
+ pub(crate) edit_active: usize,
+ /// Animación de aparición del menú de edición.
+ pub(crate) edit_anim: Tween<f32>,
+ /// Portapapeles del sistema.
+ pub(crate) clipboard: SystemClipboard,
+ /// `true` mientras el cajón de nudos (overlay izquierdo) está abierto.
+ pub(crate) show_list: bool,
+ /// Último tamaño conocido del lienzo `(w, h)` en pixels.
+ pub(crate) canvas_size: (f32, f32),
+}
+
+impl Model {
+ /// Constructor vacío base; lo usan `from_state` y `seeded_model`
+ /// para no repetir la inicialización de todos los campos.
+ pub(crate) fn blank(embedder: Embedder) -> Self {
+ Model {
+ store: NoteStore::new(),
+ field: SemanticField::new(),
+ order: Vec::new(),
+ selected: None,
+ captura: TextInputState::new(),
+ resonancia: None,
+ cuerda_sel: None,
+ renombrando: None,
+ nombre_input: TextInputState::new(),
+ tejiendo: None,
+ pan_x: 0.0,
+ title: TextInputState::new(),
+ body: EditorState::default(),
+ tags: TextInputState::new(),
+ search: TextInputState::new(),
+ focus: Focus::Captura,
+ theme: Theme::dark(),
+ data_path: None,
+ embedder,
+ embed_latest: BTreeMap::new(),
+ embed_seq: 0,
+ keypair: None,
+ status: None,
+ publishing: false,
+ receiving: false,
+ peers: Vec::new(),
+ peer_input: TextInputState::new(),
+ passphrase: TextInputState::masked(),
+ unlocking: false,
+ pending: None,
+ p2p: None,
+ menu_open: None,
+ menu_active: usize::MAX,
+ menu_anim: Tween::idle(1.0),
+ edit_menu: None,
+ edit_active: usize::MAX,
+ edit_anim: Tween::idle(1.0),
+ clipboard: SystemClipboard::new(),
+ show_list: false,
+ canvas_size: (1280.0, 640.0),
+ }
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/net.rs b/00_unanchay/khipu/khipu-app/src/net.rs
index 6213c1c..830e864 100644
--- a/00_unanchay/khipu/khipu-app/src/net.rs
+++ b/00_unanchay/khipu/khipu-app/src/net.rs
@@ -1,7 +1,7 @@
//! `net` — el lado P2P del cuaderno: sellar/exportar el sobre firmado,
//! verificar e ingerir uno ajeno, descubrir pares y publicarse por libp2p.
//! El transporte (LAN directo + WAN vía `card-net`/Kademlia + relay/NAT)
-//! vive en `khipu-share`/`khipu-brahman`; acá sólo se orquesta.
+//! vive en `khipu-share`/`khipu-brahman`; aquí sólo se orquesta.
use std::sync::Arc;
@@ -9,7 +9,8 @@ use khipu_share::{SharedNote, SignedBundle};
use llimphi_ui::Handle;
use crate::panels::note_matches;
-use crate::{khipu_dir, now_secs, schedule_embedding, Model, Msg, P2p};
+use crate::estado::{khipu_dir, now_secs, schedule_embedding};
+use crate::modelo::{Model, Msg, P2p};
pub(crate) fn export_notebook(model: &Model) -> String {
let Some(kp) = model.keypair.as_ref() else {
diff --git a/00_unanchay/khipu/khipu-app/src/panels.rs b/00_unanchay/khipu/khipu-app/src/panels.rs
index 9713866..116d739 100644
--- a/00_unanchay/khipu/khipu-app/src/panels.rs
+++ b/00_unanchay/khipu/khipu-app/src/panels.rs
@@ -1,10 +1,12 @@
-//! `panels` — los constructores de vistas en flujo: cabecera, cajón de
-//! notas (lista + búsqueda), panel de recibir (pares P2P), y el editor de
-//! la nota (título/cuerpo/etiquetas + stats). Frontends puros sobre el
-//! `Model`; la lógica vive en la raíz y en `map`.
+//! `panels` — los constructores de vistas en flujo: cabecera, barra de
+//! captura, franja de sueltos, panel de resonancia, tarjeta de cuerda,
+//! cajón de nudos (lista + búsqueda), panel de recibir (pares P2P) y el
+//! editor del nudo. Frontends puros sobre el `Model`; la lógica vive en
+//! la raíz y en `cuerdas`.
+use khipu_core::{EstadoNudo, Note, NoteId};
use llimphi_ui::llimphi_layout::taffy::{
- prelude::{length, percent, FlexDirection, Rect, Size, Style},
+ prelude::{auto, length, percent, FlexDirection, Position, Rect, Size, Style},
AlignItems, Dimension, JustifyContent,
};
use llimphi_ui::llimphi_raster::peniko::Color;
@@ -12,16 +14,25 @@ use llimphi_ui::llimphi_text::Alignment;
use llimphi_ui::View;
use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
use llimphi_widget_text_editor::{text_editor_view, EditorMetrics, EditorPalette};
-use llimphi_widget_text_input::{text_input_view, TextInputPalette};
-use khipu_core::{Note, NoteId};
+use llimphi_widget_text_input::{text_input_view_full, TextInputPalette};
-use crate::{
- current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
- LIST_WIDTH, ROW_H,
+use crate::cuerdas::short_label;
+use crate::estado::{es_sedimento, now_secs};
+use crate::modelo::{
+ Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H, LIST_WIDTH, ROW_H,
+ SUELTOS_MAX,
};
+// =====================================================================
+// Cabecera
+// =====================================================================
+
pub(crate) fn header_view(model: &Model) -> View<Msg> {
- let title = format!("khipu · {} notas", model.store.len());
+ let title = format!(
+ "khipu · {} nudos · {} cuerdas",
+ model.store.len(),
+ model.store.cuerdas_len()
+ );
let title_node = View::new(Style {
size: Size {
width: Dimension::auto(),
@@ -39,36 +50,13 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
})
.text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
- let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
+ let list_label = if model.show_list { "ocultar nudos" } else { "☰ nudos" };
let list_btn = button(
list_label,
model.theme.bg_button,
if model.show_list { model.theme.accent } else { model.theme.fg_muted },
Msg::ToggleList,
);
- let new_btn = button(
- "+ nueva (Ctrl+N)",
- model.theme.bg_button,
- model.theme.fg_text,
- Msg::NewNote,
- );
- let archive_label = if model.show_archive {
- "ocultar archivo"
- } else {
- "ver archivo"
- };
- let archive_btn = button(
- archive_label,
- model.theme.bg_button,
- model.theme.fg_muted,
- Msg::ToggleArchive,
- );
- let del_btn = button(
- "borrar",
- model.theme.bg_button,
- model.theme.fg_muted,
- Msg::DeleteSelected,
- );
let export_btn = button(
"exportar",
model.theme.bg_button,
@@ -81,11 +69,7 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
model.theme.fg_muted,
Msg::Import,
);
- let publish_label = if model.publishing {
- "publicando"
- } else {
- "publicar"
- };
+ let publish_label = if model.publishing { "publicando" } else { "publicar" };
let publish_btn = button(
publish_label,
model.theme.bg_button,
@@ -124,25 +108,14 @@ pub(crate) fn header_view(model: &Model) -> View<Msg> {
..Default::default()
})
.fill(model.theme.bg_panel_alt)
- .children(vec![
- title_node,
- list_btn,
- new_btn,
- archive_btn,
- del_btn,
- export_btn,
- import_btn,
- publish_btn,
- receive_btn,
- ])
+ .children(vec![title_node, list_btn, export_btn, import_btn, publish_btn, receive_btn])
}
pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
// El ancho crece con el largo del texto — los labels más
- // explícitos («+ nueva (Ctrl+N)», «ocultar archivo») piden más
- // espacio que un «borrar» seco.
+ // explícitos piden más espacio que un «tejer» seco.
let chars = label.chars().count() as f32;
- let width = (chars * 7.2 + 22.0).max(86.0);
+ let width = (chars * 7.2 + 22.0).max(72.0);
View::new(Style {
size: Size {
width: length(width),
@@ -156,6 +129,7 @@ pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
},
align_items: Some(AlignItems::Center),
justify_content: Some(JustifyContent::Center),
+ flex_shrink: 0.0,
..Default::default()
})
.fill(bg)
@@ -164,6 +138,527 @@ pub(crate) fn button(label: &str, bg: Color, fg: Color, msg: Msg) -> View<Msg> {
.on_click(msg)
}
+// =====================================================================
+// Captura + sueltos
+// =====================================================================
+
+/// La barra de captura: el gesto raíz. Un input siempre listo — escribes,
+/// Enter, y el nudo cae; la resonancia hace el resto.
+pub(crate) fn captura_bar(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
+ let input = text_input_view_full(
+ &model.captura,
+ "bota la idea aquí… (Enter la anuda al aire)",
+ model.focus == Focus::Captura,
+ input_palette,
+ |ev| Msg::Campo(Focus::Captura, ev),
+ );
+ let input_wrap = View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: length(28.0_f32),
+ },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .children(vec![input]);
+ let botar = button("⏎ botar", model.theme.bg_button, model.theme.accent, Msg::Capturar);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(40.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(6.0_f32),
+ bottom: length(6.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ gap: Size {
+ width: length(8.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel_alt)
+ .children(vec![input_wrap, botar])
+}
+
+/// La franja de sueltos frescos: chips clickeables con los nudos que
+/// todavía no resonaron con nada, del más nuevo al más viejo. Lo que
+/// sedimentó no estorba aquí — vive en el cajón y en la búsqueda.
+pub(crate) fn sueltos_strip(model: &Model) -> View<Msg> {
+ let now = now_secs();
+ let sueltos = model.store.sueltos();
+ let frescos: Vec<&Note> = sueltos
+ .iter()
+ .copied()
+ .filter(|n| !es_sedimento(n, now))
+ .collect();
+ let sedimento = sueltos.len() - frescos.len();
+
+ let mut children: Vec<View<Msg>> = Vec::new();
+ let label = View::new(Style {
+ size: Size {
+ width: length(64.0_f32),
+ height: percent(1.0_f32),
+ },
+ flex_shrink: 0.0,
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .text_aligned("sueltos:".to_string(), 11.0, model.theme.fg_muted, Alignment::Start);
+ children.push(label);
+
+ for n in frescos.iter().take(SUELTOS_MAX) {
+ let marca = match n.estado {
+ EstadoNudo::Pendiente => "☐ ",
+ EstadoNudo::Hecho => "☑ ",
+ EstadoNudo::Normal => "",
+ };
+ let texto: String = n.title.chars().take(18).collect();
+ let sufijo = if n.title.chars().count() > 18 { "…" } else { "" };
+ let fg = if model.selected == Some(n.id) {
+ model.theme.accent
+ } else {
+ model.theme.fg_text
+ };
+ children.push(button(
+ &format!("{marca}{texto}{sufijo}"),
+ model.theme.bg_button,
+ fg,
+ Msg::SelectNote(n.id),
+ ));
+ }
+ if frescos.len() > SUELTOS_MAX {
+ children.push(button(
+ &format!("+{}", frescos.len() - SUELTOS_MAX),
+ model.theme.bg_button,
+ model.theme.fg_muted,
+ Msg::ToggleList,
+ ));
+ }
+ if sedimento > 0 {
+ children.push(button(
+ &format!("· sedimento {sedimento}"),
+ model.theme.bg_panel,
+ model.theme.fg_muted,
+ Msg::ToggleList,
+ ));
+ }
+ if frescos.is_empty() && sedimento == 0 {
+ children.push(
+ View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: percent(1.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .text_aligned("ninguno — todo anudado".to_string(), 11.0, model.theme.fg_muted, Alignment::Start),
+ );
+ }
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(34.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(4.0_f32),
+ bottom: length(4.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel)
+ .children(children)
+}
+
+// =====================================================================
+// Resonancia
+// =====================================================================
+
+/// El panel de resonancia: aparece apenas una captura toca nudos previos.
+/// Cada vecino es una fila clickeable — si vive en una cuerda, el click
+/// anuda ahí; si está suelto, el click los anuda juntos en una cuerda
+/// nueva. Abajo: cuerda nueva sólo con este nudo, o dejarlo suelto (Esc).
+pub(crate) fn resonancia_panel(model: &Model) -> Option<View<Msg>> {
+ let res = model.resonancia.as_ref()?;
+ let nudo = model.store.get(res.nudo)?;
+ let theme = &model.theme;
+
+ let mut rows: Vec<View<Msg>> = Vec::new();
+ let titulo = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(
+ format!("«{}» resuena con:", short_label(&nudo.title)),
+ 12.0,
+ theme.fg_text,
+ Alignment::Start,
+ );
+ rows.push(titulo);
+
+ if res.calculando {
+ rows.push(
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned("buscando ecos…".to_string(), 11.0, theme.fg_muted, Alignment::Start),
+ );
+ }
+
+ for (vid, aff) in &res.vecinos {
+ let Some(v) = model.store.get(*vid) else { continue };
+ let (destino, msg) = match v.cuerda.and_then(|c| model.store.cuerda(c)) {
+ Some(c) => (format!("→ anudar a «{}»", c.nombre), Msg::Anudar(res.nudo, c.id)),
+ None => ("→ anudar juntos (cuerda nueva)".to_string(), Msg::AnudarJuntos(res.nudo, *vid)),
+ };
+ let fila = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(36.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(8.0_f32),
+ right: length(8.0_f32),
+ top: length(3.0_f32),
+ bottom: length(3.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_button)
+ .radius(4.0)
+ .hover_fill(theme.bg_button_hover)
+ .on_click(msg)
+ .children(vec![
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(15.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!("{} ({:.2})", short_label(&v.title), aff),
+ 11.0,
+ theme.fg_text,
+ Alignment::Start,
+ ),
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(13.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(destino, 10.0, theme.fg_muted, Alignment::Start),
+ ]);
+ rows.push(fila);
+ }
+
+ let acciones = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ flex_shrink: 0.0,
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ button("cuerda nueva", theme.bg_button, theme.accent, Msg::NuevaCuerda(res.nudo)),
+ button("dejar suelto (Esc)", theme.bg_button, theme.fg_muted, Msg::DescartarResonancia),
+ ]);
+ rows.push(acciones);
+
+ let card = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(340.0_f32),
+ height: Dimension::auto(),
+ },
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .border(1.0, theme.accent)
+ .radius(8.0)
+ .children(rows);
+
+ // Anclado arriba al centro del lienzo, justo bajo la captura.
+ Some(
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: percent(0.5_f32),
+ top: length(10.0_f32),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(0.0_f32),
+ height: Dimension::auto(),
+ },
+ ..Default::default()
+ })
+ .children(vec![View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(-170.0_f32),
+ top: length(0.0_f32),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(340.0_f32),
+ height: Dimension::auto(),
+ },
+ ..Default::default()
+ })
+ .children(vec![card])]),
+ )
+}
+
+// =====================================================================
+// Tarjeta de cuerda
+// =====================================================================
+
+/// La tarjeta de la cuerda seleccionada: nombre, conteo, y los verbos —
+/// tejer (el encuadre LLM), renombrar, desatar. Flota abajo a la
+/// izquierda del lienzo.
+pub(crate) fn tarjeta_cuerda(model: &Model) -> Option<View<Msg>> {
+ let cid = model.cuerda_sel?;
+ let cuerda = model.store.cuerda(cid)?;
+ let theme = &model.theme;
+ let nudos = model.store.nudos_de(cid);
+ let pendientes = nudos
+ .iter()
+ .filter(|n| n.estado == EstadoNudo::Pendiente)
+ .count();
+
+ let nombre = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(cuerda.nombre.clone(), 14.0, theme.accent, Alignment::Start);
+
+ let mut detalle = format!("{} nudos", nudos.len());
+ if pendientes > 0 {
+ detalle.push_str(&format!(" · {pendientes} pendientes"));
+ }
+ if cuerda.tejido.is_some() {
+ detalle.push_str(" · tejida ◈");
+ }
+ let detalle_row = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(detalle, 11.0, theme.fg_muted, Alignment::Start);
+
+ let tejer_label = if model.tejiendo == Some(cid) {
+ "tejiendo…"
+ } else if cuerda.tejido.is_some() {
+ "re-tejer"
+ } else {
+ "tejer"
+ };
+ let tejer_btn = if model.tejiendo.is_some() {
+ button(tejer_label, theme.bg_button, theme.fg_muted, Msg::Tick)
+ } else {
+ button(tejer_label, theme.bg_button, theme.accent, Msg::Tejer(cid))
+ };
+ let botones = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ flex_shrink: 0.0,
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ tejer_btn,
+ button("renombrar", theme.bg_button, theme.fg_muted, Msg::RenombrarCuerda(cid)),
+ button("desatar", theme.bg_button, theme.fg_muted, Msg::BorrarCuerda(cid)),
+ button("×", theme.bg_button, theme.fg_muted, Msg::SelectCuerda(None)),
+ ]);
+
+ let card = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(360.0_f32),
+ height: Dimension::auto(),
+ },
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .border(1.0, theme.border)
+ .radius(8.0)
+ .children(vec![nombre, detalle_row, botones]);
+
+ Some(
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(12.0_f32),
+ top: auto(),
+ right: auto(),
+ bottom: length(12.0_f32),
+ },
+ size: Size {
+ width: length(360.0_f32),
+ height: Dimension::auto(),
+ },
+ ..Default::default()
+ })
+ .children(vec![card]),
+ )
+}
+
+/// El input de renombre/bautizo de cuerda: tarjeta flotante arriba al
+/// centro. Enter confirma, Esc cancela (en `on_key`).
+pub(crate) fn nombre_overlay(model: &Model, input_palette: &TextInputPalette) -> Option<View<Msg>> {
+ let cid = model.renombrando?;
+ let cuerda = model.store.cuerda(cid)?;
+ let theme = &model.theme;
+
+ let label = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .text_aligned(
+ format!("nombre de la cuerda «{}» — Enter confirma, Esc cancela", cuerda.nombre),
+ 10.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+ let input = text_input_view_full(
+ &model.nombre_input,
+ "nombre…",
+ model.focus == Focus::Nombre,
+ input_palette,
+ |ev| Msg::Campo(Focus::Nombre, ev),
+ );
+ let input_row = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(28.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![input]);
+
+ let card = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(320.0_f32),
+ height: Dimension::auto(),
+ },
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(8.0_f32),
+ bottom: length(10.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .border(1.0, theme.accent)
+ .radius(8.0)
+ .children(vec![label, input_row]);
+
+ Some(
+ View::new(Style {
+ position: Position::Absolute,
+ inset: Rect {
+ left: length(90.0_f32),
+ top: length(10.0_f32),
+ right: auto(),
+ bottom: auto(),
+ },
+ size: Size {
+ width: length(320.0_f32),
+ height: Dimension::auto(),
+ },
+ ..Default::default()
+ })
+ .children(vec![card]),
+ )
+}
+
+// =====================================================================
+// Cajón de nudos (lista + búsqueda)
+// =====================================================================
+
pub(crate) fn list_panel(
model: &Model,
palette: &ListPalette,
@@ -173,11 +668,11 @@ pub(crate) fn list_panel(
let query = model.search.text();
let q = query.trim();
- // Particionamos en horizonte vs archivo y ordenamos cada parte por
- // masa viva decreciente. Si hay query, ambas listas quedan
- // pre-filtradas por coincidencia en título/cuerpo/etiquetas.
- let mut visible: Vec<(NoteId, f32, &Note)> = Vec::new();
- let mut archive: Vec<(NoteId, f32, &Note)> = Vec::new();
+ // Todos los nudos, filtrados por la búsqueda, del más tocado al más
+ // quieto. El sedimento va al final con marca propia — presente pero
+ // sin estorbar.
+ let mut vivos: Vec<&Note> = Vec::new();
+ let mut sedimento: Vec<&Note> = Vec::new();
let mut hidden_by_query = 0usize;
for id in &model.order {
let Some(n) = model.store.get(*id) else {
@@ -187,32 +682,28 @@ pub(crate) fn list_panel(
hidden_by_query += 1;
continue;
}
- let m = current_mass(&model.gravity, n, now);
- if model.gravity.is_visible(m) {
- visible.push((*id, m, n));
+ if es_sedimento(n, now) {
+ sedimento.push(n);
} else {
- archive.push((*id, m, n));
+ vivos.push(n);
}
}
- let by_mass_desc = |a: &(NoteId, f32, &Note), b: &(NoteId, f32, &Note)| {
- b.1.partial_cmp(&a.1)
- .unwrap_or(core::cmp::Ordering::Equal)
- .then(a.0.cmp(&b.0))
+ let by_recency = |a: &&Note, b: &&Note| {
+ b.updated_at
+ .max(b.created_at)
+ .cmp(&a.updated_at.max(a.created_at))
+ .then(a.id.cmp(&b.id))
};
- visible.sort_by(by_mass_desc);
- archive.sort_by(by_mass_desc);
+ vivos.sort_by(by_recency);
+ sedimento.sort_by(by_recency);
- let mut chain: Vec<(NoteId, f32, &Note)> = visible.clone();
- if model.show_archive {
- chain.extend(archive.iter().cloned());
- }
-
- let rows: Vec<ListRow<Msg>> = chain
- .into_iter()
- .map(|(id, mass, n)| ListRow {
- label: row_label(n, mass),
- selected: Some(id) == model.selected,
- on_click: Msg::SelectNote(id),
+ let rows: Vec<ListRow<Msg>> = vivos
+ .iter()
+ .chain(sedimento.iter())
+ .map(|n| ListRow {
+ label: row_label(model, n, now),
+ selected: Some(n.id) == model.selected,
+ on_click: Msg::SelectNote(n.id),
})
.collect();
@@ -220,23 +711,13 @@ pub(crate) fn list_panel(
format!(
"buscar «{}» · {}/{} coinciden",
q,
- visible.len() + if model.show_archive { archive.len() } else { 0 },
- visible.len() + archive.len() + hidden_by_query
- )
- } else if archive.is_empty() {
- format!("notas · {}", visible.len())
- } else if model.show_archive {
- format!(
- "notas · {} horizonte + {} archivo",
- visible.len(),
- archive.len()
+ vivos.len() + sedimento.len(),
+ vivos.len() + sedimento.len() + hidden_by_query
)
+ } else if sedimento.is_empty() {
+ format!("nudos · {}", vivos.len())
} else {
- format!(
- "notas · {} horizonte (+{} archivo)",
- visible.len(),
- archive.len()
- )
+ format!("nudos · {} vivos + {} sedimento", vivos.len(), sedimento.len())
};
let spec = ListSpec {
@@ -248,12 +729,12 @@ pub(crate) fn list_panel(
palette: *palette,
};
- let search_input = text_input_view(
+ let search_input = text_input_view_full(
&model.search,
"buscar (título, cuerpo, etiquetas)",
model.focus == Focus::Search,
input_palette,
- Msg::Focus(Focus::Search),
+ |ev| Msg::Campo(Focus::Search, ev),
);
let search_row = View::new(Style {
size: Size {
@@ -302,19 +783,19 @@ pub(crate) fn list_panel(
/// Panel izquierdo en modo "recibir": arriba un input de dirección manual
/// (`host:puerto`, habilita WAN) con botones jalar/cancelar; debajo, la
/// lista de pares descubiertos en la LAN (click ⇒ jalar de él). Reemplaza
-/// transitoriamente la lista de notas.
+/// transitoriamente la lista de nudos.
pub(crate) fn receive_panel(
model: &Model,
palette: &ListPalette,
input_palette: &TextInputPalette,
) -> View<Msg> {
// Fila de dirección manual + jalar.
- let addr_input = text_input_view(
+ let addr_input = text_input_view_full(
&model.peer_input,
"host:puerto o /ip4/…/p2p/…",
model.focus == Focus::PeerAddr,
input_palette,
- Msg::Focus(Focus::PeerAddr),
+ |ev| Msg::Campo(Focus::PeerAddr, ev),
);
let addr_wrap = View::new(Style {
size: Size {
@@ -436,21 +917,43 @@ pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
n.tags.iter().any(|t| t.to_lowercase().contains(&q))
}
-pub(crate) fn row_label(n: &Note, mass: f32) -> String {
+/// Fila del cajón: una marca de estado + el título. `◉` anudado, `○`
+/// suelto fresco, `·` sedimento, `☐/☑` pendiente/hecho, `◈` tejido.
+pub(crate) fn row_label(model: &Model, n: &Note, now: u64) -> String {
+ let es_tejido = n
+ .cuerda
+ .and_then(|c| model.store.cuerda(c))
+ .map(|c| c.tejido == Some(n.id))
+ .unwrap_or(false);
+ let marca = if es_tejido {
+ '◈'
+ } else {
+ match n.estado {
+ EstadoNudo::Pendiente => '☐',
+ EstadoNudo::Hecho => '☑',
+ EstadoNudo::Normal => {
+ if n.cuerda.is_some() {
+ '◉'
+ } else if es_sedimento(n, now) {
+ '·'
+ } else {
+ '○'
+ }
+ }
+ }
+ };
let title = if n.title.is_empty() {
"(sin título)"
} else {
n.title.as_str()
};
- // Una barra de tres bloques visualiza la masa (0..1.5 mapeada a
- // 0..3). Sobre el horizonte se ve llena; cayendo, se vacía.
- let bars = (mass.clamp(0.0, 1.5) / 0.5).round() as usize;
- let glyph: String = (0..3)
- .map(|i| if i < bars { '▮' } else { '▯' })
- .collect();
- format!("{glyph} {title}")
+ format!("{marca} {title}")
}
+// =====================================================================
+// Editor
+// =====================================================================
+
pub(crate) fn editor_panel(
model: &Model,
input_palette: &TextInputPalette,
@@ -468,28 +971,28 @@ pub(crate) fn editor_panel(
})
.fill(model.theme.bg_panel)
.text_aligned(
- "selecciona o crea una nota".to_string(),
+ "elige un nudo".to_string(),
12.0,
model.theme.fg_muted,
Alignment::Center,
)
};
- if model.selected.is_none() {
+ let Some(id) = model.selected else {
return wrap_panel(model, none_view());
- }
+ };
let metrics = EditorMetrics::for_font_size(13.0);
let title_field = field(
model,
"título",
- text_input_view(
+ text_input_view_full(
&model.title,
"(sin título)",
model.focus == Focus::Title,
input_palette,
- Msg::Focus(Focus::Title),
+ |ev| Msg::Campo(Focus::Title, ev),
),
);
@@ -505,15 +1008,16 @@ pub(crate) fn editor_panel(
let tags_field = field(
model,
"etiquetas (coma separadas)",
- text_input_view(
+ text_input_view_full(
&model.tags,
- "p. ej. cocina, jardín",
+ "p. ej. salud, wawa",
model.focus == Focus::Tags,
input_palette,
- Msg::Focus(Focus::Tags),
+ |ev| Msg::Campo(Focus::Tags, ev),
),
);
+ let acciones = acciones_nudo(model, id);
let stats = stats_view(model);
let column = View::new(Style {
@@ -535,11 +1039,65 @@ pub(crate) fn editor_panel(
..Default::default()
})
.fill(model.theme.bg_panel)
- .children(vec![title_field, body_field, tags_field, stats]);
+ .children(vec![title_field, body_field, tags_field, acciones, stats]);
wrap_panel(model, column)
}
+/// Los verbos del nudo abierto: ciclar estado (TODO), anudar/desanudar,
+/// resonar de nuevo, borrar.
+fn acciones_nudo(model: &Model, id: NoteId) -> View<Msg> {
+ let Some(n) = model.store.get(id) else {
+ return View::new(Style::default());
+ };
+ let theme = &model.theme;
+ let estado_label = match n.estado {
+ EstadoNudo::Normal => "marcar pendiente",
+ EstadoNudo::Pendiente => "marcar hecho",
+ EstadoNudo::Hecho => "quitar marca",
+ };
+ let mut botones = vec![button(
+ estado_label,
+ theme.bg_button,
+ if n.estado == EstadoNudo::Pendiente {
+ theme.accent
+ } else {
+ theme.fg_muted
+ },
+ Msg::CiclarEstado(id),
+ )];
+ match n.cuerda {
+ Some(_) => botones.push(button(
+ "desanudar",
+ theme.bg_button,
+ theme.fg_muted,
+ Msg::Desanudar(id),
+ )),
+ None => botones.push(button(
+ "resonar",
+ theme.bg_button,
+ theme.accent,
+ Msg::Resonar(id),
+ )),
+ }
+ botones.push(button("borrar", theme.bg_button, theme.fg_muted, Msg::DeleteSelected));
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ flex_shrink: 0.0,
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(botones)
+}
+
pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
View::new(Style {
size: Size {
@@ -624,7 +1182,7 @@ pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
},
..Default::default()
})
- .fill(border)
+ .border(1.0, border)
.radius(4.0)
.on_click(Msg::Focus(Focus::Body))
.children(vec![editor]);
@@ -671,15 +1229,24 @@ pub(crate) fn stats_view(model: &Model) -> View<Msg> {
model
.store
.get(nid)
- .map(|n| format!("{} ({:.2})", n.title, score))
+ .map(|n| format!("{} ({:.2})", short_label(&n.title), score))
})
.collect();
- let mut lines = vec![
- format!("→ enlaza a: {}", join_or_dash(&fwd_titles)),
- format!("← backlinks: {}", join_or_dash(&back_titles)),
- format!("∼ vecinos: {}", join_or_dash(&nearest)),
- ];
+ let mut lines = Vec::new();
+ // La pertenencia primero: en qué cuerda vive (o suelto).
+ if let Some(n) = model.store.get(id) {
+ match n.cuerda.and_then(|c| model.store.cuerda(c)) {
+ Some(c) => lines.push(format!("⌇ cuerda: «{}»", c.nombre)),
+ None => lines.push("⌇ suelto — sin cuerda todavía".to_string()),
+ }
+ if let Some(o) = &n.origen {
+ lines.push(format!("⇤ origen: {} · {}", o.app, o.referencia));
+ }
+ }
+ lines.push(format!("→ enlaza a: {}", join_or_dash(&fwd_titles)));
+ lines.push(format!("← backlinks: {}", join_or_dash(&back_titles)));
+ lines.push(format!("∼ vecinos: {}", join_or_dash(&nearest)));
// Procedencia: si la nota llegó por compartir, lleva una etiqueta
// `de:<autor>`. La mostramos explícita.
if let Some(n) = model.store.get(id) {
@@ -726,3 +1293,142 @@ pub(crate) fn join_or_dash(items: &[String]) -> String {
items.join(", ")
}
}
+
+// =====================================================================
+// Vistas de estado y passphrase (usadas desde app.rs)
+// =====================================================================
+
+/// Barra de estado al pie: muestra el último mensaje de export/import/tejido.
+pub(crate) fn status_bar(model: &Model) -> Option<View<Msg>> {
+ let text = model.status.as_ref()?;
+ Some(
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(22.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel_alt)
+ .text_aligned(text.clone(), 11.0, model.theme.fg_muted, Alignment::Start),
+ )
+}
+
+/// Prompt modal de passphrase: tarjeta centrada con el input (enmascarado
+/// con •) y dos botones. Enter desbloquea, Esc cancela (ver `on_key`).
+pub(crate) fn unlock_view(model: &Model, input_palette: &TextInputPalette) -> View<Msg> {
+ let titulo = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(22.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ "Desbloquea tu identidad para firmar".to_string(),
+ 14.0,
+ model.theme.fg_text,
+ Alignment::Start,
+ );
+
+ let hint = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ "La semilla vive cifrada (Argon2id). La primera vez, esta passphrase la crea."
+ .to_string(),
+ 11.0,
+ model.theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let input = text_input_view_full(
+ &model.passphrase,
+ "passphrase",
+ model.focus == Focus::Passphrase,
+ input_palette,
+ |ev| Msg::Campo(Focus::Passphrase, ev),
+ );
+ let input_row = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![input]);
+
+ let unlock_btn = button(
+ "desbloquear (Enter)",
+ model.theme.bg_button,
+ model.theme.accent,
+ Msg::Unlock,
+ );
+ let cancel_btn = button(
+ "cancelar (Esc)",
+ model.theme.bg_button,
+ model.theme.fg_muted,
+ Msg::CancelUnlock,
+ );
+ let buttons = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ gap: Size {
+ width: length(8.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![unlock_btn, cancel_btn]);
+
+ let card = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(420.0_f32),
+ height: Dimension::auto(),
+ },
+ padding: Rect {
+ left: length(18.0_f32),
+ right: length(18.0_f32),
+ top: length(16.0_f32),
+ bottom: length(16.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(10.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel)
+ .radius(6.0)
+ .children(vec![titulo, hint, input_row, buttons]);
+
+ 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()
+ })
+ .fill(model.theme.bg_app)
+ .children(vec![card])
+}
diff --git a/00_unanchay/khipu/khipu-app/src/tejido.rs b/00_unanchay/khipu/khipu-app/src/tejido.rs
new file mode 100644
index 0000000..68253cc
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/tejido.rs
@@ -0,0 +1,139 @@
+//! `tejido` — la síntesis de una cuerda: el encuadre que khipu redacta
+//! sobre los nudos de una idea recurrente.
+//!
+//! El tejido existe para el momento en que una idea engordó pero sigue
+//! difusa (el caso canónico: la idea de khipu mismo). Junta los nudos en
+//! cronología y le pide al LLM un encuadre en tres partes: qué se viene
+//! repitiendo, qué ya está claro, y qué falta decidir (las fugas). El
+//! resultado es un nudo-síntesis **editable** anudado al final de la
+//! cuerda; re-tejer lo reemplaza.
+//!
+//! El backend sale de `pluma-llm::from_env()`: Anthropic/Gemini/DeepSeek/
+//! Cohere/Ollama según el entorno, y **Mock sin credenciales** — el botón
+//! siempre hace algo, aunque sea un eco de prueba. La llamada corre en un
+//! worker (`Handle::spawn`) con su propio runtime: nunca bloquea la UI.
+
+use khipu_core::{Cuerda, Note};
+use llimphi_ui::Handle;
+use pluma_llm::pluma_llm_core::ChatRequest;
+
+use crate::modelo::{Model, Msg};
+
+/// Tokens máximos de la respuesta — un encuadre, no un ensayo.
+const MAX_TOKENS: u32 = 1024;
+
+const SYSTEM: &str = "Eres el tejedor de un khipu: recibes los nudos (pensamientos \
+sueltos capturados en distintos momentos y estados mentales) de una cuerda — una \
+idea recurrente de la misma persona. Tejé un encuadre breve en español, directo y \
+sin florituras, con exactamente estas tres secciones:\n\
+\n\
+## Lo que se repite\nla idea central que insiste a través de los nudos.\n\
+## Lo que ya está claro\ndecisiones o certezas que los nudos ya asentaron.\n\
+## Lo que falta\nlas fugas: qué queda por decidir o hacer para concretarla.\n\
+\n\
+Máximo ~250 palabras. No inventes contenido que los nudos no digan; si un nudo \
+contradice a otro, señalalo en «Lo que falta».";
+
+/// Arma el prompt de usuario: los nudos de la cuerda en cronología, con
+/// su fecha, excluyendo el tejido anterior (la síntesis no se cita a sí
+/// misma — se reemplaza).
+pub(crate) fn prompt_de(cuerda: &Cuerda, nudos: &[&Note]) -> String {
+ let mut out = format!("Cuerda: «{}»\n\nNudos, en orden de captura:\n", cuerda.nombre);
+ for n in nudos {
+ if cuerda.tejido == Some(n.id) {
+ continue;
+ }
+ out.push_str(&format!("- [{}] {}", fecha_corta(n.created_at), n.title));
+ if !n.body.trim().is_empty() {
+ out.push_str(&format!("\n {}", n.body.trim().replace('\n', "\n ")));
+ }
+ if n.estado == khipu_core::EstadoNudo::Pendiente {
+ out.push_str(" (pendiente)");
+ }
+ out.push('\n');
+ }
+ out
+}
+
+/// Segundo Unix → `aaaa-mm-dd` sin dependencias de calendario (días
+/// civiles desde epoch, algoritmo de Howard Hinnant).
+fn fecha_corta(secs: u64) -> String {
+ let dias = (secs / 86_400) as i64;
+ let z = dias + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = z.rem_euclid(146_097);
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let y = if m <= 2 { y + 1 } else { y };
+ format!("{y:04}-{m:02}-{d:02}")
+}
+
+/// Dispara el tejido de `cuerda` en un worker. Reentra al `update` con
+/// [`Msg::TejidoListo`] — texto del encuadre o error legible.
+pub(crate) fn tejer(model: &Model, cuerda: khipu_core::CuerdaId, h: &Handle<Msg>) {
+ let Some(c) = model.store.cuerda(cuerda) else {
+ return;
+ };
+ let nudos = model.store.nudos_de(cuerda);
+ let prompt = prompt_de(c, &nudos);
+ h.spawn(move || {
+ let resultado = (|| -> Result<String, String> {
+ let client = pluma_llm::from_env().map_err(|e| format!("sin backend LLM: {e}"))?;
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| format!("sin runtime: {e}"))?;
+ let req = ChatRequest::una_vuelta(prompt, MAX_TOKENS).con_sistema(SYSTEM);
+ let resp = rt
+ .block_on(client.complete(&req))
+ .map_err(|e| format!("falló el tejido: {e}"))?;
+ if resp.content.trim().is_empty() {
+ return Err("el modelo devolvió un tejido vacío".into());
+ }
+ Ok(resp.content)
+ })();
+ Msg::TejidoListo(cuerda, resultado)
+ });
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use khipu_core::NoteStore;
+
+ #[test]
+ fn prompt_lists_nudos_chronologically_without_tejido() {
+ let mut s = NoteStore::new();
+ let c = s.crear_cuerda("khipu", 0);
+ let a = s.create("primera idea", "con cuerpo", vec![], 100);
+ let b = s.create("segunda idea", "", vec![], 200_000_000);
+ let t = s.create("tejido · khipu", "síntesis vieja", vec![], 300_000_000);
+ for id in [a, b, t] {
+ s.anudar(id, c);
+ }
+ s.cuerda_mut(c).unwrap().tejido = Some(t);
+ let cuerda = s.cuerda(c).unwrap();
+ let nudos = s.nudos_de(c);
+ let p = prompt_de(cuerda, &nudos);
+ assert!(p.contains("«khipu»"));
+ assert!(p.contains("primera idea"));
+ assert!(p.contains("con cuerpo"));
+ assert!(p.contains("segunda idea"));
+ // El tejido anterior no se cita a sí mismo.
+ assert!(!p.contains("síntesis vieja"));
+ // Cronología: la primera antes que la segunda.
+ assert!(p.find("primera").unwrap() < p.find("segunda").unwrap());
+ }
+
+ #[test]
+ fn fecha_corta_maps_known_dates() {
+ assert_eq!(fecha_corta(0), "1970-01-01");
+ assert_eq!(fecha_corta(86_400), "1970-01-02");
+ // 2026-07-14 00:00 UTC (verificado con `date -u -d @1783987200`).
+ assert_eq!(fecha_corta(1_783_987_200), "2026-07-14");
+ }
+}
diff --git a/00_unanchay/khipu/khipu-brahman/Cargo.toml b/00_unanchay/khipu/khipu-brahman/Cargo.toml
index 4f085c3..f6b7793 100644
--- a/00_unanchay/khipu/khipu-brahman/Cargo.toml
+++ b/00_unanchay/khipu/khipu-brahman/Cargo.toml
@@ -12,7 +12,6 @@ description = "khipu-brahman — transporte de sobres khipu sobre libp2p (Brahma
khipu-share = { path = "../khipu-share" }
card-net = { workspace = true }
libp2p = { workspace = true }
-libp2p-stream = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-brahman/LEEME.md b/00_unanchay/khipu/khipu-brahman/LEEME.md
new file mode 100644
index 0000000..7fa27cc
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/LEEME.md
@@ -0,0 +1,28 @@
+# khipu-brahman
+
+*Read this in English: [README.md](README.md).*
+
+Transporte de sobres khipu sobre libp2p, vía la capa P2P compartida [`card_net::BrahmanNet`] (TCP + Noise + Yamux + Kademlia).
+
+Es el hermano WAN del transporte LAN de `khipu-share::net`: en vez de un
+`TcpStream` directo, abre un stream libp2p sobre el protocolo
+`SOBRE_PROTOCOL` y manda el mismo sobre serializado. Como el sobre va
+firmado y direccionado por contenido, **el transporte no necesita ser
+confiable**: quien recibe verifica con `khipu_share::open` antes de
+ingerir. El cifrado Noise sólo agrega confidencialidad en tránsito.
+
+El descubrimiento es por DHT Kademlia: `KhipuNode::anunciar` publica
+bajo una clave fija y `KhipuNode::descubrir` lista a quién la provee —
+rendezvous sin saber la `Multiaddr` de antemano (hace falta al menos un
+peer bootstrap en la tabla, vía `KhipuNode::add_peer`). La travesía de
+NAT (relay + dcutr + autonat) **sí** está cableada en `BrahmanNet`
+(`shared/card/card-net`); ver el test `jalar_a_traves_de_un_relay` en
+`tests/p2p_roundtrip.rs`, que jala un sobre a través de un circuito relay.
+Detrás de NAT simétrico todavía conviene un relay público alcanzable.
+
+Marco de cable: `u32` big-endian con el largo del sobre + el sobre
+(postcard). Un sobre por stream — espejo del marco de `khipu-share::net`.
+
+---
+
+Parte de **khipu** — ver [khipu](../LEEME.md).
diff --git a/00_unanchay/khipu/khipu-brahman/README.md b/00_unanchay/khipu/khipu-brahman/README.md
new file mode 100644
index 0000000..7217ff4
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/README.md
@@ -0,0 +1,12 @@
+# khipu-brahman
+
+Transport of khipu envelopes over libp2p, through the shared P2P layer
+`card_net::BrahmanNet` (TCP + Noise + Yamux + Kademlia).
+
+It is the WAN sibling of `khipu-share::net`'s LAN transport: instead of a direct
+`TcpStream`, it opens a libp2p stream over the `SOBRE_PROTOCOL` protocol and sends
+the same serialized envelope.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-core/LEEME.md b/00_unanchay/khipu/khipu-core/LEEME.md
index b84829f..67c6a72 100644
--- a/00_unanchay/khipu/khipu-core/LEEME.md
+++ b/00_unanchay/khipu/khipu-core/LEEME.md
@@ -1,21 +1,29 @@
# khipu-core
-> Modelo de nota + store para [khipu](../README.md). Sin UI.
+*Read this in English: [README.md](README.md).*
-`Note` lleva contenido, timestamp de creación, timestamp de último acceso, y `mass: f32`. El store es CRUD simple sobre archivos JSON en `$XDG_DATA_HOME/khipu/`. Cada vez que se lee una nota se actualiza su `last_access` (señal que [khipu-gravity](../khipu-gravity/README.md) usa para reforzar).
+> El núcleo agnóstico de [khipu](../LEEME.md): nudos, cuerdas y el grafo de enlaces. Sin UI, sin disco, sin red.
+
+- **`Note` (el nudo)**: título, cuerpo, etiquetas, timestamps; `cuerda: Option<CuerdaId>` (a lo sumo una), `estado` (Normal/Pendiente/Hecho — TODOs sin due-dates) y `origen` (estampa de la app que lo capturó, fase 2). Conserva `mass`/`last_access` para los consumidores con física temporal (pata, iniy-derive).
+- **`Cuerda`**: una idea recurrente — nombre, fecha, y su `tejido: Option<NoteId>` (el nudo-síntesis). No guarda la lista de nudos: se deriva (`nudos_de`) para que no haya dos fuentes de verdad.
+- **`NoteStore`**: CRUD determinista (`BTreeMap`), cuerdas (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), grafo de wiki-links `[[Título]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` para migraciones seguras.
+- **`nombre::propose_cuerda_name`**: el bautizo — el término más compartido y saliente de títulos/etiquetas del grupo.
## API
```rust
-use khipu_core::{Note, Store};
+use khipu_core::NoteStore;
-let store = Store::open()?;
-let id = store.create("nota nueva")?;
-let note = store.read(id)?;
-store.touch(id)?; // refresca last_access
+let mut s = NoteStore::new();
+let a = s.create("idea que vuelve", "", vec![], now);
+let b = s.create("la misma idea, otra vez", "", vec![], now);
+let c = s.crear_cuerda("idea", now);
+s.anudar(a, c);
+s.anudar(b, c);
+assert_eq!(s.nudos_de(c).len(), 2);
+assert!(s.sueltos().is_empty());
```
## Deps
-- `serde`, `serde_json`
-- `directories` para el XDG path
+- `serde` (los tipos viajan por postcard/JSON según el caller)
diff --git a/00_unanchay/khipu/khipu-core/README.md b/00_unanchay/khipu/khipu-core/README.md
index 7355ae1..4338775 100644
--- a/00_unanchay/khipu/khipu-core/README.md
+++ b/00_unanchay/khipu/khipu-core/README.md
@@ -1,21 +1,27 @@
# khipu-core
-> Note model + store for [khipu](../README.md). No UI.
+> The agnostic core of [khipu](../README.md): knots, cords and the link graph. No UI, no disk, no network.
-`Note` carries content, creation timestamp, last-access timestamp, and `mass: f32`. The store is simple CRUD over JSON files in `$XDG_DATA_HOME/khipu/`. Every read updates `last_access` (the signal [khipu-gravity](../khipu-gravity/README.md) uses to reinforce).
+- **`Note` (the knot)**: title, body, tags, timestamps; `cuerda: Option<CuerdaId>` (at most one), `estado` (Normal/Pendiente/Hecho — TODOs without due-dates) and `origen` (stamp of the capturing app, phase 2). Keeps `mass`/`last_access` for consumers with temporal physics (pata, iniy-derive).
+- **`Cuerda`**: a recurrent idea — name, date, and its `tejido: Option<NoteId>` (the synthesis knot). It doesn't store its knot list: it's derived (`nudos_de`) so there are never two sources of truth.
+- **`NoteStore`**: deterministic CRUD (`BTreeMap`), cords (`crear_cuerda`/`anudar`/`desanudar`/`sueltos`/`borrar_cuerda`), wiki-link graph `[[Title]]` (forward/back/orphans/dangling), `insert_raw` + `coherente()` for safe migrations.
+- **`nombre::propose_cuerda_name`**: the christening — the most shared, salient term across the group's titles/tags.
## API
```rust
-use khipu_core::{Note, Store};
+use khipu_core::NoteStore;
-let store = Store::open()?;
-let id = store.create("new note")?;
-let note = store.read(id)?;
-store.touch(id)?; // refresh last_access
+let mut s = NoteStore::new();
+let a = s.create("recurring idea", "", vec![], now);
+let b = s.create("same idea, again", "", vec![], now);
+let c = s.crear_cuerda("idea", now);
+s.anudar(a, c);
+s.anudar(b, c);
+assert_eq!(s.nudos_de(c).len(), 2);
+assert!(s.sueltos().is_empty());
```
## Deps
-- `serde`, `serde_json`
-- `directories` for XDG path
+- `serde` (types travel as postcard/JSON depending on the caller)
diff --git a/00_unanchay/khipu/khipu-core/src/lib.rs b/00_unanchay/khipu/khipu-core/src/lib.rs
index 9f1d870..276b875 100644
--- a/00_unanchay/khipu/khipu-core/src/lib.rs
+++ b/00_unanchay/khipu/khipu-core/src/lib.rs
@@ -1,23 +1,29 @@
-//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
+//! `khipu-core` — el núcleo agnóstico del cuaderno de resonancia.
//!
-//! Una nota es texto con título, etiquetas y enlaces `[[...]]`. El
-//! [`NoteStore`] las guarda y deriva el grafo: forward-links, backlinks,
-//! huérfanas y enlaces colgantes. Sin UI, sin storage en disco, sin red
-//! — tipos puros y deterministas.
+//! La unidad es el **nudo** ([`Note`]): un pensamiento botado en
+//! cualquier estado mental — una idea, un TODO, una cita. Los nudos que
+//! resuenan entre sí se anudan en una **cuerda** ([`Cuerda`]): una idea
+//! recurrente que crece con cada retorno, en vez de olvidarse. El
+//! [`NoteStore`] guarda ambos y deriva el grafo: forward-links,
+//! backlinks, nudos por cuerda, sueltos. Sin UI, sin storage en disco,
+//! sin red — tipos puros y deterministas.
//!
-//! - [`note`] — el modelo [`Note`].
+//! - [`note`] — el modelo [`Note`] (nudo), [`Cuerda`], [`EstadoNudo`], [`Origen`].
//! - [`links`] — el parser de wiki-links `[[...]]`.
-//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
+//! - [`store`] — el [`NoteStore`]: nudos + cuerdas + grafo de enlaces.
+//! - [`nombre`] — propuesta de nombre para una cuerda naciente.
//!
-//! La gravedad semántica (clustering por afinidad de embeddings) vive en
-//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
+//! La resonancia semántica (afinidad de embeddings) vive en
+//! `khipu-gravity`; las lentes visuales, en los crates de frontend.
#![forbid(unsafe_code)]
pub mod links;
+pub mod nombre;
pub mod note;
pub mod store;
pub use links::parse_links;
-pub use note::{Note, NoteId};
+pub use nombre::propose_cuerda_name;
+pub use note::{Cuerda, CuerdaId, EstadoNudo, Note, NoteId, Origen};
pub use store::NoteStore;
diff --git a/00_unanchay/khipu/khipu-core/src/nombre.rs b/00_unanchay/khipu/khipu-core/src/nombre.rs
new file mode 100644
index 0000000..8187195
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/nombre.rs
@@ -0,0 +1,143 @@
+//! Propuesta de nombre para una cuerda naciente.
+//!
+//! Cuando dos o más nudos resuenan y se anudan, la cuerda necesita un
+//! nombre. No se le pide al usuario que lo invente en frío: se propone el
+//! término más recurrente y saliente de los títulos y etiquetas de sus
+//! nudos, y el bautizo arranca con esa sugerencia ya cargada — se acepta
+//! o se edita. Puro y determinista.
+
+use crate::note::Note;
+
+/// Propone un nombre para un grupo de nudos: el término más recurrente y
+/// saliente de sus títulos y etiquetas. Prefiere lo **compartido** (lo
+/// que aparece en más nudos es lo que los vuelve una idea), luego lo más
+/// frecuente, y desempata alfabético para ser determinista. `None` si no
+/// hay ninguna palabra con señal (todo vacío o palabras vacías).
+pub fn propose_cuerda_name(notes: &[&Note]) -> Option<String> {
+ use std::collections::{BTreeMap, BTreeSet};
+
+ // Por término: (en cuántos nudos aparece, frecuencia total).
+ let mut stats: BTreeMap<String, (u32, u32)> = BTreeMap::new();
+ for n in notes {
+ let mut seen: BTreeSet<String> = BTreeSet::new();
+ for tok in note_tokens(n) {
+ let e = stats.entry(tok.clone()).or_insert((0, 0));
+ e.1 += 1;
+ if seen.insert(tok) {
+ e.0 += 1;
+ }
+ }
+ }
+ if stats.is_empty() {
+ return None;
+ }
+ // max_by: gana doc-freq, luego freq total; en empate, el alfabético
+ // menor (lo invertimos con `kb.cmp(ka)` para que el menor sea "mayor").
+ let best = stats
+ .iter()
+ .max_by(|(ka, va), (kb, vb)| {
+ va.0.cmp(&vb.0).then(va.1.cmp(&vb.1)).then(kb.cmp(ka))
+ })
+ .map(|(k, _)| k.clone())?;
+ Some(capitalize(&best))
+}
+
+/// Tokens significativos de un nudo: palabras del título + etiquetas,
+/// en minúsculas, sin palabras vacías ni fragmentos cortos.
+fn note_tokens(n: &Note) -> Vec<String> {
+ let mut out = Vec::new();
+ let push = |raw: &str, out: &mut Vec<String>| {
+ let t = raw.to_lowercase();
+ if t.chars().count() >= 3 && t.chars().any(|c| c.is_alphabetic()) && !is_stopword(&t) {
+ out.push(t);
+ }
+ };
+ for raw in n.title.split(|c: char| !c.is_alphanumeric()) {
+ push(raw, &mut out);
+ }
+ for tag in &n.tags {
+ for raw in tag.split(|c: char| !c.is_alphanumeric()) {
+ push(raw, &mut out);
+ }
+ }
+ out
+}
+
+/// Mayúscula inicial respetando unicode (para mostrar el nombre).
+fn capitalize(s: &str) -> String {
+ let mut chars = s.chars();
+ match chars.next() {
+ Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
+ None => String::new(),
+ }
+}
+
+/// Palabras vacías (español, con algunas de inglés) que no sirven de
+/// nombre aunque sean frecuentes.
+fn is_stopword(t: &str) -> bool {
+ const STOP: &[&str] = &[
+ "los", "las", "una", "unos", "unas", "del", "por", "para", "con", "sin", "que",
+ "como", "más", "mas", "pero", "sus", "este", "esta", "esto", "estos", "estas",
+ "ese", "esa", "eso", "esos", "esas", "the", "and", "for", "with", "you", "este",
+ "muy", "son", "fue", "han", "hay", "sobre", "entre", "cuando", "donde", "porque",
+ ];
+ STOP.contains(&t)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::note::{EstadoNudo, NoteId};
+
+ fn note(id: NoteId, title: &str, tags: &[&str]) -> Note {
+ Note {
+ id,
+ title: title.into(),
+ body: String::new(),
+ tags: tags.iter().map(|s| s.to_string()).collect(),
+ created_at: 0,
+ updated_at: 0,
+ last_access: 0,
+ mass: 1.0,
+ cuerda: None,
+ estado: EstadoNudo::Normal,
+ origen: None,
+ }
+ }
+
+ #[test]
+ fn propose_picks_the_shared_term() {
+ let a = note(1, "Receta de pan", &["cocina"]);
+ let b = note(2, "Horno y pan", &["cocina"]);
+ let c = note(3, "Masa madre", &["cocina"]);
+ // "cocina" está en los tres nudos → gana por doc-freq.
+ let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
+ assert_eq!(name, "Cocina");
+ }
+
+ #[test]
+ fn propose_prefers_shared_over_merely_frequent() {
+ // "pan" aparece 2 veces pero en un solo nudo; "viaje" en dos.
+ let a = note(1, "Pan pan pan", &[]);
+ let b = note(2, "Viaje a la costa", &[]);
+ let c = note(3, "Viaje largo", &[]);
+ let name = propose_cuerda_name(&[&a, &b, &c]).unwrap();
+ assert_eq!(name, "Viaje");
+ }
+
+ #[test]
+ fn propose_ignores_stopwords_and_short_tokens() {
+ let a = note(1, "El de la y", &[]);
+ let b = note(2, "Por que con", &[]);
+ // Todo son palabras vacías o de <3 letras → sin señal.
+ assert!(propose_cuerda_name(&[&a, &b]).is_none());
+ }
+
+ #[test]
+ fn propose_is_deterministic_on_ties() {
+ // "alpha" y "omega" empatan en doc-freq y freq → gana el alfabético.
+ let a = note(1, "alpha omega", &[]);
+ let b = note(2, "omega alpha", &[]);
+ assert_eq!(propose_cuerda_name(&[&a, &b]).unwrap(), "Alpha");
+ }
+}
diff --git a/00_unanchay/khipu/khipu-core/src/note.rs b/00_unanchay/khipu/khipu-core/src/note.rs
index f44274c..5ba5ba5 100644
--- a/00_unanchay/khipu/khipu-core/src/note.rs
+++ b/00_unanchay/khipu/khipu-core/src/note.rs
@@ -1,18 +1,50 @@
-//! El modelo `Note` — la unidad de khipu_app.
+//! El modelo `Note` — el **nudo**, la unidad de captura de khipu.
+//!
+//! Un nudo es un pensamiento botado en cualquier estado mental: una idea,
+//! un TODO, una cita. Nace suelto; si resuena con otros, se anuda a una
+//! [`crate::Cuerda`]. No hay carpetas ni jerarquías previas — la
+//! estructura emerge de la recurrencia.
use serde::{Deserialize, Serialize};
use crate::links::parse_links;
-/// Identificador de una nota. Lo asigna el almacén, monótono y estable.
+/// Identificador de una nota (nudo). Lo asigna el almacén, monótono y estable.
pub type NoteId = u64;
-/// Una nota: título, cuerpo, etiquetas y marcas de tiempo. Los enlaces
-/// no se guardan aparte — se derivan del cuerpo bajo demanda.
+/// Identificador de una cuerda. Lo asigna el almacén, monótono y estable.
+pub type CuerdaId = u64;
+
+/// Estado accionable de un nudo. Un TODO es una expresión mental más —
+/// no hay due-dates ni recordatorios, sólo el gesto de marcarlo.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+pub enum EstadoNudo {
+ /// Un pensamiento sin más — la mayoría.
+ #[default]
+ Normal,
+ /// Accionable y sin hacer.
+ Pendiente,
+ /// Accionable y hecho. Se conserva: es memoria, no basura.
+ Hecho,
+}
+
+/// De dónde vino un nudo capturado fuera de la app: qué app lo mandó y
+/// una referencia legible (id de correo, nombre de pacha, workspace…).
+/// La estampa es del momento de captura; khipu no la reinterpreta.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Origen {
+ /// App de origen (`chasqui`, `pata`, `pacha`, …).
+ pub app: String,
+ /// Referencia dentro de esa app, legible y opaca para khipu.
+ pub referencia: String,
+}
+
+/// Una nota — el nudo: título, cuerpo, etiquetas y marcas de tiempo.
+/// Los enlaces no se guardan aparte — se derivan del cuerpo bajo demanda.
///
-/// `mass` y `last_access` son la señal temporal: la masa decae con el
-/// tiempo y `last_access` registra cuándo fue la última lectura. La
-/// física la aplica el caller (típicamente con `khipu-gravity`).
+/// `mass` y `last_access` son la física temporal *opcional* (la usan
+/// consumidores como la captura rápida de pata vía `khipu-gravity`); la
+/// app khipu ya no olvida — organiza por resonancia y sedimentación.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Note {
pub id: NoteId,
@@ -28,20 +60,21 @@ pub struct Note {
/// caller decide cómo arrancar la masa.
#[serde(default)]
pub last_access: u64,
- /// Masa de la nota. 1.0 al crearse; decae con el tiempo, sube con
- /// cada acceso. Cuando cae bajo el umbral del horizonte la nota
- /// pasa al archivo (no se borra). En payloads viejos arranca en 1.0.
+ /// Masa de la nota, para los consumidores con física temporal
+ /// (`khipu-gravity::Gravity`). La app khipu no la decae.
#[serde(default = "default_mass")]
pub mass: f32,
- /// Posición ancla en el lienzo del mapa, en coordenadas de mundo.
- /// Se fija **una sola vez** cuando la nota se coloca (por gravedad
- /// semántica, contra las notas ya asentadas) y se persiste: el mapa
- /// es estable — un pensamiento conserva su domicilio para que la
- /// memoria espacial pueda recordarlo. `None` = todavía sin colocar
- /// (el caller le asigna lugar la primera vez que la pinta). En
- /// payloads viejos arranca en `None` y se coloca al cargar.
+ /// Cuerda a la que este nudo está anudado. `None` = suelto (recién
+ /// capturado o nunca resonó — con el tiempo, sedimento).
#[serde(default)]
- pub pos: Option<(f32, f32)>,
+ pub cuerda: Option<CuerdaId>,
+ /// Estado accionable (TODO). `Normal` para la mayoría.
+ #[serde(default)]
+ pub estado: EstadoNudo,
+ /// Estampa de origen si el nudo llegó desde otra app. `None` =
+ /// capturado en khipu mismo.
+ #[serde(default)]
+ pub origen: Option<Origen>,
}
fn default_mass() -> f32 {
@@ -65,6 +98,28 @@ impl Note {
let q = query.to_lowercase();
self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
}
+
+ /// `true` si el nudo está suelto (sin cuerda).
+ pub fn suelto(&self) -> bool {
+ self.cuerda.is_none()
+ }
+}
+
+/// Una cuerda: una idea recurrente. No se crea por adelantado — nace
+/// cuando dos o más nudos resuenan y el usuario los anuda. Su masa no se
+/// guarda: se deriva de sus nudos (`khipu_gravity::masa_cuerda`).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Cuerda {
+ pub id: CuerdaId,
+ /// Nombre de la idea. Editable — se bautiza al nacer con una
+ /// propuesta derivada del contenido.
+ pub nombre: String,
+ /// Segundo Unix en que se anudó por primera vez.
+ pub created_at: u64,
+ /// El nudo-síntesis (tejido) de esta cuerda, si ya se tejió. Vive
+ /// como una nota más, anudada a la cuerda; re-tejer lo reemplaza.
+ #[serde(default)]
+ pub tejido: Option<NoteId>,
}
#[cfg(test)]
@@ -81,7 +136,9 @@ mod tests {
updated_at: 0,
last_access: 0,
mass: 1.0,
- pos: None,
+ cuerda: None,
+ estado: EstadoNudo::Normal,
+ origen: None,
}
}
@@ -105,4 +162,12 @@ mod tests {
assert!(n.matches("pan"));
assert!(!n.matches("ausente"));
}
+
+ #[test]
+ fn new_notes_are_loose_by_default() {
+ let n = note("x", "y");
+ assert!(n.suelto());
+ assert_eq!(n.estado, EstadoNudo::Normal);
+ assert!(n.origen.is_none());
+ }
}
diff --git a/00_unanchay/khipu/khipu-core/src/store.rs b/00_unanchay/khipu/khipu-core/src/store.rs
index fc6aa20..2dddb66 100644
--- a/00_unanchay/khipu/khipu-core/src/store.rs
+++ b/00_unanchay/khipu/khipu-core/src/store.rs
@@ -1,31 +1,49 @@
-//! `NoteStore` — el almacén de notas y su grafo de enlaces.
+//! `NoteStore` — el almacén de nudos, cuerdas y su grafo de enlaces.
//!
-//! Guarda las notas en un `BTreeMap` para que toda iteración sea
+//! Guarda notas y cuerdas en `BTreeMap` para que toda iteración sea
//! determinista (ordenada por id). Los enlaces se resuelven por título
//! sin distinguir mayúsculas: `[[cocina]]` y `[[Cocina]]` apuntan a la
-//! misma nota.
+//! misma nota. Las cuerdas son la estructura emergente: un nudo pertenece
+//! a lo sumo a una cuerda (`Note::cuerda`), y la cuerda no guarda la
+//! lista — se deriva ([`NoteStore::nudos_de`]) para que no haya dos
+//! fuentes de verdad que desincronizar.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
-use crate::note::{Note, NoteId};
+use crate::note::{Cuerda, CuerdaId, Note, NoteId};
-/// El almacén de notas de khipu_app.
+/// El almacén de nudos y cuerdas de khipu.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct NoteStore {
notes: BTreeMap<NoteId, Note>,
/// Siguiente id a asignar — monótono, nunca reutiliza huecos.
next_id: NoteId,
+ /// Las cuerdas: ideas recurrentes bautizadas. Nuevas en el formato
+ /// V4; payloads viejos arrancan sin ninguna.
+ #[serde(default)]
+ cuerdas: BTreeMap<CuerdaId, Cuerda>,
+ #[serde(default = "one")]
+ next_cuerda: CuerdaId,
+}
+
+fn one() -> u64 {
+ 1
}
impl NoteStore {
pub fn new() -> Self {
- Self { notes: BTreeMap::new(), next_id: 1 }
+ Self {
+ notes: BTreeMap::new(),
+ next_id: 1,
+ cuerdas: BTreeMap::new(),
+ next_cuerda: 1,
+ }
}
- /// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
- /// `last_access = now` — recién nacida, plenamente visible.
+ /// Crea un nudo suelto y devuelve su id. Empieza con `mass = 1.0` y
+ /// `last_access = now` — recién nacido, sin cuerda.
pub fn create(
&mut self,
title: impl Into<String>,
@@ -46,12 +64,41 @@ impl NoteStore {
updated_at: now,
last_access: now,
mass: 1.0,
- pos: None,
+ cuerda: None,
+ estado: Default::default(),
+ origen: None,
},
);
id
}
+ /// Inserta una nota **preservando su id** (reemplaza si ya existe) y
+ /// ajusta `next_id` para nunca reusarlo. Para migraciones de formato
+ /// e importaciones que deben conservar identidades — el camino normal
+ /// de creación es [`NoteStore::create`].
+ pub fn insert_raw(&mut self, note: Note) {
+ self.next_id = self.next_id.max(note.id + 1);
+ self.notes.insert(note.id, note);
+ }
+
+ /// Chequeo de coherencia interna: cada nota vive bajo su propio id,
+ /// los contadores no van a reusar ids vivos, y toda cuerda referida
+ /// por una nota (o por un tejido) existe. Lo usa la carga desde disco
+ /// para descartar decodificaciones corruptas de formatos viejos.
+ pub fn coherente(&self) -> bool {
+ let notas_ok = self.notes.iter().all(|(k, n)| {
+ *k == n.id
+ && n.id < self.next_id
+ && n.cuerda.map(|c| self.cuerdas.contains_key(&c)).unwrap_or(true)
+ });
+ let cuerdas_ok = self.cuerdas.iter().all(|(k, c)| {
+ *k == c.id
+ && c.id < self.next_cuerda
+ && c.tejido.map(|t| self.notes.contains_key(&t)).unwrap_or(true)
+ });
+ notas_ok && cuerdas_ok
+ }
+
pub fn len(&self) -> usize {
self.notes.len()
}
@@ -88,8 +135,8 @@ impl NoteStore {
}
}
- /// Marca `last_access = now`. La señal que `khipu-gravity` usa para
- /// reforzar la masa. No-op si la nota no existe; `true` si tocó.
+ /// Marca `last_access = now` — la señal de acceso para los
+ /// consumidores con física temporal. No-op si la nota no existe.
pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
match self.notes.get_mut(&id) {
Some(n) => {
@@ -112,24 +159,136 @@ impl NoteStore {
}
}
- /// Fija la posición ancla de una nota en el lienzo. Se llama una sola
- /// vez al colocarla; de ahí en más el domicilio del pensamiento no se
- /// mueve salvo que el usuario lo arrastre. `false` si la nota no existe.
- pub fn set_pos(&mut self, id: NoteId, x: f32, y: f32) -> bool {
- match self.notes.get_mut(&id) {
- Some(n) => {
- n.pos = Some((x, y));
- true
+ /// Elimina una nota y la devuelve. Si era el tejido de su cuerda, la
+ /// cuerda queda sin tejido (se puede volver a tejer).
+ pub fn remove(&mut self, id: NoteId) -> Option<Note> {
+ let n = self.notes.remove(&id)?;
+ if let Some(cid) = n.cuerda {
+ if let Some(c) = self.cuerdas.get_mut(&cid) {
+ if c.tejido == Some(id) {
+ c.tejido = None;
+ }
}
- None => false,
}
+ Some(n)
}
- /// Elimina una nota y la devuelve.
- pub fn remove(&mut self, id: NoteId) -> Option<Note> {
- self.notes.remove(&id)
+ // =================================================================
+ // Cuerdas
+ // =================================================================
+
+ /// Crea una cuerda vacía con `nombre` y devuelve su id. Los nudos se
+ /// suman con [`NoteStore::anudar`].
+ pub fn crear_cuerda(&mut self, nombre: impl Into<String>, now: u64) -> CuerdaId {
+ let id = self.next_cuerda;
+ self.next_cuerda += 1;
+ self.cuerdas.insert(
+ id,
+ Cuerda {
+ id,
+ nombre: nombre.into(),
+ created_at: now,
+ tejido: None,
+ },
+ );
+ id
+ }
+
+ pub fn cuerda(&self, id: CuerdaId) -> Option<&Cuerda> {
+ self.cuerdas.get(&id)
+ }
+
+ pub fn cuerda_mut(&mut self, id: CuerdaId) -> Option<&mut Cuerda> {
+ self.cuerdas.get_mut(&id)
+ }
+
+ /// Itera las cuerdas en orden de id (determinista).
+ pub fn cuerdas(&self) -> impl Iterator<Item = &Cuerda> {
+ self.cuerdas.values()
+ }
+
+ pub fn cuerdas_len(&self) -> usize {
+ self.cuerdas.len()
+ }
+
+ /// Anuda `nota` a `cuerda`. Si estaba en otra cuerda, se muda (y si
+ /// era su tejido, esa cuerda queda sin tejido). `false` si la nota o
+ /// la cuerda no existen.
+ pub fn anudar(&mut self, nota: NoteId, cuerda: CuerdaId) -> bool {
+ if !self.cuerdas.contains_key(&cuerda) {
+ return false;
+ }
+ let Some(previa) = self.notes.get(¬a).map(|n| n.cuerda) else {
+ return false;
+ };
+ if let Some(prev) = previa.filter(|p| *p != cuerda) {
+ if let Some(c) = self.cuerdas.get_mut(&prev) {
+ if c.tejido == Some(nota) {
+ c.tejido = None;
+ }
+ }
+ }
+ if let Some(n) = self.notes.get_mut(¬a) {
+ n.cuerda = Some(cuerda);
+ }
+ true
+ }
+
+ /// Suelta un nudo de su cuerda (queda suelto). Si era el tejido, la
+ /// cuerda queda sin tejido. `false` si la nota no existe o ya estaba
+ /// suelta.
+ pub fn desanudar(&mut self, nota: NoteId) -> bool {
+ let Some(cid) = self.notes.get(¬a).and_then(|n| n.cuerda) else {
+ return false;
+ };
+ if let Some(c) = self.cuerdas.get_mut(&cid) {
+ if c.tejido == Some(nota) {
+ c.tejido = None;
+ }
+ }
+ if let Some(n) = self.notes.get_mut(¬a) {
+ n.cuerda = None;
+ }
+ true
+ }
+
+ /// Borra una cuerda. Sus nudos NO se borran — vuelven a estar
+ /// sueltos (la cuerda es estructura, no contenido). Devuelve la
+ /// cuerda si existía.
+ pub fn borrar_cuerda(&mut self, id: CuerdaId) -> Option<Cuerda> {
+ let c = self.cuerdas.remove(&id)?;
+ for n in self.notes.values_mut() {
+ if n.cuerda == Some(id) {
+ n.cuerda = None;
+ }
+ }
+ Some(c)
+ }
+
+ /// Los nudos de una cuerda, en orden cronológico de creación
+ /// (desempate por id — determinista). Incluye el tejido si lo hay.
+ pub fn nudos_de(&self, cuerda: CuerdaId) -> Vec<&Note> {
+ let mut out: Vec<&Note> = self
+ .notes
+ .values()
+ .filter(|n| n.cuerda == Some(cuerda))
+ .collect();
+ out.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
+ out
}
+ /// Los nudos sueltos (sin cuerda), del más reciente al más viejo
+ /// (desempate por id descendente — determinista).
+ pub fn sueltos(&self) -> Vec<&Note> {
+ let mut out: Vec<&Note> = self.notes.values().filter(|n| n.suelto()).collect();
+ out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(b.id.cmp(&a.id)));
+ out
+ }
+
+ // =================================================================
+ // Grafo de enlaces
+ // =================================================================
+
/// Notas que llevan la etiqueta `tag`, en orden de id.
pub fn by_tag(&self, tag: &str) -> Vec<&Note> {
self.notes.values().filter(|n| n.has_tag(tag)).collect()
@@ -278,8 +437,6 @@ mod tests {
#[test]
fn orphans_have_no_backlinks() {
let (s, _, _, jardin) = seeded();
- // Jardín no recibe enlaces de vuelta... pero el Índice sí lo enlaza.
- // El único huérfano real sería una nota aislada.
let mut s2 = s;
let aislada = s2.create("Aislada", "sin conexiones", vec![], 0);
let orphan_ids: Vec<_> = s2.orphans().iter().map(|n| n.id).collect();
@@ -301,6 +458,7 @@ mod tests {
let n = s.get(id).unwrap();
assert_eq!(n.mass, 1.0);
assert_eq!(n.last_access, 1_700_000_000);
+ assert!(n.suelto());
}
#[test]
@@ -313,36 +471,151 @@ mod tests {
}
#[test]
- fn update_body_also_marks_last_access() {
+ fn remove_drops_the_note() {
+ let (mut s, indice, ..) = seeded();
+ assert!(s.remove(indice).is_some());
+ assert_eq!(s.len(), 2);
+ assert!(s.get(indice).is_none());
+ }
+
+ // =================================================================
+ // Cuerdas
+ // =================================================================
+
+ #[test]
+ fn crear_cuerda_and_anudar() {
+ let (mut s, indice, cocina, jardin) = seeded();
+ let c = s.crear_cuerda("Casa", 200);
+ assert_eq!(s.cuerdas_len(), 1);
+ assert!(s.anudar(cocina, c));
+ assert!(s.anudar(jardin, c));
+ assert_eq!(s.get(cocina).unwrap().cuerda, Some(c));
+ let nudos: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
+ assert_eq!(nudos, vec![cocina, jardin]);
+ // El índice sigue suelto.
+ assert!(s.get(indice).unwrap().suelto());
+ }
+
+ #[test]
+ fn anudar_rejects_missing_note_or_cuerda() {
+ let (mut s, indice, ..) = seeded();
+ let c = s.crear_cuerda("Casa", 200);
+ assert!(!s.anudar(9_999, c));
+ assert!(!s.anudar(indice, 9_999));
+ }
+
+ #[test]
+ fn anudar_moves_between_cuerdas() {
+ let (mut s, _, cocina, _) = seeded();
+ let a = s.crear_cuerda("A", 0);
+ let b = s.crear_cuerda("B", 0);
+ assert!(s.anudar(cocina, a));
+ assert!(s.anudar(cocina, b));
+ assert_eq!(s.get(cocina).unwrap().cuerda, Some(b));
+ assert!(s.nudos_de(a).is_empty());
+ }
+
+ #[test]
+ fn desanudar_frees_the_note() {
+ let (mut s, _, cocina, _) = seeded();
+ let c = s.crear_cuerda("Casa", 0);
+ s.anudar(cocina, c);
+ assert!(s.desanudar(cocina));
+ assert!(s.get(cocina).unwrap().suelto());
+ assert!(!s.desanudar(cocina)); // ya estaba suelto
+ }
+
+ #[test]
+ fn nudos_de_sorts_chronologically() {
let mut s = NoteStore::new();
- let id = s.create("x", "y", vec![], 100);
- assert!(s.update_body(id, "z", 700));
- assert_eq!(s.get(id).unwrap().last_access, 700);
+ let c = s.crear_cuerda("Idea", 0);
+ let tarde = s.create("tarde", "", vec![], 900);
+ let temprano = s.create("temprano", "", vec![], 100);
+ s.anudar(tarde, c);
+ s.anudar(temprano, c);
+ let ids: Vec<_> = s.nudos_de(c).iter().map(|n| n.id).collect();
+ assert_eq!(ids, vec![temprano, tarde]);
}
#[test]
- fn set_mass_persists_the_value() {
+ fn sueltos_lists_newest_first() {
let mut s = NoteStore::new();
- let id = s.create("x", "y", vec![], 100);
- assert!(s.set_mass(id, 0.42));
- assert!((s.get(id).unwrap().mass - 0.42).abs() < 1e-6);
+ let viejo = s.create("viejo", "", vec![], 100);
+ let nuevo = s.create("nuevo", "", vec![], 900);
+ let ids: Vec<_> = s.sueltos().iter().map(|n| n.id).collect();
+ assert_eq!(ids, vec![nuevo, viejo]);
+ }
+
+ #[test]
+ fn borrar_cuerda_frees_its_nudos() {
+ let (mut s, _, cocina, jardin) = seeded();
+ let c = s.crear_cuerda("Casa", 0);
+ s.anudar(cocina, c);
+ s.anudar(jardin, c);
+ assert!(s.borrar_cuerda(c).is_some());
+ assert!(s.get(cocina).unwrap().suelto());
+ assert!(s.get(jardin).unwrap().suelto());
+ assert_eq!(s.cuerdas_len(), 0);
+ // El contenido sobrevive a la estructura.
+ assert_eq!(s.len(), 3);
}
#[test]
- fn set_pos_anchors_the_note_once() {
+ fn removing_tejido_clears_the_cuerda_pointer() {
let mut s = NoteStore::new();
- let id = s.create("x", "y", vec![], 100);
- assert_eq!(s.get(id).unwrap().pos, None);
- assert!(s.set_pos(id, 12.0, -8.0));
- assert_eq!(s.get(id).unwrap().pos, Some((12.0, -8.0)));
- assert!(!s.set_pos(9_999, 0.0, 0.0));
+ let c = s.crear_cuerda("Idea", 0);
+ let sintesis = s.create("tejido · Idea", "encuadre", vec![], 100);
+ s.anudar(sintesis, c);
+ s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
+ s.remove(sintesis);
+ assert_eq!(s.cuerda(c).unwrap().tejido, None);
}
#[test]
- fn remove_drops_the_note() {
- let (mut s, indice, ..) = seeded();
- assert!(s.remove(indice).is_some());
- assert_eq!(s.len(), 2);
- assert!(s.get(indice).is_none());
+ fn insert_raw_preserves_ids_and_bumps_counter() {
+ let mut s = NoteStore::new();
+ let mut n = Note {
+ id: 7,
+ title: "migrada".into(),
+ body: String::new(),
+ tags: vec![],
+ created_at: 1,
+ updated_at: 1,
+ last_access: 1,
+ mass: 1.0,
+ cuerda: None,
+ estado: Default::default(),
+ origen: None,
+ };
+ s.insert_raw(n.clone());
+ assert_eq!(s.get(7).unwrap().title, "migrada");
+ // El próximo create no pisa el id preservado.
+ let nuevo = s.create("x", "", vec![], 2);
+ assert_eq!(nuevo, 8);
+ // Reemplazo por id.
+ n.title = "editada".into();
+ s.insert_raw(n);
+ assert_eq!(s.get(7).unwrap().title, "editada");
+ assert!(s.coherente());
+ }
+
+ #[test]
+ fn coherente_detects_dangling_cuerda() {
+ let mut s = NoteStore::new();
+ let id = s.create("x", "", vec![], 0);
+ assert!(s.coherente());
+ s.get_mut(id).unwrap().cuerda = Some(99); // cuerda inexistente
+ assert!(!s.coherente());
+ }
+
+ #[test]
+ fn desanudar_tejido_clears_the_pointer() {
+ let mut s = NoteStore::new();
+ let c = s.crear_cuerda("Idea", 0);
+ let sintesis = s.create("tejido", "", vec![], 100);
+ s.anudar(sintesis, c);
+ s.cuerda_mut(c).unwrap().tejido = Some(sintesis);
+ assert!(s.desanudar(sintesis));
+ assert_eq!(s.cuerda(c).unwrap().tejido, None);
}
}
diff --git a/00_unanchay/khipu/khipu-gravity/Cargo.toml b/00_unanchay/khipu/khipu-gravity/Cargo.toml
index 2794715..5d6c0af 100644
--- a/00_unanchay/khipu/khipu-gravity/Cargo.toml
+++ b/00_unanchay/khipu/khipu-gravity/Cargo.toml
@@ -9,7 +9,12 @@ description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con v
[dependencies]
khipu-core = { path = "../khipu-core" }
+# El almacenamiento e índice kNN de vectores lo provee el índice unificado de la
+# suite (`rimay-verbo-index`): antes había tres copias del mismo coseno lineal.
+# `SemanticField` guarda los vectores aquí dentro y construye grafo/gravedad/
+# clústeres/layout 2D **encima**.
+rimay-verbo-index = { workspace = true }
+rimay-verbo-core = { workspace = true }
serde = { workspace = true }
-# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
-# con futuros consumidores `no_std` (kernel wawa, WASM bare).
+# `libm`: la masa decae con `exp` (física temporal — desacoplada del índice).
libm = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-gravity/LEEME.md b/00_unanchay/khipu/khipu-gravity/LEEME.md
index 410c5f1..74e364a 100644
--- a/00_unanchay/khipu/khipu-gravity/LEEME.md
+++ b/00_unanchay/khipu/khipu-gravity/LEEME.md
@@ -1,20 +1,23 @@
# khipu-gravity
-> Algoritmo de masa/decay para [khipu](../README.md).
+*Read this in English: [README.md](README.md).*
-Pura física de notas. Cada nota tiene `mass: f32`. Cada tick (configurable, default 1h) la masa decae: `mass *= exp(-dt / half_life)`. Cada acceso la refuerza: `mass += boost`. Cuando `mass < umbral`, la nota cae del horizonte visible (no se borra, queda en archivo). El cálculo es puro — no toca el store; el caller decide qué hacer con el resultado.
+> Las físicas puras de [khipu](../LEEME.md) — tres, deterministas, sin I/O ni reloj.
+
+1. **Masa temporal** (`Gravity` + `Params`): decay exponencial por vida media + refuerzo por acceso + horizonte. La app khipu **ya no la usa** (el rediseño 2026-07 invirtió la física); la consumen la captura rápida de pata e `iniy-derive`.
+2. **Campo semántico** (`SemanticField`): vectores por nota sobre `rimay-verbo-index`, afinidad coseno, `nearest` (la base de la **resonancia** de khipu), clústeres y layout 2D. `local_embed` es el fallback trigram offline.
+3. **Masa de cuerda** (`masa_cuerda` + `frescura`): una cuerda engorda logarítmicamente con cada nudo y arde según cuán reciente fue su último crecimiento. La inversa del olvido: el refuerzo viene de la recurrencia, no de la lectura.
## API
```rust
-use khipu_gravity::{Gravity, Params};
+use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
-let g = Gravity::new(Params::default());
-let new_mass = g.decay(mass, dt);
-let new_mass = g.reinforce(mass, boost);
+let grosor = masa_cuerda(n_nudos); // 0 si vacía; retornos decrecientes
+let brillo = frescura(ultimo_nudo, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 recién crecida → 0.5 al mes
```
## Deps
-- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
-- Cero deps de I/O o tiempo (el caller pasa `dt`)
+- `libm` para `exp`/`log` (apto `no_std` futuro)
+- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectores)
diff --git a/00_unanchay/khipu/khipu-gravity/README.md b/00_unanchay/khipu/khipu-gravity/README.md
index 275e4c1..ea90655 100644
--- a/00_unanchay/khipu/khipu-gravity/README.md
+++ b/00_unanchay/khipu/khipu-gravity/README.md
@@ -1,20 +1,21 @@
# khipu-gravity
-> Mass/decay algorithm for [khipu](../README.md).
+> The pure physics of [khipu](../README.md) — three of them, deterministic, no I/O, no clock.
-Pure physics of notes. Each note has `mass: f32`. Every tick (configurable, default 1h) mass decays: `mass *= exp(-dt / half_life)`. Each access reinforces it: `mass += boost`. When `mass < threshold`, the note falls off the visible horizon (not deleted, kept in archive). Pure computation — doesn't touch the store; the caller decides what to do with the result.
+1. **Temporal mass** (`Gravity` + `Params`): exponential decay by half-life + reinforcement on access + horizon. The khipu app **no longer uses it** (the 2026-07 redesign inverted the physics); pata's quick capture and `iniy-derive` consume it.
+2. **Semantic field** (`SemanticField`): per-note vectors over `rimay-verbo-index`, cosine affinity, `nearest` (the basis of khipu's **resonance**), clusters and 2D layout. `local_embed` is the offline trigram fallback.
+3. **Cord mass** (`masa_cuerda` + `frescura`): a cord fattens logarithmically with each knot and glows by how recently it last grew. The inverse of forgetting: reinforcement comes from recurrence, not from reading.
## API
```rust
-use khipu_gravity::{Gravity, Params};
+use khipu_gravity::{masa_cuerda, frescura, FRESCURA_MEDIA_VIDA_SECS};
-let g = Gravity::new(Params::default());
-let new_mass = g.decay(mass, dt);
-let new_mass = g.reinforce(mass, boost);
+let thickness = masa_cuerda(n_knots); // 0 when empty; diminishing returns
+let glow = frescura(last_knot, now, FRESCURA_MEDIA_VIDA_SECS); // 1.0 just grown → 0.5 after a month
```
## Deps
-- `libm` for `exp` (no `std::math` when compiled to WASM)
-- Zero I/O or time deps (caller passes `dt`)
+- `libm` for `exp`/`log` (fit for future `no_std`)
+- `khipu-core` (ids), `rimay-verbo-core`/`-index` (vectors)
diff --git a/00_unanchay/khipu/khipu-gravity/src/lib.rs b/00_unanchay/khipu/khipu-gravity/src/lib.rs
index d97fc3d..394c9f7 100644
--- a/00_unanchay/khipu/khipu-gravity/src/lib.rs
+++ b/00_unanchay/khipu/khipu-gravity/src/lib.rs
@@ -1,22 +1,29 @@
-//! `khipu-gravity` — dos físicas, ambas puras y deterministas.
+//! `khipu-gravity` — tres físicas, todas puras y deterministas.
//!
//! 1. **Masa temporal** ([`Gravity`] + [`Params`]): cada nota tiene una
//! `mass: f32`. Con el tiempo `decay(mass, dt)` la apaga; con cada
//! acceso `reinforce(mass, boost)` la enciende. Bajo un umbral la
//! nota cae al «archivo» — no se borra, sólo deja el horizonte
-//! visible. El cálculo no toca el store; el caller decide qué hacer
-//! con el resultado.
+//! visible. La usan los consumidores que sí olvidan (la captura
+//! rápida de pata, iniy-derive); la app khipu ya no.
//! 2. **Gravedad semántica** ([`SemanticField`]): afinidad coseno entre
-//! vectores, vecinos, clústeres por umbral y layout 2D dirigido por
-//! fuerzas. Las notas afines se atraen, todas se repelen.
+//! vectores y vecinos ([`SemanticField::nearest`]) — la base de la
+//! **resonancia**: al capturar un nudo nuevo, sus vecinos son los
+//! pensamientos que vuelve a tocar.
+//! 3. **Masa de cuerda** ([`masa_cuerda`] + [`frescura`]): una cuerda
+//! (idea recurrente) engorda con cada nudo que se le anuda y arde
+//! según cuán reciente fue su último crecimiento. Es la inversa del
+//! olvido: el refuerzo viene de la recurrencia, no de la lectura.
//!
-//! Las dos están desacopladas — un caller puede usar sólo una. El
+//! Las tres están desacopladas — un caller puede usar sólo una. El
//! crate compila sin alcanzar `std::f32::exp` (usa [`libm`]) para
//! mantenerse apto a futuros consumos `no_std` (kernel wawa, WASM).
#![forbid(unsafe_code)]
use khipu_core::NoteId;
+use rimay_verbo_core::{EmbeddingVector, ModelId};
+use rimay_verbo_index::VectorIndex;
use serde::{Deserialize, Serialize};
/// Parámetros de la física temporal de una nota.
@@ -78,11 +85,29 @@ impl Gravity {
}
}
+/// Nombre interno del espacio vectorial de un [`SemanticField`]. Da igual cuál
+/// sea el embebedor real (trigram local, daemon `verbo`, …): dentro del campo
+/// **todos** los vectores comparten índice y por tanto modelo, así que basta un
+/// nombre estable; la dimensión se toma del primer vector insertado. El campo no
+/// mezcla espacios — igual que khipu, que ya recalcula todo si cambia el modelo.
+const FIELD_MODEL_NAME: &str = "khipu-field";
+
/// Una nube de notas con su vector semántico — el dominio de la gravedad.
+///
+/// El **almacenamiento** de vectores lo delega en [`VectorIndex<NoteId>`] (el
+/// índice unificado de la suite: kNN coseno + persistencia); encima construye
+/// grafo/gravedad/clústeres/layout 2D. El índice nace perezoso en el primer
+/// `insert` (necesita la dimensión del vector para fijar su [`ModelId`]).
+///
+/// `order` mantiene el orden de inserción de las claves — el índice es un
+/// `HashMap` sin orden garantizado, y `iter`/`clusters`/`gravity_layout` deben
+/// ser deterministas. No duplica vectores: sólo las claves.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SemanticField {
- /// `(id, vector)` en orden de inserción.
- entries: Vec<(NoteId, Vec<f32>)>,
+ /// Vectores, indexados por `NoteId`. `None` hasta el primer `insert`.
+ index: Option<VectorIndex<NoteId>>,
+ /// Claves en orden de inserción, para recorridos deterministas.
+ order: Vec<NoteId>,
}
/// Posición 2D resultante de una nota tras el layout por gravedad.
@@ -141,40 +166,73 @@ impl SemanticField {
/// Cantidad de notas en el campo.
pub fn len(&self) -> usize {
- self.entries.len()
+ self.index.as_ref().map(VectorIndex::len).unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
- self.entries.is_empty()
+ self.len() == 0
}
- /// Inserta o reemplaza el vector de una nota.
+ /// Inserta o reemplaza el vector de una nota. El vector se guarda en el
+ /// [`VectorIndex`]; el índice nace perezoso con la dimensión de este primer
+ /// vector. Un vector vacío o de dimensión incompatible con el índice ya
+ /// fijado se ignora (khipu nunca mezcla dimensiones en una sesión).
pub fn insert(&mut self, id: NoteId, vector: Vec<f32>) {
- if let Some(slot) = self.entries.iter_mut().find(|(eid, _)| *eid == id) {
- slot.1 = vector;
+ self.insert_etiquetado(id, vector, FIELD_MODEL_NAME);
+ }
+
+ /// Como [`Self::insert`], pero el índice adopta la **etiqueta real del
+ /// embebedor** (Arco II de anqara, #11): un campo nacido con la etiqueta
+ /// `"e5-small(384d)"` rechaza vectores de otro embebedor aunque la
+ /// dimensión coincida — antes el `ModelId` ficticio anulaba esa
+ /// salvaguarda y dos espacios incomparables se mezclaban sin detectarlo.
+ /// Devuelve `false` si el vector fue rechazado (etiqueta o dimensión).
+ pub fn insert_etiquetado(&mut self, id: NoteId, vector: Vec<f32>, etiqueta: &str) -> bool {
+ if vector.is_empty() {
+ return false;
+ }
+ let index = self
+ .index
+ .get_or_insert_with(|| VectorIndex::new(ModelId::new(etiqueta, vector.len())));
+ // Etiqueta distinta a la del índice ya fijado = otro espacio
+ // vectorial: se rechaza en vez de corromper.
+ if index.model().name != etiqueta {
+ return false;
+ }
+ let Ok(ev) = EmbeddingVector::new(index.model().clone(), vector) else {
+ return false;
+ };
+ if index.insert(id, ev).is_ok() {
+ if !self.order.contains(&id) {
+ self.order.push(id);
+ }
+ true
} else {
- self.entries.push((id, vector));
+ false
}
}
/// Saca la nota del campo. No-op si no estaba. `true` si se removió.
pub fn remove(&mut self, id: NoteId) -> bool {
- let before = self.entries.len();
- self.entries.retain(|(eid, _)| *eid != id);
- self.entries.len() != before
+ let removed = self.index.as_mut().map(|ix| ix.remove(&id)).unwrap_or(false);
+ if removed {
+ self.order.retain(|&eid| eid != id);
+ }
+ removed
}
/// Itera `(id, &vector)` en orden de inserción. Útil para serializar
- /// el campo completo (persistencia, exportar).
+ /// el campo completo (persistencia, exportar). El orden lo da `order`
+ /// porque el índice es un `HashMap` sin orden garantizado.
pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
- self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
+ let index = self.index.as_ref();
+ self.order
+ .iter()
+ .filter_map(move |id| index.and_then(|ix| ix.get(id)).map(|v| (*id, v)))
}
fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
- self.entries
- .iter()
- .find(|(eid, _)| *eid == id)
- .map(|(_, v)| v.as_slice())
+ self.index.as_ref().and_then(|ix| ix.get(&id))
}
/// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
@@ -185,15 +243,29 @@ impl SemanticField {
/// Las `k` notas más afines a `id`, de mayor a menor afinidad.
/// Empata por id ascendente para que el orden sea determinista.
+ ///
+ /// El kNN coseno lo hace el [`VectorIndex::search`] unificado; aquí sólo se
+ /// reimpone el desempate determinista por id (el índice no lo garantiza) y
+ /// se excluye la propia `id`.
pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
- let Some(base) = self.vector_of(id) else {
+ let Some(index) = self.index.as_ref() else {
return Vec::new();
};
- let mut scored: Vec<(NoteId, f32)> = self
- .entries
- .iter()
- .filter(|(eid, _)| *eid != id)
- .filter_map(|(eid, v)| cosine(base, v).map(|s| (*eid, s)))
+ let Some(base) = index.get(&id) else {
+ return Vec::new();
+ };
+ let Ok(query) = EmbeddingVector::new(index.model().clone(), base.to_vec()) else {
+ return Vec::new();
+ };
+ // top_k = todo (rankeamos todas, luego truncamos con desempate estable);
+ // min_score = -1.0 no filtra nada (coseno ∈ [-1, 1]), como el original.
+ let Ok(hits) = index.search(&query, index.len(), -1.0) else {
+ return Vec::new();
+ };
+ let mut scored: Vec<(NoteId, f32)> = hits
+ .into_iter()
+ .filter(|h| h.key != id)
+ .map(|h| (h.key, h.score))
.collect();
scored.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
@@ -204,11 +276,82 @@ impl SemanticField {
scored
}
+ /// Le da a `id` un domicilio fijo, **una sola vez**: cae en el baricentro
+ /// de sus parientes semánticos (ponderado por afinidad) y, si quedó pegada
+ /// a otra nota, se separa apenas. `placed` son las notas ya asentadas con su
+ /// posición de mundo. Determinista y dependiente sólo de lo ya colocado, así
+ /// el orden de inserción es estable y el mapa nunca se reacomoda solo.
+ ///
+ /// Es el anclaje incremental del campo semántico: vivía atrapado en
+ /// `khipu-app::map::place_note` (frontend); el algoritmo baja aquí para que
+ /// cualquier frontend lo reuse (Regla 2). El frontend sólo lee posiciones y
+ /// escribe la resultante en su store.
+ pub fn anchor_new(&self, id: NoteId, placed: &[(NoteId, (f32, f32))]) -> (f32, f32) {
+ // Vecinos ya colocados: su afinidad con la nota nueva y su posición.
+ let mut kin: Vec<(f32, (f32, f32))> = Vec::new();
+ for (other, pos) in placed {
+ if *other == id {
+ continue;
+ }
+ let aff = self.affinity(id, *other).unwrap_or(0.0).max(0.0);
+ kin.push((aff, *pos));
+ }
+
+ let target = if kin.is_empty() {
+ (0.0, 0.0) // primera nota del cuaderno: centro del mundo.
+ } else {
+ let wsum: f32 = kin.iter().map(|(w, _)| *w).sum();
+ if wsum > 1e-3 {
+ // Cae junto a su parentela: baricentro ponderado por afinidad.
+ let (mut tx, mut ty) = (0.0_f32, 0.0_f32);
+ for (w, (x, y)) in &kin {
+ tx += w * x;
+ ty += w * y;
+ }
+ (tx / wsum, ty / wsum)
+ } else {
+ // Ortogonal a todo: anillo determinista por id, lejos del núcleo.
+ let ang = id as f32 * ANCHOR_GOLDEN_ANGLE;
+ let rad = 180.0 + 14.0 * (id as f32).sqrt();
+ (rad * ang.cos(), rad * ang.sin())
+ }
+ };
+
+ // Separación: empuja el target hasta despegarlo de cada vecino cercano.
+ let mut p = target;
+ for _ in 0..12 {
+ let mut moved = false;
+ for (_, q) in &kin {
+ let dx = p.0 - q.0;
+ let dy = p.1 - q.1;
+ let d = (dx * dx + dy * dy).sqrt();
+ if d < ANCHOR_MIN_SEP {
+ let (ux, uy) = if d > 1e-3 {
+ (dx / d, dy / d)
+ } else {
+ let a = id as f32 * ANCHOR_GOLDEN_ANGLE;
+ (a.cos(), a.sin())
+ };
+ let push = ANCHOR_MIN_SEP - d;
+ p.0 += ux * push;
+ p.1 += uy * push;
+ moved = true;
+ }
+ }
+ if !moved {
+ break;
+ }
+ }
+ p
+ }
+
/// Agrupa las notas en clústeres: dos notas quedan en el mismo grupo
/// si su afinidad alcanza `threshold` (transitivamente). Cada
/// clúster viene ordenado por id, y la lista de clústeres también.
pub fn clusters(&self, threshold: f32) -> Vec<Vec<NoteId>> {
- let n = self.entries.len();
+ // Necesita **todos** los vectores: los toma del índice en orden estable.
+ let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
+ let n = entries.len();
let mut parent: Vec<usize> = (0..n).collect();
fn find(parent: &mut [usize], i: usize) -> usize {
@@ -227,7 +370,7 @@ impl SemanticField {
for i in 0..n {
for j in (i + 1)..n {
- let sim = cosine(&self.entries[i].1, &self.entries[j].1).unwrap_or(0.0);
+ let sim = cosine(entries[i].1, entries[j].1).unwrap_or(0.0);
if sim >= threshold {
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
if ri != rj {
@@ -240,7 +383,7 @@ impl SemanticField {
let mut groups: std::collections::BTreeMap<usize, Vec<NoteId>> = Default::default();
for i in 0..n {
let root = find(&mut parent, i);
- groups.entry(root).or_default().push(self.entries[i].0);
+ groups.entry(root).or_default().push(entries[i].0);
}
let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
for c in &mut out {
@@ -253,12 +396,14 @@ impl SemanticField {
/// Layout 2D por gravedad: las notas afines se atraen, todas se
/// repelen. Determinista — posiciones iniciales en círculo, sin RNG.
pub fn gravity_layout(&self, cfg: &GravityConfig) -> Vec<NotePlacement> {
- let n = self.entries.len();
+ // Layout sobre todos los vectores, en orden de inserción estable.
+ let entries: Vec<(NoteId, &[f32])> = self.iter().collect();
+ let n = entries.len();
if n == 0 {
return Vec::new();
}
if n == 1 {
- return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
+ return vec![NotePlacement { id: entries[0].0, x: 0.0, y: 0.0 }];
}
// Posiciones iniciales repartidas en un círculo.
@@ -273,9 +418,7 @@ impl SemanticField {
let mut aff = vec![0.0f32; n * n];
for i in 0..n {
for j in (i + 1)..n {
- let s = cosine(&self.entries[i].1, &self.entries[j].1)
- .unwrap_or(0.0)
- .max(0.0);
+ let s = cosine(entries[i].1, entries[j].1).unwrap_or(0.0).max(0.0);
aff[i * n + j] = s;
aff[j * n + i] = s;
}
@@ -307,7 +450,7 @@ impl SemanticField {
}
}
- self.entries
+ entries
.iter()
.zip(pos)
.map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
@@ -315,6 +458,76 @@ impl SemanticField {
}
}
+// =====================================================================
+// Masa de cuerda — la física de la resonancia
+// =====================================================================
+
+/// Vida media por defecto de la frescura de una cuerda: 30 días. Una
+/// cuerda que no crece en un mes arde a la mitad; nunca se apaga del
+/// todo ([`frescura`] tiene piso en el caller, no aquí).
+pub const FRESCURA_MEDIA_VIDA_SECS: f32 = 30.0 * 24.0 * 3600.0;
+
+/// Masa estructural de una cuerda: crece con cada nudo anudado, sin
+/// techo duro pero con retornos decrecientes (logarítmico) — una cuerda
+/// de 10 nudos no aplasta visualmente a una de 4. `0` nudos = masa `0`.
+pub fn masa_cuerda(nudos: usize) -> f32 {
+ libm::logf(1.0 + nudos as f32)
+}
+
+/// Frescura de una cuerda en `[0, 1]`: `1.0` si acaba de crecer
+/// (`ultimo_nudo == now`), decae exponencialmente con `media_vida_secs`.
+/// No es olvido — la cuerda conserva su masa; sólo modula el brillo
+/// («lo que crece arde, lo quieto reposa»). `now` anterior al último
+/// nudo o media vida no positiva devuelven `1.0`.
+pub fn frescura(ultimo_nudo: u64, now: u64, media_vida_secs: f32) -> f32 {
+ if now <= ultimo_nudo || media_vida_secs <= 0.0 {
+ return 1.0;
+ }
+ let dt = (now - ultimo_nudo) as f32;
+ let k = core::f32::consts::LN_2 / media_vida_secs;
+ libm::expf(-k * dt).clamp(0.0, 1.0)
+}
+
+/// Separación mínima entre nodos al anclar uno nuevo (coordenadas de mundo).
+pub const ANCHOR_MIN_SEP: f32 = 30.0;
+/// Ángulo áureo en radianes — reparte determinísticamente lo que no tiene
+/// parentela semántica sin amontonarlo.
+const ANCHOR_GOLDEN_ANGLE: f32 = 2.399_963_2;
+
+/// Embedder local de fallback: hash trigram → R^dim con signos +/-1 (random
+/// projection 1-bit signed), normalizado por L2. Determinista, independiente de
+/// idioma, sin red. Es el vector que alimenta a [`SemanticField`] cuando el
+/// daemon de verbo no está; vivía atrapado en `khipu-app::estado::embed`
+/// (frontend) y baja aquí, junto a los vectores que produce (Regla 2).
+pub fn local_embed(text: &str, dim: usize) -> Vec<f32> {
+ let mut v = vec![0.0f32; dim];
+ let lower = text.to_lowercase();
+ let bytes = lower.as_bytes();
+ if bytes.len() < 3 {
+ for (i, b) in bytes.iter().enumerate() {
+ v[i % dim] += *b as f32 / 255.0;
+ }
+ } else {
+ for w in bytes.windows(3) {
+ let mut h: u64 = 0xcbf29ce484222325;
+ for b in w {
+ h ^= *b as u64;
+ h = h.wrapping_mul(0x100000001b3);
+ }
+ let idx = (h as usize) % dim;
+ let sign = if h & 1 == 0 { 1.0 } else { -1.0 };
+ v[idx] += sign;
+ }
+ }
+ let n: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
+ if n > 0.0 {
+ for x in &mut v {
+ *x /= n;
+ }
+ }
+ v
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -328,6 +541,19 @@ mod tests {
f
}
+ #[test]
+ fn etiqueta_distinta_no_mezcla_espacios() {
+ // El agujero cerrado en el Arco II #11: misma dimensión, otro
+ // embebedor → antes se mezclaba sin detectarlo; ahora se rechaza.
+ let mut f = SemanticField::new();
+ assert!(f.insert_etiquetado(1, vec![1.0, 0.0, 0.0], "e5-small(384d)"));
+ assert!(!f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "mock-3d"));
+ assert_eq!(f.len(), 1, "el vector del otro espacio no entra");
+ // El mismo espacio sigue entrando.
+ assert!(f.insert_etiquetado(2, vec![0.0, 1.0, 0.0], "e5-small(384d)"));
+ assert_eq!(f.len(), 2);
+ }
+
#[test]
fn affinity_is_high_for_aligned_vectors() {
let f = field();
@@ -431,6 +657,42 @@ mod tests {
}
}
+#[cfg(test)]
+mod cuerda_tests {
+ use super::*;
+
+ #[test]
+ fn masa_cuerda_grows_with_diminishing_returns() {
+ assert_eq!(masa_cuerda(0), 0.0);
+ let m2 = masa_cuerda(2);
+ let m4 = masa_cuerda(4);
+ let m8 = masa_cuerda(8);
+ assert!(m2 > 0.0 && m4 > m2 && m8 > m4);
+ // Retornos decrecientes: cada nudo nuevo suma menos que el anterior.
+ assert!(masa_cuerda(5) - masa_cuerda(4) < masa_cuerda(2) - masa_cuerda(1));
+ }
+
+ #[test]
+ fn frescura_is_full_at_growth_and_halves_at_half_life() {
+ assert!((frescura(1_000, 1_000, 100.0) - 1.0).abs() < 1e-6);
+ let f = frescura(0, 100, 100.0);
+ assert!((f - 0.5).abs() < 1e-3);
+ }
+
+ #[test]
+ fn frescura_handles_clock_skew_and_bad_half_life() {
+ // Reloj hacia atrás (RTC roto): frescura plena, nunca panic.
+ assert_eq!(frescura(2_000, 1_000, 100.0), 1.0);
+ assert_eq!(frescura(0, 1_000, 0.0), 1.0);
+ }
+
+ #[test]
+ fn frescura_stays_in_unit_range_long_term() {
+ let f = frescura(0, 10_000_000_000, FRESCURA_MEDIA_VIDA_SECS);
+ assert!((0.0..=1.0).contains(&f));
+ }
+}
+
#[cfg(test)]
mod mass_tests {
use super::*;
diff --git a/00_unanchay/khipu/khipu-share/LEEME.md b/00_unanchay/khipu/khipu-share/LEEME.md
new file mode 100644
index 0000000..28fefab
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/LEEME.md
@@ -0,0 +1,37 @@
+# khipu-share
+
+*Read this in English: [README.md](README.md).*
+
+Compartir notas por la red soberana de tawasuyu sin perder su gravedad local.
+
+Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
+nunca su física temporal: la masa y el último acceso son la atención
+*de quien tiene la nota*, no una propiedad transferible. Al importar,
+cada nota nace fresca (`mass = 1.0`, `last_access = now`) — su gravedad
+arranca en el cuaderno que la recibe.
+
+El sobre es:
+- **direccionado por contenido**: su identidad es `BLAKE3(postcard(bundle))`,
+ así dos sobres con las mismas notas tienen el mismo hash;
+- **firmado Ed25519** sobre ese hash con la clave del autor, vía
+ `agora_core` — verificable sin autoridad central ni red.
+```
+use agora_core::Keypair;
+use khipu_share::{seal, open, SharedNote};
+
+let kp = Keypair::from_seed([7u8; 32]);
+let notas = vec![SharedNote {
+ title: "Receta".into(),
+ body: "sopa; ver [[Mercado]]".into(),
+ tags: vec!["cocina".into()],
+}];
+let sobre = seal(&kp, notas, 1_700_000_000).unwrap();
+
+// El receptor verifica firma + hash antes de confiar en el contenido.
+let bundle = open(&sobre).unwrap();
+assert_eq!(bundle.notes[0].title, "Receta");
+```
+
+---
+
+Parte de **khipu** — ver [khipu](../LEEME.md).
diff --git a/00_unanchay/khipu/khipu-share/README.md b/00_unanchay/khipu/khipu-share/README.md
new file mode 100644
index 0000000..b98514e
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/README.md
@@ -0,0 +1,11 @@
+# khipu-share
+
+Sharing notes over tawasuyu's sovereign network without losing their local gravity.
+
+What travels is the note's **content** (title, body, tags), never its temporal
+physics: mass and last access are the attention *of whoever holds the note*, not a
+transferable property. On import, the note starts its own gravity.
+
+---
+
+Part of **khipu** — see [khipu](../README.md).
diff --git a/00_unanchay/khipu/khipu-share/src/lib.rs b/00_unanchay/khipu/khipu-share/src/lib.rs
index 65ab1c0..a1bfb6e 100644
--- a/00_unanchay/khipu/khipu-share/src/lib.rs
+++ b/00_unanchay/khipu/khipu-share/src/lib.rs
@@ -1,4 +1,4 @@
-//! `khipu-share` — compartir notas por la red soberana de gioser sin
+//! `khipu-share` — compartir notas por la red soberana de tawasuyu sin
//! perder su gravedad local.
//!
//! Lo que viaja es el **contenido** de la nota (título, cuerpo, etiquetas),
diff --git a/00_unanchay/khipu/pantallazo.png b/00_unanchay/khipu/pantallazo.png
new file mode 100644
index 0000000..2f74749
Binary files /dev/null and b/00_unanchay/khipu/pantallazo.png differ
diff --git a/Cargo.lock b/Cargo.lock
index af98f3e..975569a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -18,6 +18,114 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "023da0e5097f46df7092d5280b02efb9bbf8d93298daeced42652463e357d636"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "atspi-common",
+ "phf",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_ios"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03e156ed3802e35eefe894ef2671bc6c889303d8a7e110b5e1b48f504b91362f"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.34.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows 0.62.2",
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.33.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c"
+dependencies = [
+ "accesskit",
+ "accesskit_ios",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
[[package]]
name = "adler2"
version = "2.0.1"
@@ -62,27 +170,28 @@ dependencies = [
[[package]]
name = "agora-core"
version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
dependencies = [
"blake3",
"ed25519-dalek",
"format",
"serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
+ "umbral",
]
[[package]]
name = "agora-keystore"
version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
dependencies = [
"aead",
"agora-core",
"argon2",
"chacha20poly1305",
"directories",
- "rand 0.8.6",
- "thiserror 2.0.18",
+ "rand 0.8.7",
+ "thiserror 2.0.19",
]
[[package]]
@@ -120,16 +229,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
dependencies = [
"android-properties",
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
"cc",
"jni",
"libc",
"log",
"ndk",
"ndk-context",
- "ndk-sys 0.6.0+11769913",
+ "ndk-sys",
"num_enum",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
@@ -149,14 +258,14 @@ dependencies = [
[[package]]
name = "anyhow"
-version = "1.0.102"
+version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "app-bus"
version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
dependencies = [
"directories",
"serde",
@@ -180,6 +289,7 @@ dependencies = [
"parking_lot",
"percent-encoding",
"windows-sys 0.60.2",
+ "wl-clipboard-rs",
"x11rb",
]
@@ -203,9 +313,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
-version = "0.7.6"
+version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "as-raw-xcb-connection"
@@ -231,10 +341,10 @@ dependencies = [
"asn1-rs-derive",
"asn1-rs-impl",
"displaydoc",
- "nom",
+ "nom 7.1.3",
"num-traits",
"rusticata-macros",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"time",
]
@@ -246,7 +356,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
"synstructure",
]
@@ -258,7 +368,45 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
]
[[package]]
@@ -279,15 +427,79 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.4",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.4",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
[[package]]
name = "async-trait"
-version = "0.1.89"
+version = "0.1.91"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
[[package]]
@@ -318,6 +530,43 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
[[package]]
name = "attohttpc"
version = "0.30.1"
@@ -352,6 +601,12 @@ dependencies = [
"match-lookup",
]
+[[package]]
+name = "base45"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03"
+
[[package]]
name = "base64"
version = "0.22.1"
@@ -387,12 +642,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
-version = "2.12.1"
+version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
-dependencies = [
- "serde_core",
-]
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "blake2"
@@ -441,6 +693,19 @@ dependencies = [
"objc2 0.5.2",
]
+[[package]]
+name = "blocking"
+version = "1.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
+dependencies = [
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
+]
+
[[package]]
name = "bs58"
version = "0.5.1"
@@ -458,22 +723,22 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
-version = "1.25.0"
+version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [
"bytemuck_derive",
]
[[package]]
name = "bytemuck_derive"
-version = "1.10.2"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -490,9 +755,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
-version = "1.11.1"
+version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "calloop"
@@ -500,7 +765,7 @@ version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
"log",
"polling",
"rustix 0.38.44",
@@ -523,7 +788,7 @@ dependencies = [
[[package]]
name = "card-net"
version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
dependencies = [
"blake3",
"futures",
@@ -531,15 +796,15 @@ dependencies = [
"libp2p-allow-block-list",
"libp2p-stream",
"serde",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
]
[[package]]
name = "cc"
-version = "1.2.63"
+version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -555,9 +820,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cfg_aliases"
-version = "0.2.1"
+version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chacha20"
@@ -570,6 +835,17 @@ dependencies = [
"cpufeatures 0.2.17",
]
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "chacha20poly1305"
version = "0.10.1"
@@ -577,7 +853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
dependencies = [
"aead",
- "chacha20",
+ "chacha20 0.9.1",
"cipher",
"poly1305",
"zeroize",
@@ -609,15 +885,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
name = "codespan-reporting"
-version = "0.11.1"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81"
dependencies = [
+ "serde",
"termcolor",
"unicode-width",
]
@@ -675,6 +952,16 @@ dependencies = [
"libc",
]
+[[package]]
+name = "core-foundation"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -688,9 +975,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
- "foreign-types",
+ "core-foundation 0.9.4",
+ "core-graphics-types 0.1.3",
+ "foreign-types 0.5.0",
"libc",
]
@@ -701,10 +988,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
dependencies = [
"bitflags 1.3.2",
- "core-foundation",
+ "core-foundation 0.9.4",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
+dependencies = [
+ "bitflags 2.13.1",
+ "core-foundation 0.10.1",
"libc",
]
+[[package]]
+name = "core_maths"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
+dependencies = [
+ "libm",
+]
+
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -740,27 +1047,27 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
-version = "0.5.15"
+version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
-version = "0.9.18"
+version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
-version = "0.8.21"
+version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
@@ -818,7 +1125,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -844,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -865,7 +1172,7 @@ checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
dependencies = [
"asn1-rs",
"displaydoc",
- "nom",
+ "nom 7.1.3",
"num-bigint",
"num-traits",
"rusticata-macros",
@@ -876,9 +1183,6 @@ name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
-dependencies = [
- "powerfmt",
-]
[[package]]
name = "digest"
@@ -924,7 +1228,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
"objc2 0.6.4",
]
@@ -936,7 +1240,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -985,6 +1289,12 @@ dependencies = [
"signature",
]
+[[package]]
+name = "ed25519-compact"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6"
+
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
@@ -1001,9 +1311,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.16.0"
+version = "1.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
[[package]]
name = "embedded-io"
@@ -1017,6 +1327,21 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
[[package]]
name = "enum-as-inner"
version = "0.6.1"
@@ -1026,7 +1351,28 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
]
[[package]]
@@ -1060,6 +1406,45 @@ dependencies = [
"num-traits",
]
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastbloom"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef975e30683b2d965054bb0a836f8973857c4ebf6acf274fe46617cd285060d8"
+dependencies = [
+ "foldhash 0.2.0",
+ "libm",
+ "portable-atomic",
+ "siphasher",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
[[package]]
name = "fax"
version = "0.2.7"
@@ -1087,6 +1472,12 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+[[package]]
+name = "fixedbitset"
+version = "0.5.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
+
[[package]]
name = "flate2"
version = "1.1.9"
@@ -1109,11 +1500,17 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "font-types"
-version = "0.9.0"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7"
+checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5"
dependencies = [
"bytemuck",
]
@@ -1128,36 +1525,44 @@ dependencies = [
]
[[package]]
-name = "fontconfig-cache-parser"
-version = "0.2.0"
+name = "font-types"
+version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18"
+checksum = "0a7299a780854a6d391be2ae1c8521c9368471b559dbfd6a8dbd9f407eaff100"
dependencies = [
"bytemuck",
- "thiserror 1.0.69",
]
[[package]]
name = "fontique"
-version = "0.4.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7"
+checksum = "ff3336bc0b87fe42305047263fa60d2eabd650d29cbe62fdeb2a66c7a0a595f9"
dependencies = [
"bytemuck",
- "fontconfig-cache-parser",
"hashbrown 0.15.5",
- "icu_locid",
+ "icu_locale_core",
+ "linebender_resource_handle",
"memmap2",
"objc2 0.6.4",
"objc2-core-foundation",
"objc2-core-text",
"objc2-foundation 0.3.2",
- "peniko",
- "read-fonts 0.29.3",
+ "read-fonts 0.35.0",
"roxmltree",
"smallvec",
"windows 0.58.0",
"windows-core 0.58.0",
+ "yeslogic-fontconfig-sys",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared 0.1.1",
]
[[package]]
@@ -1167,26 +1572,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
- "foreign-types-shared",
+ "foreign-types-shared 0.3.1",
]
[[package]]
name = "foreign-types-macros"
-version = "0.2.3"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 3.0.3",
]
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+[[package]]
+name = "fork-proof"
+version = "0.1.0"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
+dependencies = [
+ "ed25519-compact",
+ "format",
+]
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1199,7 +1619,7 @@ dependencies = [
[[package]]
name = "format"
version = "0.1.0"
-source = "git+https://gitea.tawasuyu.net/sergio/gioser.git?tag=v0.1.0#692b40336c1e9cb12ab561764d6943cb65905ab3"
+source = "git+https://git.tawasuyu.net/tawasuyu/tawasuyu.git?tag=v0.2.0#a446bb3a73a0e022158cc0ec7deb65173859644b"
dependencies = [
"blake3",
"postcard",
@@ -1209,9 +1629,9 @@ dependencies = [
[[package]]
name = "futures"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
dependencies = [
"futures-channel",
"futures-core",
@@ -1234,9 +1654,9 @@ dependencies = [
[[package]]
name = "futures-channel"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
dependencies = [
"futures-core",
"futures-sink",
@@ -1244,15 +1664,15 @@ dependencies = [
[[package]]
name = "futures-core"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
[[package]]
name = "futures-executor"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
dependencies = [
"futures-core",
"futures-task",
@@ -1272,9 +1692,9 @@ dependencies = [
[[package]]
name = "futures-io"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
[[package]]
name = "futures-lite"
@@ -1282,19 +1702,22 @@ version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
dependencies = [
+ "fastrand",
"futures-core",
+ "futures-io",
+ "parking",
"pin-project-lite",
]
[[package]]
name = "futures-macro"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -1310,15 +1733,15 @@ dependencies = [
[[package]]
name = "futures-sink"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
[[package]]
name = "futures-task"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
[[package]]
name = "futures-timer"
@@ -1328,9 +1751,9 @@ checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
[[package]]
name = "futures-util"
-version = "0.3.32"
+version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
dependencies = [
"futures-channel",
"futures-core",
@@ -1383,24 +1806,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
- "js-sys",
"libc",
"r-efi 5.3.0",
"wasip2",
- "wasm-bindgen",
]
[[package]]
name = "getrandom"
-version = "0.4.2"
+version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"r-efi 6.0.0",
- "wasip2",
- "wasip3",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
]
[[package]]
@@ -1447,21 +1869,21 @@ dependencies = [
[[package]]
name = "gpu-alloc"
-version = "0.6.0"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+checksum = "45cf04b2726f02df5508c6de726acdc90cdf97ac771a9a0ffd8ba10a6e696bf9"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
"gpu-alloc-types",
]
[[package]]
name = "gpu-alloc-types"
-version = "0.3.0"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+checksum = "b2bbed164dd10ed526c2e4fe3e721ca4a71c61730e5aafac6844b417b3227058"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
]
[[package]]
@@ -1482,7 +1904,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
"gpu-descriptor-types",
"hashbrown 0.15.5",
]
@@ -1493,7 +1915,7 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
dependencies = [
- "bitflags 2.12.1",
+ "bitflags 2.13.1",
]
[[package]]
@@ -1514,9 +1936,9 @@ dependencies = [
[[package]]
name = "h2"
-version = "0.4.14"
+version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
@@ -1539,9 +1961,23 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
+ "num-traits",
"zerocopy",
]
+[[package]]
+name = "harfrust"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8"
+dependencies = [
+ "bitflags 2.13.1",
+ "bytemuck",
+ "core_maths",
+ "read-fonts 0.35.0",
+ "smallvec",
+]
+
[[package]]
name = "hash32"
version = "0.2.1"
@@ -1559,7 +1995,16 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
- "foldhash",
+ "foldhash 0.1.5",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash 0.2.0",
]
[[package]]
@@ -1631,10 +2076,10 @@ dependencies = [
"idna",
"ipnet",
"once_cell",
- "rand 0.9.4",
+ "rand 0.9.5",
"ring",
"socket2 0.5.10",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tinyvec",
"tokio",
"tracing",
@@ -1654,10 +2099,10 @@ dependencies = [
"moka",
"once_cell",
"parking_lot",
- "rand 0.9.4",
+ "rand 0.9.5",
"resolv-conf",
"smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tracing",
]
@@ -1682,9 +2127,9 @@ dependencies = [
[[package]]
name = "http"
-version = "1.4.1"
+version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
@@ -1692,9 +2137,9 @@ dependencies = [
[[package]]
name = "http-body"
-version = "1.0.1"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
@@ -1702,9 +2147,9 @@ dependencies = [
[[package]]
name = "http-body-util"
-version = "0.1.3"
+version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
dependencies = [
"bytes",
"futures-core",
@@ -1721,9 +2166,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hyper"
-version = "1.10.1"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
dependencies = [
"atomic-waker",
"bytes",
@@ -1740,24 +2185,61 @@ dependencies = [
"want",
]
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
+ "base64",
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"hyper",
+ "ipnet",
"libc",
+ "percent-encoding",
"pin-project-lite",
- "socket2 0.6.4",
+ "socket2 0.6.5",
+ "system-configuration",
"tokio",
"tower-service",
"tracing",
+ "windows-registry",
]
[[package]]
@@ -1781,24 +2263,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
- "litemap 0.8.2",
- "tinystr 0.8.3",
- "writeable 0.6.3",
+ "litemap",
+ "serde",
+ "tinystr",
+ "writeable",
"zerovec",
]
-[[package]]
-name = "icu_locid"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637"
-dependencies = [
- "displaydoc",
- "litemap 0.7.5",
- "tinystr 0.7.6",
- "writeable 0.5.5",
-]
-
[[package]]
name = "icu_normalizer"
version = "2.2.0"
@@ -1847,19 +2318,13 @@ checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
- "writeable 0.6.3",
+ "writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
-[[package]]
-name = "id-arena"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
-
[[package]]
name = "idna"
version = "1.1.0"
@@ -1898,7 +2363,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2"
dependencies = [
"async-io",
- "core-foundation",
+ "core-foundation 0.9.4",
"fnv",
"futures",
"if-addrs",
@@ -1929,7 +2394,7 @@ dependencies = [
"hyper",
"hyper-util",
"log",
- "rand 0.9.4",
+ "rand 0.9.5",
"tokio",
"url",
"xmltree",
@@ -1957,8 +2422,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
- "serde",
- "serde_core",
]
[[package]]
@@ -1976,7 +2439,7 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
- "socket2 0.6.4",
+ "socket2 0.6.5",
"widestring",
"windows-registry",
"windows-result 0.4.1",
@@ -2016,7 +2479,7 @@ dependencies = [
"jni-sys 0.4.1",
"log",
"simd_cesu8",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"walkdir",
"windows-link",
]
@@ -2031,7 +2494,7 @@ dependencies = [
"quote",
"rustc_version",
"simd_cesu8",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -2059,28 +2522,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
dependencies = [
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
name = "jobserver"
-version = "0.1.34"
+version = "0.1.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
dependencies = [
- "getrandom 0.3.4",
+ "getrandom 0.4.3",
"libc",
]
[[package]]
name = "js-sys"
-version = "0.3.99"
+version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
- "once_cell",
"wasm-bindgen",
]
@@ -2090,6 +2552,7 @@ version = "0.1.0"
dependencies = [
"agora-core",
"app-bus",
+ "blake3",
"directories",
"khipu-brahman",
"khipu-core",
@@ -2105,11 +2568,13 @@ dependencies = [
"llimphi-widget-menubar",
"llimphi-widget-text-editor",
"llimphi-widget-text-input",
- "llimphi-widget-tiled",
+ "pluma-llm",
"postcard",
"rimay-verbo",
"serde",
"tokio",
+ "willay-checkpoint",
+ "willay-emit",
]
[[package]]
@@ -2121,8 +2586,7 @@ dependencies = [
"futures",
"khipu-share",
"libp2p",
- "libp2p-stream",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tokio-util",
]
@@ -2140,6 +2604,8 @@ version = "0.1.0"
dependencies = [
"khipu-core",
"libm",
+ "rimay-verbo-core",
+ "rimay-verbo-index",
"serde",
]
@@ -2152,10 +2618,10 @@ dependencies = [
"blake3",
"khipu-core",
"postcard",
- "rand 0.8.6",
+ "rand 0.8.7",
"serde",
"serde-big-array",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
@@ -2177,12 +2643,13 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "kurbo"
-version = "0.11.3"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
dependencies = [
"arrayvec",
"euclid",
+ "polycool",
"smallvec",
]
@@ -2192,17 +2659,11 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-[[package]]
-name = "leb128fmt"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
-
[[package]]
name = "libc"
-version = "0.2.186"
+version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libloading"
@@ -2252,7 +2713,7 @@ dependencies = [
"multiaddr",
"pin-project",
"rw-stream-sink",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
]
[[package]]
@@ -2284,9 +2745,9 @@ dependencies = [
"libp2p-swarm",
"quick-protobuf",
"quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
"rand_core 0.6.4",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"web-time",
]
@@ -2319,9 +2780,9 @@ dependencies = [
"parking_lot",
"pin-project",
"quick-protobuf",
- "rand 0.8.6",
+ "rand 0.8.7",
"rw-stream-sink",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"unsigned-varint 0.8.0",
"web-time",
@@ -2344,7 +2805,7 @@ dependencies = [
"libp2p-swarm",
"quick-protobuf",
"quick-protobuf-codec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"web-time",
]
@@ -2382,7 +2843,7 @@ dependencies = [
"quick-protobuf",
"quick-protobuf-codec",
"smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
]
@@ -2397,9 +2858,9 @@ dependencies = [
"hkdf",
"multihash",
"prost",
- "rand 0.8.6",
+ "rand 0.8.7",
"sha2",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"zeroize",
]
@@ -2422,10 +2883,10 @@ dependencies = [
"libp2p-swarm",
"quick-protobuf",
"quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
"sha2",
"smallvec",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"uint",
"web-time",
@@ -2443,7 +2904,7 @@ dependencies = [
"libp2p-core",
"libp2p-identity",
"libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
"smallvec",
"socket2 0.5.10",
"tokio",
@@ -2483,10 +2944,10 @@ dependencies = [
"multiaddr",
"multihash",
"quick-protobuf",
- "rand 0.8.6",
+ "rand 0.8.7",
"snow",
"static_assertions",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"x25519-dalek",
"zeroize",
@@ -2494,9 +2955,9 @@ dependencies = [
[[package]]
name = "libp2p-quic"
-version = "0.13.0"
+version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f"
+checksum = "9dcc597d70bf7f6f30cbe07081802c836184e48416e89e9ce73a0ba2c56a319e"
dependencies = [
"futures",
"futures-timer",
@@ -2505,11 +2966,12 @@ dependencies = [
"libp2p-identity",
"libp2p-tls",
"quinn",
- "rand 0.8.6",
+ "quinn-proto",
+ "rand 0.8.7",
"ring",
"rustls",
"socket2 0.5.10",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tokio",
"tracing",
]
@@ -2531,9 +2993,9 @@ dependencies = [
"libp2p-swarm",
"quick-protobuf",
"quick-protobuf-codec",
- "rand 0.8.6",
+ "rand 0.8.7",
"static_assertions",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"web-time",
]
@@ -2550,7 +3012,7 @@ dependencies = [
"libp2p-core",
"libp2p-identity",
"libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
"smallvec",
"tracing",
]
@@ -2565,7 +3027,7 @@ dependencies = [
"libp2p-core",
"libp2p-identity",
"libp2p-swarm",
- "rand 0.8.6",
+ "rand 0.8.7",
"tracing",
]
@@ -2584,7 +3046,7 @@ dependencies = [
"libp2p-identity",
"libp2p-swarm-derive",
"multistream-select",
- "rand 0.8.6",
+ "rand 0.8.7",
"smallvec",
"tokio",
"tracing",
@@ -2599,7 +3061,7 @@ checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c"
dependencies = [
"heck",
"quote",
- "syn",
+ "syn 2.0.119",
]
[[package]]
@@ -2613,7 +3075,7 @@ dependencies = [
"if-watch",
"libc",
"libp2p-core",
- "socket2 0.6.4",
+ "socket2 0.6.5",
"tokio",
"tracing",
]
@@ -2632,7 +3094,7 @@ dependencies = [
"ring",
"rustls",
"rustls-webpki",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"x509-parser",
"yasna",
]
@@ -2661,7 +3123,7 @@ dependencies = [
"either",
"futures",
"libp2p-core",
- "thiserror 2.0.18",
+ "thiserror 2.0.19",
"tracing",
"yamux 0.12.1",
"yamux 0.13.10",
@@ -2669,14 +3131,14 @@ dependencies = [
[[package]]
name = "libredox"
-version = "0
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.