feat: khipu standalone — notas P2P soberanas content-addressed, local-first (front-door, git-dep al monorepo)
Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por git-dep del monorepo gioser.git. cargo check pasa (5 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semantic delta · 310
00_unanchay/khipu/khipu-app/src/main.rs
- new def CLUSTER_THRESHOLD
- new def EDITOR_OVERLAY_W
- new def EDITOR_VISIBLE_LINES
- new def EMBED_DIM
- new enum Embedder
- new def FIELD_LABEL_SIZE
- new enum Focus
- new def HEADER_H
- new struct KhipuApp
- new def LIST_WIDTH
- new struct Model
- new type Model
- new enum Msg
- new type Msg
- new struct P2p
- new struct PeerInfo
- new struct PersistedState
- new struct PersistedStateV1
- new struct PersistedStateV2
- new def ROW_H
- new struct Region
- new def ZOOM_INJECT
- new fn app_id
- new fn app_menu
- new fn apply_edit_menu_action
- new fn commit_edits
- new fn connect
- new fn current_mass
- new fn data_file_path
- new fn deselect
- new fn embed
- new fn embed_blocking
- new fn embed_now
- new fn first_visible
- new fn focused_edit_flags
- new fn focused_editor
- new fn from_state
- new fn handle_menu_command
- new fn init
- new fn initial_size
- new fn khipu_dir
- new fn label
- new fn load_state
- new fn main
- new mod map
- new fn menubar_spec
- new mod net
- new fn now_secs
- new fn on_key
- new mod panels
- new fn parse_tags
- new fn persist
- new fn reinforce_and_touch
- new fn schedule_embedding
- new fn seeded_model
- new fn select
- new fn start_unlock
- new fn status_bar
- new fn title
- new fn unlock_identity
- new fn unlock_view
- new fn update
- new fn view
- new fn view_overlay
00_unanchay/khipu/khipu-app/src/map.rs
- new def GOLDEN_ANGLE
- new def MAP_MIN_SEP
- new def MapNode
- new def REGION_MATCH_DIST
- new def REGION_MIN_MEMBERS
- new def cluster_color
- new def editor_shell
- new def gravity_panel
- new def local_to_world
- new def name_region_chip
- new def naming_input
- new def node_card
- new def node_screen_pos
- new def overlay_left
- new def overlay_right
- new def paint_map
- new def pick_note
- new def pinned
- new def place_note
- new def rotate_hue
- new def short_label
- new def unnamed_cluster_centroids
- new def with_alpha
- new def world_screen
- new def world_to_local
00_unanchay/khipu/khipu-app/src/net.rs
- new def bind_addr
- new def ensure_p2p
- new def export_notebook
- new def hex8
- new def import_notebook
- new def peer_addr
- new def start_publishing
00_unanchay/khipu/khipu-app/src/panels.rs
- new def body_field_view
- new def button
- new def editor_panel
- new def field
- new def header_view
- new def join_or_dash
- new def list_panel
- new def note_matches
- new def receive_panel
- new def row_label
- new def stats_view
- new def wrap_panel
00_unanchay/khipu/khipu-brahman/src/lib.rs
- new enum BrahmanError pub
- new def KHIPU_DHT_KEY
- new struct KhipuNode pub
- new def MAX_SOBRE
- new def SOBRE_PROTOCOL pub
- new fn add_peer pub
- new fn anunciar pub
- new fn descubrir pub
- new fn dial pub
- new fn dial_str pub
- new fn fetch pub
- new fn fetch_addr_str pub
- new fn fetch_peer_str pub
- new fn listen pub
- new fn listen_str pub
- new fn peer_from_multiaddr
- new fn peer_id pub
- new fn read_frame
- new fn run_serve pub
- new fn sharing pub
- new fn standalone pub
- new fn write_frame
00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
00_unanchay/khipu/khipu-core/src/links.rs
- new fn duplicates_collapse_to_one
- new fn empty_links_are_dropped
- new fn extracts_simple_links
- new fn find_close
- new fn handles_unicode_content_around_links
- new fn parse_links pub
- new mod tests
- new fn trims_inner_whitespace
- new fn unclosed_bracket_is_ignored
- new fn utf8_len
00_unanchay/khipu/khipu-core/src/note.rs
- new struct Note pub
- new type NoteId pub
- new fn default_mass
- new fn has_tag pub
- new fn has_tag_is_case_insensitive
- new fn matches pub
- new fn matches_searches_title_and_body
- new fn note
- new fn outgoing_links pub
- new fn outgoing_links_reads_the_body
- new mod tests
00_unanchay/khipu/khipu-core/src/store.rs
- new struct NoteStore pub
- new fn backlinks pub
- new fn backlinks_find_incoming_references
- new fn by_tag pub
- new fn by_tag_filters
- new fn create pub
- new fn create_assigns_monotonic_ids
- new fn create_initializes_mass_and_last_access
- new fn dangling_links pub
- new fn dangling_links_report_missing_targets
- new fn forward_links pub
- new fn forward_links_resolve_by_title
- new fn get pub
- new fn get_mut pub
- new fn is_empty pub
- new fn iter pub
- new fn len pub
- new fn link_resolution_is_case_insensitive
- new fn new pub
- new fn orphans pub
- new fn orphans_have_no_backlinks
- new fn remove pub
- new fn remove_drops_the_note
- new fn resolve_title pub
- new fn search pub
- new fn search_scans_title_and_body
- new fn seeded
- new fn set_mass pub
- new fn set_mass_persists_the_value
- new fn set_pos pub
- new fn set_pos_anchors_the_note_once
- new mod tests
- new fn touch pub
- new fn touch_refreshes_last_access
- new fn update_body pub
- new fn update_body_also_marks_last_access
- new fn update_body_changes_links_and_timestamp
00_unanchay/khipu/khipu-gravity/src/lib.rs
- new def EPS
- new struct Gravity pub
- new struct GravityConfig pub
- new struct NotePlacement pub
- new struct Params pub
- new struct SemanticField pub
- new fn affinity pub
- new fn affinity_is_high_for_aligned_vectors
- new fn affinity_missing_note_is_none
- new fn clusters pub
- new fn clusters_group_affine_notes
- new fn cosine
- new fn decay pub
- new fn decay_halves_mass_after_one_half_life
- new fn decay_negative_dt_returns_input
- new fn decay_with_zero_dt_is_a_no_op
- new fn default
- new fn default
- new fn empty_and_single_fields_are_handled
- new fn field
- new fn find
- new fn gravity_layout pub
- new fn gravity_layout_is_deterministic
- new fn gravity_layout_places_every_note
- new fn gravity_pulls_affine_notes_closer
- new fn insert pub
- new fn insert_replaces_existing_vector
- new fn is_empty pub
- new fn is_visible pub
- new fn is_visible_uses_horizon
- new fn iter pub
- new fn iter_returns_vectors_in_insertion_order
- new fn len pub
- new fn long_decay_keeps_mass_non_negative
- new fn low_threshold_merges_everything
- new mod mass_tests
- new fn nearest pub
- new fn nearest_ranks_by_affinity
- new fn new pub
- new fn new pub
- new fn reinforce pub
- new fn reinforce_adds_boost
- new fn remove pub
- new fn remove_drops_a_note
- new mod tests
- new fn vector_of
00_unanchay/khipu/khipu-share/src/discovery.rs
- new struct Beacon pub
- new def MAGIA
- new def PUERTO_BALIZA pub
- new struct PeerVisto pub
- new fn anunciar pub
- new fn beacon
- new fn bind_balizas pub
- new fn decode pub
- new fn decode_rejects_non_beacon_traffic
- new fn descubrir pub
- new fn encode pub
- new fn encode_decode_roundtrips
- new fn escuchar_deduplica_balizas_repetidas
- new fn escuchar_en pub
- new fn escuchar_recibe_una_baliza_por_loopback
- new mod tests
00_unanchay/khipu/khipu-share/src/identity.rs
- new enum IdentityError pub
- new fn crear_y_desbloquear_da_la_misma_identidad
- new fn migra_semilla_legacy_en_claro
- new fn passphrase_incorrecta_falla
- new fn temp_dir
- new mod tests
- new fn unlock pub
00_unanchay/khipu/khipu-share/src/lib.rs
- new struct Bundle pub
- new struct ImportOutcome pub
- new enum ShareError pub
- new struct SharedNote pub
- new struct SignedBundle pub
- new fn bytes_roundtrip_preserves_verification
- new fn content_address pub
- new fn content_hash pub
- new fn content_hash_is_stable_and_content_addressed
- new mod discovery pub
- new fn forged_author_is_rejected
- new fn from_bytes pub
- new fn from_note pub
- new fn hex8 pub
- new mod identity pub
- new fn import_creates_fresh_notes_with_local_gravity
- new fn import_into pub
- new fn import_marks_author_provenance
- new mod net pub
- new fn nota
- new fn open pub
- new fn reimport_skips_existing_titles
- new fn seal pub
- new fn seal_then_open_roundtrips
- new fn tag_de pub
- new fn tampering_with_content_is_rejected
- new mod tests
- new fn to_bytes pub
- new fn wiki_links_survive_import_by_title
00_unanchay/khipu/khipu-share/src/net.rs
- new def MAX_SOBRE
- new enum NetError pub
- new fn fetch pub
- new fn fetch_against_nothing_is_an_error_not_a_panic
- new fn fetch_recovers_a_served_bundle_and_verifies
- new fn from
- new fn nota
- new fn read_frame
- new fn serve_loop pub
- new fn serve_loop_serves_the_current_supply_each_time
- new fn serve_once pub
- new mod tests
- new fn write_frame
00_unanchay/khipu/khipu-share/tests/lan_roundtrip.rs
36 files · +13630 −0
- .gitignore +3 −0
- 00_unanchay/khipu/LEEME.md +78 −0
- 00_unanchay/khipu/README.md +30 −0
- 00_unanchay/khipu/README.qu.md +32 −0
- 00_unanchay/khipu/khipu-app/Cargo.toml +41 −0
- 00_unanchay/khipu/khipu-app/LEEME.md +17 −0
- 00_unanchay/khipu/khipu-app/README.md +17 −0
- 00_unanchay/khipu/khipu-app/examples/demo_cli.rs +159 −0
- 00_unanchay/khipu/khipu-app/src/main.rs +1928 −0
- 00_unanchay/khipu/khipu-app/src/map.rs +655 −0
- 00_unanchay/khipu/khipu-app/src/net.rs +226 −0
- 00_unanchay/khipu/khipu-app/src/panels.rs +728 −0
- 00_unanchay/khipu/khipu-brahman/Cargo.toml +22 −0
- 00_unanchay/khipu/khipu-brahman/src/lib.rs +275 −0
- 00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs +193 −0
- 00_unanchay/khipu/khipu-core/Cargo.toml +11 −0
- 00_unanchay/khipu/khipu-core/LEEME.md +21 −0
- 00_unanchay/khipu/khipu-core/README.md +21 −0
- 00_unanchay/khipu/khipu-core/src/lib.rs +23 −0
- 00_unanchay/khipu/khipu-core/src/links.rs +93 −0
- 00_unanchay/khipu/khipu-core/src/note.rs +108 −0
- 00_unanchay/khipu/khipu-core/src/store.rs +348 −0
- 00_unanchay/khipu/khipu-gravity/Cargo.toml +15 −0
- 00_unanchay/khipu/khipu-gravity/LEEME.md +20 −0
- 00_unanchay/khipu/khipu-gravity/README.md +20 −0
- 00_unanchay/khipu/khipu-gravity/src/lib.rs +479 −0
- 00_unanchay/khipu/khipu-share/Cargo.toml +20 −0
- 00_unanchay/khipu/khipu-share/src/discovery.rs +180 −0
- 00_unanchay/khipu/khipu-share/src/identity.rs +125 −0
- 00_unanchay/khipu/khipu-share/src/lib.rs +349 −0
- 00_unanchay/khipu/khipu-share/src/net.rs +155 −0
- 00_unanchay/khipu/khipu-share/tests/lan_roundtrip.rs +75 −0
- Cargo.lock +6692 −0
- Cargo.toml +439 −0
- LICENSE +21 −0
- README.md +11 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/00_unanchay/khipu/LEEME.md b/00_unanchay/khipu/LEEME.md
new file mode 100644
index 0000000..ebc6d31
--- /dev/null
+++ b/00_unanchay/khipu/LEEME.md
@@ -0,0 +1,78 @@
+# khipu
+
+> `khipu` (quechua: nudos de cuerdas para registrar memoria). Notas con gravedad temporal.
+
+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.
+
+## Instalación
+
+```sh
+cargo run --release -p khipu-app
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — UI Llimphi (Wayland/X11/Win32 vía `winit`).
+- Persistencia local en `$XDG_DATA_HOME/khipu/`.
+
+## Crates
+
+| 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. |
+
+## Gravedad semántica (embeddings)
+
+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.
+
+```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.
+
+## Compartir (agora)
+
+`exportar` sella en `compartido.khipu` un sobre firmado Ed25519 con la identidad del cuaderno y direccionado por su hash BLAKE3 de contenido.
+
+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: …»).
+
+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.
+
+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é*.
+
+**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.
+
+**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.
+
+**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`).
+
+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.
+
+## Estado (2026-05-31)
+
+### 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).
+
+### Pendiente
+
+- 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).
+
+## 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.
diff --git a/00_unanchay/khipu/README.md b/00_unanchay/khipu/README.md
new file mode 100644
index 0000000..f85f02e
--- /dev/null
+++ b/00_unanchay/khipu/README.md
@@ -0,0 +1,30 @@
+# khipu
+
+> `khipu` (Quechua: knotted cord recordkeeping). Notes with temporal gravity.
+
+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.
+
+## Install
+
+```sh
+cargo run --release -p khipu-app
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — Llimphi UI (Wayland/X11/Win32 via `winit`).
+- Local persistence in `$XDG_DATA_HOME/khipu/`.
+
+## 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. |
+
+## 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.
diff --git a/00_unanchay/khipu/README.qu.md b/00_unanchay/khipu/README.qu.md
new file mode 100644
index 0000000..0f2f69a
--- /dev/null
+++ b/00_unanchay/khipu/README.qu.md
@@ -0,0 +1,32 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# khipu
+
+> `khipu` (runa-simi: watasqa wask'akuna yuyanapaq). Qillqakuna pacha tinkuywan.
+
+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.
+
+## Churay
+
+```sh
+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.
+
+## 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. |
+
+## 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.
diff --git a/00_unanchay/khipu/khipu-app/Cargo.toml b/00_unanchay/khipu/khipu-app/Cargo.toml
new file mode 100644
index 0000000..7d2580b
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "khipu-app"
+version.workspace = true
+edition.workspace = true
+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/."
+
+[[bin]]
+name = "khipu-app"
+path = "src/main.rs"
+
+[[example]]
+name = "demo_cli"
+path = "examples/demo_cli.rs"
+
+[dependencies]
+khipu-core = { path = "../khipu-core" }
+khipu-gravity = { path = "../khipu-gravity" }
+khipu-share = { path = "../khipu-share" }
+khipu-brahman = { path = "../khipu-brahman" }
+agora-core = { workspace = true }
+rimay-verbo = { 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 }
+llimphi-motion = { workspace = true }
+llimphi-clipboard = { workspace = true }
+app-bus = { workspace = true }
+directories = { workspace = true }
+postcard = { workspace = true }
+serde = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-app/LEEME.md b/00_unanchay/khipu/khipu-app/LEEME.md
new file mode 100644
index 0000000..0cf0ecf
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/LEEME.md
@@ -0,0 +1,17 @@
+# khipu-app
+
+> UI Llimphi sobre el core de [khipu](../README.md). Binario del usuario.
+
+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".
+
+## Uso
+
+```sh
+cargo run --release -p khipu-app
+```
+
+## 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
diff --git a/00_unanchay/khipu/khipu-app/README.md b/00_unanchay/khipu/khipu-app/README.md
new file mode 100644
index 0000000..afb78d4
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/README.md
@@ -0,0 +1,17 @@
+# khipu-app
+
+> Llimphi UI over the core of [khipu](../README.md). 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.
+
+## Usage
+
+```sh
+cargo run --release -p khipu-app
+```
+
+## 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
diff --git a/00_unanchay/khipu/khipu-app/examples/demo_cli.rs b/00_unanchay/khipu/khipu-app/examples/demo_cli.rs
new file mode 100644
index 0000000..553f50a
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/examples/demo_cli.rs
@@ -0,0 +1,159 @@
+//! `demo_cli` — demostración no gráfica del cuaderno.
+//!
+//! Siembra un cuaderno personal, imprime el grafo de wiki-links
+//! (forward-links, backlinks, huérfanas, enlaces colgantes) y luego la
+//! gravedad semántica: los clústeres por afinidad y los vecinos más
+//! cercanos de una nota.
+//!
+//! Los vectores semánticos van a mano —tres tópicos: cocina, jardín,
+//! oficina— para que el clustering se vea con claridad. En la app real
+//! los produce `verbo`. Corre con
+//! `cargo run -p khipu-app --example demo_cli --release`.
+
+use khipu_core::{NoteId, NoteStore};
+use khipu_gravity::{Gravity, GravityConfig, Params, SemanticField};
+
+/// Vector de tópico con un leve sesgo — notas del mismo tema quedan
+/// afines sin ser idénticas.
+fn topic(base: [f32; 3], nudge: f32) -> Vec<f32> {
+ vec![base[0] + nudge, base[1] + nudge * 0.3, base[2] - nudge * 0.2]
+}
+
+fn main() {
+ let cocina = [1.0, 0.0, 0.0];
+ let jardin = [0.0, 1.0, 0.0];
+ let oficina = [0.0, 0.0, 1.0];
+
+ let mut store = NoteStore::new();
+ let mut field = SemanticField::new();
+
+ let seed: [(&str, &str, &[&str], Vec<f32>); 7] = [
+ (
+ "Índice",
+ "mi cuaderno: [[Recetas de la abuela]], [[Jardín]] y [[Oficina]]",
+ &["meta"],
+ topic(cocina, 0.0),
+ ),
+ (
+ "Recetas de la abuela",
+ "sopa de auyama; ver también [[Lista del mercado]]",
+ &["cocina"],
+ topic(cocina, 0.05),
+ ),
+ (
+ "Lista del mercado",
+ "auyama, cilantro, pan; vuelve al [[Índice]]",
+ &["cocina"],
+ topic(cocina, 0.10),
+ ),
+ (
+ "Jardín",
+ "riego semanal; las [[Semillas de cilantro]] van en marzo",
+ &["jardín"],
+ topic(jardin, 0.05),
+ ),
+ (
+ "Semillas de cilantro",
+ "germinan en diez días",
+ &["jardín"],
+ topic(jardin, 0.10),
+ ),
+ (
+ "Oficina",
+ "[[Reunión del lunes]] y pendientes varios",
+ &["trabajo"],
+ topic(oficina, 0.05),
+ ),
+ (
+ "Diario sin enlaces",
+ "una nota suelta, no la enlaza nadie y enlaza a [[Algo Perdido]]",
+ &["personal"],
+ topic(oficina, 0.50),
+ ),
+ ];
+
+ let mut ids: Vec<(NoteId, String)> = Vec::new();
+ for (title, body, tags, vector) in seed {
+ let tags = tags.iter().map(|t| t.to_string()).collect();
+ let id = store.create(title, body, tags, 1_700_000_000);
+ field.insert(id, vector);
+ ids.push((id, title.to_string()));
+ }
+
+ let name = |id: NoteId| {
+ ids.iter()
+ .find(|(i, _)| *i == id)
+ .map(|(_, n)| n.as_str())
+ .unwrap_or("?")
+ };
+
+ println!("\n khipu · cuaderno de notas — {} notas\n", store.len());
+
+ println!(" grafo de enlaces:");
+ for note in store.iter() {
+ let fwd: Vec<&str> = store.forward_links(note.id).into_iter().map(name).collect();
+ let back: Vec<&str> = store.backlinks(note.id).into_iter().map(name).collect();
+ println!(" «{}»", note.title);
+ println!(" enlaza a : {}", fmt_list(&fwd));
+ println!(" backlinks : {}", fmt_list(&back));
+ }
+
+ let orphans: Vec<&str> = store.orphans().iter().map(|n| n.title.as_str()).collect();
+ println!("\n notas huérfanas (sin backlinks): {}", fmt_list(&orphans));
+ let dangling_owned = store.dangling_links();
+ let dangling: Vec<&str> = dangling_owned.iter().map(|s| s.as_str()).collect();
+ println!(" enlaces colgantes (destino inexistente): {}", fmt_list(&dangling));
+
+ println!("\n gravedad semántica — clústeres (afinidad ≥ 0.85):");
+ for (n, cluster) in field.clusters(0.85).iter().enumerate() {
+ let titles: Vec<&str> = cluster.iter().map(|id| name(*id)).collect();
+ println!(" grupo {}: {}", n + 1, fmt_list(&titles));
+ }
+
+ let pivot = ids[1].0; // "Recetas de la abuela"
+ println!("\n vecinos más afines a «{}»:", name(pivot));
+ for (id, score) in field.nearest(pivot, 3) {
+ println!(" {:.3} {}", score, name(id));
+ }
+
+ let layout = field.gravity_layout(&GravityConfig::default());
+ println!("\n layout 2D por gravedad ({} posiciones):", layout.len());
+ for p in &layout {
+ println!(" ({:7.1}, {:7.1}) {}", p.x, p.y, name(p.id));
+ }
+
+ println!("\n masa temporal — vida media 7 días, boost 0.4, horizonte 0.10:");
+ let g = Gravity::new(Params::default());
+ let pivot_b = ids[4].0; // "Semillas de cilantro"
+ println!(" «{}» — escenario:", name(pivot_b));
+ let mut mass = 1.0_f32;
+ println!(" inicial mass = {:.3}", mass);
+ mass = g.decay(mass, 3.0 * 24.0 * 3600.0);
+ println!(
+ " 3 días sin acceso → decay mass = {:.3} ({})",
+ mass,
+ if g.is_visible(mass) { "visible" } else { "archivo" }
+ );
+ mass = g.decay(mass, 14.0 * 24.0 * 3600.0);
+ println!(
+ " +14 días sin acceso → decay mass = {:.3} ({})",
+ mass,
+ if g.is_visible(mass) { "visible" } else { "archivo" }
+ );
+ mass = g.reinforce(mass);
+ println!(
+ " el usuario abre la nota → reinforce mass = {:.3} ({})",
+ mass,
+ if g.is_visible(mass) { "visible" } else { "archivo" }
+ );
+ println!();
+}
+
+/// Formatea una lista de nombres, o `—` si está vacía.
+fn fmt_list(items: &[&str]) -> String {
+ if items.is_empty() {
+ "—".to_string()
+ } else {
+ items.join(", ")
+ }
+}
diff --git a/00_unanchay/khipu/khipu-app/src/main.rs b/00_unanchay/khipu/khipu-app/src/main.rs
new file mode 100644
index 0000000..19366fd
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/main.rs
@@ -0,0 +1,1928 @@
+//! `khipu-app` — cuaderno de notas 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.
+//!
+//! **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.
+//!
+//! **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;
+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"
+ };
+
+ 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)
+}
+
+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
new file mode 100644
index 0000000..a378c83
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/map.rs
@@ -0,0 +1,655 @@
+//! `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/net.rs b/00_unanchay/khipu/khipu-app/src/net.rs
new file mode 100644
index 0000000..6213c1c
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/net.rs
@@ -0,0 +1,226 @@
+//! `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.
+
+use std::sync::Arc;
+
+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};
+
+pub(crate) fn export_notebook(model: &Model) -> String {
+ let Some(kp) = model.keypair.as_ref() else {
+ return "sin identidad para firmar".into();
+ };
+ let Some(dir) = khipu_dir() else {
+ return "sin directorio de datos".into();
+ };
+ // Compartir selectivo: si hay texto en el buscador, exportamos sólo
+ // las notas que filtra (mismo criterio que la lista); si está vacío,
+ // todo el cuaderno.
+ let query = model.search.text();
+ let q = query.trim();
+ let notes: Vec<SharedNote> = model
+ .order
+ .iter()
+ .filter_map(|id| model.store.get(*id))
+ .filter(|n| q.is_empty() || note_matches(n, q))
+ .map(SharedNote::from_note)
+ .collect();
+ if notes.is_empty() {
+ return "no hay notas para exportar (¿el filtro no coincide?)".into();
+ }
+ let n = notes.len();
+ let sobre = match khipu_share::seal(kp, notes, now_secs()) {
+ Ok(s) => s,
+ Err(_) => return "falló el sellado".into(),
+ };
+ let Ok(bytes) = sobre.to_bytes() else {
+ return "falló serializar el sobre".into();
+ };
+ let path = dir.join("compartido.khipu");
+ let tmp = path.with_extension("khipu.tmp");
+ if std::fs::write(&tmp, &bytes)
+ .and_then(|_| std::fs::rename(&tmp, &path))
+ .is_err()
+ {
+ return "no se pudo escribir el sobre".into();
+ }
+ let hash = sobre.content_address().unwrap_or([0u8; 32]);
+ let filtro = if q.is_empty() {
+ String::new()
+ } else {
+ format!(" (filtro «{q}»)")
+ };
+ format!(
+ "exportadas {n} notas{filtro} → compartido.khipu · {}",
+ hex8(&hash)
+ )
+}
+
+/// Verifica e ingiere `compartido.khipu`. Las notas nuevas nacen con
+/// gravedad fresca; sus embeddings se recalculan en segundo plano. Un
+/// sobre con firma inválida se rechaza entero. Devuelve la línea de estado.
+pub(crate) fn import_notebook(model: &mut Model, h: &Handle<Msg>) -> String {
+ let Some(dir) = khipu_dir() else {
+ return "sin directorio de datos".into();
+ };
+ let path = dir.join("compartido.khipu");
+ let Ok(bytes) = std::fs::read(&path) else {
+ return "no hay compartido.khipu para importar".into();
+ };
+ let sobre = match SignedBundle::from_bytes(&bytes) {
+ Ok(s) => s,
+ Err(_) => return "sobre ilegible".into(),
+ };
+ let outcome = match khipu_share::open(&sobre) {
+ Ok(bundle) => khipu_share::import_into(&mut model.store, bundle, now_secs()),
+ Err(_) => return "firma inválida — sobre rechazado".into(),
+ };
+ for id in &outcome.created {
+ model.order.push(*id);
+ schedule_embedding(model, *id, h);
+ }
+ format!(
+ "importadas {} · omitidas {} (ya existían)",
+ outcome.created.len(),
+ outcome.skipped
+ )
+}
+
+/// Dirección donde el servidor escucha. `KHIPU_BIND` la sobrescribe;
+/// default localhost para no exponerse sin querer.
+pub(crate) fn bind_addr() -> String {
+ std::env::var("KHIPU_BIND").unwrap_or_else(|_| "127.0.0.1:7700".into())
+}
+
+/// Dirección del par a quien jalarle el cuaderno. `KHIPU_PEER` la
+/// sobrescribe; default coincide con [`bind_addr`] para probar en local.
+pub(crate) fn peer_addr() -> String {
+ std::env::var("KHIPU_PEER").unwrap_or_else(|_| "127.0.0.1:7700".into())
+}
+
+/// Arma el nodo libp2p la primera vez que se necesita: runtime tokio
+/// dedicado + `KhipuNode` que empieza a escuchar (para ser alcanzable y
+/// obtener nuestra dirección de marcado). Idempotente. `false` si no se
+/// pudo (sin runtime o sin red).
+pub(crate) fn ensure_p2p(model: &mut Model) -> bool {
+ if model.p2p.is_some() {
+ return true;
+ }
+ let Ok(rt) = tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(2)
+ .enable_all()
+ .build()
+ else {
+ return false;
+ };
+ // `KhipuNode::standalone` arranca el swarm con `tokio::spawn`: hay que
+ // estar dentro del runtime.
+ let node = {
+ let _g = rt.enter();
+ match khipu_brahman::KhipuNode::standalone() {
+ Ok(n) => Arc::new(n),
+ Err(_) => return false,
+ }
+ };
+ let dial_addr = rt
+ .block_on(node.listen_str("/ip4/0.0.0.0/tcp/0"))
+ .unwrap_or_default();
+ // Si hay un nodo bootstrap configurado, nos unimos a la malla DHT para
+ // poder descubrir y ser descubiertos (`anunciar`/`descubrir`).
+ if let Ok(boot) = std::env::var("KHIPU_BOOTSTRAP") {
+ let _ = node.dial_str(&boot);
+ }
+ model.p2p = Some(P2p {
+ rt: Arc::new(rt),
+ node,
+ dial_addr,
+ serving: false,
+ });
+ true
+}
+
+/// Levanta (una sola vez) el servidor TCP que sirve `compartido.khipu`.
+/// El hilo lee el archivo en cada conexión, así sirve siempre la versión
+/// vigente; vive hasta que el proceso termina. Devuelve la línea de estado.
+pub(crate) fn start_publishing(model: &mut Model, h: &Handle<Msg>) -> String {
+ if model.publishing {
+ return format!("ya publicando en {}", bind_addr());
+ }
+ let Some(dir) = khipu_dir() else {
+ return "sin directorio de datos".into();
+ };
+ let addr = bind_addr();
+ let listener = match std::net::TcpListener::bind(&addr) {
+ Ok(l) => l,
+ Err(e) => return format!("no se pudo escuchar en {addr}: {e}"),
+ };
+ // Puerto efectivo (resuelve `:0` si se usara) para anunciarlo en la baliza.
+ let tcp_port = listener.local_addr().map(|a| a.port()).unwrap_or(0);
+ let path = dir.join("compartido.khipu");
+ std::thread::spawn(move || {
+ khipu_share::net::serve_loop(listener, move || std::fs::read(&path));
+ });
+ // Baliza periódica para que los pares nos descubran sin saber la IP.
+ let beacon = khipu_share::discovery::Beacon {
+ author: model.keypair.as_ref().map(|k| k.public_key()).unwrap_or([0u8; 32]),
+ port: tcp_port,
+ name: "khipu".into(),
+ };
+ std::thread::spawn(move || loop {
+ let _ = khipu_share::discovery::anunciar(&beacon);
+ std::thread::sleep(std::time::Duration::from_secs(2));
+ });
+ model.publishing = true;
+
+ // Además del TCP/LAN, servimos por libp2p (cifrado, WAN). El nodo se
+ // arma perezoso; servimos `compartido.khipu` y nos anunciamos en la DHT.
+ let p2p_status = if ensure_p2p(model) {
+ let dir2 = dir.clone();
+ if let Some(p) = model.p2p.as_mut() {
+ if !p.serving {
+ let path2 = dir2.join("compartido.khipu");
+ let node = p.node.clone();
+ let _g = p.rt.enter();
+ node.run_serve(move || std::fs::read(&path2).ok());
+ node.anunciar();
+ p.serving = true;
+ }
+ // Si hay un relay configurado (KHIPU_RELAY=/ip4/.../p2p/<id>),
+ // reservamos un circuito ahí para ser alcanzables detrás de NAT.
+ // Async (dial + identify + reserva tardan ~2s): cuando termina,
+ // reentra con Msg::RelayReady para mostrar la dirección.
+ if let Ok(relay) = std::env::var("KHIPU_RELAY") {
+ let (rt, node, h2) = (p.rt.clone(), p.node.clone(), h.clone());
+ rt.spawn(async move {
+ let _ = node.dial_str(&relay);
+ // Esperamos a que AutoNAT confirme la dirección del relay
+ // (boot_delay + dial-back) antes de pedir la reserva.
+ tokio::time::sleep(std::time::Duration::from_secs(6)).await;
+ let circuit = format!("{relay}/p2p-circuit");
+ let msg = match node.listen_str(&circuit).await {
+ Ok(addr) => addr,
+ Err(e) => format!("falló reservar circuito: {e}"),
+ };
+ h2.dispatch(Msg::RelayReady(msg));
+ });
+ }
+ format!(" · libp2p: {}", p.dial_addr)
+ } else {
+ String::new()
+ }
+ } else {
+ String::new()
+ };
+ format!("publicando en {addr} (LAN){p2p_status}")
+}
+
+/// Prefijo hex (4 bytes / 8 hex) de un hash, para mostrar una dirección
+/// de contenido sin abrumar.
+pub(crate) fn hex8(hash: &[u8; 32]) -> String {
+ hash[..4].iter().map(|b| format!("{b:02x}")).collect()
+}
diff --git a/00_unanchay/khipu/khipu-app/src/panels.rs b/00_unanchay/khipu/khipu-app/src/panels.rs
new file mode 100644
index 0000000..9713866
--- /dev/null
+++ b/00_unanchay/khipu/khipu-app/src/panels.rs
@@ -0,0 +1,728 @@
+//! `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`.
+
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, FlexDirection, Rect, Size, Style},
+ AlignItems, Dimension, JustifyContent,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+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 crate::{
+ current_mass, now_secs, Focus, Model, Msg, EDITOR_VISIBLE_LINES, FIELD_LABEL_SIZE, HEADER_H,
+ LIST_WIDTH, ROW_H,
+};
+
+pub(crate) fn header_view(model: &Model) -> View<Msg> {
+ let title = format!("khipu · {} notas", model.store.len());
+ let title_node = View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: percent(1.0_f32),
+ },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(14.0_f32),
+ right: length(8.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .text_aligned(title, 14.0, model.theme.fg_text, Alignment::Start);
+
+ let list_label = if model.show_list { "ocultar notas" } else { "☰ notas" };
+ 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,
+ model.theme.fg_muted,
+ Msg::Export,
+ );
+ let import_btn = button(
+ "importar",
+ model.theme.bg_button,
+ model.theme.fg_muted,
+ Msg::Import,
+ );
+ let publish_label = if model.publishing {
+ "publicando"
+ } else {
+ "publicar"
+ };
+ let publish_btn = button(
+ publish_label,
+ model.theme.bg_button,
+ if model.publishing {
+ model.theme.accent
+ } else {
+ model.theme.fg_muted
+ },
+ Msg::Publish,
+ );
+ let receive_btn = button(
+ "recibir",
+ model.theme.bg_button,
+ model.theme.fg_muted,
+ Msg::Receive,
+ );
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(HEADER_H),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(0.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(8.0_f32),
+ height: length(0.0_f32),
+ },
+ ..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,
+ ])
+}
+
+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.
+ let chars = label.chars().count() as f32;
+ let width = (chars * 7.2 + 22.0).max(86.0);
+ View::new(Style {
+ size: Size {
+ width: length(width),
+ height: length(26.0_f32),
+ },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ justify_content: Some(JustifyContent::Center),
+ ..Default::default()
+ })
+ .fill(bg)
+ .radius(4.0)
+ .text_aligned(label.to_string(), 11.0, fg, Alignment::Center)
+ .on_click(msg)
+}
+
+pub(crate) fn list_panel(
+ model: &Model,
+ palette: &ListPalette,
+ input_palette: &TextInputPalette,
+) -> View<Msg> {
+ let now = now_secs();
+ 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();
+ let mut hidden_by_query = 0usize;
+ for id in &model.order {
+ let Some(n) = model.store.get(*id) else {
+ continue;
+ };
+ if !q.is_empty() && !note_matches(n, q) {
+ hidden_by_query += 1;
+ continue;
+ }
+ let m = current_mass(&model.gravity, n, now);
+ if model.gravity.is_visible(m) {
+ visible.push((*id, m, n));
+ } else {
+ archive.push((*id, m, 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))
+ };
+ visible.sort_by(by_mass_desc);
+ archive.sort_by(by_mass_desc);
+
+ 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),
+ })
+ .collect();
+
+ let caption = if !q.is_empty() {
+ 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()
+ )
+ } else {
+ format!(
+ "notas · {} horizonte (+{} archivo)",
+ visible.len(),
+ archive.len()
+ )
+ };
+
+ let spec = ListSpec {
+ total: rows.len(),
+ rows,
+ caption: Some(caption),
+ truncated_hint: None,
+ row_height: ROW_H,
+ palette: *palette,
+ };
+
+ let search_input = text_input_view(
+ &model.search,
+ "buscar (título, cuerpo, etiquetas)",
+ model.focus == Focus::Search,
+ input_palette,
+ Msg::Focus(Focus::Search),
+ );
+ let search_row = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(28.0_f32),
+ },
+ flex_shrink: 0.0,
+ 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_alt)
+ .children(vec![search_input]);
+
+ let list_wrap = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ flex_basis: length(0.0_f32),
+ min_size: Size {
+ width: length(0.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![list_view(spec)]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(LIST_WIDTH),
+ height: percent(1.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![search_row, list_wrap])
+}
+
+/// 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.
+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(
+ &model.peer_input,
+ "host:puerto o /ip4/…/p2p/…",
+ model.focus == Focus::PeerAddr,
+ input_palette,
+ Msg::Focus(Focus::PeerAddr),
+ );
+ let addr_wrap = View::new(Style {
+ size: Size {
+ width: Dimension::auto(),
+ height: length(26.0_f32),
+ },
+ flex_grow: 1.0,
+ ..Default::default()
+ })
+ .children(vec![addr_input]);
+ let jalar = button(
+ "jalar",
+ model.theme.bg_button,
+ model.theme.accent,
+ Msg::FetchManual,
+ );
+ let addr_row = 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(6.0_f32),
+ right: length(6.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_alt)
+ .children(vec![addr_wrap, jalar]);
+
+ let cancel = button(
+ "cancelar",
+ model.theme.bg_button,
+ model.theme.fg_muted,
+ Msg::CancelPeers,
+ );
+ let cancel_row = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(34.0_f32),
+ },
+ flex_shrink: 0.0,
+ padding: Rect {
+ left: length(6.0_f32),
+ right: length(6.0_f32),
+ top: length(2.0_f32),
+ bottom: length(4.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel_alt)
+ .children(vec![cancel]);
+
+ let rows: Vec<ListRow<Msg>> = model
+ .peers
+ .iter()
+ .map(|p| ListRow {
+ label: p.label.clone(),
+ selected: false,
+ on_click: Msg::FetchFrom(p.addr.clone()),
+ })
+ .collect();
+ let caption = if model.peers.is_empty() {
+ "pares en la LAN: ninguno aún".to_string()
+ } else {
+ format!("pares en la LAN · {} (click para jalar)", model.peers.len())
+ };
+ let spec = ListSpec {
+ total: rows.len(),
+ rows,
+ caption: Some(caption),
+ truncated_hint: None,
+ row_height: ROW_H,
+ palette: *palette,
+ };
+ let list_wrap = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ flex_basis: length(0.0_f32),
+ min_size: Size {
+ width: length(0.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![list_view(spec)]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: length(LIST_WIDTH),
+ height: percent(1.0_f32),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(vec![addr_row, cancel_row, list_wrap])
+}
+
+/// Coincidencia sobre título, cuerpo y etiquetas. Case-insensitive.
+pub(crate) fn note_matches(n: &Note, query: &str) -> bool {
+ if n.matches(query) {
+ return true;
+ }
+ let q = query.to_lowercase();
+ n.tags.iter().any(|t| t.to_lowercase().contains(&q))
+}
+
+pub(crate) fn row_label(n: &Note, mass: f32) -> String {
+ 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}")
+}
+
+pub(crate) fn editor_panel(
+ model: &Model,
+ input_palette: &TextInputPalette,
+ editor_palette: &EditorPalette,
+) -> View<Msg> {
+ let none_view = || -> 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(model.theme.bg_panel)
+ .text_aligned(
+ "selecciona o crea una nota".to_string(),
+ 12.0,
+ model.theme.fg_muted,
+ Alignment::Center,
+ )
+ };
+
+ if model.selected.is_none() {
+ return wrap_panel(model, none_view());
+ }
+
+ let metrics = EditorMetrics::for_font_size(13.0);
+
+ let title_field = field(
+ model,
+ "título",
+ text_input_view(
+ &model.title,
+ "(sin título)",
+ model.focus == Focus::Title,
+ input_palette,
+ Msg::Focus(Focus::Title),
+ ),
+ );
+
+ let body_input = text_editor_view(
+ &model.body,
+ editor_palette,
+ metrics,
+ EDITOR_VISIBLE_LINES,
+ |ev| Some(Msg::EditorPointer(ev)),
+ );
+ let body_field = body_field_view(model, body_input);
+
+ let tags_field = field(
+ model,
+ "etiquetas (coma separadas)",
+ text_input_view(
+ &model.tags,
+ "p. ej. cocina, jardín",
+ model.focus == Focus::Tags,
+ input_palette,
+ Msg::Focus(Focus::Tags),
+ ),
+ );
+
+ let stats = stats_view(model);
+
+ let column = View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ 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(8.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(model.theme.bg_panel)
+ .children(vec![title_field, body_field, tags_field, stats]);
+
+ wrap_panel(model, column)
+}
+
+pub(crate) fn wrap_panel(_model: &Model, child: View<Msg>) -> View<Msg> {
+ 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),
+ },
+ ..Default::default()
+ })
+ .children(vec![child])
+}
+
+pub(crate) fn field(model: &Model, label: &str, control: View<Msg>) -> View<Msg> {
+ let label_node = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(14.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ label.to_string(),
+ FIELD_LABEL_SIZE,
+ model.theme.fg_muted,
+ Alignment::Start,
+ );
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_shrink: 0.0,
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![label_node, control])
+}
+
+pub(crate) fn body_field_view(model: &Model, editor: View<Msg>) -> View<Msg> {
+ let label_node = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(14.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ "cuerpo (wiki-links con [[Título]])".to_string(),
+ FIELD_LABEL_SIZE,
+ model.theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let focused = model.focus == Focus::Body;
+ let border = if focused {
+ model.theme.border_focus
+ } else {
+ model.theme.border
+ };
+
+ let editor_wrap = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_grow: 1.0,
+ padding: Rect {
+ left: length(1.0_f32),
+ right: length(1.0_f32),
+ top: length(1.0_f32),
+ bottom: length(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(border)
+ .radius(4.0)
+ .on_click(Msg::Focus(Focus::Body))
+ .children(vec![editor]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ 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),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![label_node, editor_wrap])
+}
+
+pub(crate) fn stats_view(model: &Model) -> View<Msg> {
+ let Some(id) = model.selected else {
+ return View::new(Style::default());
+ };
+ let fwd = model.store.forward_links(id);
+ let back = model.store.backlinks(id);
+ let fwd_titles: Vec<String> = fwd
+ .iter()
+ .filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
+ .collect();
+ let back_titles: Vec<String> = back
+ .iter()
+ .filter_map(|i| model.store.get(*i).map(|n| n.title.clone()))
+ .collect();
+ let nearest: Vec<String> = model
+ .field
+ .nearest(id, 3)
+ .into_iter()
+ .filter_map(|(nid, score)| {
+ model
+ .store
+ .get(nid)
+ .map(|n| format!("{} ({:.2})", 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)),
+ ];
+ // Procedencia: si la nota llegó por compartir, lleva una etiqueta
+ // `de:<autor>`. La mostramos explícita.
+ if let Some(n) = model.store.get(id) {
+ let autores: Vec<&str> = n
+ .tags
+ .iter()
+ .filter_map(|t| t.strip_prefix("de:"))
+ .collect();
+ if !autores.is_empty() {
+ lines.push(format!("✎ de: {}", autores.join(", ")));
+ }
+ }
+
+ let nodes: Vec<View<Msg>> = lines
+ .into_iter()
+ .map(|s| {
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(16.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(s, 11.0, model.theme.fg_muted, Alignment::Start)
+ })
+ .collect();
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: Dimension::auto(),
+ },
+ flex_shrink: 0.0,
+ ..Default::default()
+ })
+ .children(nodes)
+}
+
+pub(crate) fn join_or_dash(items: &[String]) -> String {
+ if items.is_empty() {
+ "—".to_string()
+ } else {
+ items.join(", ")
+ }
+}
diff --git a/00_unanchay/khipu/khipu-brahman/Cargo.toml b/00_unanchay/khipu/khipu-brahman/Cargo.toml
new file mode 100644
index 0000000..4f085c3
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/Cargo.toml
@@ -0,0 +1,22 @@
+[package]
+name = "khipu-brahman"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu-brahman — transporte de sobres khipu sobre libp2p (BrahmanNet): stream cifrado /khipu/sobre/1.0.0 + descubrimiento por DHT. El sobre se verifica con khipu-share::open; el transporte no necesita ser confiable."
+
+[dependencies]
+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 }
+thiserror = { workspace = true }
+
+[dev-dependencies]
+agora-core = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-brahman/src/lib.rs b/00_unanchay/khipu/khipu-brahman/src/lib.rs
new file mode 100644
index 0000000..922c931
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/src/lib.rs
@@ -0,0 +1,275 @@
+//! `khipu-brahman` — 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`.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use card_net::{BrahmanNet, Multiaddr, PeerId, Protocol};
+use futures::StreamExt;
+use khipu_share::SignedBundle;
+use libp2p::StreamProtocol;
+use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
+use tokio_util::compat::FuturesAsyncReadCompatExt;
+
+/// Protocolo de stream libp2p para transferir un sobre khipu.
+pub const SOBRE_PROTOCOL: StreamProtocol = StreamProtocol::new("/khipu/sobre/1.0.0");
+
+/// Clave DHT bajo la que los cuadernos khipu se anuncian y se descubren.
+const KHIPU_DHT_KEY: &[u8] = b"khipu/sobre/1.0.0";
+
+/// Tope defensivo de un sobre entrante (64 MiB), igual que el transporte LAN.
+const MAX_SOBRE: u32 = 64 * 1024 * 1024;
+
+/// Falla del transporte libp2p de sobres.
+#[derive(Debug, thiserror::Error)]
+pub enum BrahmanError {
+ #[error("nodo libp2p: {0}")]
+ Nodo(String),
+ #[error("abrir stream: {0}")]
+ Stream(String),
+ #[error("marco inválido: {0}")]
+ Marco(String),
+ #[error("io de red: {0}")]
+ Io(String),
+ #[error("sobre ilegible: {0}")]
+ Sobre(String),
+}
+
+/// Un cuaderno khipu en la red P2P. Envoltura fina sobre [`BrahmanNet`]
+/// que habla el protocolo de sobres.
+pub struct KhipuNode {
+ net: Arc<BrahmanNet>,
+}
+
+impl KhipuNode {
+ /// Crea un nodo con su propio [`BrahmanNet`] (keypair efímera). Debe
+ /// llamarse dentro de un runtime de tokio — el swarm corre en una task.
+ pub fn standalone() -> Result<Self, BrahmanError> {
+ let net = BrahmanNet::new().map_err(|e| BrahmanError::Nodo(e.to_string()))?;
+ Ok(Self { net: Arc::new(net) })
+ }
+
+ /// Comparte un [`BrahmanNet`] ya existente (p. ej. el mismo nodo que
+ /// usan agora/minga), registrando el protocolo de sobres encima.
+ pub fn sharing(net: Arc<BrahmanNet>) -> Self {
+ Self { net }
+ }
+
+ /// Identidad de red de este nodo.
+ pub fn peer_id(&self) -> PeerId {
+ self.net.peer_id
+ }
+
+ /// Empieza a escuchar en `addr`; devuelve la dirección efectiva.
+ pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
+ self.net.listen(addr).await
+ }
+
+ /// Escucha en una multiaddr dada como texto y devuelve la **dirección
+ /// para compartir** ya con `/p2p/<peer-id>` — lista para pegarle a un
+ /// par. Pensada para callers que no quieren tocar tipos de libp2p.
+ pub async fn listen_str(&self, addr: &str) -> Result<String, BrahmanError> {
+ let m: Multiaddr = addr
+ .parse()
+ .map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
+ let bound = self.net.listen(m).await;
+ let s = bound.to_string();
+ // Una reserva de circuito ya viene con `…/p2p-circuit/p2p/<self>`:
+ // no le agregamos otro `/p2p/`. Un tcp pelado no trae peer-id, así
+ // que sí se lo anexamos para que sea una dirección de marcado.
+ if s.contains("/p2p/") {
+ Ok(s)
+ } else {
+ Ok(format!("{s}/p2p/{}", self.net.peer_id))
+ }
+ }
+
+ /// Marca a un par para conectarse.
+ pub fn dial(&self, addr: Multiaddr) {
+ self.net.dial(addr);
+ }
+
+ /// Marca a un par dado como texto (multiaddr).
+ pub fn dial_str(&self, addr: &str) -> Result<(), BrahmanError> {
+ let m: Multiaddr = addr
+ .parse()
+ .map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
+ self.net.dial(m);
+ Ok(())
+ }
+
+ /// Siembra la tabla DHT con un par conocido (bootstrap para descubrir).
+ pub fn add_peer(&self, peer: PeerId, addr: Multiaddr) {
+ self.net.add_dht_peer(peer, addr);
+ }
+
+ /// Anuncia que este nodo sirve un cuaderno khipu (clave DHT fija).
+ pub fn anunciar(&self) {
+ self.net.start_providing(KHIPU_DHT_KEY);
+ }
+
+ /// Descubre nodos khipu por DHT (sin incluirse a sí mismo).
+ pub async fn descubrir(&self) -> Vec<PeerId> {
+ let mut peers = self.net.find_providers(KHIPU_DHT_KEY).await;
+ let me = self.net.peer_id;
+ peers.retain(|p| *p != me);
+ peers
+ }
+
+ /// Sirve sobres: por cada stream entrante llama a `supply` y manda lo
+ /// que devuelva (típicamente leer `compartido.khipu`). `None` ⇒ no hay
+ /// sobre, cierra el stream. Bloqueante: corre en su propia task hasta
+ /// el shutdown del nodo.
+ pub fn run_serve<F>(&self, supply: F) -> tokio::task::JoinHandle<()>
+ where
+ F: Fn() -> Option<Vec<u8>> + Send + Sync + 'static,
+ {
+ let mut control = self.net.control.clone();
+ tokio::spawn(async move {
+ let mut incoming = match control.accept(SOBRE_PROTOCOL) {
+ Ok(i) => i,
+ Err(_) => return, // ya hay un accept para este protocolo
+ };
+ let supply = Arc::new(supply);
+ while let Some((_peer, stream)) = incoming.next().await {
+ let supply = Arc::clone(&supply);
+ tokio::spawn(async move {
+ if let Some(bytes) = supply() {
+ let mut compat = stream.compat();
+ let _ = write_frame(&mut compat, &bytes).await;
+ }
+ });
+ }
+ })
+ }
+
+ /// Jala el sobre de un par dado por su **dirección de marcado** como
+ /// texto (`/ip4/.../tcp/.../p2p/<peer-id>`): extrae el peer-id, dial-ea
+ /// y reintenta el fetch unos segundos mientras se establece la
+ /// conexión. La forma que usa la app.
+ pub async fn fetch_addr_str(&self, addr: &str) -> Result<SignedBundle, BrahmanError> {
+ let m: Multiaddr = addr
+ .parse()
+ .map_err(|e| BrahmanError::Nodo(format!("multiaddr inválida: {e}")))?;
+ let peer = peer_from_multiaddr(&m)
+ .ok_or_else(|| BrahmanError::Nodo("la multiaddr no trae /p2p/<peer-id>".into()))?;
+ self.net.dial(m);
+ let mut intentos = 0u32;
+ loop {
+ match self.fetch(peer).await {
+ Ok(s) => return Ok(s),
+ Err(_) if intentos < 60 => {
+ intentos += 1;
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ }
+
+ /// Jala el sobre de un par dado por su **peer-id** como texto (la forma
+ /// que devuelve [`descubrir`](Self::descubrir)). Reintenta mientras el
+ /// swarm establece la conexión usando las direcciones que aprendió por
+ /// la DHT/identify. La app la usa para jalar de un par descubierto.
+ pub async fn fetch_peer_str(&self, peer: &str) -> Result<SignedBundle, BrahmanError> {
+ let pid: PeerId = peer
+ .parse()
+ .map_err(|e| BrahmanError::Nodo(format!("peer-id inválido: {e}")))?;
+ let mut intentos = 0u32;
+ loop {
+ match self.fetch(pid).await {
+ Ok(s) => return Ok(s),
+ Err(_) if intentos < 60 => {
+ intentos += 1;
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ Err(e) => return Err(e),
+ }
+ }
+ }
+
+ /// Abre un stream a `peer` y jala su sobre. **No lo verifica** — el
+ /// caller debe pasarlo por [`khipu_share::open`] antes de confiar.
+ pub async fn fetch(&self, peer: PeerId) -> Result<SignedBundle, BrahmanError> {
+ let mut control = self.net.control.clone();
+ let stream = control
+ .open_stream(peer, SOBRE_PROTOCOL)
+ .await
+ .map_err(|e| BrahmanError::Stream(e.to_string()))?;
+ let mut compat = stream.compat();
+ let bytes = read_frame(&mut compat).await?;
+ SignedBundle::from_bytes(&bytes).map_err(|e| BrahmanError::Sobre(format!("{e:?}")))
+ }
+}
+
+/// Extrae el `PeerId` **destino** de una multiaddr: el ÚLTIMO componente
+/// `/p2p/<id>`. En una directa (`…/tcp/P/p2p/<peer>`) es el único; en un
+/// circuito (`…/p2p/<relay>/p2p-circuit/p2p/<destino>`) es el de después
+/// del relay, no el relay.
+fn peer_from_multiaddr(addr: &Multiaddr) -> Option<PeerId> {
+ addr.iter()
+ .filter_map(|p| match p {
+ Protocol::P2p(id) => Some(id),
+ _ => None,
+ })
+ .last()
+}
+
+async fn write_frame<S>(stream: &mut S, payload: &[u8]) -> Result<(), BrahmanError>
+where
+ S: AsyncWrite + Unpin,
+{
+ if payload.len() as u64 > MAX_SOBRE as u64 {
+ return Err(BrahmanError::Marco("sobre demasiado grande".into()));
+ }
+ stream
+ .write_all(&(payload.len() as u32).to_be_bytes())
+ .await
+ .map_err(|e| BrahmanError::Io(e.to_string()))?;
+ stream
+ .write_all(payload)
+ .await
+ .map_err(|e| BrahmanError::Io(e.to_string()))?;
+ stream.flush().await.map_err(|e| BrahmanError::Io(e.to_string()))?;
+ Ok(())
+}
+
+async fn read_frame<S>(stream: &mut S) -> Result<Vec<u8>, BrahmanError>
+where
+ S: AsyncRead + Unpin,
+{
+ let mut len_buf = [0u8; 4];
+ stream
+ .read_exact(&mut len_buf)
+ .await
+ .map_err(|e| BrahmanError::Io(e.to_string()))?;
+ let len = u32::from_be_bytes(len_buf);
+ if len > MAX_SOBRE {
+ return Err(BrahmanError::Marco(format!("largo {len} excede el tope")));
+ }
+ let mut buf = vec![0u8; len as usize];
+ stream
+ .read_exact(&mut buf)
+ .await
+ .map_err(|e| BrahmanError::Io(e.to_string()))?;
+ Ok(buf)
+}
diff --git a/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs b/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
new file mode 100644
index 0000000..fed520f
--- /dev/null
+++ b/00_unanchay/khipu/khipu-brahman/tests/p2p_roundtrip.rs
@@ -0,0 +1,193 @@
+//! Integración: transferir un sobre khipu entre dos nodos libp2p reales en
+//! localhost. Espeja el molde de `minga-p2p`/`agora-net-brahman`: un nodo
+//! escucha y sirve, el otro dial-ea, abre stream y jala; el sobre que llega
+//! se verifica con `khipu_share::open`.
+
+use std::time::{Duration, Instant};
+
+use agora_core::Keypair;
+use card_net::Multiaddr;
+use khipu_brahman::KhipuNode;
+use khipu_share::{open, seal, SharedNote};
+
+/// Camino que usa la app: `listen_str` da la dirección para compartir y
+/// `fetch_addr_str` la consume (dial + reintento + fetch).
+#[tokio::test]
+async fn jalar_por_direccion_str_como_la_app() {
+ let autor = Keypair::from_seed([32u8; 32]);
+ let sobre = seal(
+ &autor,
+ vec![SharedNote {
+ title: "via str".into(),
+ body: "listen_str + fetch_addr_str".into(),
+ tags: vec![],
+ }],
+ 1,
+ )
+ .unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ let server = KhipuNode::standalone().unwrap();
+ let client = KhipuNode::standalone().unwrap();
+ let dial = server.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
+ let _serve = server.run_serve(move || Some(bytes.clone()));
+
+ // fetch_addr_str ya reintenta internamente mientras se conecta.
+ let recibido = client.fetch_addr_str(&dial).await.expect("fetch por str");
+ let bundle = open(&recibido).expect("verifica tras el viaje");
+ assert_eq!(bundle.notes[0].title, "via str");
+}
+
+/// Descubrimiento por DHT: A publica y se anuncia bajo la clave khipu en
+/// la DHT; B —unido a la malla por un rendezvous— la descubre con
+/// `descubrir()` y le jala el cuaderno por peer-id, sin conocer su
+/// dirección de antemano.
+#[tokio::test]
+async fn descubrir_por_dht_y_jalar() {
+ let autor = Keypair::from_seed([34u8; 32]);
+ let sobre = seal(
+ &autor,
+ vec![SharedNote {
+ title: "via dht".into(),
+ body: "descubierto por la DHT".into(),
+ tags: vec![],
+ }],
+ 1,
+ )
+ .unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ // Rendezvous: nodo de la malla al que ambos se conectan.
+ let rendezvous = KhipuNode::standalone().unwrap();
+ let r_addr = rendezvous.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
+
+ // A publica, se une a la malla y se anuncia en la DHT.
+ let a = KhipuNode::standalone().unwrap();
+ let _a_listen = a.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
+ let _serve = a.run_serve(move || Some(bytes.clone()));
+ a.dial_str(&r_addr).unwrap();
+ tokio::time::sleep(Duration::from_secs(2)).await;
+ a.anunciar();
+ tokio::time::sleep(Duration::from_secs(1)).await;
+
+ // B se une por el rendezvous y descubre a A por DHT.
+ let b = KhipuNode::standalone().unwrap();
+ b.dial_str(&r_addr).unwrap();
+ tokio::time::sleep(Duration::from_secs(2)).await;
+
+ let mut peers = Vec::new();
+ for _ in 0..20 {
+ peers = b.descubrir().await;
+ if peers.contains(&a.peer_id()) {
+ break;
+ }
+ tokio::time::sleep(Duration::from_millis(300)).await;
+ }
+ assert!(peers.contains(&a.peer_id()), "B debe descubrir a A por DHT");
+
+ // B jala de A por su peer-id (la dirección la aprendió por la DHT).
+ let recibido = tokio::time::timeout(
+ Duration::from_secs(20),
+ b.fetch_peer_str(&a.peer_id().to_string()),
+ )
+ .await
+ .expect("el fetch por peer-id no debería colgar")
+ .expect("recibir por peer-id descubierto");
+ let bundle = open(&recibido).expect("verifica");
+ assert_eq!(bundle.notes[0].title, "via dht");
+}
+
+/// NAT traversal: A reserva un circuito en un relay público y sirve;
+/// B le jala el cuaderno *a través del relay* (Circuit Relay v2), sin
+/// dirección directa a A. Verifica la maquinaria relay/dcutr de card-net.
+#[tokio::test]
+async fn jalar_a_traves_de_un_relay() {
+ let autor = Keypair::from_seed([33u8; 32]);
+ let sobre = seal(
+ &autor,
+ vec![SharedNote {
+ title: "relay".into(),
+ body: "viajó por un circuito relay".into(),
+ tags: vec![],
+ }],
+ 1,
+ )
+ .unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ // Relay público.
+ let relay = KhipuNode::standalone().unwrap();
+ let relay_addr = relay.listen_str("/ip4/127.0.0.1/tcp/0").await.unwrap();
+
+ // A: sirve y reserva un circuito en el relay (su dirección pasa a ser
+ // `…/p2p/<relay>/p2p-circuit/p2p/<A>`).
+ let a = KhipuNode::standalone().unwrap();
+ let _serve = a.run_serve(move || Some(bytes.clone()));
+ // A se conecta al relay; AutoNAT (con el dial-back de A) le confirma al
+ // relay su dirección externa, necesaria para la reserva. Esperamos a
+ // que ese sondeo (boot_delay + round-trip) ocurra antes de reservar.
+ a.dial_str(&relay_addr).unwrap();
+ tokio::time::sleep(Duration::from_secs(6)).await;
+ let circuit = format!("{relay_addr}/p2p-circuit");
+ let a_addr = tokio::time::timeout(Duration::from_secs(15), a.listen_str(&circuit))
+ .await
+ .expect("la reserva del circuito no debería colgar")
+ .unwrap();
+ assert!(a_addr.contains("p2p-circuit"), "A debe anunciarse vía circuito");
+
+ // B jala el cuaderno de A a través del relay.
+ let b = KhipuNode::standalone().unwrap();
+ let recibido = tokio::time::timeout(Duration::from_secs(25), b.fetch_addr_str(&a_addr))
+ .await
+ .expect("el fetch por relay no debería colgar")
+ .expect("recibir vía relay");
+ let bundle = open(&recibido).expect("verifica tras el viaje por relay");
+ assert_eq!(bundle.notes[0].title, "relay");
+}
+
+#[tokio::test]
+async fn jalar_un_sobre_entre_dos_nodos_libp2p() {
+ // Sellar el cuaderno a servir.
+ let autor = Keypair::from_seed([31u8; 32]);
+ let sobre = seal(
+ &autor,
+ vec![SharedNote {
+ title: "P2P".into(),
+ body: "viajó por libp2p".into(),
+ tags: vec!["brahman".into()],
+ }],
+ 1,
+ )
+ .unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ // Dos nodos en localhost.
+ let server = KhipuNode::standalone().unwrap();
+ let client = KhipuNode::standalone().unwrap();
+ let server_pid = server.peer_id();
+
+ let addr = server.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
+ let _serve = server.run_serve(move || Some(bytes.clone()));
+
+ // El cliente dial-ea por multiaddr + peer-id.
+ let dial: Multiaddr = format!("{addr}/p2p/{server_pid}").parse().unwrap();
+ client.dial(dial);
+
+ // Reintentar el fetch hasta que la conexión esté lista.
+ let deadline = Instant::now() + Duration::from_secs(8);
+ let recibido = loop {
+ match client.fetch(server_pid).await {
+ Ok(s) => break s,
+ Err(_) if Instant::now() < deadline => {
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+ Err(e) => panic!("fetch falló: {e}"),
+ }
+ };
+
+ // Idéntico bit a bit y verificable.
+ assert_eq!(recibido, sobre);
+ let bundle = open(&recibido).expect("firma válida tras el viaje libp2p");
+ assert_eq!(bundle.notes[0].title, "P2P");
+ assert_eq!(bundle.author, autor.public_key());
+}
diff --git a/00_unanchay/khipu/khipu-core/Cargo.toml b/00_unanchay/khipu/khipu-core/Cargo.toml
new file mode 100644
index 0000000..5597d79
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "khipu-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu-app — núcleo de toma de notas: el modelo Note, el almacén con etiquetas y búsqueda, y el grafo de wiki-links [[...]] con backlinks."
+
+[dependencies]
+serde = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-core/LEEME.md b/00_unanchay/khipu/khipu-core/LEEME.md
new file mode 100644
index 0000000..b84829f
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/LEEME.md
@@ -0,0 +1,21 @@
+# khipu-core
+
+> Modelo de nota + store para [khipu](../README.md). Sin UI.
+
+`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).
+
+## API
+
+```rust
+use khipu_core::{Note, Store};
+
+let store = Store::open()?;
+let id = store.create("nota nueva")?;
+let note = store.read(id)?;
+store.touch(id)?; // refresca last_access
+```
+
+## Deps
+
+- `serde`, `serde_json`
+- `directories` para el XDG path
diff --git a/00_unanchay/khipu/khipu-core/README.md b/00_unanchay/khipu/khipu-core/README.md
new file mode 100644
index 0000000..7355ae1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/README.md
@@ -0,0 +1,21 @@
+# khipu-core
+
+> Note model + store for [khipu](../README.md). No UI.
+
+`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).
+
+## API
+
+```rust
+use khipu_core::{Note, Store};
+
+let store = Store::open()?;
+let id = store.create("new note")?;
+let note = store.read(id)?;
+store.touch(id)?; // refresh last_access
+```
+
+## Deps
+
+- `serde`, `serde_json`
+- `directories` for XDG path
diff --git a/00_unanchay/khipu/khipu-core/src/lib.rs b/00_unanchay/khipu/khipu-core/src/lib.rs
new file mode 100644
index 0000000..9f1d870
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/lib.rs
@@ -0,0 +1,23 @@
+//! `khipu_app-core` — el núcleo agnóstico de la toma de notas.
+//!
+//! 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.
+//!
+//! - [`note`] — el modelo [`Note`].
+//! - [`links`] — el parser de wiki-links `[[...]]`.
+//! - [`store`] — el [`NoteStore`] y el grafo de enlaces.
+//!
+//! La gravedad semántica (clustering por afinidad de embeddings) vive en
+//! `khipu_app-gravity`; las lentes visuales, en los crates de frontend.
+
+#![forbid(unsafe_code)]
+
+pub mod links;
+pub mod note;
+pub mod store;
+
+pub use links::parse_links;
+pub use note::{Note, NoteId};
+pub use store::NoteStore;
diff --git a/00_unanchay/khipu/khipu-core/src/links.rs b/00_unanchay/khipu/khipu-core/src/links.rs
new file mode 100644
index 0000000..df11590
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/links.rs
@@ -0,0 +1,93 @@
+//! Parser de wiki-links — los destinos `[[...]]` dentro de una nota.
+//!
+//! Un solo format: dobles corchetes con el título adentro. El texto se
+//! recorta; los enlaces vacíos se descartan; el orden de aparición se
+//! conserva y se deduplica (un mismo destino enlazado dos veces cuenta
+//! una sola vez como arista).
+
+/// Extrae los destinos `[[...]]` de `text`, recortados y deduplicados,
+/// en orden de aparición.
+pub fn parse_links(text: &str) -> Vec<String> {
+ let bytes = text.as_bytes();
+ let mut out: Vec<String> = Vec::new();
+ let mut i = 0;
+ while i + 3 < bytes.len() {
+ if bytes[i] == b'[' && bytes[i + 1] == b'[' {
+ if let Some(close) = find_close(text, i + 2) {
+ let inner = text[i + 2..close].trim();
+ if !inner.is_empty() && !out.iter().any(|l| l == inner) {
+ out.push(inner.to_string());
+ }
+ i = close + 2;
+ continue;
+ }
+ }
+ // Avanza un carácter UTF-8 completo, no un byte.
+ i += utf8_len(bytes[i]);
+ }
+ out
+}
+
+/// Posición del `]]` que cierra a partir de `from`, si existe.
+fn find_close(text: &str, from: usize) -> Option<usize> {
+ let bytes = text.as_bytes();
+ let mut i = from;
+ while i + 1 < bytes.len() {
+ if bytes[i] == b']' && bytes[i + 1] == b']' {
+ return Some(i);
+ }
+ // Un `[[` antes del cierre aborta: enlaces anidados no son válidos.
+ if bytes[i] == b'[' && bytes[i + 1] == b'[' {
+ return None;
+ }
+ i += 1;
+ }
+ None
+}
+
+/// Largo en bytes del carácter UTF-8 que empieza en `b`.
+fn utf8_len(b: u8) -> usize {
+ match b {
+ 0x00..=0x7F => 1,
+ 0xC0..=0xDF => 2,
+ 0xE0..=0xEF => 3,
+ _ => 4,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn extracts_simple_links() {
+ let links = parse_links("ver [[Cocina]] y también [[Jardín]].");
+ assert_eq!(links, vec!["Cocina", "Jardín"]);
+ }
+
+ #[test]
+ fn trims_inner_whitespace() {
+ assert_eq!(parse_links("[[ Taller ]]"), vec!["Taller"]);
+ }
+
+ #[test]
+ fn empty_links_are_dropped() {
+ assert_eq!(parse_links("[[]] y [[ ]]"), Vec::<String>::new());
+ }
+
+ #[test]
+ fn duplicates_collapse_to_one() {
+ assert_eq!(parse_links("[[A]] [[B]] [[A]]"), vec!["A", "B"]);
+ }
+
+ #[test]
+ fn unclosed_bracket_is_ignored() {
+ assert_eq!(parse_links("texto [[sin cerrar"), Vec::<String>::new());
+ }
+
+ #[test]
+ fn handles_unicode_content_around_links() {
+ let links = parse_links("café ☕ con [[Niños]] — añoño");
+ assert_eq!(links, vec!["Niños"]);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-core/src/note.rs b/00_unanchay/khipu/khipu-core/src/note.rs
new file mode 100644
index 0000000..f44274c
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/note.rs
@@ -0,0 +1,108 @@
+//! El modelo `Note` — la unidad de khipu_app.
+
+use serde::{Deserialize, Serialize};
+
+use crate::links::parse_links;
+
+/// Identificador de una nota. 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.
+///
+/// `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`).
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Note {
+ pub id: NoteId,
+ pub title: String,
+ pub body: String,
+ pub tags: Vec<String>,
+ /// Segundo Unix de creación.
+ pub created_at: u64,
+ /// Segundo Unix de la última edición.
+ pub updated_at: u64,
+ /// Segundo Unix del último acceso (lectura). Defecto: `created_at`
+ /// en notas nuevas; en payloads viejos sin el campo, `0` y el
+ /// 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.
+ #[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.
+ #[serde(default)]
+ pub pos: Option<(f32, f32)>,
+}
+
+fn default_mass() -> f32 {
+ 1.0
+}
+
+impl Note {
+ /// Destinos `[[...]]` que el cuerpo de la nota referencia.
+ pub fn outgoing_links(&self) -> Vec<String> {
+ parse_links(&self.body)
+ }
+
+ /// `true` si la nota lleva la etiqueta `tag` (sin distinguir mayúsculas).
+ pub fn has_tag(&self, tag: &str) -> bool {
+ self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
+ }
+
+ /// `true` si `query` aparece en el título o el cuerpo (sin distinguir
+ /// mayúsculas).
+ pub fn matches(&self, query: &str) -> bool {
+ let q = query.to_lowercase();
+ self.title.to_lowercase().contains(&q) || self.body.to_lowercase().contains(&q)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn note(title: &str, body: &str) -> Note {
+ Note {
+ id: 1,
+ title: title.into(),
+ body: body.into(),
+ tags: vec!["casa".into()],
+ created_at: 0,
+ updated_at: 0,
+ last_access: 0,
+ mass: 1.0,
+ pos: None,
+ }
+ }
+
+ #[test]
+ fn outgoing_links_reads_the_body() {
+ let n = note("Cocina", "preparar con [[Horno]] y [[Cuchillos]]");
+ assert_eq!(n.outgoing_links(), vec!["Horno", "Cuchillos"]);
+ }
+
+ #[test]
+ fn has_tag_is_case_insensitive() {
+ let n = note("x", "y");
+ assert!(n.has_tag("CASA"));
+ assert!(!n.has_tag("trabajo"));
+ }
+
+ #[test]
+ fn matches_searches_title_and_body() {
+ let n = note("Lista de mercado", "comprar pan");
+ assert!(n.matches("MERCADO"));
+ assert!(n.matches("pan"));
+ assert!(!n.matches("ausente"));
+ }
+}
diff --git a/00_unanchay/khipu/khipu-core/src/store.rs b/00_unanchay/khipu/khipu-core/src/store.rs
new file mode 100644
index 0000000..fc6aa20
--- /dev/null
+++ b/00_unanchay/khipu/khipu-core/src/store.rs
@@ -0,0 +1,348 @@
+//! `NoteStore` — el almacén de notas y su grafo de enlaces.
+//!
+//! Guarda las notas en un `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.
+
+use std::collections::BTreeMap;
+
+use serde::{Deserialize, Serialize};
+
+use crate::note::{Note, NoteId};
+
+/// El almacén de notas de khipu_app.
+#[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,
+}
+
+impl NoteStore {
+ pub fn new() -> Self {
+ Self { notes: BTreeMap::new(), next_id: 1 }
+ }
+
+ /// Crea una nota y devuelve su id. Empieza con `mass = 1.0` y
+ /// `last_access = now` — recién nacida, plenamente visible.
+ pub fn create(
+ &mut self,
+ title: impl Into<String>,
+ body: impl Into<String>,
+ tags: Vec<String>,
+ now: u64,
+ ) -> NoteId {
+ let id = self.next_id;
+ self.next_id += 1;
+ self.notes.insert(
+ id,
+ Note {
+ id,
+ title: title.into(),
+ body: body.into(),
+ tags,
+ created_at: now,
+ updated_at: now,
+ last_access: now,
+ mass: 1.0,
+ pos: None,
+ },
+ );
+ id
+ }
+
+ pub fn len(&self) -> usize {
+ self.notes.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.notes.is_empty()
+ }
+
+ pub fn get(&self, id: NoteId) -> Option<&Note> {
+ self.notes.get(&id)
+ }
+
+ pub fn get_mut(&mut self, id: NoteId) -> Option<&mut Note> {
+ self.notes.get_mut(&id)
+ }
+
+ /// Itera las notas en orden de id (determinista).
+ pub fn iter(&self) -> impl Iterator<Item = &Note> {
+ self.notes.values()
+ }
+
+ /// Reemplaza el cuerpo de una nota y actualiza su marca de tiempo.
+ /// También marca `last_access` — editar cuenta como acceso.
+ /// `false` si la nota no existe.
+ pub fn update_body(&mut self, id: NoteId, body: impl Into<String>, now: u64) -> bool {
+ match self.notes.get_mut(&id) {
+ Some(n) => {
+ n.body = body.into();
+ n.updated_at = now;
+ n.last_access = now;
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// 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ó.
+ pub fn touch(&mut self, id: NoteId, now: u64) -> bool {
+ match self.notes.get_mut(&id) {
+ Some(n) => {
+ n.last_access = now;
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// Asigna directamente la masa de una nota. La física vive en
+ /// `khipu-gravity`; el store sólo persiste el resultado.
+ pub fn set_mass(&mut self, id: NoteId, mass: f32) -> bool {
+ match self.notes.get_mut(&id) {
+ Some(n) => {
+ n.mass = mass;
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// 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
+ }
+ None => false,
+ }
+ }
+
+ /// Elimina una nota y la devuelve.
+ pub fn remove(&mut self, id: NoteId) -> Option<Note> {
+ self.notes.remove(&id)
+ }
+
+ /// 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()
+ }
+
+ /// Notas cuyo título o cuerpo contienen `query`, en orden de id.
+ pub fn search(&self, query: &str) -> Vec<&Note> {
+ self.notes.values().filter(|n| n.matches(query)).collect()
+ }
+
+ /// Ids de las notas cuyo título es `title` (sin distinguir
+ /// mayúsculas). Pueden ser varias: los títulos no son únicos.
+ pub fn resolve_title(&self, title: &str) -> Vec<NoteId> {
+ self.notes
+ .values()
+ .filter(|n| n.title.eq_ignore_ascii_case(title))
+ .map(|n| n.id)
+ .collect()
+ }
+
+ /// Ids de las notas a las que `id` enlaza por `[[...]]`, resueltas y
+ /// deduplicadas. Un enlace a un título inexistente simplemente no
+ /// aporta ningún id (enlace colgante).
+ pub fn forward_links(&self, id: NoteId) -> Vec<NoteId> {
+ let Some(note) = self.notes.get(&id) else {
+ return Vec::new();
+ };
+ let mut out: Vec<NoteId> = Vec::new();
+ for title in note.outgoing_links() {
+ for target in self.resolve_title(&title) {
+ if !out.contains(&target) {
+ out.push(target);
+ }
+ }
+ }
+ out.sort_unstable();
+ out
+ }
+
+ /// Ids de las notas que enlazan hacia `id` (backlinks), en orden de id.
+ pub fn backlinks(&self, id: NoteId) -> Vec<NoteId> {
+ let Some(target) = self.notes.get(&id) else {
+ return Vec::new();
+ };
+ let title = &target.title;
+ self.notes
+ .values()
+ .filter(|n| {
+ n.id != id
+ && n.outgoing_links()
+ .iter()
+ .any(|l| l.eq_ignore_ascii_case(title))
+ })
+ .map(|n| n.id)
+ .collect()
+ }
+
+ /// Notas sin ningún backlink — las islas del grafo.
+ pub fn orphans(&self) -> Vec<&Note> {
+ self.notes
+ .values()
+ .filter(|n| self.backlinks(n.id).is_empty())
+ .collect()
+ }
+
+ /// Destinos `[[...]]` que ninguna nota satisface — enlaces colgantes.
+ pub fn dangling_links(&self) -> Vec<String> {
+ let mut out: Vec<String> = Vec::new();
+ for note in self.notes.values() {
+ for title in note.outgoing_links() {
+ if self.resolve_title(&title).is_empty()
+ && !out.iter().any(|t| t.eq_ignore_ascii_case(&title))
+ {
+ out.push(title);
+ }
+ }
+ }
+ out
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Almacén con tres notas enlazadas: Índice → Cocina, Índice → Jardín.
+ fn seeded() -> (NoteStore, NoteId, NoteId, NoteId) {
+ let mut s = NoteStore::new();
+ let indice = s.create("Índice", "ver [[Cocina]] y [[Jardín]]", vec!["meta".into()], 100);
+ let cocina = s.create("Cocina", "recetas; vuelve al [[Índice]]", vec!["casa".into()], 100);
+ let jardin = s.create("Jardín", "plantas y riego", vec!["casa".into()], 100);
+ (s, indice, cocina, jardin)
+ }
+
+ #[test]
+ fn create_assigns_monotonic_ids() {
+ let (s, indice, cocina, jardin) = seeded();
+ assert_eq!((indice, cocina, jardin), (1, 2, 3));
+ assert_eq!(s.len(), 3);
+ }
+
+ #[test]
+ fn forward_links_resolve_by_title() {
+ let (s, indice, cocina, jardin) = seeded();
+ assert_eq!(s.forward_links(indice), vec![cocina, jardin]);
+ }
+
+ #[test]
+ fn backlinks_find_incoming_references() {
+ let (s, indice, cocina, _) = seeded();
+ // Cocina enlaza al Índice → el Índice tiene a Cocina como backlink.
+ assert_eq!(s.backlinks(indice), vec![cocina]);
+ }
+
+ #[test]
+ fn link_resolution_is_case_insensitive() {
+ let mut s = NoteStore::new();
+ let a = s.create("Taller", "trabajo", vec![], 0);
+ let b = s.create("Notas", "voy al [[taller]]", vec![], 0);
+ assert_eq!(s.forward_links(b), vec![a]);
+ assert_eq!(s.backlinks(a), vec![b]);
+ }
+
+ #[test]
+ fn by_tag_filters() {
+ let (s, _, cocina, jardin) = seeded();
+ let casa: Vec<_> = s.by_tag("casa").iter().map(|n| n.id).collect();
+ assert_eq!(casa, vec![cocina, jardin]);
+ }
+
+ #[test]
+ fn search_scans_title_and_body() {
+ let (s, _, cocina, _) = seeded();
+ let hits: Vec<_> = s.search("recetas").iter().map(|n| n.id).collect();
+ assert_eq!(hits, vec![cocina]);
+ }
+
+ #[test]
+ fn update_body_changes_links_and_timestamp() {
+ let (mut s, indice, _, jardin) = seeded();
+ assert!(s.update_body(indice, "ahora sólo [[Jardín]]", 200));
+ assert_eq!(s.forward_links(indice), vec![jardin]);
+ assert_eq!(s.get(indice).unwrap().updated_at, 200);
+ }
+
+ #[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();
+ assert!(orphan_ids.contains(&aislada));
+ assert!(!orphan_ids.contains(&jardin));
+ }
+
+ #[test]
+ fn dangling_links_report_missing_targets() {
+ let mut s = NoteStore::new();
+ s.create("Nota", "apunta a [[Inexistente]]", vec![], 0);
+ assert_eq!(s.dangling_links(), vec!["Inexistente"]);
+ }
+
+ #[test]
+ fn create_initializes_mass_and_last_access() {
+ let mut s = NoteStore::new();
+ let id = s.create("x", "y", vec![], 1_700_000_000);
+ let n = s.get(id).unwrap();
+ assert_eq!(n.mass, 1.0);
+ assert_eq!(n.last_access, 1_700_000_000);
+ }
+
+ #[test]
+ fn touch_refreshes_last_access() {
+ let mut s = NoteStore::new();
+ let id = s.create("x", "y", vec![], 100);
+ assert!(s.touch(id, 500));
+ assert_eq!(s.get(id).unwrap().last_access, 500);
+ assert!(!s.touch(9_999, 500));
+ }
+
+ #[test]
+ fn update_body_also_marks_last_access() {
+ 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);
+ }
+
+ #[test]
+ fn set_mass_persists_the_value() {
+ 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);
+ }
+
+ #[test]
+ fn set_pos_anchors_the_note_once() {
+ 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));
+ }
+
+ #[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());
+ }
+}
diff --git a/00_unanchay/khipu/khipu-gravity/Cargo.toml b/00_unanchay/khipu/khipu-gravity/Cargo.toml
new file mode 100644
index 0000000..2794715
--- /dev/null
+++ b/00_unanchay/khipu/khipu-gravity/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "khipu-gravity"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu — dos físicas puras: masa temporal (decay/refuerzo con vida media y horizonte) y gravedad semántica (afinidad coseno, vecinos, clústeres y layout 2D)."
+
+[dependencies]
+khipu-core = { path = "../khipu-core" }
+serde = { workspace = true }
+# `libm`: la masa decae con `exp` y queremos mantenernos compatibles
+# con futuros consumidores `no_std` (kernel wawa, WASM bare).
+libm = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-gravity/LEEME.md b/00_unanchay/khipu/khipu-gravity/LEEME.md
new file mode 100644
index 0000000..410c5f1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-gravity/LEEME.md
@@ -0,0 +1,20 @@
+# khipu-gravity
+
+> Algoritmo de masa/decay para [khipu](../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.
+
+## API
+
+```rust
+use khipu_gravity::{Gravity, Params};
+
+let g = Gravity::new(Params::default());
+let new_mass = g.decay(mass, dt);
+let new_mass = g.reinforce(mass, boost);
+```
+
+## Deps
+
+- `libm` para `exp` (sin `std::math` cuando se compila a WASM)
+- Cero deps de I/O o tiempo (el caller pasa `dt`)
diff --git a/00_unanchay/khipu/khipu-gravity/README.md b/00_unanchay/khipu/khipu-gravity/README.md
new file mode 100644
index 0000000..275e4c1
--- /dev/null
+++ b/00_unanchay/khipu/khipu-gravity/README.md
@@ -0,0 +1,20 @@
+# khipu-gravity
+
+> Mass/decay algorithm for [khipu](../README.md).
+
+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.
+
+## API
+
+```rust
+use khipu_gravity::{Gravity, Params};
+
+let g = Gravity::new(Params::default());
+let new_mass = g.decay(mass, dt);
+let new_mass = g.reinforce(mass, boost);
+```
+
+## Deps
+
+- `libm` for `exp` (no `std::math` when compiled to WASM)
+- Zero I/O or time deps (caller passes `dt`)
diff --git a/00_unanchay/khipu/khipu-gravity/src/lib.rs b/00_unanchay/khipu/khipu-gravity/src/lib.rs
new file mode 100644
index 0000000..d97fc3d
--- /dev/null
+++ b/00_unanchay/khipu/khipu-gravity/src/lib.rs
@@ -0,0 +1,479 @@
+//! `khipu-gravity` — dos físicas, ambas 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.
+//! 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.
+//!
+//! Las dos 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 serde::{Deserialize, Serialize};
+
+/// Parámetros de la física temporal de una nota.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct Params {
+ /// Vida media: el `dt` (en segundos) tras el cual una masa se
+ /// reduce a la mitad sin acceso. Default 7 días.
+ pub half_life_secs: f32,
+ /// Incremento de masa por acceso. Default 0.4.
+ pub boost: f32,
+ /// Bajo este valor la nota cae al archivo (no se borra).
+ /// Default 0.10.
+ pub horizon: f32,
+}
+
+impl Default for Params {
+ fn default() -> Self {
+ Self {
+ half_life_secs: 7.0 * 24.0 * 3600.0,
+ boost: 0.4,
+ horizon: 0.10,
+ }
+ }
+}
+
+/// Física temporal: decay con el tiempo, refuerzo con cada acceso.
+/// Pura — no toca reloj ni store. El caller pasa `dt` y aplica el
+/// resultado donde corresponda.
+#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
+pub struct Gravity {
+ pub params: Params,
+}
+
+impl Gravity {
+ pub fn new(params: Params) -> Self {
+ Self { params }
+ }
+
+ /// Masa decaída tras `dt` segundos sin acceso. `dt` negativo o
+ /// `half_life_secs <= 0` devuelven la masa intacta.
+ pub fn decay(&self, mass: f32, dt_secs: f32) -> f32 {
+ if dt_secs <= 0.0 || self.params.half_life_secs <= 0.0 {
+ return mass.max(0.0);
+ }
+ let k = core::f32::consts::LN_2 / self.params.half_life_secs;
+ let factor = libm::expf(-k * dt_secs);
+ (mass * factor).max(0.0)
+ }
+
+ /// Masa tras un acceso — suma el `boost` configurado.
+ pub fn reinforce(&self, mass: f32) -> f32 {
+ (mass + self.params.boost).max(0.0)
+ }
+
+ /// `true` si la masa está sobre el horizonte (visible). `false`
+ /// significa archivo, no borrada.
+ pub fn is_visible(&self, mass: f32) -> bool {
+ mass >= self.params.horizon
+ }
+}
+
+/// Una nube de notas con su vector semántico — el dominio de la gravedad.
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
+pub struct SemanticField {
+ /// `(id, vector)` en orden de inserción.
+ entries: Vec<(NoteId, Vec<f32>)>,
+}
+
+/// Posición 2D resultante de una nota tras el layout por gravedad.
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct NotePlacement {
+ pub id: NoteId,
+ pub x: f32,
+ pub y: f32,
+}
+
+/// Parámetros del layout dirigido por fuerzas.
+#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
+pub struct GravityConfig {
+ /// Pasos de relajación.
+ pub iterations: usize,
+ /// Fuerza de atracción entre notas afines.
+ pub attraction: f32,
+ /// Fuerza de repulsión entre todo par de notas.
+ pub repulsion: f32,
+ /// Radio del círculo de posiciones iniciales.
+ pub radius: f32,
+ /// Fracción de la fuerza neta que se aplica por paso (amortiguación).
+ pub step: f32,
+}
+
+impl Default for GravityConfig {
+ fn default() -> Self {
+ Self {
+ iterations: 120,
+ attraction: 0.02,
+ repulsion: 800.0,
+ radius: 240.0,
+ step: 0.85,
+ }
+ }
+}
+
+/// Similitud coseno de dos vectores. `None` si difieren de largo.
+fn cosine(a: &[f32], b: &[f32]) -> Option<f32> {
+ if a.len() != b.len() {
+ return None;
+ }
+ let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
+ let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
+ let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
+ if na == 0.0 || nb == 0.0 {
+ return Some(0.0);
+ }
+ Some((dot / (na * nb)).clamp(-1.0, 1.0))
+}
+
+impl SemanticField {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Cantidad de notas en el campo.
+ pub fn len(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.entries.is_empty()
+ }
+
+ /// Inserta o reemplaza el vector de una nota.
+ 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;
+ } else {
+ self.entries.push((id, vector));
+ }
+ }
+
+ /// 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
+ }
+
+ /// Itera `(id, &vector)` en orden de inserción. Útil para serializar
+ /// el campo completo (persistencia, exportar).
+ pub fn iter(&self) -> impl Iterator<Item = (NoteId, &[f32])> {
+ self.entries.iter().map(|(id, v)| (*id, v.as_slice()))
+ }
+
+ fn vector_of(&self, id: NoteId) -> Option<&[f32]> {
+ self.entries
+ .iter()
+ .find(|(eid, _)| *eid == id)
+ .map(|(_, v)| v.as_slice())
+ }
+
+ /// Afinidad (similitud coseno) entre dos notas. `None` si alguna no
+ /// existe o los vectores difieren de largo.
+ pub fn affinity(&self, a: NoteId, b: NoteId) -> Option<f32> {
+ cosine(self.vector_of(a)?, self.vector_of(b)?)
+ }
+
+ /// Las `k` notas más afines a `id`, de mayor a menor afinidad.
+ /// Empata por id ascendente para que el orden sea determinista.
+ pub fn nearest(&self, id: NoteId, k: usize) -> Vec<(NoteId, f32)> {
+ let Some(base) = self.vector_of(id) 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)))
+ .collect();
+ scored.sort_by(|a, b| {
+ b.1.partial_cmp(&a.1)
+ .unwrap_or(core::cmp::Ordering::Equal)
+ .then(a.0.cmp(&b.0))
+ });
+ scored.truncate(k);
+ scored
+ }
+
+ /// 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();
+ let mut parent: Vec<usize> = (0..n).collect();
+
+ fn find(parent: &mut [usize], i: usize) -> usize {
+ let mut root = i;
+ while parent[root] != root {
+ root = parent[root];
+ }
+ let mut cur = i;
+ while parent[cur] != root {
+ let next = parent[cur];
+ parent[cur] = root;
+ cur = next;
+ }
+ root
+ }
+
+ 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);
+ if sim >= threshold {
+ let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
+ if ri != rj {
+ parent[ri] = rj;
+ }
+ }
+ }
+ }
+
+ 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);
+ }
+ let mut out: Vec<Vec<NoteId>> = groups.into_values().collect();
+ for c in &mut out {
+ c.sort_unstable();
+ }
+ out.sort_by(|a, b| a.first().cmp(&b.first()));
+ out
+ }
+
+ /// 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();
+ if n == 0 {
+ return Vec::new();
+ }
+ if n == 1 {
+ return vec![NotePlacement { id: self.entries[0].0, x: 0.0, y: 0.0 }];
+ }
+
+ // Posiciones iniciales repartidas en un círculo.
+ let mut pos: Vec<(f32, f32)> = (0..n)
+ .map(|i| {
+ let a = core::f32::consts::TAU * i as f32 / n as f32;
+ (cfg.radius * a.cos(), cfg.radius * a.sin())
+ })
+ .collect();
+
+ // Afinidades precomputadas (no cambian entre pasos).
+ 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);
+ aff[i * n + j] = s;
+ aff[j * n + i] = s;
+ }
+ }
+
+ const EPS: f32 = 0.001;
+ for _ in 0..cfg.iterations {
+ let mut force = vec![(0.0f32, 0.0f32); n];
+ for i in 0..n {
+ for j in (i + 1)..n {
+ let dx = pos[j].0 - pos[i].0;
+ let dy = pos[j].1 - pos[i].1;
+ let dist = (dx * dx + dy * dy).sqrt().max(EPS);
+ let (ux, uy) = (dx / dist, dy / dist);
+ // Atracción crece con la distancia y la afinidad;
+ // repulsión cae con el cuadrado de la distancia.
+ let attract = cfg.attraction * aff[i * n + j] * dist;
+ let repel = cfg.repulsion / (dist * dist);
+ let net = attract - repel; // >0 → acercar
+ force[i].0 += net * ux;
+ force[i].1 += net * uy;
+ force[j].0 -= net * ux;
+ force[j].1 -= net * uy;
+ }
+ }
+ for i in 0..n {
+ pos[i].0 += force[i].0 * cfg.step;
+ pos[i].1 += force[i].1 * cfg.step;
+ }
+ }
+
+ self.entries
+ .iter()
+ .zip(pos)
+ .map(|((id, _), (x, y))| NotePlacement { id: *id, x, y })
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Tres vectores: 1 y 2 casi paralelos, 3 ortogonal.
+ fn field() -> SemanticField {
+ let mut f = SemanticField::new();
+ f.insert(1, vec![1.0, 0.0, 0.0]);
+ f.insert(2, vec![0.9, 0.1, 0.0]);
+ f.insert(3, vec![0.0, 0.0, 1.0]);
+ f
+ }
+
+ #[test]
+ fn affinity_is_high_for_aligned_vectors() {
+ let f = field();
+ let near = f.affinity(1, 2).unwrap();
+ let far = f.affinity(1, 3).unwrap();
+ assert!(near > 0.95);
+ assert!(far.abs() < 1e-6);
+ assert!(near > far);
+ }
+
+ #[test]
+ fn affinity_missing_note_is_none() {
+ assert!(field().affinity(1, 99).is_none());
+ }
+
+ #[test]
+ fn nearest_ranks_by_affinity() {
+ let f = field();
+ let near = f.nearest(1, 2);
+ assert_eq!(near[0].0, 2); // el más afín a 1
+ assert_eq!(near.len(), 2);
+ assert!(near[0].1 > near[1].1);
+ }
+
+ #[test]
+ fn insert_replaces_existing_vector() {
+ let mut f = SemanticField::new();
+ f.insert(1, vec![1.0, 0.0]);
+ f.insert(1, vec![0.0, 1.0]);
+ assert_eq!(f.len(), 1);
+ assert_eq!(f.vector_of(1), Some([0.0, 1.0].as_slice()));
+ }
+
+ #[test]
+ fn clusters_group_affine_notes() {
+ let f = field();
+ // Umbral alto: 1 y 2 juntos, 3 solo.
+ let cs = f.clusters(0.8);
+ assert_eq!(cs, vec![vec![1, 2], vec![3]]);
+ }
+
+ #[test]
+ fn low_threshold_merges_everything() {
+ let cs = field().clusters(-1.0);
+ assert_eq!(cs, vec![vec![1, 2, 3]]);
+ }
+
+ #[test]
+ fn gravity_layout_places_every_note() {
+ let placements = field().gravity_layout(&GravityConfig::default());
+ assert_eq!(placements.len(), 3);
+ let ids: Vec<_> = placements.iter().map(|p| p.id).collect();
+ assert_eq!(ids, vec![1, 2, 3]);
+ }
+
+ #[test]
+ fn gravity_pulls_affine_notes_closer() {
+ let f = field();
+ let p = f.gravity_layout(&GravityConfig::default());
+ let dist = |a: NoteId, b: NoteId| {
+ let pa = p.iter().find(|x| x.id == a).unwrap();
+ let pb = p.iter().find(|x| x.id == b).unwrap();
+ ((pa.x - pb.x).powi(2) + (pa.y - pb.y).powi(2)).sqrt()
+ };
+ // Las notas afines (1,2) terminan más cerca que las disímiles (1,3).
+ assert!(dist(1, 2) < dist(1, 3));
+ }
+
+ #[test]
+ fn gravity_layout_is_deterministic() {
+ let f = field();
+ let a = f.gravity_layout(&GravityConfig::default());
+ let b = f.gravity_layout(&GravityConfig::default());
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn remove_drops_a_note() {
+ let mut f = field();
+ assert!(f.remove(2));
+ assert_eq!(f.len(), 2);
+ assert!(!f.remove(99));
+ }
+
+ #[test]
+ fn iter_returns_vectors_in_insertion_order() {
+ let f = field();
+ let collected: Vec<_> = f.iter().map(|(id, v)| (id, v.to_vec())).collect();
+ assert_eq!(collected.len(), 3);
+ assert_eq!(collected[0].0, 1);
+ assert_eq!(collected[2].0, 3);
+ }
+
+ #[test]
+ fn empty_and_single_fields_are_handled() {
+ assert!(SemanticField::new().gravity_layout(&GravityConfig::default()).is_empty());
+ let mut one = SemanticField::new();
+ one.insert(7, vec![1.0, 1.0]);
+ let p = one.gravity_layout(&GravityConfig::default());
+ assert_eq!(p, vec![NotePlacement { id: 7, x: 0.0, y: 0.0 }]);
+ }
+}
+
+#[cfg(test)]
+mod mass_tests {
+ use super::*;
+
+ #[test]
+ fn decay_halves_mass_after_one_half_life() {
+ let g = Gravity::new(Params {
+ half_life_secs: 1000.0,
+ ..Params::default()
+ });
+ let after = g.decay(1.0, 1000.0);
+ assert!((after - 0.5).abs() < 1e-3);
+ }
+
+ #[test]
+ fn decay_with_zero_dt_is_a_no_op() {
+ let g = Gravity::default();
+ assert!((g.decay(0.7, 0.0) - 0.7).abs() < 1e-6);
+ }
+
+ #[test]
+ fn decay_negative_dt_returns_input() {
+ let g = Gravity::default();
+ assert!((g.decay(0.7, -10.0) - 0.7).abs() < 1e-6);
+ }
+
+ #[test]
+ fn reinforce_adds_boost() {
+ let g = Gravity::new(Params { boost: 0.25, ..Params::default() });
+ assert!((g.reinforce(0.5) - 0.75).abs() < 1e-6);
+ }
+
+ #[test]
+ fn is_visible_uses_horizon() {
+ let g = Gravity::new(Params { horizon: 0.1, ..Params::default() });
+ assert!(g.is_visible(0.10));
+ assert!(g.is_visible(0.99));
+ assert!(!g.is_visible(0.05));
+ }
+
+ #[test]
+ fn long_decay_keeps_mass_non_negative() {
+ let g = Gravity::default();
+ assert!(g.decay(1.0, 1_000_000_000.0) >= 0.0);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-share/Cargo.toml b/00_unanchay/khipu/khipu-share/Cargo.toml
new file mode 100644
index 0000000..aa896a6
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/Cargo.toml
@@ -0,0 +1,20 @@
+[package]
+name = "khipu-share"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "khipu-share — sobres de notas portables, firmados Ed25519 y direccionados por contenido (BLAKE3) sobre agora. La gravedad temporal es local: se comparte el contenido, no la atención."
+
+[dependencies]
+khipu-core = { path = "../khipu-core" }
+agora-core = { workspace = true }
+agora-keystore = { workspace = true }
+rand = { workspace = true }
+blake3 = { workspace = true }
+postcard = { workspace = true }
+serde = { workspace = true }
+serde-big-array = "0.5"
+thiserror = { workspace = true }
diff --git a/00_unanchay/khipu/khipu-share/src/discovery.rs b/00_unanchay/khipu/khipu-share/src/discovery.rs
new file mode 100644
index 0000000..0f17d9a
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/src/discovery.rs
@@ -0,0 +1,180 @@
+//! Descubrimiento de pares khipu en la LAN por baliza UDP.
+//!
+//! Un cuaderno que publica emite cada pocos segundos una [`Beacon`] —su
+//! clave pública, el puerto TCP donde sirve y un nombre— por broadcast de
+//! la LAN y por loopback. Quien quiere recibir escucha una ventana corta,
+//! junta los pares vistos y le jala el cuaderno al primero (con
+//! [`crate::net::fetch`]), sin tener que saber la IP de antemano.
+//!
+//! `std::net` puro, best-effort: redes que bloquean broadcast no
+//! descubren nada, y ahí sigue valiendo apuntar a un par por dirección
+//! explícita. El descubrimiento no afloja la seguridad: la baliza sólo
+//! dice "dónde", el sobre que llega por TCP se verifica igual con
+//! [`crate::open`].
+
+use std::net::{Ipv4Addr, SocketAddr, UdpSocket};
+use std::time::{Duration, Instant};
+
+use serde::{Deserialize, Serialize};
+
+/// Puerto UDP estándar donde se emiten y escuchan las balizas.
+pub const PUERTO_BALIZA: u16 = 7701;
+
+/// Prefijo que marca un datagrama como baliza khipu — descarta tráfico
+/// UDP ajeno antes de intentar parsearlo.
+const MAGIA: [u8; 4] = *b"KHPU";
+
+/// Lo que un cuaderno anuncia de sí mismo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Beacon {
+ /// Clave pública Ed25519 del cuaderno (identidad de quien publica).
+ pub author: [u8; 32],
+ /// Puerto TCP donde ese cuaderno sirve el sobre (para `fetch`).
+ pub port: u16,
+ /// Nombre legible para mostrar en una lista de pares.
+ pub name: String,
+}
+
+impl Beacon {
+ /// Serializa la baliza al cable: `MAGIA` seguido del postcard.
+ pub fn encode(&self) -> Vec<u8> {
+ let mut out = MAGIA.to_vec();
+ if let Ok(body) = postcard::to_allocvec(self) {
+ out.extend_from_slice(&body);
+ }
+ out
+ }
+
+ /// Parsea un datagrama. `None` si no lleva la magia o no decodifica —
+ /// así un paquete UDP cualquiera no nos hace ruido.
+ pub fn decode(bytes: &[u8]) -> Option<Beacon> {
+ let body = bytes.strip_prefix(&MAGIA)?;
+ postcard::from_bytes(body).ok()
+ }
+}
+
+/// Un par visto en la red: dónde jalarle el cuaderno y qué anunció.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PeerVisto {
+ /// Dirección TCP de `fetch`: la IP de origen del datagrama + el
+ /// `port` de la baliza. El que anuncia no necesita conocer su propia
+ /// IP — la deduce quien recibe.
+ pub fetch_addr: SocketAddr,
+ pub beacon: Beacon,
+}
+
+/// Emite la baliza una vez: a broadcast de la LAN y a loopback (para que
+/// dos cuadernos en la misma máquina se vean). Best-effort — los errores
+/// de envío de cada destino se ignoran; sólo falla si no se pudo ni armar
+/// el socket emisor.
+pub fn anunciar(beacon: &Beacon) -> std::io::Result<()> {
+ let sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0))?;
+ sock.set_broadcast(true)?;
+ let bytes = beacon.encode();
+ let _ = sock.send_to(&bytes, (Ipv4Addr::BROADCAST, PUERTO_BALIZA));
+ let _ = sock.send_to(&bytes, (Ipv4Addr::LOCALHOST, PUERTO_BALIZA));
+ Ok(())
+}
+
+/// Escucha balizas en un socket ya bindeado durante `ventana`, devolviendo
+/// los pares vistos (deduplicados por dirección de fetch). Bloqueante.
+/// Separar el socket del bind permite testear sobre un puerto efímero.
+pub fn escuchar_en(sock: &UdpSocket, ventana: Duration) -> Vec<PeerVisto> {
+ let _ = sock.set_read_timeout(Some(Duration::from_millis(150)));
+ let fin = Instant::now() + ventana;
+ let mut vistos: Vec<PeerVisto> = Vec::new();
+ let mut buf = [0u8; 2048];
+ while Instant::now() < fin {
+ match sock.recv_from(&mut buf) {
+ Ok((n, origen)) => {
+ if let Some(beacon) = Beacon::decode(&buf[..n]) {
+ let fetch_addr = SocketAddr::new(origen.ip(), beacon.port);
+ if !vistos.iter().any(|p| p.fetch_addr == fetch_addr) {
+ vistos.push(PeerVisto { fetch_addr, beacon });
+ }
+ }
+ }
+ Err(e)
+ if matches!(
+ e.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
+ ) =>
+ {
+ continue
+ }
+ Err(_) => break,
+ }
+ }
+ vistos
+}
+
+/// Bindea el socket de escucha en el puerto estándar de balizas.
+pub fn bind_balizas() -> std::io::Result<UdpSocket> {
+ UdpSocket::bind((Ipv4Addr::UNSPECIFIED, PUERTO_BALIZA))
+}
+
+/// Conveniencia: bindea el puerto estándar y escucha `ventana`.
+pub fn descubrir(ventana: Duration) -> std::io::Result<Vec<PeerVisto>> {
+ Ok(escuchar_en(&bind_balizas()?, ventana))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn beacon() -> Beacon {
+ Beacon {
+ author: [9u8; 32],
+ port: 7700,
+ name: "khipu de prueba".into(),
+ }
+ }
+
+ #[test]
+ fn encode_decode_roundtrips() {
+ let b = beacon();
+ assert_eq!(Beacon::decode(&b.encode()), Some(b));
+ }
+
+ #[test]
+ fn decode_rejects_non_beacon_traffic() {
+ assert_eq!(Beacon::decode(b"hola mundo"), None);
+ assert_eq!(Beacon::decode(b"KHP"), None); // magia truncada
+ assert_eq!(Beacon::decode(&[]), None);
+ }
+
+ #[test]
+ fn escuchar_recibe_una_baliza_por_loopback() {
+ // Escucha en un puerto efímero (no el estándar, para no chocar con
+ // otros tests ni con un khipu corriendo).
+ let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
+ let destino = listener.local_addr().unwrap();
+
+ let b = beacon();
+ let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
+ emisor.send_to(&b.encode(), destino).unwrap();
+
+ let vistos = escuchar_en(&listener, Duration::from_millis(500));
+ assert_eq!(vistos.len(), 1);
+ // La dirección de fetch combina la IP de origen con el port anunciado.
+ assert_eq!(vistos[0].fetch_addr.ip(), Ipv4Addr::LOCALHOST);
+ assert_eq!(vistos[0].fetch_addr.port(), b.port);
+ assert_eq!(vistos[0].beacon, b);
+ }
+
+ #[test]
+ fn escuchar_deduplica_balizas_repetidas() {
+ let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
+ let destino = listener.local_addr().unwrap();
+
+ let b = beacon();
+ let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
+ // Tres balizas idénticas: un solo par visto.
+ for _ in 0..3 {
+ emisor.send_to(&b.encode(), destino).unwrap();
+ }
+
+ let vistos = escuchar_en(&listener, Duration::from_millis(400));
+ assert_eq!(vistos.len(), 1);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-share/src/identity.rs b/00_unanchay/khipu/khipu-share/src/identity.rs
new file mode 100644
index 0000000..11a6bc3
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/src/identity.rs
@@ -0,0 +1,125 @@
+//! Identidad del cuaderno, guardada cifrada con [`agora_keystore`].
+//!
+//! La clave privada (la semilla Ed25519 de 32 bytes) nunca queda en claro
+//! en disco: vive cifrada bajo una passphrase (Argon2id + ChaCha20-Poly1305).
+//! [`unlock`] la descifra a pedido. Quien tenga acceso de lectura al disco
+//! no obtiene la identidad sin la passphrase — el endurecimiento frente al
+//! `identidad.seed` en claro de las primeras versiones.
+//!
+//! Migración: si encuentra una semilla legacy en claro, la cifra dentro
+//! del keystore y borra el archivo en claro, conservando la identidad.
+
+use std::path::Path;
+
+use agora_core::Keypair;
+use agora_keystore::Keystore;
+use rand::RngCore;
+
+/// Falla al desbloquear o crear la identidad.
+#[derive(Debug, thiserror::Error)]
+pub enum IdentityError {
+ #[error("keystore: {0}")]
+ Keystore(String),
+ #[error("semilla legacy inválida (no son 32 bytes)")]
+ SemillaLegacy,
+}
+
+/// Desbloquea —o crea— la identidad del cuaderno guardada cifrada en
+/// `keys_dir`, descifrándola con `passphrase`. Orden de resolución:
+///
+/// 1. Si el keystore ya tiene una identidad → la descifra. Passphrase
+/// incorrecta ⇒ [`IdentityError::Keystore`] (no se distingue de otros
+/// fallos del keystore a propósito: no filtramos si existe o no).
+/// 2. Si hay una semilla legacy en claro en `legacy_seed` → la **migra**:
+/// la cifra en el keystore con `passphrase`, borra el archivo en claro
+/// y devuelve esa identidad.
+/// 3. Si no hay nada → genera una identidad nueva (semilla de `OsRng`),
+/// la guarda cifrada y la devuelve.
+pub fn unlock(
+ keys_dir: &Path,
+ legacy_seed: Option<&Path>,
+ passphrase: &str,
+) -> Result<Keypair, IdentityError> {
+ let ks = Keystore::open(keys_dir).map_err(|e| IdentityError::Keystore(e.to_string()))?;
+ let ids = ks.list().map_err(|e| IdentityError::Keystore(e.to_string()))?;
+
+ // 1. Identidad ya guardada: descifrar.
+ if let Some(id) = ids.first().copied() {
+ let seed = ks
+ .load(id, passphrase)
+ .map_err(|e| IdentityError::Keystore(e.to_string()))?;
+ return Ok(Keypair::from_seed(seed));
+ }
+
+ // 2. Migrar una semilla legacy en claro, si la hay.
+ if let Some(path) = legacy_seed {
+ if let Ok(bytes) = std::fs::read(path) {
+ let seed =
+ <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| IdentityError::SemillaLegacy)?;
+ let kp = Keypair::from_seed(seed);
+ ks.save(kp.identity_id(), &seed, passphrase)
+ .map_err(|e| IdentityError::Keystore(e.to_string()))?;
+ let _ = std::fs::remove_file(path); // ya está cifrada; fuera el claro
+ return Ok(kp);
+ }
+ }
+
+ // 3. Identidad nueva.
+ let mut seed = [0u8; 32];
+ rand::rngs::OsRng.fill_bytes(&mut seed);
+ let kp = Keypair::from_seed(seed);
+ ks.save(kp.identity_id(), &seed, passphrase)
+ .map_err(|e| IdentityError::Keystore(e.to_string()))?;
+ Ok(kp)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// Directorio temporal único por nombre de test (evita tempfile como
+ /// dependencia y las colisiones entre tests del mismo proceso).
+ fn temp_dir(etiqueta: &str) -> std::path::PathBuf {
+ let d = std::env::temp_dir().join(format!("khipu-id-{}-{}", std::process::id(), etiqueta));
+ let _ = std::fs::remove_dir_all(&d);
+ d
+ }
+
+ #[test]
+ fn crear_y_desbloquear_da_la_misma_identidad() {
+ let dir = temp_dir("crear");
+ let a = unlock(&dir, None, "secreta").unwrap();
+ let b = unlock(&dir, None, "secreta").unwrap();
+ assert_eq!(a.public_key(), b.public_key());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn passphrase_incorrecta_falla() {
+ let dir = temp_dir("malpass");
+ let _ = unlock(&dir, None, "correcta").unwrap();
+ assert!(unlock(&dir, None, "incorrecta").is_err());
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ #[test]
+ fn migra_semilla_legacy_en_claro() {
+ let base = temp_dir("migra");
+ std::fs::create_dir_all(&base).unwrap();
+ let legacy = base.join("identidad.seed");
+ let semilla = [7u8; 32];
+ std::fs::write(&legacy, semilla).unwrap();
+
+ let keys = base.join("keys");
+ let kp = unlock(&keys, Some(&legacy), "secreta").unwrap();
+ // Conserva la identidad de la semilla legacy.
+ assert_eq!(kp.public_key(), Keypair::from_seed(semilla).public_key());
+ // El archivo en claro fue borrado.
+ assert!(!legacy.exists());
+ // Y ahora se descifra del keystore, sin legacy.
+ let kp2 = unlock(&keys, None, "secreta").unwrap();
+ assert_eq!(kp2.public_key(), kp.public_key());
+
+ let _ = std::fs::remove_dir_all(&base);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-share/src/lib.rs b/00_unanchay/khipu/khipu-share/src/lib.rs
new file mode 100644
index 0000000..65ab1c0
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/src/lib.rs
@@ -0,0 +1,349 @@
+//! `khipu-share` — compartir notas por la red soberana de gioser 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");
+//! ```
+
+#![forbid(unsafe_code)]
+
+pub mod discovery;
+pub mod identity;
+pub mod net;
+
+use std::collections::HashSet;
+
+use agora_core::{verify_signature, Keypair};
+use khipu_core::{Note, NoteId, NoteStore};
+use serde::{Deserialize, Serialize};
+use serde_big_array::BigArray;
+
+/// El contenido transferible de una nota — sin id, masa ni timestamps.
+/// La gravedad temporal se queda en el cuaderno de origen.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SharedNote {
+ pub title: String,
+ pub body: String,
+ pub tags: Vec<String>,
+}
+
+impl SharedNote {
+ /// Proyecta una [`Note`] local a su contenido compartible, descartando
+ /// id, masa y marcas de tiempo.
+ pub fn from_note(n: &Note) -> Self {
+ Self {
+ title: n.title.clone(),
+ body: n.body.clone(),
+ tags: n.tags.clone(),
+ }
+ }
+}
+
+/// El cuerpo firmable: el autor, cuándo se selló, y las notas. Es lo que
+/// se serializa y se hashea — el orden de las notas y las etiquetas es
+/// significativo para que el hash sea estable.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Bundle {
+ /// Clave pública Ed25519 del autor (32 bytes).
+ pub author: [u8; 32],
+ /// Segundo Unix en que se selló el sobre.
+ pub created_at: u64,
+ pub notes: Vec<SharedNote>,
+}
+
+/// Un [`Bundle`] con la firma del autor sobre su hash de contenido.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SignedBundle {
+ pub bundle: Bundle,
+ /// Firma Ed25519 sobre [`Bundle::content_hash`].
+ #[serde(with = "BigArray")]
+ pub signature: [u8; 64],
+}
+
+impl Bundle {
+ /// La dirección de contenido del sobre: `BLAKE3(postcard(self))`. Es
+ /// también el mensaje que el autor firma.
+ pub fn content_hash(&self) -> Result<[u8; 32], ShareError> {
+ let bytes = postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)?;
+ Ok(*blake3::hash(&bytes).as_bytes())
+ }
+}
+
+impl SignedBundle {
+ /// Serializa el sobre a bytes (postcard) para escribirlo a disco o
+ /// mandarlo por cualquier canal.
+ pub fn to_bytes(&self) -> Result<Vec<u8>, ShareError> {
+ postcard::to_allocvec(self).map_err(|_| ShareError::Serializacion)
+ }
+
+ /// Reconstruye un sobre desde bytes. No verifica la firma — para eso
+ /// está [`open`].
+ pub fn from_bytes(bytes: &[u8]) -> Result<Self, ShareError> {
+ postcard::from_bytes(bytes).map_err(|_| ShareError::Serializacion)
+ }
+
+ /// La dirección de contenido del sobre (delegada a [`Bundle::content_hash`]).
+ pub fn content_address(&self) -> Result<[u8; 32], ShareError> {
+ self.bundle.content_hash()
+ }
+}
+
+/// Sella las notas en un sobre firmado por `kp`.
+pub fn seal(
+ kp: &Keypair,
+ notes: Vec<SharedNote>,
+ created_at: u64,
+) -> Result<SignedBundle, ShareError> {
+ let bundle = Bundle {
+ author: kp.public_key(),
+ created_at,
+ notes,
+ };
+ let hash = bundle.content_hash()?;
+ let signature = kp.sign(&hash);
+ Ok(SignedBundle { bundle, signature })
+}
+
+/// Abre un sobre: recomputa su hash de contenido y verifica que la firma
+/// corresponda a la clave del autor declarado. Devuelve el [`Bundle`]
+/// verificado, o un error si el contenido fue alterado o la firma no
+/// corresponde. Verificación offline — sin autoridad central.
+pub fn open(signed: &SignedBundle) -> Result<&Bundle, ShareError> {
+ let hash = signed.bundle.content_hash()?;
+ verify_signature(&signed.bundle.author, &hash, &signed.signature)
+ .map_err(|_| ShareError::FirmaInvalida)?;
+ Ok(&signed.bundle)
+}
+
+/// Resultado de ingerir un sobre en un cuaderno.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ImportOutcome {
+ /// Ids de las notas efectivamente creadas.
+ pub created: Vec<NoteId>,
+ /// Notas omitidas por título ya presente (la importación repetida es
+ /// inofensiva — no duplica).
+ pub skipped: usize,
+}
+
+/// Inserta las notas de un `bundle` en `store` como notas nuevas: id e
+/// historia local frescos, `mass = 1.0`, `last_access = now`. La gravedad
+/// arranca en el cuaderno receptor.
+///
+/// Las notas cuyo título (sin distinguir mayúsculas) ya existe en el
+/// cuaderno se omiten, de modo que importar dos veces el mismo sobre no
+/// duplica. Los wiki-links `[[Título]]` sobreviven solos: khipu resuelve
+/// enlaces por título, así que si las notas enlazadas también se importan,
+/// el grafo se rearma sin remapear ids.
+///
+/// Cada nota importada recibe una etiqueta de procedencia [`tag_de`]
+/// (`de:<hex8 del autor>`) — así queda asentado de quién vino sin tocar
+/// el modelo `Note` ni la persistencia. Es una etiqueta normal: visible,
+/// buscable y removible.
+pub fn import_into(store: &mut NoteStore, bundle: &Bundle, now: u64) -> ImportOutcome {
+ let mut existing: HashSet<String> =
+ store.iter().map(|n| n.title.to_lowercase()).collect();
+ let marca = tag_de(&bundle.author);
+ let mut out = ImportOutcome::default();
+ for sn in &bundle.notes {
+ let key = sn.title.to_lowercase();
+ // Sólo deduplicamos por títulos no vacíos: dos "(sin título)" no
+ // son la misma nota.
+ if !sn.title.is_empty() && existing.contains(&key) {
+ out.skipped += 1;
+ continue;
+ }
+ let mut tags = sn.tags.clone();
+ if !tags.iter().any(|t| t == &marca) {
+ tags.push(marca.clone());
+ }
+ let id = store.create(sn.title.clone(), sn.body.clone(), tags, now);
+ existing.insert(key);
+ out.created.push(id);
+ }
+ out
+}
+
+/// Prefijo hex (4 bytes / 8 hex) de una clave o hash — identifica un
+/// autor de forma legible sin volcar los 32 bytes.
+pub fn hex8(bytes: &[u8; 32]) -> String {
+ bytes[..4].iter().map(|b| format!("{b:02x}")).collect()
+}
+
+/// La etiqueta de procedencia para un autor: `de:<hex8>`.
+pub fn tag_de(author: &[u8; 32]) -> String {
+ format!("de:{}", hex8(author))
+}
+
+/// Falla al sellar, abrir o transportar un sobre.
+#[derive(Debug, thiserror::Error, PartialEq, Eq)]
+pub enum ShareError {
+ #[error("no se pudo (de)serializar el sobre")]
+ Serializacion,
+ #[error("la firma no corresponde al contenido y la clave del autor")]
+ FirmaInvalida,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn nota(title: &str, body: &str, tags: &[&str]) -> SharedNote {
+ SharedNote {
+ title: title.into(),
+ body: body.into(),
+ tags: tags.iter().map(|s| s.to_string()).collect(),
+ }
+ }
+
+ #[test]
+ fn seal_then_open_roundtrips() {
+ let kp = Keypair::from_seed([1u8; 32]);
+ let notes = vec![nota("A", "cuerpo a", &["x"]), nota("B", "[[A]]", &[])];
+ let sobre = seal(&kp, notes.clone(), 42).unwrap();
+ let bundle = open(&sobre).unwrap();
+ assert_eq!(bundle.author, kp.public_key());
+ assert_eq!(bundle.created_at, 42);
+ assert_eq!(bundle.notes, notes);
+ }
+
+ #[test]
+ fn content_hash_is_stable_and_content_addressed() {
+ let kp = Keypair::from_seed([2u8; 32]);
+ let notes = vec![nota("A", "a", &["t"])];
+ let a = seal(&kp, notes.clone(), 100).unwrap();
+ let b = seal(&kp, notes, 100).unwrap();
+ // Mismas notas + mismo autor + mismo instante → mismo hash y firma.
+ assert_eq!(a.content_address().unwrap(), b.content_address().unwrap());
+ assert_eq!(a.signature, b.signature);
+ }
+
+ #[test]
+ fn tampering_with_content_is_rejected() {
+ let kp = Keypair::from_seed([3u8; 32]);
+ let mut sobre = seal(&kp, vec![nota("real", "intacto", &[])], 1).unwrap();
+ // Alteramos el cuerpo sin rehacer la firma.
+ sobre.bundle.notes[0].body = "manipulado".into();
+ assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
+ }
+
+ #[test]
+ fn forged_author_is_rejected() {
+ let autor = Keypair::from_seed([4u8; 32]);
+ let impostor = Keypair::from_seed([5u8; 32]);
+ let mut sobre = seal(&autor, vec![nota("n", "c", &[])], 1).unwrap();
+ // El impostor pone su clave pero no puede producir la firma válida.
+ sobre.bundle.author = impostor.public_key();
+ assert_eq!(open(&sobre), Err(ShareError::FirmaInvalida));
+ }
+
+ #[test]
+ fn bytes_roundtrip_preserves_verification() {
+ let kp = Keypair::from_seed([6u8; 32]);
+ let sobre = seal(&kp, vec![nota("n", "c", &["a", "b"])], 9).unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+ let recuperado = SignedBundle::from_bytes(&bytes).unwrap();
+ assert_eq!(recuperado, sobre);
+ assert!(open(&recuperado).is_ok());
+ }
+
+ #[test]
+ fn import_creates_fresh_notes_with_local_gravity() {
+ let kp = Keypair::from_seed([7u8; 32]);
+ let sobre = seal(&kp, vec![nota("Receta", "sopa", &["cocina"])], 1).unwrap();
+ let bundle = open(&sobre).unwrap();
+
+ let mut store = NoteStore::new();
+ let out = import_into(&mut store, bundle, 5_000);
+ assert_eq!(out.created.len(), 1);
+ assert_eq!(out.skipped, 0);
+
+ let n = store.get(out.created[0]).unwrap();
+ assert_eq!(n.title, "Receta");
+ // Conserva sus etiquetas y suma la de procedencia.
+ assert!(n.tags.contains(&"cocina".to_string()));
+ assert!(n.tags.iter().any(|t| t.starts_with("de:")));
+ // Gravedad fresca: masa plena y acceso = ahora del receptor.
+ assert_eq!(n.mass, 1.0);
+ assert_eq!(n.last_access, 5_000);
+ assert_eq!(n.created_at, 5_000);
+ }
+
+ #[test]
+ fn import_marks_author_provenance() {
+ let kp = Keypair::from_seed([13u8; 32]);
+ let sobre = seal(&kp, vec![nota("N", "c", &[])], 1).unwrap();
+ let bundle = open(&sobre).unwrap();
+ let mut store = NoteStore::new();
+ let out = import_into(&mut store, bundle, 1);
+ let n = store.get(out.created[0]).unwrap();
+ assert!(n.tags.contains(&tag_de(&kp.public_key())));
+ }
+
+ #[test]
+ fn reimport_skips_existing_titles() {
+ let kp = Keypair::from_seed([8u8; 32]);
+ let sobre = seal(
+ &kp,
+ vec![nota("A", "a", &[]), nota("B", "b", &[])],
+ 1,
+ )
+ .unwrap();
+ let bundle = open(&sobre).unwrap();
+
+ let mut store = NoteStore::new();
+ let first = import_into(&mut store, bundle, 10);
+ assert_eq!(first.created.len(), 2);
+
+ // Segunda importación del mismo sobre: nada nuevo.
+ let second = import_into(&mut store, bundle, 20);
+ assert_eq!(second.created.len(), 0);
+ assert_eq!(second.skipped, 2);
+ assert_eq!(store.len(), 2);
+ }
+
+ #[test]
+ fn wiki_links_survive_import_by_title() {
+ let kp = Keypair::from_seed([9u8; 32]);
+ let sobre = seal(
+ &kp,
+ vec![
+ nota("Índice", "ver [[Receta]]", &[]),
+ nota("Receta", "sopa", &[]),
+ ],
+ 1,
+ )
+ .unwrap();
+ let bundle = open(&sobre).unwrap();
+
+ let mut store = NoteStore::new();
+ let out = import_into(&mut store, bundle, 1);
+ let indice = out.created[0];
+ // El enlace [[Receta]] resuelve a la otra nota importada.
+ assert_eq!(store.forward_links(indice).len(), 1);
+ }
+}
diff --git a/00_unanchay/khipu/khipu-share/src/net.rs b/00_unanchay/khipu/khipu-share/src/net.rs
new file mode 100644
index 0000000..583cc88
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/src/net.rs
@@ -0,0 +1,155 @@
+//! Transporte LAN mínimo para sobres khipu sobre TCP.
+//!
+//! El sobre ya es firmado y direccionado por contenido, así que el
+//! transporte no necesita ser confiable: quien recibe verifica con
+//! [`crate::open`] antes de creer nada. Esto es `std::net` puro — sin
+//! libp2p ni async — pensado para "jalar el cuaderno de un par en la LAN".
+//!
+//! Marco de cable: un `u32` big-endian con el largo del sobre, seguido
+//! del sobre serializado (postcard). Un sobre por conexión.
+
+use std::io::{self, Read, Write};
+use std::net::{TcpListener, TcpStream, ToSocketAddrs};
+
+use crate::{ShareError, SignedBundle};
+
+/// Tope defensivo para un sobre entrante (64 MiB): evita que un par
+/// hostil pida un alloc gigante declarando un largo inflado.
+const MAX_SOBRE: u32 = 64 * 1024 * 1024;
+
+/// Falla del transporte de sobres.
+#[derive(Debug, thiserror::Error)]
+pub enum NetError {
+ #[error("io de red: {0}")]
+ Io(String),
+ #[error("marco inválido: {0}")]
+ Protocolo(String),
+ #[error(transparent)]
+ Sobre(#[from] ShareError),
+}
+
+impl From<io::Error> for NetError {
+ fn from(e: io::Error) -> Self {
+ NetError::Io(e.to_string())
+ }
+}
+
+fn write_frame(stream: &mut TcpStream, payload: &[u8]) -> io::Result<()> {
+ if payload.len() as u64 > MAX_SOBRE as u64 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "sobre demasiado grande para el marco",
+ ));
+ }
+ stream.write_all(&(payload.len() as u32).to_be_bytes())?;
+ stream.write_all(payload)?;
+ stream.flush()
+}
+
+fn read_frame(stream: &mut TcpStream) -> Result<Vec<u8>, NetError> {
+ let mut len_buf = [0u8; 4];
+ stream.read_exact(&mut len_buf)?;
+ let len = u32::from_be_bytes(len_buf);
+ if len > MAX_SOBRE {
+ return Err(NetError::Protocolo(format!(
+ "largo declarado {len} excede el tope"
+ )));
+ }
+ let mut buf = vec![0u8; len as usize];
+ stream.read_exact(&mut buf)?;
+ Ok(buf)
+}
+
+/// Conecta a `addr`, lee un sobre y lo deserializa. **No lo verifica** —
+/// el caller debe pasar el resultado por [`crate::open`] antes de confiar.
+pub fn fetch(addr: impl ToSocketAddrs) -> Result<SignedBundle, NetError> {
+ let mut stream = TcpStream::connect(addr)?;
+ let bytes = read_frame(&mut stream)?;
+ Ok(SignedBundle::from_bytes(&bytes)?)
+}
+
+/// Atiende una sola conexión: manda `payload` y vuelve. Útil para tests
+/// y para un "compartir una vez".
+pub fn serve_once(listener: &TcpListener, payload: &[u8]) -> io::Result<()> {
+ let (mut stream, _) = listener.accept()?;
+ write_frame(&mut stream, payload)
+}
+
+/// Atiende conexiones para siempre. Por cada una llama a `supply` para
+/// obtener los bytes a mandar — típicamente leer `compartido.khipu` del
+/// disco, así sirve siempre la versión vigente. Una conexión cuyo
+/// `supply` falla (todavía no hay sobre, p. ej.) se salta sin tumbar el
+/// servidor. Bloqueante: pensado para correr en su propio hilo.
+pub fn serve_loop<F>(listener: TcpListener, supply: F)
+where
+ F: Fn() -> io::Result<Vec<u8>>,
+{
+ for conn in listener.incoming() {
+ let Ok(mut stream) = conn else { continue };
+ if let Ok(bytes) = supply() {
+ let _ = write_frame(&mut stream, &bytes);
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{open, seal, SharedNote};
+ use agora_core::Keypair;
+
+ fn nota(title: &str, body: &str) -> SharedNote {
+ SharedNote {
+ title: title.into(),
+ body: body.into(),
+ tags: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn fetch_recovers_a_served_bundle_and_verifies() {
+ let kp = Keypair::from_seed([11u8; 32]);
+ let sobre = seal(&kp, vec![nota("Red", "hola por TCP")], 1).unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ let server = std::thread::spawn(move || serve_once(&listener, &bytes).unwrap());
+
+ let recibido = fetch(addr).unwrap();
+ server.join().unwrap();
+
+ assert_eq!(recibido, sobre);
+ // El sobre que llegó por la red verifica firma + hash.
+ let bundle = open(&recibido).unwrap();
+ assert_eq!(bundle.notes[0].title, "Red");
+ }
+
+ #[test]
+ fn serve_loop_serves_the_current_supply_each_time() {
+ let kp = Keypair::from_seed([12u8; 32]);
+ let sobre = seal(&kp, vec![nota("A", "a")], 1).unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+ let addr = listener.local_addr().unwrap();
+ // El servidor corre en su hilo; lo dejamos colgado al terminar el
+ // test (los dos fetch ya probaron lo que importa).
+ std::thread::spawn(move || {
+ serve_loop(listener, move || Ok(bytes.clone()));
+ });
+
+ // Dos pares distintos jalan el mismo cuaderno.
+ let a = fetch(addr).unwrap();
+ let b = fetch(addr).unwrap();
+ assert_eq!(a, sobre);
+ assert_eq!(b, sobre);
+ }
+
+ #[test]
+ fn fetch_against_nothing_is_an_error_not_a_panic() {
+ // Puerto cerrado: connect falla, devolvemos NetError::Io.
+ let err = fetch("127.0.0.1:1").unwrap_err();
+ assert!(matches!(err, NetError::Io(_)));
+ }
+}
diff --git a/00_unanchay/khipu/khipu-share/tests/lan_roundtrip.rs b/00_unanchay/khipu/khipu-share/tests/lan_roundtrip.rs
new file mode 100644
index 0000000..b4372a2
--- /dev/null
+++ b/00_unanchay/khipu/khipu-share/tests/lan_roundtrip.rs
@@ -0,0 +1,75 @@
+//! Integración: el camino completo de compartir por LAN, todo en loopback.
+//!
+//! Sella un cuaderno → lo sirve por TCP → anuncia su baliza → otro par lo
+//! descubre por UDP, le jala el sobre y lo verifica. Es la cadena que
+//! ejercitan los botones «publicar» y «recibir» de khipu-app, sin GUI.
+
+use std::net::{Ipv4Addr, TcpListener, UdpSocket};
+use std::time::Duration;
+
+use agora_core::Keypair;
+use khipu_core::NoteStore;
+use khipu_share::discovery::{escuchar_en, Beacon};
+use khipu_share::{net, open, seal, SharedNote};
+
+#[test]
+fn descubrir_jalar_y_verificar_de_punta_a_punta() {
+ // --- Lado que publica ---
+ let autor = Keypair::from_seed([21u8; 32]);
+ let sobre = seal(
+ &autor,
+ vec![SharedNote {
+ title: "Compartida por LAN".into(),
+ body: "viajó por descubrimiento + TCP".into(),
+ tags: vec!["red".into()],
+ }],
+ 100,
+ )
+ .unwrap();
+ let bytes = sobre.to_bytes().unwrap();
+
+ // Sirve el sobre por TCP en un puerto efímero.
+ let tcp = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
+ let tcp_port = tcp.local_addr().unwrap().port();
+ std::thread::spawn(move || {
+ let _ = net::serve_once(&tcp, &bytes);
+ });
+
+ // Anuncia la baliza apuntando a ese puerto TCP.
+ let beacon = Beacon {
+ author: autor.public_key(),
+ port: tcp_port,
+ name: "khipu publicador".into(),
+ };
+
+ // --- Lado que recibe ---
+ // Escucha balizas en un puerto UDP efímero (en la app sería el
+ // estándar 7701); el publicador le manda la suya por loopback.
+ let listener = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
+ let destino = listener.local_addr().unwrap();
+ let emisor = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).unwrap();
+ emisor.send_to(&beacon.encode(), destino).unwrap();
+
+ // Descubre al par y le jala el cuaderno.
+ let pares = escuchar_en(&listener, Duration::from_millis(500));
+ assert_eq!(pares.len(), 1, "debería haber descubierto un par");
+ let par = &pares[0];
+ assert_eq!(par.fetch_addr.port(), tcp_port);
+ assert_eq!(par.beacon.author, autor.public_key());
+
+ let recibido = net::fetch(par.fetch_addr).unwrap();
+
+ // Verifica firma + hash e ingiere como nota fresca.
+ let bundle = open(&recibido).unwrap();
+ let mut store = NoteStore::new();
+ let resultado = khipu_share::import_into(&mut store, bundle, 9_000);
+ assert_eq!(resultado.created.len(), 1);
+ let nota = store.get(resultado.created[0]).unwrap();
+ assert_eq!(nota.title, "Compartida por LAN");
+ assert!(nota.tags.contains(&"red".to_string()));
+ // La nota lleva la procedencia del autor que la selló.
+ assert!(nota.tags.contains(&khipu_share::tag_de(&autor.public_key())));
+ // Gravedad fresca en el receptor.
+ assert_eq!(nota.mass, 1.0);
+ assert_eq!(nota.last_access, 9_000);
+}
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..85613de
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,6692 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "aes"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "aes-gcm"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
+dependencies = [
+ "aead",
+ "aes",
+ "cipher",
+ "ctr",
+ "ghash",
+ "subtle",
+]
+
+[[package]]
+name = "agora-core"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "blake3",
+ "ed25519-dalek",
+ "format",
+ "serde",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "agora-keystore"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "aead",
+ "agora-core",
+ "argon2",
+ "chacha20poly1305",
+ "directories",
+ "rand 0.8.6",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.12.1",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys 0.6.0+11769913",
+ "num_enum",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "app-bus"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "directories",
+ "serde",
+ "toml",
+]
+
+[[package]]
+name = "arboard"
+version = "3.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2 0.6.4",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.60.2",
+ "x11rb",
+]
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures 0.2.17",
+ "password-hash",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.18",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.4",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "asynchronous-codec"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a860072022177f903e59730004fb5dc13db9275b79bb2aef7ba8ce831956c233"
+dependencies = [
+ "bytes",
+ "futures-sink",
+ "futures-util",
+ "memchr",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "atomic-polyfill"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
+dependencies = [
+ "critical-section",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "attohttpc"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9"
+dependencies = [
+ "base64",
+ "http",
+ "log",
+ "url",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base-x"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270"
+
+[[package]]
+name = "base256emoji"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c"
+dependencies = [
+ "const-str",
+ "match-lookup",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "blake3"
+version = "1.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "cc",
+ "cfg-if",
+ "constant_time_eq",
+ "cpufeatures 0.3.0",
+]
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.12.1",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "card-net"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "blake3",
+ "futures",
+ "libp2p",
+ "libp2p-allow-block-list",
+ "libp2p-stream",
+ "serde",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures 0.2.17",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "cobs"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
+dependencies = [
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "color"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-str"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3"
+
+[[package]]
+name = "constant_time_eq"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "critical-section"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "typenum",
+]
+
+[[package]]
+name = "ctr"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
+dependencies = [
+ "cipher",
+]
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "data-encoding-macro"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3259c913752a86488b501ed8680446a5ed2d5aeac6e596cb23ba3800768ea32c"
+dependencies = [
+ "data-encoding",
+ "data-encoding-macro-internal",
+]
+
+[[package]]
+name = "data-encoding-macro-internal"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
+dependencies = [
+ "data-encoding",
+ "syn",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "directories"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "dtoa"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+
+[[package]]
+name = "embedded-io"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
+
+[[package]]
+name = "embedded-io"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
+
+[[package]]
+name = "enum-as-inner"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "fax"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flate2"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "font-types"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02a596f5713680923a2080d86de50fe472fb290693cf0f701187a1c8b36996b7"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "font-types"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "fontconfig-cache-parser"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7f8afb20c8069fd676d27b214559a337cc619a605d25a87baa90b49a06f3b18"
+dependencies = [
+ "bytemuck",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "fontique"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64763d1f274c8383333851435b6cdf071c31cfcdb39fd5860d20943205a007a7"
+dependencies = [
+ "bytemuck",
+ "fontconfig-cache-parser",
+ "hashbrown 0.15.5",
+ "icu_locid",
+ "memmap2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-core-text",
+ "objc2-foundation 0.3.2",
+ "peniko",
+ "read-fonts 0.29.3",
+ "roxmltree",
+ "smallvec",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "format"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "blake3",
+ "postcard",
+ "serde",
+ "serde-big-array",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-bounded"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91f328e7fb845fc832912fb6a34f40cf6d1888c92f974d1893a54e97b5ff542e"
+dependencies = [
+ "futures-timer",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-rustls"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f2f12607f92c69b12ed746fabf9ca4f5c482cba46679c1a75b874ed7c26adb"
+dependencies = [
+ "futures-io",
+ "rustls",
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-timer"
+version = "3.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.4",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+ "wasip2",
+ "wasip3",
+]
+
+[[package]]
+name = "ghash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
+dependencies = [
+ "opaque-debug",
+ "polyval",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+dependencies = [
+ "bitflags 2.12.1",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+dependencies = [
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+ "windows 0.58.0",
+]
+
+[[package]]
+name = "gpu-descriptor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
+dependencies = [
+ "bitflags 2.12.1",
+ "gpu-descriptor-types",
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "gpu-descriptor-types"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "grid"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5"
+
+[[package]]
+name = "guillotiere"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782"
+dependencies = [
+ "euclid",
+ "svg_fmt",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
+name = "hash32"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heapless"
+version = "0.7.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
+dependencies = [
+ "atomic-polyfill",
+ "hash32",
+ "rustc_version",
+ "serde",
+ "spin",
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hexf-parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
+
+[[package]]
+name = "hickory-proto"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502"
+dependencies = [
+ "async-trait",
+ "cfg-if",
+ "data-encoding",
+ "enum-as-inner",
+ "futures-channel",
+ "futures-io",
+ "futures-util",
+ "idna",
+ "ipnet",
+ "once_cell",
+ "rand 0.9.4",
+ "ring",
+ "socket2 0.5.10",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tokio",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "hickory-resolver"
+version = "0.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "hickory-proto",
+ "ipconfig",
+ "moka",
+ "once_cell",
+ "parking_lot",
+ "rand 0.9.4",
+ "resolv-conf",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "libc",
+ "pin-project-lite",
+ "socket2 0.6.4",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+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",
+ "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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable 0.6.3",
+ "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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "if-addrs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "if-watch"
+version = "3.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71c02a5161c313f0cbdbadc511611893584a10a7b6153cb554bdf83ddce99ec2"
+dependencies = [
+ "async-io",
+ "core-foundation",
+ "fnv",
+ "futures",
+ "if-addrs",
+ "ipnet",
+ "log",
+ "netlink-packet-core",
+ "netlink-packet-route",
+ "netlink-proto",
+ "netlink-sys",
+ "rtnetlink",
+ "system-configuration",
+ "tokio",
+ "windows 0.62.2",
+]
+
+[[package]]
+name = "igd-next"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "516893339c97f6011282d5825ac94fc1c7aad5cad26bdc2d0cee068c0bf97f97"
+dependencies = [
+ "async-trait",
+ "attohttpc",
+ "bytes",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "rand 0.9.4",
+ "tokio",
+ "url",
+ "xmltree",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png 0.18.1",
+ "tiff",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "ipconfig"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
+dependencies = [
+ "socket2 0.6.4",
+ "widestring",
+ "windows-registry",
+ "windows-result 0.4.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.18",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom 0.3.4",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khipu-app"
+version = "0.1.0"
+dependencies = [
+ "agora-core",
+ "app-bus",
+ "directories",
+ "khipu-brahman",
+ "khipu-core",
+ "khipu-gravity",
+ "khipu-share",
+ "llimphi-clipboard",
+ "llimphi-motion",
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-context-menu",
+ "llimphi-widget-edit-menu",
+ "llimphi-widget-list",
+ "llimphi-widget-menubar",
+ "llimphi-widget-text-editor",
+ "llimphi-widget-text-input",
+ "llimphi-widget-tiled",
+ "postcard",
+ "rimay-verbo",
+ "serde",
+ "tokio",
+]
+
+[[package]]
+name = "khipu-brahman"
+version = "0.1.0"
+dependencies = [
+ "agora-core",
+ "card-net",
+ "futures",
+ "khipu-share",
+ "libp2p",
+ "libp2p-stream",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "khipu-core"
+version = "0.1.0"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "khipu-gravity"
+version = "0.1.0"
+dependencies = [
+ "khipu-core",
+ "libm",
+ "serde",
+]
+
+[[package]]
+name = "khipu-share"
+version = "0.1.0"
+dependencies = [
+ "agora-core",
+ "agora-keystore",
+ "blake3",
+ "khipu-core",
+ "postcard",
+ "rand 0.8.6",
+ "serde",
+ "serde-big-array",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "kurbo"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "smallvec",
+]
+
+[[package]]
+name = "lazy_static"
+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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libp2p"
+version = "0.56.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce71348bf5838e46449ae240631117b487073d5f347c06d434caddcb91dceb5a"
+dependencies = [
+ "bytes",
+ "either",
+ "futures",
+ "futures-timer",
+ "getrandom 0.2.17",
+ "libp2p-allow-block-list",
+ "libp2p-autonat",
+ "libp2p-connection-limits",
+ "libp2p-core",
+ "libp2p-dcutr",
+ "libp2p-dns",
+ "libp2p-identify",
+ "libp2p-identity",
+ "libp2p-kad",
+ "libp2p-mdns",
+ "libp2p-metrics",
+ "libp2p-noise",
+ "libp2p-quic",
+ "libp2p-relay",
+ "libp2p-swarm",
+ "libp2p-tcp",
+ "libp2p-upnp",
+ "libp2p-yamux",
+ "multiaddr",
+ "pin-project",
+ "rw-stream-sink",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "libp2p-allow-block-list"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d16ccf824ee859ca83df301e1c0205270206223fd4b1f2e512a693e1912a8f4a"
+dependencies = [
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+]
+
+[[package]]
+name = "libp2p-autonat"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fab5e25c49a7d48dac83d95d8f3bac0a290d8a5df717012f6e34ce9886396c0b"
+dependencies = [
+ "async-trait",
+ "asynchronous-codec",
+ "either",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-request-response",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "rand 0.8.6",
+ "rand_core 0.6.4",
+ "thiserror 2.0.18",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-connection-limits"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a18b8b607cf3bfa2f8c57db9c7d8569a315d5cc0a282e6bfd5ebfc0a9840b2a0"
+dependencies = [
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+]
+
+[[package]]
+name = "libp2p-core"
+version = "0.43.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "249128cd37a2199aff30a7675dffa51caf073b51aa612d2f544b19932b9aebca"
+dependencies = [
+ "either",
+ "fnv",
+ "futures",
+ "futures-timer",
+ "libp2p-identity",
+ "multiaddr",
+ "multihash",
+ "multistream-select",
+ "parking_lot",
+ "pin-project",
+ "quick-protobuf",
+ "rand 0.8.6",
+ "rw-stream-sink",
+ "thiserror 2.0.18",
+ "tracing",
+ "unsigned-varint 0.8.0",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-dcutr"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b4107305e12158af3e66960b6181789c547394c9c9a8696f721521602bfc73a"
+dependencies = [
+ "asynchronous-codec",
+ "either",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "hashlink",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "thiserror 2.0.18",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-dns"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b770c1c8476736ca98c578cba4b505104ff8e842c2876b528925f9766379f9a"
+dependencies = [
+ "async-trait",
+ "futures",
+ "hickory-resolver",
+ "libp2p-core",
+ "libp2p-identity",
+ "parking_lot",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-identify"
+version = "0.47.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ab792a8b68fdef443a62155b01970c81c3aadab5e659621b063ef252a8e65e8"
+dependencies = [
+ "asynchronous-codec",
+ "either",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-identity"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9525f3831544f7ae497bde79adf114ef127b0fbbb97edbbf692a80408636421c"
+dependencies = [
+ "bs58",
+ "ed25519-dalek",
+ "hkdf",
+ "multihash",
+ "prost",
+ "rand 0.8.6",
+ "sha2",
+ "thiserror 2.0.18",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "libp2p-kad"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13d3fd632a5872ec804d37e7413ceea20588f69d027a0fa3c46f82574f4dee60"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "either",
+ "fnv",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "rand 0.8.6",
+ "sha2",
+ "smallvec",
+ "thiserror 2.0.18",
+ "tracing",
+ "uint",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-mdns"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66872d0f1ffcded2788683f76931be1c52e27f343edb93bc6d0bcd8887be443"
+dependencies = [
+ "futures",
+ "hickory-proto",
+ "if-watch",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "rand 0.8.6",
+ "smallvec",
+ "socket2 0.5.10",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-metrics"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "805a555148522cb3414493a5153451910cb1a146c53ffbf4385708349baf62b7"
+dependencies = [
+ "futures",
+ "libp2p-core",
+ "libp2p-dcutr",
+ "libp2p-identify",
+ "libp2p-identity",
+ "libp2p-kad",
+ "libp2p-relay",
+ "libp2p-swarm",
+ "pin-project",
+ "prometheus-client",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-noise"
+version = "0.46.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc73eacbe6462a0eb92a6527cac6e63f02026e5407f8831bde8293f19217bfbf"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "futures",
+ "libp2p-core",
+ "libp2p-identity",
+ "multiaddr",
+ "multihash",
+ "quick-protobuf",
+ "rand 0.8.6",
+ "snow",
+ "static_assertions",
+ "thiserror 2.0.18",
+ "tracing",
+ "x25519-dalek",
+ "zeroize",
+]
+
+[[package]]
+name = "libp2p-quic"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8dc448b2de9f4745784e3751fe8bc6c473d01b8317edd5ababcb0dec803d843f"
+dependencies = [
+ "futures",
+ "futures-timer",
+ "if-watch",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-tls",
+ "quinn",
+ "rand 0.8.6",
+ "ring",
+ "rustls",
+ "socket2 0.5.10",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-relay"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9b0392ed623243ad298326b9f806d51191829ac7585cc825c54c6c67b04d9"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "either",
+ "futures",
+ "futures-bounded",
+ "futures-timer",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "quick-protobuf",
+ "quick-protobuf-codec",
+ "rand 0.8.6",
+ "static_assertions",
+ "thiserror 2.0.18",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-request-response"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9f1cca83488b90102abac7b67d5c36fc65bc02ed47620228af7ed002e6a1478"
+dependencies = [
+ "async-trait",
+ "futures",
+ "futures-bounded",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "rand 0.8.6",
+ "smallvec",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-stream"
+version = "0.4.0-alpha"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d6bd8025c80205ec2810cfb28b02f362ab48a01bee32c50ab5f12761e033464"
+dependencies = [
+ "futures",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm",
+ "rand 0.8.6",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-swarm"
+version = "0.47.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce88c6c4bf746c8482480345ea3edfd08301f49e026889d1cbccfa1808a9ed9e"
+dependencies = [
+ "either",
+ "fnv",
+ "futures",
+ "futures-timer",
+ "hashlink",
+ "libp2p-core",
+ "libp2p-identity",
+ "libp2p-swarm-derive",
+ "multistream-select",
+ "rand 0.8.6",
+ "smallvec",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "libp2p-swarm-derive"
+version = "0.35.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd297cf53f0cb3dee4d2620bb319ae47ef27c702684309f682bdb7e55a18ae9c"
+dependencies = [
+ "heck",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "libp2p-tcp"
+version = "0.44.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb6585b9309699f58704ec9ab0bb102eca7a3777170fa91a8678d73ca9cafa93"
+dependencies = [
+ "futures",
+ "futures-timer",
+ "if-watch",
+ "libc",
+ "libp2p-core",
+ "socket2 0.6.4",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-tls"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96ff65a82e35375cbc31ebb99cacbbf28cb6c4fefe26bf13756ddcf708d40080"
+dependencies = [
+ "futures",
+ "futures-rustls",
+ "libp2p-core",
+ "libp2p-identity",
+ "rcgen",
+ "ring",
+ "rustls",
+ "rustls-webpki",
+ "thiserror 2.0.18",
+ "x509-parser",
+ "yasna",
+]
+
+[[package]]
+name = "libp2p-upnp"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4757e65fe69399c1a243bbb90ec1ae5a2114b907467bf09f3575e899815bb8d3"
+dependencies = [
+ "futures",
+ "futures-timer",
+ "igd-next",
+ "libp2p-core",
+ "libp2p-swarm",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "libp2p-yamux"
+version = "0.47.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f15df094914eb4af272acf9adaa9e287baa269943f32ea348ba29cfb9bfc60d8"
+dependencies = [
+ "either",
+ "futures",
+ "libp2p-core",
+ "thiserror 2.0.18",
+ "tracing",
+ "yamux 0.12.1",
+ "yamux 0.13.10",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
+dependencies = [
+ "bitflags 2.12.1",
+ "libc",
+ "plain",
+ "redox_syscall 0.8.1",
+]
+
+[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "litrs"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
+
+[[package]]
+name = "llimphi-clipboard"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "arboard",
+ "llimphi-widget-text-editor",
+]
+
+[[package]]
+name = "llimphi-compositor"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-layout",
+ "llimphi-text",
+ "vello",
+ "wgpu",
+]
+
+[[package]]
+name = "llimphi-hal"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "pollster",
+ "raw-window-handle",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "llimphi-layout"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "taffy",
+]
+
+[[package]]
+name = "llimphi-motion"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-raster"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-hal",
+ "pollster",
+ "vello",
+]
+
+[[package]]
+name = "llimphi-text"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "parley",
+ "vello",
+]
+
+[[package]]
+name = "llimphi-theme"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-raster",
+]
+
+[[package]]
+name = "llimphi-ui"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-compositor",
+ "llimphi-hal",
+ "llimphi-layout",
+ "llimphi-raster",
+ "llimphi-text",
+ "pollster",
+]
+
+[[package]]
+name = "llimphi-widget-button"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-context-menu"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-panel",
+]
+
+[[package]]
+name = "llimphi-widget-edit-menu"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-context-menu",
+ "llimphi-widget-text-editor",
+]
+
+[[package]]
+name = "llimphi-widget-list"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-menubar"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "app-bus",
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-button",
+ "llimphi-widget-context-menu",
+]
+
+[[package]]
+name = "llimphi-widget-panel"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "llimphi-widget-text-editor"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-text-editor-core",
+ "tree-sitter",
+]
+
+[[package]]
+name = "llimphi-widget-text-editor-core"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "peniko",
+ "ropey",
+ "tree-sitter",
+ "tree-sitter-python",
+ "tree-sitter-rust",
+]
+
+[[package]]
+name = "llimphi-widget-text-input"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+ "llimphi-widget-text-editor",
+]
+
+[[package]]
+name = "llimphi-widget-tiled"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "llimphi-theme",
+ "llimphi-ui",
+]
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "match-lookup"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[package]]
+name = "memmap2"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "metal"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e"
+dependencies = [
+ "bitflags 2.12.1",
+ "block",
+ "core-graphics-types",
+ "foreign-types",
+ "log",
+ "objc",
+ "paste",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moka"
+version = "0.12.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046"
+dependencies = [
+ "crossbeam-channel",
+ "crossbeam-epoch",
+ "crossbeam-utils",
+ "equivalent",
+ "parking_lot",
+ "portable-atomic",
+ "smallvec",
+ "tagptr",
+ "uuid",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "multiaddr"
+version = "0.18.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe6351f60b488e04c1d21bc69e56b89cb3f5e8f5d22557d6e8031bdfd79b6961"
+dependencies = [
+ "arrayref",
+ "byteorder",
+ "data-encoding",
+ "libp2p-identity",
+ "multibase",
+ "multihash",
+ "percent-encoding",
+ "serde",
+ "static_assertions",
+ "unsigned-varint 0.8.0",
+ "url",
+]
+
+[[package]]
+name = "multibase"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77"
+dependencies = [
+ "base-x",
+ "base256emoji",
+ "data-encoding",
+ "data-encoding-macro",
+]
+
+[[package]]
+name = "multihash"
+version = "0.19.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447"
+dependencies = [
+ "unsigned-varint 0.8.0",
+]
+
+[[package]]
+name = "multistream-select"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea0df8e5eec2298a62b326ee4f0d7fe1a6b90a09dfcf9df37b38f947a8c42f19"
+dependencies = [
+ "bytes",
+ "futures",
+ "log",
+ "pin-project",
+ "smallvec",
+ "unsigned-varint 0.7.2",
+]
+
+[[package]]
+name = "naga"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.12.1",
+ "cfg_aliases",
+ "codespan-reporting",
+ "hexf-parse",
+ "indexmap",
+ "log",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "strum",
+ "termcolor",
+ "thiserror 2.0.18",
+ "unicode-xid",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.12.1",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys 0.6.0+11769913",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "netlink-packet-core"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3463cbb78394cb0141e2c926b93fc2197e473394b761986eca3b9da2c63ae0f4"
+dependencies = [
+ "paste",
+]
+
+[[package]]
+name = "netlink-packet-route"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ce3636fa715e988114552619582b530481fd5ef176a1e5c1bf024077c2c9445"
+dependencies = [
+ "bitflags 2.12.1",
+ "libc",
+ "log",
+ "netlink-packet-core",
+]
+
+[[package]]
+name = "netlink-proto"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
+dependencies = [
+ "bytes",
+ "futures",
+ "log",
+ "netlink-packet-core",
+ "netlink-sys",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "netlink-sys"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "libc",
+ "log",
+ "tokio",
+]
+
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.12.1",
+ "cfg-if",
+ "cfg_aliases",
+ "libc",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "libc",
+ "objc2 0.5.2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2 0.6.4",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.12.1",
+ "dispatch2",
+ "objc2 0.6.4",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.12.1",
+ "dispatch2",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-text"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "dispatch",
+ "libc",
+ "objc2 0.5.2",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.12.1",
+ "objc2 0.6.4",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2",
+ "objc2 0.5.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.12.1",
+ "block2",
+ "objc2 0.5.2",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+dependencies = [
+ "critical-section",
+ "portable-atomic",
+]
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "orbclient"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "parley"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e28dadbe655332fd7d996794ec8d0c376695f6ca47bc75aa01e0967c7f28e42a"
+dependencies = [
+ "fontique",
+ "hashbrown 0.15.5",
+ "peniko",
+ "skrifa 0.31.3",
+ "swash",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64",
+ "serde_core",
+]
+
+[[package]]
+name = "peniko"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b44f9ddd2f480176b34278eb653ec1c8062f3b143a4e16eeff5ffac3334e288"
+dependencies = [
+ "color",
+ "kurbo",
+ "linebender_resource_handle",
+ "smallvec",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "png"
+version = "0.17.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
+dependencies = [
+ "bitflags 1.3.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.12.1",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.4",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "polyval"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
+
+[[package]]
+name = "postcard"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
+dependencies = [
+ "cobs",
+ "embedded-io 0.4.0",
+ "embedded-io 0.6.1",
+ "heapless",
+ "serde",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn",
+]
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit 0.25.12+spec-1.1.0",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
+[[package]]
+name = "prometheus-client"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf41c1a7c32ed72abe5082fb19505b969095c12da9f5732a4bc9878757fd087c"
+dependencies = [
+ "dtoa",
+ "itoa",
+ "parking_lot",
+ "prometheus-client-derive-encode",
+]
+
+[[package]]
+name = "prometheus-client-derive-encode"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "prost"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
+dependencies = [
+ "bytes",
+ "prost-derive",
+]
+
+[[package]]
+name = "prost-derive"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
+dependencies = [
+ "anyhow",
+ "itertools",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pxfm"
+version = "0.1.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quick-protobuf"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6da84cc204722a989e01ba2f6e1e276e190f22263d0cb6ce8526fcdb0d2e1f"
+dependencies = [
+ "byteorder",
+]
+
+[[package]]
+name = "quick-protobuf-codec"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15a0580ab32b169745d7a39db2ba969226ca16738931be152a3209b409de2474"
+dependencies = [
+ "asynchronous-codec",
+ "bytes",
+ "quick-protobuf",
+ "thiserror 1.0.69",
+ "unsigned-varint 0.8.0",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.39.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "futures-io",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "socket2 0.6.4",
+ "thiserror 2.0.18",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash 2.1.2",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.18",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2 0.6.4",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "rcgen"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "yasna",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.29.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04ca636dac446b5664bd16c069c00a9621806895b8bb02c2dc68542b23b8f25d"
+dependencies = [
+ "bytemuck",
+ "font-types 0.9.0",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.33.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50ea612a55c08586a1d15134be8a776186c440c312ebda3b9e8efbfe4255b7f4"
+dependencies = [
+ "bytemuck",
+ "font-types 0.9.0",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.37.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5"
+dependencies = [
+ "bytemuck",
+ "font-types 0.11.3",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.17",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "resolv-conf"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
+
+[[package]]
+name = "rimay-verbo"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "rimay-verbo-core",
+ "rimay-verbo-daemon",
+ "rimay-verbo-mock",
+]
+
+[[package]]
+name = "rimay-verbo-core"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "async-trait",
+ "serde",
+ "thiserror 2.0.18",
+]
+
+[[package]]
+name = "rimay-verbo-daemon"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "async-trait",
+ "postcard",
+ "rimay-verbo-core",
+ "serde",
+ "tokio",
+]
+
+[[package]]
+name = "rimay-verbo-mock"
+version = "0.1.0"
+source = "git+https://gitea.gioser.net/sergio/gioser.git#7a412ae2b60e3be40d8b5a53257dc95006ea9f55"
+dependencies = [
+ "async-trait",
+ "rimay-verbo-core",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "ropey"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93411e420bcd1a75ddd1dc3caf18c23155eda2c090631a85af21ba19e97093b5"
+dependencies = [
+ "smallvec",
+ "str_indices",
+]
+
+[[package]]
+name = "roxmltree"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
+
+[[package]]
+name = "rtnetlink"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b960d5d873a75b5be9761b1e73b146f52dddcd27bac75263f40fba686d4d7b5"
+dependencies = [
+ "futures-channel",
+ "futures-util",
+ "log",
+ "netlink-packet-core",
+ "netlink-packet-route",
+ "netlink-proto",
+ "netlink-sys",
+ "nix",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.12.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags 2.12.1",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "rw-stream-sink"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8c9026ff5d2f23da5e45bbc283f156383001bfb09c4e44256d02c1a685fe9a1"
+dependencies = [
+ "futures",
+ "pin-project",
+ "static_assertions",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit",
+ "tiny-skia",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-big-array"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "skrifa"
+version = "0.31.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbeb4ca4399663735553a09dd17ce7e49a0a0203f03b706b39628c4d913a8607"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.29.3",
+]
+
+[[package]]
+name = "skrifa"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "576e60c7de4bb6a803a0312f9bef17e78cf1e8d25a80e1ade76770d7a0237955"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.33.1",
+]
+
+[[package]]
+name = "skrifa"
+version = "0.40.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac"
+dependencies = [
+ "bytemuck",
+ "read-fonts 0.37.0",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.12.1",
+ "calloop",
+ "calloop-wayland-source",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "snow"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "850948bee068e713b8ab860fe1adc4d109676ab4c3b621fd8147f06b261f2f85"
+dependencies = [
+ "aes-gcm",
+ "blake2",
+ "chacha20poly1305",
+ "curve25519-dalek",
+ "rand_core 0.6.4",
+ "ring",
+ "rustc_version",
+ "sha2",
+ "subtle",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spirv"
+version = "0.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.12.1",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "str_indices"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6"
+
+[[package]]
+name = "streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
+name = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "svg_fmt"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb"
+
+[[package]]
+name = "swash"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64"
+dependencies = [
+ "skrifa 0.40.0",
+ "yazi",
+ "zeno",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
+dependencies = [
+ "bitflags 2.12.1",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "taffy"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41ba83ebaf2954d31d05d67340fd46cebe99da2b7133b0dd68d70c65473a437b"
+dependencies = [
+ "arrayvec",
+ "grid",
+ "serde",
+ "slotmap",
+]
+
+[[package]]
+name = "tagptr"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl 2.0.18",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "time"
+version = "0.3.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
+
+[[package]]
+name = "time-macros"
+version = "0.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f"
+dependencies = [
+ "displaydoc",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.4",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_edit 0.22.27",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime 0.6.11",
+ "toml_write",
+ "winnow 0.7.15",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.12+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
+dependencies = [
+ "indexmap",
+ "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_parser",
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow 1.0.3",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "tree-sitter"
+version = "0.24.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75"
+dependencies = [
+ "cc",
+ "regex",
+ "regex-syntax",
+ "streaming-iterator",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-language"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782"
+
+[[package]]
+name = "tree-sitter-python"
+version = "0.23.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d065aaa27f3aaceaf60c1f0e0ac09e1cb9eb8ed28e7bcdaa52129cffc7f4b04"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "tree-sitter-rust"
+version = "0.23.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a"
+dependencies = [
+ "cc",
+ "tree-sitter-language",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "uint"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "909988d098b2f738727b161a106cfc7cab00c539c2687a8836f8e565976fb53e"
+dependencies = [
+ "byteorder",
+ "crunchy",
+ "hex",
+ "static_assertions",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "unsigned-varint"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6889a77d49f1f013504cec6bf97a2c730394adedaeb1deb5ea08949a50541105"
+
+[[package]]
+name = "unsigned-varint"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
+dependencies = [
+ "getrandom 0.4.2",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vello"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa3f8a53870a2ee699ce05b738a3f9974c92c35ed4874de86052ac68d214811c"
+dependencies = [
+ "bytemuck",
+ "futures-intrusive",
+ "log",
+ "peniko",
+ "png 0.17.16",
+ "skrifa 0.35.0",
+ "static_assertions",
+ "thiserror 2.0.18",
+ "vello_encoding",
+ "vello_shaders",
+ "wgpu",
+]
+
+[[package]]
+name = "vello_encoding"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c69b0fe94b0ac7e47619c504ee2c377355174f5c46353c46d03fa5f7e435922b"
+dependencies = [
+ "bytemuck",
+ "guillotiere",
+ "peniko",
+ "skrifa 0.35.0",
+ "smallvec",
+]
+
+[[package]]
+name = "vello_shaders"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2ebea426bb2f95b7610bca09178b03d809ede1d3c500a9acf6eca43e8f200be"
+dependencies = [
+ "bytemuck",
+ "naga",
+ "thiserror 2.0.18",
+ "vello_encoding",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen 0.57.1",
+]
+
+[[package]]
+name = "wasip3"
+version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
+dependencies = [
+ "wit-bindgen 0.51.0",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.72"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.122"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-encoder"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
+dependencies = [
+ "leb128fmt",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasm-metadata"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
+dependencies = [
+ "anyhow",
+ "indexmap",
+ "wasm-encoder",
+ "wasmparser",
+]
+
+[[package]]
+name = "wasmparser"
+version = "0.244.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
+dependencies = [
+ "bitflags 2.12.1",
+ "hashbrown 0.15.5",
+ "indexmap",
+ "semver",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.4",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
+dependencies = [
+ "bitflags 2.12.1",
+ "rustix 1.1.4",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.12.1",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.4",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
+dependencies = [
+ "bitflags 2.12.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.12.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.12.1",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "wgpu"
+version = "24.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.12.1",
+ "cfg_aliases",
+ "document-features",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "24.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499"
+dependencies = [
+ "arrayvec",
+ "bit-vec",
+ "bitflags 2.12.1",
+ "cfg_aliases",
+ "document-features",
+ "indexmap",
+ "log",
+ "naga",
+ "once_cell",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "24.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags 2.12.1",
+ "block",
+ "bytemuck",
+ "cfg_aliases",
+ "core-graphics-types",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-alloc",
+ "gpu-allocator",
+ "gpu-descriptor",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "metal",
+ "naga",
+ "ndk-sys 0.5.0+25.2.9519653",
+ "objc",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "renderdoc-sys",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.18",
+ "wasm-bindgen",
+ "web-sys",
+ "wgpu-types",
+ "windows 0.58.0",
+ "windows-core 0.58.0",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c"
+dependencies = [
+ "bitflags 2.12.1",
+ "js-sys",
+ "log",
+ "web-sys",
+]
+
+[[package]]
+name = "widestring"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core 0.58.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core 0.62.2",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core 0.62.2",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement 0.58.0",
+ "windows-interface 0.58.0",
+ "windows-result 0.2.0",
+ "windows-strings 0.1.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement 0.60.2",
+ "windows-interface 0.59.3",
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core 0.62.2",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-registry"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
+dependencies = [
+ "windows-link",
+ "windows-result 0.4.1",
+ "windows-strings 0.5.1",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result 0.2.0",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows-threading"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "winit"
+version = "0.30.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.12.1",
+ "block2",
+ "bytemuck",
+ "calloop",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.2",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "sctk-adwaita",
+ "smithay-client-toolkit",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
+]
+
+[[package]]
+name = "winnow"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lan
The patch goes on, but was cut here: fetch the commit with git to see it whole.