feat: pluma standalone — autoría multilienzo (haz de cuerpos) + notebook reactivo (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 (40 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Delta semántico · 1600
00_unanchay/pluma/foreign-docx/src/lib.rs
- nueva def CONTENT_TYPES_XML
- nueva def DOCUMENT_HEAD
- nueva def DOCUMENT_RELS_XML
- nueva def DOCUMENT_TAIL
- nueva struct DocumentoImportado pub
- nueva enum DocxError pub
- nueva def PACKAGE_RELS_XML
- nueva fn archivo_sin_document_xml_devuelve_error
- nueva fn bytes_no_zip_devuelve_zip_error
- nueva fn cuerpo_orden_y_atoms_referencian_los_mismos_uuids
- nueva fn cuerpo_y_atoms
- nueva fn docx_de
- nueva fn escapar_xml
- nueva fn extraer_parrafos
- nueva fn nombre_local
- nueva fn parrafos_se_separan_en_atoms
- nueva fn parrafos_vacios_se_omiten
- nueva fn parse_docx pub
- nueva mod pruebas
- nueva fn write_docx pub
- nueva fn write_docx_borrow pub
- nueva fn write_docx_cuerpo_vacio_genera_doc_minimo_valido
- nueva fn write_docx_escapa_caracteres_xml
- nueva fn write_docx_roundtrip_preserva_orden_y_texto
00_unanchay/pluma/pluma-align-embeddings/src/lib.rs
- nueva enum ModoAlineacion pub
- nueva struct ParamsAlineacion pub
- nueva fn alinear_por_embeddings pub
- nueva fn cuerpo_con_atomos
- nueva fn cuerpo_vacio_devuelve_carta_vacia_sin_llamar_al_provider
- nueva fn default
- nueva fn indice
- nueva fn mejor_para_cada_a_puede_apuntar_dos_a_uno
- nueva fn mejores_por_fila
- nueva fn mutuo_mejor_descarta_pares_ambiguos
- nueva fn mutuos_mejor
- nueva mod pruebas
- nueva fn recolectar_textos
- nueva fn textos_identicos_dan_fuerza_uno
- nueva fn textos_random_no_pasan_umbral_alto
00_unanchay/pluma/pluma-align/src/lib.rs
- nueva struct Alineamiento pub
- nueva struct CartaHebras pub
- nueva enum OrigenAlineamiento pub
- nueva fn agregar pub
- nueva fn alinear_explicito pub
- nueva fn alinear_explicito_descarta_pares_ajenos
- nueva fn alinear_uno_a_uno pub
- nueva fn alinear_uno_a_uno_empareja_hasta_el_mas_corto
- nueva fn con_par pub
- nueva fn contraparte pub
- nueva fn contraparte_y_toca_funcionan_en_ambos_sentidos
- nueva fn cuerpos_paralelos
- nueva fn deserializar pub
- nueva fn hebras_de pub
- nueva fn hebras_de_filtra_por_atomo_en_ambos_lados
- nueva fn marcar_stale_anteriores_a pub
- nueva fn marcar_stale_solo_toca_anteriores_al_umbral
- nueva fn nueva pub
- nueva fn nuevo pub
- nueva fn nuevo_alineamiento_clampea_fuerza
- nueva mod pruebas
- nueva fn roundtrip_postcard_de_carta
- nueva fn serializar pub
- nueva fn timestamp pub
- nueva fn toca pub
00_unanchay/pluma/pluma-app/src/clipboard.rs
- nueva def ArboardClipboard
- nueva fn get
- nueva def new
- nueva fn set
00_unanchay/pluma/pluma-app/src/init.rs
- nueva fn inicializar_backend
- nueva def init_modelo
00_unanchay/pluma/pluma-app/src/main.rs
00_unanchay/pluma/pluma-app/src/model.rs
- nueva def BACKENDS
- nueva def METRICS
- nueva def Model
- nueva def Msg
- nueva def VISIBLE_LINES
00_unanchay/pluma/pluma-app/src/update.rs
- nueva def TrabajoLlm
- nueva fn abrir_archivo
- nueva def actualizar
- nueva fn aplicar_edit_menu
- nueva fn cambiar_activo
- nueva def contar_stale_del_activo
- nueva fn crear_doc_nuevo
- nueva fn cycle_backend
- nueva fn ejecutar_menu_command
- nueva fn exportar_md
- nueva fn guardar_activo
- nueva fn lanzar
- nueva def menu_principal
- nueva fn mover_atom_caret
- nueva fn recibir_hija
- nueva fn recomputar_matches
- nueva fn regenerar_siguiente_stale
- nueva fn saltar_a_match
- nueva fn tocar_madre
- nueva fn trabajo_de_tipo
00_unanchay/pluma/pluma-app/src/util.rs
- nueva def ahora_unix
- nueva def etiqueta_backend
- nueva def etiqueta_intencion
- nueva def etiqueta_tipo
- nueva def expandir_ruta
- nueva def extension_lower
- nueva def recortar
- nueva def ruta_sled
00_unanchay/pluma/pluma-app/src/view.rs
- nueva def VIEWPORT
- nueva fn barra_find
- nueva fn barra_status
- nueva fn cartel_diff
- nueva fn divider
- nueva fn menubar_spec
- nueva fn panel_documentos
- nueva fn panel_editor
- nueva fn panel_llm
- nueva fn seccion_archivo
- nueva fn seccion_hijas
- nueva fn seccion_historial
- nueva def vista
- nueva fn vista_diff
- nueva def vista_overlay
00_unanchay/pluma/pluma-core/src/lib.rs
- nueva enum CoherenceState pub
- nueva struct NarrativeAtom pub
- nueva fn branch_shares_content_arc
- nueva fn depends_on pub
- nueva fn depends_on_dedups
- nueva fn hash_matches pub
- nueva fn new pub
- nueva fn new_atom_is_valid_with_matching_hash
- nueva fn set_content pub
- nueva fn set_content_rehashes_and_marks_pending
- nueva fn sha256 pub
- nueva fn tampered_content_fails_hash_check
- nueva mod tests
00_unanchay/pluma/pluma-cuerpo/src/lib.rs
- nueva struct Cuerpo pub
- nueva enum Intencion pub
- nueva type Lengua pub
- nueva struct MetaCuerpo pub
- nueva fn agregar pub
- nueva fn agregar_insertar_remover_mover_actualiza_modificado
- nueva fn ahora_test
- nueva fn con_lengua pub
- nueva fn deriva_de pub
- nueva fn derivado_exige_madre
- nueva fn deserializar pub
- nueva fn es_derivada pub
- nueva fn es_derivado pub
- nueva fn es_stale pub
- nueva fn insertar pub
- nueva fn insertar_mas_alla_del_final_aterriza_al_final
- nueva fn intencion_es_derivada_distingue_original_de_lo_demas
- nueva fn marcar_fresco pub
- nueva fn marcar_fresco_actualiza_ambos_timestamps
- nueva fn mover pub
- nueva fn nuevo pub
- nueva fn nuevo_es_consistente_y_vacio
- nueva fn original_no_puede_tener_madre
- nueva fn posicion pub
- nueva mod pruebas
- nueva fn remover pub
- nueva fn roundtrip_postcard_es_simetrico
- nueva fn serializar pub
- nueva fn stale_solo_aplica_si_madre_se_modifico_despues
- nueva fn valida_consistencia pub
00_unanchay/pluma/pluma-deck-app/src/main.rs
- nueva struct Deck
- nueva struct Historial
- nueva struct Model
- nueva type Model
- nueva enum Modo
- nueva enum Msg
- nueva type Msg
- nueva def PANEL_INICIAL
- nueva fn abrir
- nueva fn app_menu
- nueva fn bienvenida
- nueva fn comando_a_msg
- nueva fn deshacer
- nueva fn deshacer_rehacer_round_trip
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn menubar_spec
- nueva fn new
- nueva fn on_key
- nueva fn on_wheel
- nueva mod pruebas
- nueva fn registrar
- nueva fn registrar_tras_deshacer_limpia_el_futuro
- nueva fn rehacer
- nueva fn respeta_el_tope_descartando_lo_mas_viejo
- nueva fn title
- nueva fn update
- nueva fn view
- nueva fn view_overlay
- nueva fn viewport_of
00_unanchay/pluma/pluma-deck-core/src/adaptador.rs
- nueva fn atom
- nueva fn como_texto
- nueva fn empujar
- nueva fn parrafos_sueltos_sin_encabezado_forman_un_slide_sin_titulo
- nueva fn recorrido_desde_atomos pub
- nueva fn recorrido_desde_atomos_rutea_en_orden_de_lectura
- nueva fn recorrido_desde_cuerpo pub
- nueva fn recorrido_desde_cuerpo_resuelve_el_orden_y_omite_faltantes
- nueva fn slides_agrupan_por_encabezado
- nueva fn slides_desde_textos pub
- nueva mod tests
00_unanchay/pluma/pluma-deck-core/src/camara.rs
- nueva struct Camara pub
- nueva enum Ease pub
- nueva def FIT_MARGEN pub
- nueva def PANEL
- nueva struct Rect pub
- nueva def ZOOM_MAX pub
- nueva def ZOOM_MIN pub
- nueva fn aplicar pub
- nueva fn aprox
- nueva fn centro pub
- nueva fn centro_cae_en_centro_de_panel
- nueva fn default
- nueva fn delta_pantalla_a_mundo pub
- nueva fn ease_suave_extremos_y_simetria
- nueva fn fit pub
- nueva fn fit_centra_y_encuadra
- nueva fn interpolar pub
- nueva fn interpolar_extremos_y_zoom_logaritmico
- nueva fn new pub
- nueva fn new pub
- nueva fn pan pub
- nueva fn pan_mueve_el_punto_bajo_el_cursor
- nueva fn round_trip_world_screen_con_giro
- nueva fn round_trip_world_screen_sin_giro
- nueva fn screen_to_world pub
- nueva mod tests
- nueva fn world_to_screen pub
- nueva fn zoom_a_cursor pub
- nueva fn zoom_a_cursor_deja_fijo_el_punto_bajo_el_cursor
- nueva fn zoom_a_cursor_respeta_clamps
00_unanchay/pluma/pluma-deck-core/src/lib.rs
- nueva def DRAG_DECISION_PX pub
- nueva struct DeckState pub
- nueva enum DragOutcome pub
- nueva def HORIZONTAL_BIAS pub
- nueva struct SnapResult pub
- nueva mod adaptador pub
- nueva mod camara pub
- nueva fn goto pub
- nueva fn goto_updates_index_and_offset
- nueva fn horizontal_drag_starts_after_threshold
- nueva fn new pub
- nueva fn pointer_down pub
- nueva fn pointer_end pub
- nueva fn pointer_move pub
- nueva mod recorrido pub
- nueva fn reposition pub
- nueva fn snap_clamps_to_bounds
- nueva fn snap_rounds_to_nearest_page
- nueva mod tests
- nueva fn vertical_drag_is_cancelled
00_unanchay/pluma/pluma-deck-core/src/recorrido.rs
- nueva struct Autoplay pub
- nueva enum ContenidoMarco pub
- nueva def DURACION_PASO_S pub
- nueva def DWELL_S pub
- nueva def MIN_MARCO pub
- nueva struct Marco pub
- nueva type MarcoId pub
- nueva def PANEL
- nueva struct Recorrido pub
- nueva struct RecorridoState pub
- nueva struct RejillaOpts pub
- nueva struct Vuelo
- nueva fn aabb pub
- nueva fn aabb_recto_coincide_con_rect_y_girado_lo_envuelve
- nueva fn activo pub
- nueva fn agregar_marco pub
- nueva fn animando pub
- nueva fn anterior pub
- nueva fn arrastrar_delta pub
- nueva fn arrastrar_delta_panea_y_cancela_vuelo
- nueva fn autoplay_al_final_sin_bucle_se_detiene_y_con_bucle_reinicia
- nueva fn autoplay_avanza_tras_el_dwell_esperando_a_que_aterrice_el_vuelo
- nueva fn autoplay_pausado_no_hace_nada
- nueva fn avanzar pub
- nueva fn avanzar_completa_el_vuelo_y_aterriza_exacto
- nueva fn bbox pub
- nueva fn bbox_une_todos_los_marcos
- nueva fn con_giro pub
- nueva fn contiene pub
- nueva fn correr_hasta_avance
- nueva fn default
- nueva fn default
- nueva fn default
- nueva fn deserializar pub
- nueva fn eliminar_marco pub
- nueva fn eliminar_marco_purga_la_ruta
- nueva fn en_rejilla pub
- nueva fn en_rejilla_coloca_y_rutea_en_orden_de_lectura
- nueva fn fit pub
- nueva fn iniciar_vuelo
- nueva fn ir_a_paso pub
- nueva fn marco pub
- nueva fn marco_con_imagen_es_agnostico_al_hit_test
- nueva fn marco_en_paso pub
- nueva fn marco_en_paso_resuelve_id
- nueva fn marco_en_punto pub
- nueva fn marco_en_punto_devuelve_el_de_arriba
- nueva fn marco_en_punto_respeta_giro
- nueva fn mover_marco pub
- nueva fn mover_marco_traslada_el_rect
- nueva fn mover_paso pub
- nueva fn mover_paso_reordena_el_guion
- nueva fn n_pasos pub
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn pan_libre_mueve_y_cancela_vuelo
- nueva fn pausa pub
- nueva fn play pub
- nueva fn pointer_down pub
- nueva fn pointer_move pub
- nueva fn pointer_up pub
- nueva fn recorrido_demo
- nueva fn redimensionar_marco pub
- nueva fn redimensionar_marco_clampa_al_minimo
- nueva fn rotar_marco pub
- nueva fn rotar_marco_acumula
- nueva fn roundtrip_postcard_preserva_marcos_ruta_y_contenido
- nueva fn saltar_a_paso pub
- nueva fn saltar_a_paso_es_instantaneo
- nueva fn serializar pub
- nueva fn siguiente pub
- nueva fn siguiente_y_anterior_respetan_bordes
- nueva mod tests
- nueva fn tick pub
- nueva fn toggle pub
- nueva fn vista_general pub
- nueva fn vista_general_vuela_a_encuadrar_todo_sin_tocar_el_paso
- nueva fn wheel pub
- nueva fn wheel_hace_zoom_y_cancela_vuelo
00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs
00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs
00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs
00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs
- nueva struct Demo
- nueva def MD_EJEMPLO
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva def PANEL_INICIAL
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn on_key
- nueva fn on_wheel
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs
- nueva def FONDO
- nueva def HUD_FONDO
- nueva def HUD_TEXTO
- nueva def IMG_CACHE
- nueva def MARCO_ACENTO
- nueva def MARCO_BORDE
- nueva def MARCO_FONDO
- nueva struct MarcoPintura
- nueva def PANEL_RECT
- nueva enum Pintura
- nueva def RUTA
- nueva def SELECCION
- nueva type Scene
- nueva def TEXTO
- nueva def TEXTO_TENUE
- nueva type Ts
- nueva def ZOOM_BASE pub
- nueva fn decodificar
- nueva fn dentro pub
- nueva fn imagen_cacheada
- nueva fn marco_local_xf
- nueva fn panel_actual pub
- nueva fn panel_set
- nueva fn pintar
- nueva fn pintar_etiqueta
- nueva fn pintar_hud
- nueva fn pintar_imagen
- nueva fn pintar_ruta
- nueva fn pintar_texto
- nueva fn recorrido_view pub
- nueva fn recorrido_view_editor pub
- nueva fn to_rect
- nueva fn vista
- nueva fn world_to_screen_affine
00_unanchay/pluma/pluma-deck-web/src/lib.rs
- nueva struct Deck pub
- nueva struct Inner
- nueva fn __unused_event_marker pub
- nueva fn clear_instant_next_frame
- nueva fn current_index pub
- nueva fn current_offset_px
- nueva fn goto pub
- nueva fn install_pointerdown
- nueva fn install_pointerend
- nueva fn install_pointermove
- nueva fn install_resize
- nueva fn mount pub
- nueva fn on_change pub
- nueva fn page_count pub
- nueva mod recorrido pub
- nueva fn set_offset
- nueva fn strip pub
00_unanchay/pluma/pluma-deck-web/src/recorrido.rs
- nueva def DWELL_MS pub
- nueva def EASE_VUELO
- nueva def EXPORT_CSS
- nueva def EXPORT_JS
- nueva struct Inner
- nueva struct MarcoExport pub
- nueva def PANEL
- nueva struct RecorridoWeb pub
- nueva def WHEEL_NORM
- nueva def ZOOM_BASE pub
- nueva fn anterior pub
- nueva fn aplicar
- nueva fn aplicar_camara
- nueva fn attr_f64
- nueva fn autoplay_activo pub
- nueva fn camara_css
- nueva fn camara_css_refleja_world_to_screen
- nueva fn camara_identidad_centra_el_origen
- nueva fn detener_autoplay pub
- nueva fn escapar_html
- nueva fn estilo
- nueva fn export_html_es_documento_autocontenido
- nueva fn export_html_sin_marcos_sigue_siendo_valido
- nueva fn exportar_html pub
- nueva fn goto pub
- nueva fn iniciar_autoplay pub
- nueva fn install_keys
- nueva fn install_pointer
- nueva fn install_wheel
- nueva fn leer_marcos
- nueva fn mount pub
- nueva fn on_change pub
- nueva fn panel
- nueva fn panel_de
- nueva fn paso_actual pub
- nueva fn recorrido_a_html pub
- nueva fn siguiente pub
- nueva mod tests
- nueva fn toggle_autoplay pub
- nueva fn vista_general pub
00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs
- nueva enum CambioAtom pub
- nueva struct EditorCuerpo pub
- nueva def SEPARADOR pub
- nueva fn agregar_parrafos_emite_crear
- nueva fn aplicar_cambios pub
- nueva fn aplicar_cambios_extiende_atom_ids_para_crear_y_remueve_eliminar
- nueva fn aplicar_cambios_remueve_atom_ids_eliminados
- nueva fn cambios_mixtos_solo_emiten_lo_que_cambia
- nueva fn cuerpo_con_atoms
- nueva fn diff pub
- nueva fn eliminar_parrafos_emite_eliminar_para_los_ids_sobrantes
- nueva fn from_cuerpo pub
- nueva fn from_cuerpo_concatena_con_separador_y_mantiene_orden
- nueva fn indice
- nueva fn mutar_un_parrafo_emite_mutar_con_uuid_preservado
- nueva fn parrafos pub
- nueva fn parrafos_split_y_trim_de_vacios
- nueva mod pruebas
- nueva fn set_texto pub
- nueva fn sin_cambios_diff_vacio
00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs
00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs
- nueva struct Demo
- nueva def METRICS
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva def VISIBLE_LINES
- nueva fn atom_siguiente
- nueva fn chip
- nueva fn derivar_por_tabla
- nueva fn formatear_estado_hebras
- nueva fn guardar
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn on_key
- nueva fn persistir
- nueva fn ref_idx
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs
- nueva def CICLO_BACKENDS
- nueva struct Demo
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva enum MsgUi
- nueva def PX_POR_LINEA
- nueva enum TrabajoLlm
- nueva fn ahora_unix
- nueva fn cache_dir
- nueva fn cargar_de_store
- nueva fn construir_chat
- nueva fn contar_stale
- nueva fn etiqueta_backend
- nueva fn from
- nueva fn init
- nueva fn initial_size
- nueva fn lanzar_trabajo
- nueva fn main
- nueva fn on_key
- nueva fn on_wheel
- nueva fn persistir_estado_ui
- nueva fn sembrar_madre_base
- nueva fn siguiente_backend
- nueva fn title
- nueva fn toolbar_view
- nueva fn trabajo_de_tipo
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs
- nueva struct Demo
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva fn conectar_daemon_si_existe
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn socket_verbo_default
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs
- nueva struct Demo
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva enum MsgUi
- nueva enum TrabajoLlm
- nueva fn ahora_unix
- nueva fn construir_chat
- nueva fn env
- nueva fn from
- nueva fn init
- nueva fn initial_size
- nueva fn lanzar_trabajo
- nueva fn main
- nueva fn title
- nueva fn toolbar_view
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs
- nueva struct Demo
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva fn conectar_daemon_si_existe
- nueva fn construir_chat_para_demo
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn socket_verbo_default
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs
- nueva struct Demo
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva fn cache_dir
- nueva fn cargar_de_store
- nueva fn conectar_daemon_si_existe
- nueva fn construir_chat
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn socket_verbo_default
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs
- nueva struct Demo
- nueva struct HijaZona
- nueva def METRICS
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva enum TrabajoLlm
- nueva def VISIBLE_LINES
- nueva fn ahora_unix
- nueva fn card_hija
- nueva fn chip
- nueva fn construir_chat
- nueva fn etiqueta
- nueva fn init
- nueva fn initial_size
- nueva fn lanzar
- nueva fn main
- nueva fn on_key
- nueva fn panel_hijas_view
- nueva fn sincronizar
- nueva fn title
- nueva fn toolbar_view
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs
- nueva struct CuerpoIde pub
- nueva def PALETA_ZONAS
- nueva fn alias_legacy_aplicar_cambios_locales_sigue_funcionando
- nueva fn aplicar_cambios pub
- nueva fn aplicar_cambios_alinea_atom_ids_con_los_nuevos_uuids
- nueva fn aplicar_cambios_locales pub
- nueva fn apply_key pub
- nueva fn apply_key_with_clipboard pub
- nueva fn atom_id_en_linea pub
- nueva fn atom_id_en_linea_atribuye_separador_al_atom_previo
- nueva fn atom_ids_de_zona pub
- nueva fn atom_ids_de_zona_devuelve_orden_correcto
- nueva fn atoms_de_zona pub
- nueva fn caret pub
- nueva fn caret_atraviesa_separador_pero_se_queda_en_linea_fundida
- nueva fn caret_helpers_son_passthrough_consistente
- nueva fn color_de_grupo pub
- nueva fn con_opciones pub
- nueva fn cuerpo_con_atoms
- nueva fn cuerpo_ide_desde_texto pub
- nueva fn cuerpo_ide_desde_texto_construye_sin_grafo_y_sincronizado
- nueva fn cuerpo_ide_view pub
- nueva fn default
- nueva fn default_es_equivalente_a_nuevo_vacio
- nueva fn diff pub
- nueva fn diff_sin_cambios_corta_temprano_y_no_toca_texto
- nueva fn editar_buffer_y_diff_emite_mutar_con_uuid_preservado
- nueva fn from_cuerpo pub
- nueva fn from_cuerpo_arranca_con_todas_las_junctions_como_separador
- nueva fn from_cuerpo_carga_texto_concatenado_y_arranca_sincronizado
- nueva fn fundir_junction pub
- nueva fn fundir_junction_quita_la_guarda_de_esa_linea
- nueva fn fundir_junction_unifica_el_color_del_grupo
- nueva fn indice
- nueva fn ir_a_zona pub
- nueva fn ir_a_zona_anterior pub
- nueva fn ir_a_zona_mueve_el_caret
- nueva fn ir_a_zona_siguiente pub
- nueva fn ir_a_zona_siguiente_y_anterior_con_wrap
- nueva fn junction_antes_del_caret pub
- nueva fn junction_antes_del_caret_apunta_a_la_correcta
- nueva fn line_tints_asigna_un_color_por_grupo
- nueva fn linea_de_junction pub
- nueva fn linea_de_junction_devuelve_la_linea_vacia_correcta
- nueva fn lineas_de_zona pub
- nueva fn lineas_de_zona_con_fusion_cubre_atoms_y_junctions
- nueva fn lineas_de_zona_simple
- nueva fn n_atoms pub
- nueva fn n_parrafos_buffer pub
- nueva fn n_parrafos_buffer_cuenta_split_actual_no_atom_ids_memorizados
- nueva fn n_zonas pub
- nueva fn n_zonas_arranca_igual_a_n_atoms
- nueva fn n_zonas_cae_cuando_fundimos_junctions
- nueva fn n_zonas_cuerpo_vacio
- nueva fn nuevo_vacio pub
- nueva fn nuevo_vacio_arranca_sincronizado
- nueva fn paleta_ciclica_repite_color_pasados_8_grupos
- nueva fn paleta_zonas_len pub
- nueva fn pendiente_sync pub
- nueva fn posicion_de_atom pub
- nueva fn posicion_de_atom_devuelve_linea_inicial_correcta
- nueva fn posicion_y_atom_id_son_inversas_para_atomos_single_line
- nueva mod pruebas
- nueva fn recargar pub
- nueva fn recargar_resetea_estado_a_cuerpo_nuevo
- nueva fn recargar_resetea_junctions_a_separador
- nueva fn recomputar_guard_lines pub
- nueva fn refrescar_guardas_si_cambio
- nueva fn seleccionar_zona pub
- nueva fn seleccionar_zona_fundida_abarca_lineas_intermedias
- nueva fn seleccionar_zona_planta_anchor_y_extiende_al_final
- nueva fn separar_junction pub
- nueva fn separar_junction_revierte_la_fusion
- nueva fn separar_revierte_color_a_grupos_originales
- nueva fn set_caret pub
- nueva fn texto_buffer pub
- nueva fn togglear_junction pub
- nueva fn togglear_junction_es_idempotente_doble_aplica
- nueva fn togglear_junction_fuera_de_rango_es_noop
- nueva fn zona_de_atom_idx pub
- nueva fn zona_de_atom_idx_mapea_grupos
- nueva fn zona_de_linea pub
- nueva fn zona_de_linea_devuelve_none_sobre_guarda
- nueva fn zona_del_caret pub
- nueva fn zona_del_caret_sigue_al_caret
00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs
- nueva struct Palette pub
- nueva fn block_view
- nueva mod cuerpo_ide pub
- nueva fn default
- nueva fn edge_segments
- nueva fn editor_view pub
- nueva fn from_theme pub
- nueva fn line_view
- nueva fn mark_view
- nueva mod multilienzo pub
- nueva mod multilienzo_editor pub
- nueva mod tests
- nueva fn tone_color pub
- nueva fn tone_label pub
- nueva fn tone_label_id pub
- nueva fn tone_labels_are_set
- nueva fn tones_have_distinct_colors
00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs
- nueva struct HebraPintada
- nueva type IndiceAtoms pub
- nueva def LIMITE
- nueva struct MultilienzoConfig pub
- nueva struct PaletaHebras pub
- nueva fn armar_multilienzo
- nueva fn atenuar_alpha
- nueva fn bloque_atom
- nueva fn carril_hebras
- nueva fn columna_cuerpo
- nueva fn cuerpo_con_atomos
- nueva fn default
- nueva fn default
- nueva fn embeddings_modulan_alpha_por_fuerza
- nueva fn intencion_label
- nueva fn mezclar
- nueva fn multilienzo_view pub
- nueva fn multilienzo_view_interactivo pub
- nueva fn multilienzo_view_resaltado pub
- nueva fn precomputar_hebras
- nueva fn precomputar_hebras_resuelve_orden_atom_a_atom_b
- nueva fn preview_text
- nueva fn preview_text_trunca_respetando_utf8
- nueva mod pruebas
- nueva fn stale_pinta_punteada_y_color_stale
- nueva fn vacio_devuelve_vista_sin_panico
- nueva fn variante_interactiva_invoca_callback_por_cada_atomo
00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs
- nueva struct ConfigMultilienzoEditor pub
- nueva struct HebraEditor
- nueva fn carril_editor
- nueva fn columna_editor
- nueva fn default
- nueva fn ide_con_textos
- nueva fn intencion_label
- nueva fn modular_alpha
- nueva fn multilienzo_editor_view pub
- nueva fn overlay_separadores_atomos
- nueva fn precomputar_hebras_alinea_centros_de_linea_con_scroll
- nueva fn precomputar_hebras_editor
- nueva fn precomputar_y_separadores
- nueva mod pruebas
- nueva fn scroll_offset_desplaza_y_de_la_hebra
- nueva fn separadores_se_computan_uno_por_juntura_entre_atomos
- nueva fn separadores_siguen_al_scroll
- nueva fn separadores_vacio_para_un_solo_atomo
- nueva fn sincronizar_scroll pub
- nueva fn sincronizar_scroll_copia_al_resto_y_clampea
- nueva fn sincronizar_scroll_desde_activo pub
- nueva fn sincronizar_scroll_es_idempotente_sin_cambios
- nueva fn stale_pinta_punteada
- nueva fn vacio_devuelve_vista_sin_panico
00_unanchay/pluma/pluma-graph-transform/src/lib.rs
- nueva fn ciclo_completo_con_ejecutor_llm_persiste_atoms_nuevos
- nueva fn indice_atoms pub
- nueva fn indice_atoms_resuelve_los_atomos_del_grafo
- nueva fn persistir_producto pub
- nueva fn persistir_producto_de_tabla_pone_atoms_nuevos_en_grafo
- nueva mod pruebas
00_unanchay/pluma/pluma-graph/src/lib.rs
- nueva struct NarrativeGraph pub
- nueva fn atoms pub
- nueva fn chain
- nueva fn contains pub
- nueva fn cycle_has_no_topological_order
- nueva fn dependents pub
- nueva fn from_atoms pub
- nueva fn get pub
- nueva fn get_mut pub
- nueva fn insert pub
- nueva fn insert_wires_adjacency
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn new pub
- nueva fn propagate_from_leaf_affects_nothing
- nueva fn propagate_marks_all_descendants_pending
- nueva fn propagate_mutation pub
- nueva mod tests
- nueva fn topological_order pub
- nueva fn topological_order_respects_dependencies
00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs
- nueva def API_VERSION
- nueva struct AnthropicClient pub
- nueva enum AnthropicContentBlock
- nueva struct AnthropicMessagesResponse
- nueva struct AnthropicUsage
- nueva def ENDPOINT_DEFAULT
- nueva struct ErrorBody
- nueva struct ErrorEnvelope
- nueva def MODEL_DEFAULT
- nueva def TIMEOUT_DEFAULT_SECS
- nueva fn complete
- nueva fn construir_payload
- nueva fn from_env pub
- nueva fn headers
- nueva fn mensaje_con_imagen_emite_bloques_image_y_text
- nueva fn model_id
- nueva fn payload_con_cache_desactivado_emite_system_plano
- nueva fn payload_con_system_cacheado_emite_bloque_ephemeral
- nueva fn payload_sin_system_no_lleva_el_campo
- nueva mod pruebas
- nueva fn roles_se_mapean_correctamente_en_el_payload
- nueva fn sin_cache_system pub
- nueva fn with_api_key pub
- nueva fn with_endpoint pub
- nueva fn with_model pub
00_unanchay/pluma/pluma-llm-cohere/src/lib.rs
- nueva struct CohereClient pub
- nueva enum CohereContentBlock
- nueva struct CohereError
- nueva struct CohereMessage
- nueva struct CohereResponse
- nueva struct CohereTokens
- nueva struct CohereUsage
- nueva def ENDPOINT_DEFAULT
- nueva def ENV_KEY
- nueva def MODEL_DEFAULT
- nueva def TIMEOUT_DEFAULT_SECS
- nueva fn complete
- nueva fn construir_payload
- nueva fn from_env pub
- nueva fn headers
- nueva fn model_id
- nueva fn parsea_response_completa_con_content_y_usage
- nueva fn payload_con_system_inserta_primer_mensaje_system
- nueva fn payload_sin_system_solo_user
- nueva mod pruebas
- nueva fn roles_assistant_pasa_como_assistant
- nueva fn with_api_key pub
- nueva fn with_endpoint pub
- nueva fn with_model pub
00_unanchay/pluma/pluma-llm-core/src/lib.rs
- nueva trait ChatClient pub
- nueva enum ChatError pub
- nueva struct ChatImage pub
- nueva struct ChatMessage pub
- nueva struct ChatRequest pub
- nueva struct ChatResponse pub
- nueva struct ChatUsage pub
- nueva enum Role pub
- nueva struct StopReason pub
- nueva fn assistant pub
- nueva fn chat_image_from_bytes_codifica_base64
- nueva fn con_sistema pub
- nueva fn con_temperatura pub
- nueva fn con_temperatura_clampea
- nueva fn encadenable_combina_sistema_y_temperatura
- nueva fn from_bytes pub
- nueva fn mensaje_con_imagenes_y_retrocompat_serde
- nueva fn new pub
- nueva mod pruebas
- nueva fn tiene_imagenes pub
- nueva fn una_vuelta pub
- nueva fn una_vuelta_construye_request_mínimo
- nueva fn user pub
- nueva fn user_con_imagenes pub
00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs
- nueva fn main
00_unanchay/pluma/pluma-llm-gemini/src/lib.rs
- nueva def ENDPOINT_BASE
- nueva def ENV_KEY_FALLBACK
- nueva def ENV_KEY_PRIMARY
- nueva struct GeminiCandidate
- nueva struct GeminiClient pub
- nueva struct GeminiContent
- nueva struct GeminiErrorBody
- nueva struct GeminiErrorEnvelope
- nueva struct GeminiPart
- nueva struct GeminiResponse
- nueva struct GeminiUsage
- nueva def MODEL_DEFAULT
- nueva def TIMEOUT_DEFAULT_SECS
- nueva fn assistant_se_mapea_a_role_model
- nueva fn complete
- nueva fn construir_payload
- nueva fn from_env pub
- nueva fn headers
- nueva fn mensaje_con_imagen_agrega_part_inline_data
- nueva fn model_id
- nueva fn parsea_response_con_candidates_y_usage
- nueva fn payload_con_system_lleva_systemInstruction_top_level
- nueva fn payload_sin_system_omite_systemInstruction
- nueva mod pruebas
- nueva fn url_generate
- nueva fn url_generate_incluye_modelo_y_endpoint
- nueva fn with_api_key pub
- nueva fn with_endpoint_base pub
- nueva fn with_model pub
00_unanchay/pluma/pluma-llm-mock/src/lib.rs
- nueva enum Donde
- nueva struct MockChatClient pub
- nueva fn complete
- nueva fn con_eco_prefix pub
- nueva fn con_model_id pub
- nueva fn con_respuesta pub
- nueva fn con_respuesta_si_system pub
- nueva fn con_respuesta_si_system_diferencia_acciones_con_mismo_user
- nueva fn default
- nueva fn eco_cae_cuando_no_hay_coincidencia
- nueva fn model_id
- nueva fn model_id_y_eco_prefix_configurables
- nueva fn primer_match_de_la_tabla_gana
- nueva mod pruebas
- nueva fn rt
- nueva fn tabla_gana_sobre_eco
- nueva fn ultimo_user
- nueva fn usa_el_ultimo_mensaje_user_aunque_haya_assistant_intercalado
00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs
- nueva def DEEPSEEK_ENDPOINT
- nueva def DEEPSEEK_ENV
- nueva def DEEPSEEK_MODEL_DEFAULT
- nueva def OLLAMA_ENDPOINT_DEFAULT
- nueva struct OpenAiChatResponse
- nueva struct OpenAiChoice
- nueva struct OpenAiCompatibleClient pub
- nueva struct OpenAiErrorBody
- nueva struct OpenAiErrorEnvelope
- nueva struct OpenAiMessageOut
- nueva struct OpenAiUsage
- nueva struct PromptTokensDetails
- nueva def TIMEOUT_DEFAULT_SECS
- nueva fn complete
- nueva fn construir_payload
- nueva fn custom pub
- nueva fn deepseek_from_env pub
- nueva fn headers
- nueva fn model_id
- nueva fn ollama_local pub
- nueva fn payload_con_system_inserta_primer_mensaje_system
- nueva fn payload_sin_system_solo_pone_user
- nueva fn presets_construyen_endpoints_y_modelos_esperados
- nueva mod pruebas
- nueva fn roles_se_mapean_user_assistant
- nueva fn with_model pub
- nueva fn with_model_encadena
- nueva fn with_timeout pub
00_unanchay/pluma/pluma-llm/src/lib.rs
- nueva enum BackendKind pub
- nueva enum BuildError pub
- nueva struct LlmConfig pub
- nueva fn build_client pub
- nueva fn build_mock_y_devuelve_chat_client_funcional
- nueva fn build_ollama_con_model_y_endpoint_custom
- nueva fn build_ollama_sin_model_es_config_error
- nueva fn default
- nueva fn default_kind_es_mock
- nueva fn detectar_backend_por_env
- nueva fn from_env pub
- nueva fn parse pub
- nueva fn parse_acepta_aliases
- nueva mod pruebas
- nueva fn serde_roundtrip_de_llmconfig
00_unanchay/pluma/pluma-md-reader-web/src/lib.rs
- nueva struct Reader pub
- nueva fn clear pub
- nueva fn container pub
- nueva fn new pub
- nueva fn open_url pub
- nueva fn render_html pub
- nueva fn render_md pub
- nueva fn show_error pub
- nueva fn show_loading pub
00_unanchay/pluma/pluma-md/src/export.rs
- nueva fn cuerpo_y_atoms
- nueva mod pruebas
- nueva fn to_md pub
- nueva fn to_md_borrow pub
- nueva fn to_md_borrow_y_to_md_dan_el_mismo_resultado
- nueva fn to_md_concatena_en_orden_con_doble_newline
- nueva fn to_md_devuelve_vacio_si_cuerpo_vacio
- nueva fn to_md_preserva_prefijos_de_heading
- nueva fn to_md_salta_atoms_ausentes_del_indice
00_unanchay/pluma/pluma-md/src/import.rs
- nueva struct DocumentoImportado pub
- nueva fn blockquote_se_emite_como_bloque
- nueva fn bloques_planos
- nueva fn code_block_se_preserva_como_bloque
- nueva fn cuerpo_y_atoms_referencian_los_mismos_uuids
- nueva fn encabezado_lleva_prefijo_hash_segun_nivel
- nueva fn formato_inline_se_aplana_a_texto
- nueva fn lineas_vacias_no_producen_atoms_huerfanos
- nueva fn lista_genera_un_atom_por_item
- nueva fn parrafos_basicos_se_separan
- nueva fn parse_md pub
- nueva mod pruebas
- nueva fn separador_horizontal_corta_el_bloque
- nueva fn textos
00_unanchay/pluma/pluma-md/src/lib.rs
- nueva fn default_options pub
- nueva fn events pub
- nueva mod export pub
- nueva mod import pub
- nueva fn renders_code_fence
- nueva fn renders_h1
- nueva fn renders_list
- nueva fn renders_table_gfm
- nueva mod tests
- nueva fn themed_wrapper_sanitizes_theme_name
- nueva fn to_html pub
- nueva fn to_themed_html pub
00_unanchay/pluma/pluma-notebook-core/src/cell.rs
- nueva struct Cell pub
- nueva type CellId pub
- nueva enum CellKind pub
- nueva struct CellOutput pub
- nueva enum CellState pub
- nueva enum OutputPayload pub
- nueva struct Position pub
- nueva fn cell
- nueva fn content_hash pub
- nueva fn empty pub
- nueva fn kind_changes_the_hash
- nueva fn language_changes_the_hash
- nueva fn last_output_does_not_change_the_hash
- nueva fn new pub
- nueva fn output_payload_port_kind
- nueva fn port_kind pub
- nueva fn position_does_not_change_the_hash
- nueva fn same_content_hashes_equal
- nueva fn tag
- nueva mod tests
- nueva fn text pub
00_unanchay/pluma/pluma-notebook-core/src/notebook.rs
- nueva struct Notebook pub
- nueva fn add_dependency pub
- nueva fn add_dependency_rejects_cycles
- nueva fn add_dependency_rejects_self_and_missing
- nueva fn all_digests
- nueva fn cell pub
- nueva fn cell_digest_reflects_upstream_changes
- nueva fn cell_mut
- nueva fn cells pub
- nueva fn chain
- nueva fn code
- nueva fn dependency_order_does_not_affect_digest
- nueva fn dependents pub
- nueva fn dependents_transitive pub
- nueva fn dependents_transitive_of_leaf_is_empty
- nueva fn dependents_transitive_walks_the_cone
- nueva fn depends_transitively
- nueva fn digest pub
- nueva fn editing_a_cell_propagates_staleness
- nueva fn editing_a_leaf_does_not_stale_its_ancestors
- nueva fn editing_a_source_changes_the_digest
- nueva fn embed_cells_carry_their_module
- nueva fn execution_order pub
- nueva fn execution_order_respects_dependencies
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn new pub
- nueva fn notebook_digest pub
- nueva fn notebook_digest_is_stable_across_calls
- nueva fn position pub
- nueva fn position_does_not_affect_digest
- nueva fn position_round_trips_and_is_optional
- nueva fn propagate_stale pub
- nueva fn push pub
- nueva fn push_keeps_display_order
- nueva fn set_last_output pub
- nueva fn set_position pub
- nueva fn set_source pub
- nueva fn set_state pub
- nueva mod tests
00_unanchay/pluma/pluma-notebook-exec/src/lib.rs
- nueva trait Kernel pub
- nueva enum KernelError pub
- nueva struct KernelMock pub
- nueva type KernelOutput pub
- nueva struct RunReport pub
- nueva fn embed_no_invoca_al_kernel
- nueva fn execute
- nueva fn falla_en_b_skipea_a_c
- nueva fn falla_persiste_el_error_como_output
- nueva fn falla_si_contiene
- nueva fn kernel_grabador
- nueva fn markdown_no_invoca_al_kernel
- nueva fn new pub
- nueva fn notebook_cadena
- nueva fn ok_kernel
- nueva fn run_all pub
- nueva fn run_all_marca_fresh_cuando_todo_ok
- nueva fn run_all_persiste_output_en_la_celda
- nueva fn run_from pub
- nueva fn run_from_de_una_hoja_solo_corre_esa
- nueva fn run_from_inexistente_devuelve_none
- nueva fn run_from_solo_toca_el_cono
- nueva fn run_subset
- nueva mod tests
00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_demo.rs
00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_dominium_demo.rs
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva struct PreviewImage
- nueva struct Showcase
- nueva fn decode_png
- nueva fn futures_block_on
- nueva fn init
- nueva fn initial_size
- nueva fn main
- nueva fn preview_panel_view
- nueva fn spawn_decode_preview
- nueva fn spawn_exec_from
- nueva fn title
- nueva fn update
- nueva fn view
00_unanchay/pluma/pluma-notebook-graph-llimphi/src/lib.rs
- nueva struct AutoLayout pub
- nueva struct CountingKernel
- nueva struct ResolvedPosition pub
- nueva fn apply_connect pub
- nueva fn apply_connect_adds_dependency_and_propagates_stale
- nueva fn apply_connect_and_exec pub
- nueva fn apply_connect_and_exec_corre_solo_si_conecta
- nueva fn apply_connect_and_exec_devuelve_none_si_ciclo
- nueva fn apply_connect_refuses_cycles
- nueva fn apply_connect_refuses_self_loops
- nueva fn apply_drag pub
- nueva fn apply_drag_accumulates_deltas
- nueva fn apply_drag_clamps_negative_coordinates
- nueva fn apply_drag_creates_position_if_missing
- nueva fn auto_layout_ranks_by_dag_depth
- nueva fn build_nodegraph pub
- nueva fn build_produces_one_node_per_cell_and_one_wire_per_edge
- nueva fn chain
- nueva fn default
- nueva fn exec_from pub
- nueva fn exec_from_solo_corre_el_cono
- nueva fn execute
- nueva fn first_line_summary
- nueva fn label_includes_kind_prefix_and_snippet
- nueva fn manual_position_overrides_auto_layout
- nueva fn new
- nueva fn node_label
- nueva fn notebook_graph_view pub
- nueva fn notebook_graph_view_with_exec pub
- nueva fn output_port_label
- nueva fn output_port_reflects_last_output
- nueva fn resolve_positions
- nueva mod tests
00_unanchay/pluma/pluma-notebook-kernel-llm/examples/notebook_llm_demo.rs
- nueva def TEXTO_FUENTE
- nueva fn construir_chat
- nueva fn main
00_unanchay/pluma/pluma-notebook-kernel-llm/src/lib.rs
- nueva enum Accion
- nueva struct LlmKernel pub
- nueva fn con_max_tokens pub
- nueva fn con_temperatura pub
- nueva fn construir_prompt
- nueva fn execute
- nueva fn from_arc pub
- nueva fn language_no_soportado_es_runtime_error
- nueva fn new pub
- nueva fn parsear_language
- nueva fn parsear_language_cubre_los_lenguajes_soportados
- nueva fn prompt_libre_no_pone_system
- nueva mod pruebas
- nueva fn reescribir_separa_prompt_y_texto_por_la_primera_nueva_linea
- nueva fn resumir_con_palabras_objetivo_lo_anota_en_system
- nueva fn rt
- nueva fn traducir_fuerza_temperatura_baja
- nueva fn traducir_pide_al_chat_y_devuelve_payload_text
00_unanchay/pluma/pluma-notebook-kernel-media/src/lib.rs
- nueva struct Canvas
- nueva struct Config
- nueva enum DynSource
- nueva struct MediaKernel pub
- nueva enum Op
- nueva fn caps_reject_oversized
- nueva fn channels
- nueva fn default
- nueva fn duration_seconds
- nueva fn execute
- nueva fn fill
- nueva fn fill_rect
- nueva fn filled
- nueva fn fmin_fmax_validated
- nueva fn heat_rgba
- nueva fn hline
- nueva fn into_png
- nueva fn lin_to_db
- nueva fn missing_op_fails
- nueva fn missing_source_fails
- nueva fn new pub
- nueva fn open
- nueva fn parse
- nueva fn parse_f32
- nueva fn parse_minimal_config
- nueva fn parse_u32
- nueva fn parse_usize
- nueva fn parse_waterfall_with_opts
- nueva fn rejects_unknown_keys
- nueva fn run
- nueva fn run_levels
- nueva fn run_waterfall
- nueva fn run_waveform
- nueva fn sample_rate
- nueva fn set_px
- nueva fn strip_comment
- nueva mod tests
- nueva fn unknown_op_fails
- nueva fn vline
00_unanchay/pluma/pluma-notebook-kernel-multi/src/lib.rs
- nueva struct MultiKernel pub
- nueva fn default
- nueva fn execute
- nueva fn new pub
00_unanchay/pluma/pluma-notebook-kernel-python/src/lib.rs
- nueva struct PythonKernel pub
- nueva def STDOUT_CAPTURE_PREAMBLE
- nueva def STDOUT_READ_EXPR
- nueva fn eval_or_exec
- nueva fn excepcion_es_runtime_error
- nueva fn execute
- nueva fn expresion_aritmetica_devuelve_escalar
- nueva fn expresion_string_devuelve_text
- nueva fn float_va_a_scalar
- nueva fn format_pyerr
- nueva fn format_pyerr_with_stdout
- nueva fn install_stdout_capture
- nueva fn lenguaje_desconocido_falla
- nueva fn merge_stdout
- nueva fn new pub
- nueva fn print_antes_de_error_se_preserva_en_el_traceback
- nueva fn print_en_bloque_exec
- nueva fn print_va_a_stdout
- nueva fn print_y_valor_conviven
- nueva fn read_captured_stdout
- nueva fn scalar_payload
- nueva fn statement_bloque_no_devuelve_valor
- nueva mod tests
- nueva fn value_to_output
- nueva fn varios_prints_se_concatenan
00_unanchay/pluma/pluma-notebook-kernel-tinkuy/src/lib.rs
- nueva struct Outcome
- nueva struct Params
- nueva struct Raster
- nueva struct SplitMix64
- nueva struct TinkuyKernel pub
- nueva fn celda_vacia_produce_png_con_defaults
- nueva fn clave_desconocida_falla_explicito
- nueva fn default
- nueva fn determinismo_misma_seed_mismo_cid
- nueva fn disc
- nueva fn distintas_seeds_distinto_cid
- nueva fn execute
- nueva fn hex32
- nueva fn integracion_run_from_con_celda_tinkuy
- nueva fn into_png
- nueva fn lenguaje_desconocido_devuelve_runtime_error
- nueva fn lerp_rgba
- nueva fn line
- nueva fn new pub
- nueva fn new
- nueva fn new
- nueva fn next_centered
- nueva fn next_u64
- nueva fn parse
- nueva fn parse_f32
- nueva fn parse_u32
- nueva fn parse_u64
- nueva fn parse_usize
- nueva fn parsea_keys_con_comentarios_y_hex_seed
- nueva fn project
- nueva fn put
- nueva fn render_png
- nueva fn run_sim
- nueva fn side_excesivo_falla
- nueva fn strip_comment
- nueva mod tests
00_unanchay/pluma/pluma-notebook-kernel-wasm/src/lib.rs
- nueva def DEFAULT_FUEL pub
- nueva struct Host
- nueva enum InternalError
- nueva struct WasmKernel pub
- nueva fn _start_es_aceptado_como_entry
- nueva fn assert_scalar
- nueva fn default
- nueva fn devuelve_escalar_f64
- nueva fn devuelve_escalar_i32
- nueva fn execute
- nueva fn first_scalar
- nueva fn fuel_cap_corta_bucle_infinito
- nueva fn integracion_run_from_con_kernel_real
- nueva fn lenguaje_desconocido_devuelve_runtime_error
- nueva fn modulo_sin_entry_falla
- nueva fn new pub
- nueva fn print_concatena_stdout
- nueva fn run
- nueva mod tests
- nueva fn wat_invalido_falla
- nueva fn with_fuel pub
- nueva fn zero_val
00_unanchay/pluma/pluma-notebook-llimphi/src/main.rs
- nueva def APPROX_VIEWPORT_H
- nueva def CANVAS_BODY_H
- nueva def CANVAS_BODY_LINES_VISIBLE
- nueva def CANVAS_CARD_H
- nueva def CANVAS_CARD_H_EDITING
- nueva def CANVAS_CARD_W
- nueva def CANVAS_EDITOR_H
- nueva def CANVAS_FOOTER_H
- nueva def CANVAS_HEADER_H
- nueva struct EditState
- nueva def LINEAR_MAX_BODY_CHARS
- nueva def LINEAR_MAX_BODY_LINES
- nueva enum Mode
- nueva struct Model
- nueva type Model
- nueva enum Msg
- nueva type Msg
- nueva struct Palette
- nueva def RUN_BTN_SIZE
- nueva def SCROLLBAR_W
- nueva def STEP
- nueva struct Viewer
- nueva fn app_menu
- nueva fn bounds_of_cells
- nueva fn canvas_card_scaled
- nueva fn canvas_view
- nueva fn card_header_body
- nueva fn card_with_height
- nueva fn demo_notebook
- nueva fn edge_segments
- nueva fn edit_button_view
- nueva fn edit_input_view
- nueva fn fit_all
- nueva fn format_output
- nueva fn from_theme
- nueva fn handle_menu_command
- nueva fn header_bar
- nueva fn init
- nueva fn initial_size
- nueva fn kind_label
- nueva fn language_of
- nueva fn line_view
- nueva fn linear_body_height
- nueva fn linear_card
- nueva fn linear_view
- nueva fn main
- nueva fn menubar_spec
- nueva fn notebook_edit_flags
- nueva fn on_key
- nueva fn on_wheel
- nueva fn orphan_notice
- nueva fn output_footer
- nueva fn run_button_view
- nueva fn scrollbar_v
- nueva fn short_hex
- nueva fn state_label
- nueva fn title
- nueva fn truncate_line
- nueva fn truncate_source
- nueva fn update
- nueva fn vertical_bounds
- nueva fn view
- nueva fn view_overlay
- nueva fn viewport_to_fit
00_unanchay/pluma/pluma-notebook-store/src/lib.rs
- nueva struct Envelope
- nueva enum Error pub
- nueva type Result pub
- nueva def SCHEMA pub
- nueva fn load pub
- nueva fn notebook_ejemplo
- nueva fn save pub
- nueva fn save_es_atomico_no_deja_tmp
- nueva fn save_load_roundtrip_preserva_digest
- nueva fn schema_desconocida_falla
- nueva mod tests
- nueva fn tmp_path
00_unanchay/pluma/pluma-render-plan/src/lib.rs
- nueva struct AtomBlock pub
- nueva enum CoherenceTone pub
- nueva struct Edge pub
- nueva struct LayoutConfig pub
- nueva struct RenderPlan pub
- nueva struct SidepaneMark pub
- nueva fn blocks_are_stacked_by_topological_depth
- nueva fn branches_land_in_separate_columns
- nueva fn build_plan pub
- nueva fn chain
- nueva fn chain_has_one_edge_per_dependency
- nueva fn coherence_states_map_to_tones
- nueva fn default
- nueva fn depths
- nueva fn edge_connects_dependency_bottom_to_dependent_top
- nueva fn empty_graph_yields_empty_plan
- nueva fn intensity_is_normalized_to_unit_range
- nueva fn of
- nueva fn one_block_and_one_mark_per_atom
- nueva fn plan_is_deterministic
- nueva fn preview
- nueva fn preview_truncates_long_content
- nueva fn raw_intensity
- nueva mod tests
00_unanchay/pluma/pluma-semantic/src/lib.rs
- nueva struct ConceptSet pub
- nueva struct SemanticScorer pub
- nueva fn atom_matching_a_concept_anchor_scores_near_one
- nueva fn build pub
- nueva fn concept_list
- nueva fn concepts pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn new pub
- nueva fn rescoring_replaces_previous_vectors
- nueva fn score pub
- nueva fn score_fills_one_entry_per_concept
- nueva mod tests
00_unanchay/pluma/pluma-store/src/lib.rs
- nueva struct GraphStore pub
- nueva enum StoreError pub
- nueva fn get_atom pub
- nueva fn is_empty pub
- nueva fn len pub
- nueva fn load_graph pub
- nueva mod multilienzo pub
- nueva fn open pub
- nueva fn put_atom pub
- nueva fn put_get_remove_roundtrip
- nueva fn remove_atom pub
- nueva fn save_and_load_graph_preserves_structure
- nueva fn save_graph pub
- nueva fn temp_store
- nueva mod tests
00_unanchay/pluma/pluma-store/src/multilienzo.rs
- nueva struct EstadoUi pub
- nueva def KEY_ESTADO_UI
- nueva enum MultilienzoError pub
- nueva struct PlumaStore pub
- nueva def TREE_ATOMS
- nueva def TREE_CARTAS
- nueva def TREE_CUERPOS
- nueva def TREE_TRANSFORMACIONES
- nueva def TREE_UI
- nueva fn atom_roundtrip_y_count
- nueva fn atoms_len pub
- nueva fn carta_con_par_roundtrip_por_clave_y_bidir
- nueva fn carta_sin_par_no_persiste
- nueva fn cartas_len pub
- nueva fn clave_carta
- nueva fn clave_de_par
- nueva fn cuerpo_remove_devuelve_none
- nueva fn cuerpo_roundtrip_conserva_metadatos_y_orden
- nueva fn cuerpos_len pub
- nueva fn estado_ui_roundtrip_y_default_si_vacio
- nueva fn flush pub
- nueva fn flush_no_pierde_dato_tras_reabrir
- nueva fn get_atom pub
- nueva fn get_carta pub
- nueva fn get_carta_bidir pub
- nueva fn get_cuerpo pub
- nueva fn get_estado_ui pub
- nueva fn get_transformacion pub
- nueva fn iter_atoms pub
- nueva fn iter_cartas pub
- nueva fn iter_cuerpos pub
- nueva fn iter_recupera_todos
- nueva fn iter_transformaciones pub
- nueva fn open pub
- nueva mod pruebas
- nueva fn put_atom pub
- nueva fn put_carta pub
- nueva fn put_cuerpo pub
- nueva fn put_estado_ui pub
- nueva fn put_transformacion pub
- nueva fn remove_cuerpo pub
- nueva fn store_temp
- nueva fn transformacion_roundtrip_y_filtro_por_madre
- nueva fn transformaciones_de pub
00_unanchay/pluma/pluma-transform-llm/src/lib.rs
- nueva struct ConfigAplicacion
- nueva struct EjecutorReescribirLlm pub
- nueva struct EjecutorResumirLlm pub
- nueva struct EjecutorTonoLlm pub
- nueva struct EjecutorTraducirLlm pub
- nueva fn aplicar
- nueva fn aplicar
- nueva fn aplicar
- nueva fn aplicar
- nueva fn aplicar_con_atoms pub
- nueva fn aplicar_con_atoms pub
- nueva fn aplicar_con_atoms pub
- nueva fn aplicar_con_atoms pub
- nueva fn con_system_prompt pub
- nueva fn ejecutar_lote
- nueva fn etiqueta_intencion
- nueva fn from_arc pub
- nueva fn from_arc pub
- nueva fn from_arc pub
- nueva fn from_arc pub
- nueva fn indice
- nueva fn limpiar_respuesta
- nueva fn limpiar_respuesta_quita_comillas_envolventes
- nueva fn madre_es
- nueva fn madre_vacia_es_madre_invalida
- nueva fn mapear_chat_error
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva fn new pub
- nueva mod pruebas
- nueva fn resumir_palabras_objetivo_debe_coincidir
- nueva fn tono_emite_intencion_tono_con_etiqueta
- nueva fn traducir_lengua_mismatch_devuelve_backend_error
- nueva fn traducir_llm_emite_una_request_por_atom_y_arma_hija
- nueva fn trait_aplicar_sin_atoms_falla_con_mensaje_orientativo
00_unanchay/pluma/pluma-transform-tabla/src/lib.rs
- nueva struct EjecutorTraducirTabla pub
- nueva fn aplicar
- nueva fn con_branch_suffix pub
- nueva fn con_branch_suffix_override_se_aplica_al_hija
- nueva fn lengua_destino_inconsistente_devuelve_backend_error
- nueva fn madre_es
- nueva fn new pub
- nueva mod pruebas
- nueva fn tabla_con_huecos_omite_atoms_sin_traduccion
- nueva fn tabla_que_no_cubre_la_madre_es_madre_invalida
- nueva fn tipo_no_traducir_devuelve_tipo_no_soportado
- nueva fn traduce_madre_completa_y_genera_hebras_derivadas
00_unanchay/pluma/pluma-transform/src/lib.rs
- nueva trait Ejecutor pub
- nueva struct EjecutorIdentidad pub
- nueva enum ErrorEjecutor pub
- nueva struct ProductoTransformacion pub
- nueva enum TipoTransformacion pub
- nueva struct Transformacion pub
- nueva fn aplicar
- nueva fn hija_de_identidad_marca_stale_si_madre_cambia_despues
- nueva fn identidad_produce_hija_con_mismos_uuids_y_carta_uno_a_uno
- nueva fn identidad_rechaza_otros_tipos
- nueva fn intencion_natural pub
- nueva fn intencion_natural_de_cada_tipo
- nueva fn madre_de_3_atomos
- nueva fn marcar_regenerada pub
- nueva fn nueva pub
- nueva mod pruebas
- nueva fn transformacion_se_marca_regenerada
195 archivos · +38852 −0
- .gitignore +3 −0
- 00_unanchay/pluma/LEEME.md +91 −0
- 00_unanchay/pluma/PRUEBAS.md +390 −0
- 00_unanchay/pluma/README.md +46 −0
- 00_unanchay/pluma/README.qu.md +43 −0
- 00_unanchay/pluma/foreign-docx/Cargo.toml +16 −0
- 00_unanchay/pluma/foreign-docx/src/lib.rs +460 −0
- 00_unanchay/pluma/pluma-align-embeddings/Cargo.toml +21 −0
- 00_unanchay/pluma/pluma-align-embeddings/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-align-embeddings/README.md +19 −0
- 00_unanchay/pluma/pluma-align-embeddings/src/lib.rs +372 −0
- 00_unanchay/pluma/pluma-align/Cargo.toml +14 −0
- 00_unanchay/pluma/pluma-align/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-align/README.md +18 −0
- 00_unanchay/pluma/pluma-align/src/lib.rs +385 −0
- 00_unanchay/pluma/pluma-app/Cargo.toml +46 −0
- 00_unanchay/pluma/pluma-app/LEEME.md +16 −0
- 00_unanchay/pluma/pluma-app/README.md +16 −0
- 00_unanchay/pluma/pluma-app/src/clipboard.rs +30 −0
- 00_unanchay/pluma/pluma-app/src/init.rs +149 −0
- 00_unanchay/pluma/pluma-app/src/main.rs +200 −0
- 00_unanchay/pluma/pluma-app/src/model.rs +168 −0
- 00_unanchay/pluma/pluma-app/src/update.rs +1004 −0
- 00_unanchay/pluma/pluma-app/src/util.rs +101 −0
- 00_unanchay/pluma/pluma-app/src/view.rs +821 −0
- 00_unanchay/pluma/pluma-core/Cargo.toml +13 −0
- 00_unanchay/pluma/pluma-core/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-core/README.md +19 −0
- 00_unanchay/pluma/pluma-core/src/lib.rs +138 −0
- 00_unanchay/pluma/pluma-cuerpo/Cargo.toml +14 −0
- 00_unanchay/pluma/pluma-cuerpo/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-cuerpo/README.md +19 −0
- 00_unanchay/pluma/pluma-cuerpo/src/lib.rs +379 −0
- 00_unanchay/pluma/pluma-deck-app/Cargo.toml +28 −0
- 00_unanchay/pluma/pluma-deck-app/src/main.rs +706 −0
- 00_unanchay/pluma/pluma-deck-core/Cargo.toml +25 −0
- 00_unanchay/pluma/pluma-deck-core/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-deck-core/README.md +18 −0
- 00_unanchay/pluma/pluma-deck-core/src/adaptador.rs +141 −0
- 00_unanchay/pluma/pluma-deck-core/src/camara.rs +257 −0
- 00_unanchay/pluma/pluma-deck-core/src/lib.rs +195 −0
- 00_unanchay/pluma/pluma-deck-core/src/recorrido.rs +850 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml +25 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs +181 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs +210 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs +163 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs +146 −0
- 00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs +463 −0
- 00_unanchay/pluma/pluma-deck-web/Cargo.toml +32 −0
- 00_unanchay/pluma/pluma-deck-web/LEEME.md +54 −0
- 00_unanchay/pluma/pluma-deck-web/README.md +19 −0
- 00_unanchay/pluma/pluma-deck-web/src/lib.rs +212 −0
- 00_unanchay/pluma/pluma-deck-web/src/recorrido.rs +603 −0
- 00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml +14 −0
- 00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md +20 −0
- 00_unanchay/pluma/pluma-editor-cuerpo/README.md +20 −0
- 00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs +352 −0
- 00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml +37 −0
- 00_unanchay/pluma/pluma-editor-llimphi/LEEME.md +13 −0
- 00_unanchay/pluma/pluma-editor-llimphi/README.md +13 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs +439 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/editor_unico_demo.rs +582 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_completo_demo.rs +995 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_demo.rs +275 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_dinamico_demo.rs +398 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_llm_demo.rs +328 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/multilienzo_store_demo.rs +353 −0
- 00_unanchay/pluma/pluma-editor-llimphi/examples/zona_transform_demo.rs +757 −0
- 00_unanchay/pluma/pluma-editor-llimphi/src/cuerpo_ide.rs +1334 −0
- 00_unanchay/pluma/pluma-editor-llimphi/src/lib.rs +322 −0
- 00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo.rs +713 −0
- 00_unanchay/pluma/pluma-editor-llimphi/src/multilienzo_editor.rs +744 −0
- 00_unanchay/pluma/pluma-graph-transform/Cargo.toml +22 −0
- 00_unanchay/pluma/pluma-graph-transform/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-graph-transform/README.md +19 −0
- 00_unanchay/pluma/pluma-graph-transform/src/lib.rs +188 −0
- 00_unanchay/pluma/pluma-graph/Cargo.toml +12 −0
- 00_unanchay/pluma/pluma-graph/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-graph/README.md +19 −0
- 00_unanchay/pluma/pluma-graph/src/lib.rs +211 −0
- 00_unanchay/pluma/pluma-llm-anthropic/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-llm-anthropic/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-llm-anthropic/README.md +19 −0
- 00_unanchay/pluma/pluma-llm-anthropic/src/lib.rs +389 −0
- 00_unanchay/pluma/pluma-llm-cohere/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-llm-cohere/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-llm-cohere/README.md +18 −0
- 00_unanchay/pluma/pluma-llm-cohere/src/lib.rs +298 −0
- 00_unanchay/pluma/pluma-llm-core/Cargo.toml +17 −0
- 00_unanchay/pluma/pluma-llm-core/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-llm-core/README.md +19 −0
- 00_unanchay/pluma/pluma-llm-core/src/lib.rs +290 −0
- 00_unanchay/pluma/pluma-llm-gemini/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-llm-gemini/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-llm-gemini/README.md +18 −0
- 00_unanchay/pluma/pluma-llm-gemini/examples/smoke.rs +37 −0
- 00_unanchay/pluma/pluma-llm-gemini/src/lib.rs +381 −0
- 00_unanchay/pluma/pluma-llm-mock/Cargo.toml +15 −0
- 00_unanchay/pluma/pluma-llm-mock/LEEME.md +23 −0
- 00_unanchay/pluma/pluma-llm-mock/README.md +23 −0
- 00_unanchay/pluma/pluma-llm-mock/src/lib.rs +238 −0
- 00_unanchay/pluma/pluma-llm-openai-compatible/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-llm-openai-compatible/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-llm-openai-compatible/README.md +18 −0
- 00_unanchay/pluma/pluma-llm-openai-compatible/src/lib.rs +352 −0
- 00_unanchay/pluma/pluma-llm/Cargo.toml +22 −0
- 00_unanchay/pluma/pluma-llm/LEEME.md +20 −0
- 00_unanchay/pluma/pluma-llm/README.md +20 −0
- 00_unanchay/pluma/pluma-llm/src/lib.rs +329 −0
- 00_unanchay/pluma/pluma-md-reader-web/Cargo.toml +25 −0
- 00_unanchay/pluma/pluma-md-reader-web/LEEME.md +21 −0
- 00_unanchay/pluma/pluma-md-reader-web/README.md +21 −0
- 00_unanchay/pluma/pluma-md-reader-web/src/lib.rs +88 −0
- 00_unanchay/pluma/pluma-md/Cargo.toml +13 −0
- 00_unanchay/pluma/pluma-md/LEEME.md +17 −0
- 00_unanchay/pluma/pluma-md/README.md +17 −0
- 00_unanchay/pluma/pluma-md/src/export.rs +116 −0
- 00_unanchay/pluma/pluma-md/src/import.rs +232 −0
- 00_unanchay/pluma/pluma-md/src/lib.rs +95 −0
- 00_unanchay/pluma/pluma-notebook-app/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-notebook-app/LEEME.md +17 −0
- 00_unanchay/pluma/pluma-notebook-app/README.md +17 −0
- 00_unanchay/pluma/pluma-notebook-app/src/main.rs +91 −0
- 00_unanchay/pluma/pluma-notebook-core/Cargo.toml +19 −0
- 00_unanchay/pluma/pluma-notebook-core/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-notebook-core/README.md +19 −0
- 00_unanchay/pluma/pluma-notebook-core/src/cell.rs +222 −0
- 00_unanchay/pluma/pluma-notebook-core/src/lib.rs +49 −0
- 00_unanchay/pluma/pluma-notebook-core/src/notebook.rs +444 −0
- 00_unanchay/pluma/pluma-notebook-exec/Cargo.toml +18 −0
- 00_unanchay/pluma/pluma-notebook-exec/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-notebook-exec/README.md +19 −0
- 00_unanchay/pluma/pluma-notebook-exec/src/lib.rs +316 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/Cargo.toml +29 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/LEEME.md +10 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/README.md +10 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_demo.rs +132 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/examples/notebook_graph_dominium_demo.rs +429 −0
- 00_unanchay/pluma/pluma-notebook-graph-llimphi/src/lib.rs +634 −0
- 00_unanchay/pluma/pluma-notebook-kernel-llm/Cargo.toml +19 −0
- 00_unanchay/pluma/pluma-notebook-kernel-llm/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-notebook-kernel-llm/README.md +18 −0
- 00_unanchay/pluma/pluma-notebook-kernel-llm/examples/notebook_llm_demo.rs +130 −0
- 00_unanchay/pluma/pluma-notebook-kernel-llm/src/lib.rs +330 −0
- 00_unanchay/pluma/pluma-notebook-kernel-media/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-notebook-kernel-media/src/lib.rs +684 −0
- 00_unanchay/pluma/pluma-notebook-kernel-multi/Cargo.toml +15 −0
- 00_unanchay/pluma/pluma-notebook-kernel-multi/src/lib.rs +54 −0
- 00_unanchay/pluma/pluma-notebook-kernel-python/Cargo.toml +22 −0
- 00_unanchay/pluma/pluma-notebook-kernel-python/LEEME.md +30 −0
- 00_unanchay/pluma/pluma-notebook-kernel-python/README.md +30 −0
- 00_unanchay/pluma/pluma-notebook-kernel-python/src/lib.rs +322 −0
- 00_unanchay/pluma/pluma-notebook-kernel-tinkuy/Cargo.toml +19 −0
- 00_unanchay/pluma/pluma-notebook-kernel-tinkuy/src/lib.rs +650 −0
- 00_unanchay/pluma/pluma-notebook-kernel-wasm/Cargo.toml +20 −0
- 00_unanchay/pluma/pluma-notebook-kernel-wasm/LEEME.md +30 −0
- 00_unanchay/pluma/pluma-notebook-kernel-wasm/README.md +30 −0
- 00_unanchay/pluma/pluma-notebook-kernel-wasm/src/lib.rs +347 −0
- 00_unanchay/pluma/pluma-notebook-llimphi/Cargo.toml +31 −0
- 00_unanchay/pluma/pluma-notebook-llimphi/LEEME.md +10 −0
- 00_unanchay/pluma/pluma-notebook-llimphi/README.md +10 −0
- 00_unanchay/pluma/pluma-notebook-llimphi/src/main.rs +1350 −0
- 00_unanchay/pluma/pluma-notebook-store/Cargo.toml +18 −0
- 00_unanchay/pluma/pluma-notebook-store/LEEME.md +20 −0
- 00_unanchay/pluma/pluma-notebook-store/README.md +20 −0
- 00_unanchay/pluma/pluma-notebook-store/src/lib.rs +115 −0
- 00_unanchay/pluma/pluma-render-plan/Cargo.toml +14 −0
- 00_unanchay/pluma/pluma-render-plan/LEEME.md +21 −0
- 00_unanchay/pluma/pluma-render-plan/README.md +21 −0
- 00_unanchay/pluma/pluma-render-plan/src/lib.rs +391 −0
- 00_unanchay/pluma/pluma-semantic/Cargo.toml +16 −0
- 00_unanchay/pluma/pluma-semantic/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-semantic/README.md +18 −0
- 00_unanchay/pluma/pluma-semantic/src/lib.rs +137 −0
- 00_unanchay/pluma/pluma-store/Cargo.toml +23 −0
- 00_unanchay/pluma/pluma-store/LEEME.md +20 −0
- 00_unanchay/pluma/pluma-store/README.md +20 −0
- 00_unanchay/pluma/pluma-store/src/lib.rs +128 −0
- 00_unanchay/pluma/pluma-store/src/multilienzo.rs +496 −0
- 00_unanchay/pluma/pluma-transform-llm/Cargo.toml +22 −0
- 00_unanchay/pluma/pluma-transform-llm/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-transform-llm/README.md +19 −0
- 00_unanchay/pluma/pluma-transform-llm/src/lib.rs +691 −0
- 00_unanchay/pluma/pluma-transform-tabla/Cargo.toml +19 −0
- 00_unanchay/pluma/pluma-transform-tabla/LEEME.md +19 −0
- 00_unanchay/pluma/pluma-transform-tabla/README.md +19 −0
- 00_unanchay/pluma/pluma-transform-tabla/src/lib.rs +304 −0
- 00_unanchay/pluma/pluma-transform/Cargo.toml +20 −0
- 00_unanchay/pluma/pluma-transform/LEEME.md +18 −0
- 00_unanchay/pluma/pluma-transform/README.md +18 −0
- 00_unanchay/pluma/pluma-transform/src/lib.rs +361 −0
- Cargo.lock +7595 −0
- Cargo.toml +484 −0
- LICENSE +21 −0
- README.md +16 −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/pluma/LEEME.md b/00_unanchay/pluma/LEEME.md
new file mode 100644
index 0000000..bb46fda
--- /dev/null
+++ b/00_unanchay/pluma/LEEME.md
@@ -0,0 +1,91 @@
+# pluma
+
+> Documentos vivos. Markdown como grafo de átomos editables; LLM como transformador, no como autor.
+
+`pluma` trata un documento como un DAG de párrafos (átomos) con identidad estable. La edición preserva ids; el LLM se invoca como **transformación pura** sobre subgrafos (resumir esta sección, traducir aquel párrafo) — siempre con diff visible y reversible. Incluye notebook (con kernels Python/WASM/LLM/cosmos/dominium), editor visual, deck (slides) y reader web.
+
+## Instalación
+
+```sh
+# editor de markdown (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# reader web (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows** — apps Llimphi nativas.
+- **Wawa** — `pluma` viaja como app del kernel (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` renderiza markdown en navegador (el reader que usa este sitio).
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`pluma-core`](pluma-core/README.md) | Modelo de documento: átomos, grafo, ids. |
+| [`pluma-cuerpo`](pluma-cuerpo/README.md) | Texto del documento como secuencia de átomos. |
+| [`pluma-store`](pluma-store/README.md) | Persistencia (`$XDG_DATA_HOME/pluma/`). |
+| [`pluma-md`](pluma-md/README.md) | Parser GFM (pulldown-cmark) → HTML temable. |
+| [`pluma-md-reader-web`](pluma-md-reader-web/README.md) | Reader markdown para WASM. |
+| [`pluma-graph`](pluma-graph/README.md) | DAG de átomos con identidad. |
+| [`pluma-graph-transform`](pluma-graph-transform/README.md) | Mutaciones del DAG (insert/mutar/eliminar). |
+| [`pluma-transform`](pluma-transform/README.md) | Marco general de transformaciones puras. |
+| [`pluma-transform-llm`](pluma-transform-llm/README.md) | Transforms LLM (resumir, traducir, ...). |
+| [`pluma-transform-tabla`](pluma-transform-tabla/README.md) | Transforms tabulares. |
+| [`pluma-llm`](pluma-llm/README.md) | Fachada `Arc<dyn ChatClient>` con autodetect. |
+| [`pluma-llm-core`](pluma-llm-core/README.md) | Trait `ChatClient`. |
+| [`pluma-llm-anthropic`](pluma-llm-anthropic/README.md) | Backend Claude API. |
+| [`pluma-llm-gemini`](pluma-llm-gemini/README.md) | Backend Gemini. |
+| [`pluma-llm-cohere`](pluma-llm-cohere/README.md) | Backend Cohere. |
+| [`pluma-llm-openai-compatible`](pluma-llm-openai-compatible/README.md) | OpenAI / DeepSeek / Ollama / proxies. |
+| [`pluma-llm-mock`](pluma-llm-mock/README.md) | Backend mock para tests. |
+| [`pluma-align`](pluma-align/README.md) | Alineamiento texto–texto. |
+| [`pluma-align-embeddings`](pluma-align-embeddings/README.md) | Alineamiento por embeddings. |
+| [`pluma-semantic`](pluma-semantic/README.md) | Anotaciones semánticas del documento. |
+| [`pluma-editor-cuerpo`](pluma-editor-cuerpo/README.md) | Editor texto↔átomos con diff (greedy). |
+| [`pluma-editor-llimphi`](pluma-editor-llimphi/README.md) | Editor visual Llimphi. |
+| [`pluma-app`](pluma-app/README.md) | Binario del editor. |
+| [`pluma-render-plan`](pluma-render-plan/README.md) | Plan de render del documento. |
+| [`pluma-deck-core`](pluma-deck-core/README.md) | Deck (slides) sobre pluma. |
+| [`pluma-deck-web`](pluma-deck-web/README.md) | Deck en navegador. |
+| [`pluma-notebook-core`](pluma-notebook-core/README.md) | Notebook: celdas + outputs addressable. |
+| [`pluma-notebook-store`](pluma-notebook-store/README.md) | Persistencia notebook. |
+| [`pluma-notebook-exec`](pluma-notebook-exec/README.md) | Despacho a kernels. |
+| [`pluma-notebook-kernel-python`](pluma-notebook-kernel-python/README.md) | Python via RustPython/WASM. |
+| [`pluma-notebook-kernel-wasm`](pluma-notebook-kernel-wasm/README.md) | WASM genérico (cranelift AOT). |
+| [`pluma-notebook-kernel-llm`](pluma-notebook-kernel-llm/README.md) | Celdas LLM. |
+| [`pluma-notebook-kernel-cosmos`](pluma-notebook-kernel-cosmos/README.md) | Kernel astronomía (cosmos-sky). |
+| [`pluma-notebook-kernel-dominium`](pluma-notebook-kernel-dominium/README.md) | Kernel simulador (dominium). |
+| [`pluma-notebook-llimphi`](pluma-notebook-llimphi/README.md) | Notebook UI Llimphi. |
+| [`pluma-notebook-graph-llimphi`](pluma-notebook-graph-llimphi/README.md) | Vista grafo del notebook (celdas como nodos). |
+| [`pluma-notebook-app`](pluma-notebook-app/README.md) | Binario del notebook. |
+
+## Estado (2026-05-31)
+
+### Hecho
+
+- Núcleo de documento vivo: `pluma-core`/`pluma-cuerpo`/`pluma-graph` (DAG de átomos con ids estables) + `pluma-graph-transform` + `pluma-store` (sled).
+- Transformaciones puras: `pluma-transform` + `pluma-transform-llm` (resumir/traducir) + `pluma-transform-tabla`, con diff visible y reversible.
+- Fachada LLM `pluma-llm` con autodetect + backends anthropic/gemini/cohere/openai-compatible/mock.
+- Alineación texto-texto (`pluma-align`) + por embeddings (`pluma-align-embeddings`) + anotaciones semánticas (`pluma-semantic`).
+- Editor visual `pluma-editor-llimphi` + binario `pluma-app`; reader web `pluma-md-reader-web`.
+- Notebook: `pluma-notebook-core`/`-exec`/`-store` + UI `pluma-notebook-llimphi` + vista grafo `pluma-notebook-graph-llimphi` + binario. Kernels reales: LLM, cosmos, dominium, media, tinkuy.
+- Deck: `pluma-deck-core`/`-web` + modo Recorrido tipo Prezi (`pluma-deck-recorrido-llimphi`) con autoría completa, persistencia (postcard), camino narrativo visible, modo presentador (autoplay/bucle) y undo/redo; binario `pluma-deck`.
+- Menú principal + menús contextuales cableados en las apps.
+
+### Pendiente
+
+- `pluma-notebook-kernel-python` (RustPython) y `-wasm` (wasmi): cimientos funcionando; falta el camino WASM-AOT cranelift completo y librerías nativas.
+- Puente `foreign-docx`: import/export DOCX aún parcial; resto de la familia `foreign-*` (xlsx/pptx/psd) no en disco.
+- Deuda del deck: split de tullpu + `Camara` (ver PLAN §6.sexies).
+
+## Consideraciones
+
+- **El LLM no escribe; transforma.** No hay "modo redacción libre" — cada llamada devuelve una mutación atómica que el usuario aprueba o rechaza.
+- Los IDs de átomo son la unidad de verdad: rename/move conservan referencias internas y links externos.
+- Kernels del notebook son **WASM-first** (sandboxing del notebook por defecto).
diff --git a/00_unanchay/pluma/PRUEBAS.md b/00_unanchay/pluma/PRUEBAS.md
new file mode 100644
index 0000000..78382ac
--- /dev/null
+++ b/00_unanchay/pluma/PRUEBAS.md
@@ -0,0 +1,390 @@
+# Cómo probar pluma — guía rápida
+
+Pluma es la familia de crates para edición de documentos multilienzo: un
+documento es un *haz de cuerpos* (lienzos) — el original, sus
+traducciones, resúmenes, anotaciones — alineados párrafo a párrafo por
+*hebras*. Esta guía muestra qué correr para ver cada pieza funcionando.
+
+> Última actualización: 2026-05-26. Las versiones de modelo y nombres de
+> env vars están vigentes al cierre de la primera iteración del stack
+> LLM+multilienzo.
+
+## TL;DR
+
+```bash
+# Tests unitarios del haz multilienzo (sin red, sin GUI):
+cargo test \
+ -p pluma-cuerpo \
+ -p pluma-align \
+ -p pluma-align-embeddings \
+ -p pluma-transform \
+ -p pluma-transform-tabla \
+ -p pluma-transform-llm \
+ -p pluma-graph-transform \
+ -p pluma-store \
+ -p pluma-llm-core \
+ -p pluma-llm-mock \
+ -p pluma-llm-anthropic \
+ -p pluma-llm-gemini \
+ -p pluma-llm-openai-compatible \
+ -p pluma-llm \
+ -p pluma-notebook-kernel-llm \
+ -p pluma-editor-llimphi
+
+# Demo visual sin red ni keys (mock pre-poblado):
+cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
+```
+
+Lo demás en esta guía es para ir cubriendo el resto del stack en
+profundidad y/o usar IAs reales.
+
+## 1. Validar tu API key contra un servicio real (gasto mínimo)
+
+Antes de meterse a generar un multilienzo entero, corré el smoke de
+Gemini para confirmar que tu key funciona. Una sola request,
+~30 tokens, fracción de centavo:
+
+```bash
+GEMINI_API_KEY=tu_key cargo run \
+ -p pluma-llm-gemini --example smoke --release
+```
+
+Salida esperada:
+
+```
+smoke :: usando modelo gemini-2.5-flash
+respuesta: Rimay
+tokens: input=28 output=2 cache_read=0 cache_creation=0
+stop_reason: MAX_TOKENS
+```
+
+Existe el mismo patrón para los otros backends en cuanto se sumen
+smokes — Anthropic y DeepSeek implementan la misma `ChatClient` y se
+prueban igual contra sus respectivas API keys.
+
+## 2. El multilienzo visual
+
+Tres demos visuales del editor, todos ejecutables sin recompilar entre
+ellos:
+
+### 2.1 Estático — datos hardcoded
+
+Lo más sencillo. No necesita ni LLM ni embeddings.
+
+```bash
+cargo run -p pluma-editor-llimphi --example multilienzo_demo --release
+```
+
+Tres cuerpos (`es` / `qu` runa simi / `en` resumen) con los cuatro
+estados de hebra: Derivada fresca verde, Embeddings azul atenuado por
+fuerza, Manual ámbar, Stale gris punteado.
+
+### 2.2 LLM-driven — un solo flujo, cinco backends
+
+El factory `pluma_llm::from_env` elige el backend según env vars.
+Cambiar de IA = una variable.
+
+```bash
+# Sin keys → mock predecible con traducciones hardcoded:
+cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Gemini real:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+ cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Anthropic:
+ANTHROPIC_API_KEY=sk-ant-... \
+ cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# DeepSeek:
+DEEPSEEK_API_KEY=... PLUMA_LLM_BACKEND=deepseek \
+ cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+
+# Ollama 100% local (requiere `ollama serve` y el modelo pulled):
+PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
+ cargo run -p pluma-editor-llimphi --example multilienzo_llm_demo --release
+```
+
+Para que las hebras `qu↔en` sean semánticamente verdaderas (no
+random del mock), levanta el embedder global en paralelo:
+
+```bash
+verbo-daemon --provider fastembed &
+# (descarga ~120 MB de multilingual-e5-small la primera vez)
+
+# después correr cualquiera de los demos
+```
+
+El demo detecta el socket en `$XDG_RUNTIME_DIR/verbo.sock` y se conecta
+automáticamente.
+
+### 2.3 Dinámico — botones de transformación
+
+Toolbar con cuatro botones (`→ qu`, `→ en`, `tono formal`, `resumir 30p`).
+Click → spawn thread con runtime tokio efímero → LLM transparente →
+columna nueva al volver. Un trabajo a la vez (los botones quedan
+deshabilitados durante la ejecución).
+
+```bash
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+ cargo run -p pluma-editor-llimphi \
+ --example multilienzo_dinamico_demo --release
+```
+
+### 2.3.0 Importar un archivo `.docx` como cuerpo madre
+
+```rust
+use foreign_docx::parse_docx;
+let bytes = std::fs::read("informe.docx")?;
+let imp = parse_docx(&bytes, "es", "informe.docx", ahora_unix())?;
+// imp.cuerpo: Original, branch_id "es", lengua None
+// imp.atoms: un NarrativeAtom por <w:p> con texto no vacío
+```
+
+Mismo shape que `pluma_md::DocumentoImportado` para que el caller los
+trate uniforme. Formato Word (negrita, cursiva, estilos, headers,
+footers, tablas, comments) se descarta — solo contenido legible.
+
+### 2.3.1 Importar un archivo `.md` como cuerpo madre
+
+```rust
+use pluma_md::parse_md;
+let texto = std::fs::read_to_string("notas.md")?;
+let imp = parse_md(&texto, "es", "notas.md", ahora_unix());
+// imp.atoms: un NarrativeAtom por bloque (párrafo, lista, encabezado…)
+// imp.cuerpo: Intencion::Original con todos los atoms en orden.
+for atom in &imp.atoms {
+ graph.insert(atom.clone());
+}
+// Pasar imp.cuerpo como madre a cualquier ejecutor LLM.
+```
+
+Formato inline (negrita, cursiva, code inline) se aplana — el LLM
+recibe texto limpio. Encabezados preservan jerarquía vía prefijo `# `,
+`## `, etc.
+
+### 2.4 Completo — toolbar dinámica CON persistencia + focus + búsqueda
+
+El más cercano a "app real": botones LLM como en 2.3, pero CADA
+transformación se persiste en `~/.cache/gioser/pluma-multilienzo-completo/`
+antes de mostrarse. Cierra el demo, volvé a abrirlo: cuerpos y hebras
+siguen ahí + podés seguir generando.
+
+```bash
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+ cargo run -p pluma-editor-llimphi \
+ --example multilienzo_completo_demo --release
+
+# Reset:
+MULTILIENZO_COMPLETO_RESET=1 cargo run -p pluma-editor-llimphi \
+ --example multilienzo_completo_demo --release
+```
+
+Tras unas cuantas corridas tendrás un haz crecido: una madre `es` con
+varias derivadas (`qu`, `en`, formal, resumen). El editor las muestra
+todas alineadas por hebras Derivadas 1↔1.
+
+**Atajos y botones del demo completo**:
+
+| Acción | Cómo |
+|---|---|
+| Derivar cuerpo nuevo | Botones `→ qu` · `→ en` · `tono formal` · `resumir 30p` |
+| Editar la madre | Botón `editar madre` (anexa marca incremental al 1er párrafo) |
+| Marcar todas las hijas stale | Botón `tocar madre` |
+| Regenerar hija stale | Botón `regenerar stale (N)` — una a la vez |
+| Cambiar IA en runtime | Botón `modelo: X` — cicla por los 6 backends |
+| Scroll horizontal | `Shift + rueda del mouse` · o eje X de touchpad |
+| Focus mode | Botón `solo madre` / `todos` |
+| Búsqueda transversal | Tipeá cualquier texto · `Backspace` borra · `Esc` limpia |
+| Persistencia UI | Automática: scroll/focus/búsqueda se restauran al reabrir |
+| Reset cache | `MULTILIENZO_COMPLETO_RESET=1` en el env |
+
+### 2.5 Persistente — sobrevive entre corridas
+
+Primera vez: genera y guarda en `~/.cache/gioser/pluma-multilienzo/`.
+Siguientes: lee y muestra instantáneo, sin red.
+
+```bash
+# Primera corrida — pega al LLM:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+ cargo run -p pluma-editor-llimphi \
+ --example multilienzo_store_demo --release
+
+# Segunda corrida — sin red ni tokens:
+cargo run -p pluma-editor-llimphi --example multilienzo_store_demo --release
+
+# Resetear el cache:
+MULTILIENZO_RESET=1 cargo run -p pluma-editor-llimphi \
+ --example multilienzo_store_demo --release
+```
+
+## 3. El stack LLM por crate (sin GUI)
+
+Si querés ver cómo encajan las piezas sin abrir una ventana, los tests
+unitarios cuentan bien la historia. Cada crate tiene su `pruebas` mod
+con happy path + failure modes:
+
+```bash
+# El contrato + tipos:
+cargo test -p pluma-llm-core
+
+# Determinista para tests:
+cargo test -p pluma-llm-mock
+
+# Cada backend:
+cargo test -p pluma-llm-anthropic
+cargo test -p pluma-llm-gemini
+cargo test -p pluma-llm-openai-compatible
+
+# Fachada / factory:
+cargo test -p pluma-llm
+
+# Ejecutores de transformación:
+cargo test -p pluma-transform-tabla
+cargo test -p pluma-transform-llm
+
+# Pegamento con el grafo y la store:
+cargo test -p pluma-graph-transform
+cargo test -p pluma-store
+
+# Notebook con kernel LLM:
+cargo test -p pluma-notebook-kernel-llm
+```
+
+## 4. Embeddings: provider real vs mock
+
+`pluma-align-embeddings` calcula hebras `qu↔en` (o cualquier par) con
+cualquier `rimay_verbo_core::Provider`. Tres formas de servirlo:
+
+```bash
+# Mock determinista (sin descarga, sin red, vectores random estables):
+verbo-daemon --provider mock
+
+# BGE local sin API key (descarga ~120 MB la primera vez):
+verbo-daemon --provider fastembed
+
+# Otro socket o dimensión:
+verbo-daemon --provider mock --socket /tmp/mi.sock --dim 768
+```
+
+Y en la app:
+
+```rust
+let daemon = DaemonClient::connect("$XDG_RUNTIME_DIR/verbo.sock").await?;
+let carta = alinear_por_embeddings(&qu, &en, &idx, &daemon, ¶ms, ahora).await?;
+```
+
+Cualquier consumidor de `&dyn Provider` (pluma-semantic, chasqui-core,
+khipu) habla igual.
+
+## 5. Notebook + LLM — demo CLI ejecutable
+
+`notebook_llm_demo` arma un notebook con cuatro celdas (markdown fuente
++ traducir-qu + tono-formal + resumir-20), corre `run_all` con el
+`LlmKernel`, e imprime los outputs por consola. No usa ventana — útil
+para verificar que el flujo entero funciona en un servidor sin GUI.
+
+```bash
+# Sin keys (mock que diferencia acciones por system):
+cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+
+# Con Gemini:
+GEMINI_API_KEY=... PLUMA_LLM_BACKEND=gemini \
+ cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+
+# Con Ollama:
+PLUMA_LLM_BACKEND=ollama PLUMA_LLM_MODEL=llama3.1 \
+ cargo run -p pluma-notebook-kernel-llm --example notebook_llm_demo --release
+```
+
+Salida esperada (modo mock):
+
+```
+notebook_llm_demo :: LLM = mock-nb
+=== ejecución ===
+ejecutadas: 4 fallidas: 0 skipped: 0
+=== outputs ===
+[1/markdown] sin output
+[2/llm-traducir-qu] Kuntur wayqu hanaqpachatakta…
+[3/llm-tono-formal] El cóndor surcó con majestuosidad…
+[4/llm-resumir-20] Amanecer andino: cóndor, llamas, tejedora.
+```
+
+## 5.bis Notebook + LLM — uso programático
+
+```rust
+use pluma_llm::{build_client, BackendKind, LlmConfig};
+use pluma_notebook_kernel_llm::LlmKernel;
+use pluma_notebook_exec::run_all;
+
+let chat = build_client(&LlmConfig {
+ kind: BackendKind::Gemini,
+ ..Default::default()
+})?;
+let kernel = LlmKernel::from_arc(chat);
+
+let mut notebook = /* armar celdas con language="llm-traducir-qu", etc. */;
+let reporte = run_all(&mut notebook, &kernel).await.unwrap();
+println!("ejecutadas: {} | fallidas: {}", reporte.executed.len(), reporte.failed.len());
+```
+
+Lenguajes que `LlmKernel` entiende:
+
+| `language` | Qué hace |
+|------------------------|-------------------------------------------------------|
+| `llm-prompt` | El source ES el prompt completo. Sin system. |
+| `llm-traducir-{LANG}` | Traduce al idioma dado (qu, en, fr…). |
+| `llm-tono-{ETIQUETA}` | Reescribe con tono (formal, casual, infantil…). |
+| `llm-resumir` | Resumen libre. |
+| `llm-resumir-{N}` | Resumen a aproximadamente N palabras. |
+| `llm-reescribir` | Primera línea del source = prompt, resto = texto. |
+
+## 6. Mapa del stack
+
+```
+pluma-core NarrativeAtom + coherencia
+pluma-graph NarrativeGraph (DAG con propagación PendingEvaluation)
+pluma-cuerpo Cuerpo + MetaCuerpo + Intencion
+pluma-align Alineamiento + CartaHebras + alineadores manuales
+pluma-align-embeddings alineador semántico async via Provider
+pluma-transform trait Ejecutor async + EjecutorIdentidad
+pluma-transform-tabla EjecutorTraducirTabla (tabla explícita)
+pluma-transform-llm Ejecutor{Traducir,Tono,Resumir,Reescribir}Llm
+pluma-graph-transform indice_atoms + persistir_producto (pegamento)
+pluma-store PlumaStore (atoms+cuerpos+transformaciones+cartas+EstadoUi en sled)
+pluma-editor-llimphi::multilienzo vista columnas+carriles+hebras
+pluma-editor-llimphi::cuerpo_ide text-editor IDE de Llimphi sobre EditorCuerpo
+ (apply_key → buffer; diff → CambioAtom)
+pluma-editor-cuerpo sincronia cuerpo ↔ buffer plano (diff Mutar/Crear/Eliminar)
+ reusa Uuids al mutar → hebras vivas tras edit
+shared/foreign-docx importar .docx como cuerpo madre
+
+pluma-llm-core trait ChatClient (contrato agnóstico de proveedor)
+pluma-llm-mock determinista para tests
+pluma-llm-anthropic Claude con prompt caching del system
+pluma-llm-gemini Gemini con cachedContentTokenCount
+pluma-llm-cohere Cohere Command (Anthropic-shape de response)
+pluma-llm-openai-compatible DeepSeek + Ollama + Groq/Together/vLLM
+pluma-llm fachada transparente: build_client(&cfg)/from_env
+
+pluma-notebook-{core,exec,store} notebook reactivo (DAG de celdas)
+pluma-notebook-kernel-llm conecta el notebook al LLM transparente
+pluma-notebook-kernel-{python,wasm} los otros kernels
+
+rimay-verbo-{core,mock,daemon,daemon-bin,fastembed} embedder global
+```
+
+## 7. Variables de entorno reconocidas
+
+| Variable | Quién | Para qué |
+|---|---|---|
+| `PLUMA_LLM_BACKEND` | `pluma_llm::from_env` | `anthropic`/`gemini`/`deepseek`/`ollama`/`mock` |
+| `PLUMA_LLM_MODEL` | `pluma_llm::from_env` | sobrescribe el default del backend |
+| `PLUMA_LLM_ENDPOINT` | `pluma_llm::from_env` | proxy interno o endpoint custom |
+| `ANTHROPIC_API_KEY` | `pluma-llm-anthropic` | Claude |
+| `GEMINI_API_KEY` o `GOOGLE_API_KEY` | `pluma-llm-gemini` | Gemini AI Studio |
+| `DEEPSEEK_API_KEY` | `pluma-llm-openai-compatible` | DeepSeek |
+| `COHERE_API_KEY` | `pluma-llm-cohere` | Cohere Command |
+| `MULTILIENZO_RESET` | `multilienzo_store_demo` | `=1` para limpiar el cache |
+| `XDG_RUNTIME_DIR` | `verbo-daemon` y clientes | path del socket del embedder |
+| `XDG_CACHE_HOME` | `multilienzo_store_demo` | base del cache de PlumaStore |
diff --git a/00_unanchay/pluma/README.md b/00_unanchay/pluma/README.md
new file mode 100644
index 0000000..a7464c3
--- /dev/null
+++ b/00_unanchay/pluma/README.md
@@ -0,0 +1,46 @@
+# pluma
+
+> Living documents. Markdown as a graph of editable atoms; LLM as transformer, not author.
+
+`pluma` treats a document as a DAG of paragraphs (atoms) with stable identity. Editing preserves ids; the LLM is invoked as **pure transformation** over subgraphs (summarize this section, translate that paragraph) — always with visible, reversible diff. Includes notebook (with Python/WASM/LLM/cosmos/dominium kernels), visual editor, deck (slides) and web reader.
+
+## Install
+
+```sh
+# markdown editor (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# web reader (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows** — native Llimphi apps.
+- **Wawa** — `pluma` ships as a kernel app (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` renders markdown in the browser (the reader this site uses).
+
+## Crates
+
+Core + parser: `pluma-core`, `pluma-cuerpo`, `pluma-store`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-graph-transform`, `pluma-semantic`, `pluma-align`, `pluma-align-embeddings`, `pluma-render-plan`.
+
+Transforms: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
+
+LLM facade: `pluma-llm`, `pluma-llm-core`, `pluma-llm-anthropic`, `pluma-llm-gemini`, `pluma-llm-cohere`, `pluma-llm-openai-compatible`, `pluma-llm-mock`.
+
+Editor: `pluma-editor-cuerpo`, `pluma-editor-llimphi`, `pluma-app`.
+
+Deck: `pluma-deck-core`, `pluma-deck-web`.
+
+Notebook: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app,kernel-python,kernel-wasm,kernel-llm,kernel-cosmos,kernel-dominium}`.
+
+Full table in [README.md](README.md).
+
+## Considerations
+
+- **The LLM doesn't write; it transforms.** No "free writing mode" — each call returns an atomic mutation that the user approves or rejects.
+- Atom IDs are the unit of truth: rename/move preserves internal refs and outside links.
+- Notebook kernels are **WASM-first** (notebook sandboxing by default).
diff --git a/00_unanchay/pluma/README.qu.md b/00_unanchay/pluma/README.qu.md
new file mode 100644
index 0000000..51cfacc
--- /dev/null
+++ b/00_unanchay/pluma/README.qu.md
@@ -0,0 +1,43 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# pluma
+
+> Kawsasqa qillqakuna. Markdown — átomu-grafu hina, LLM transformadormi, manataq autor.
+
+`pluma` qillqasqata DAG hina qhawan: párrafokuna (átomokuna) sumaq sutiyuq. Tikraspa idskuna waqaychasqa; LLM **ch'uya transformación** hina sub-grafukunapi qharin (kay parte huñuy, chay párrafo tikray) — qhawana diff-wan, kutichinapaq atisqa. Notebook (Python/WASM/LLM/cosmos/dominium kernels), rikuna editor, deck (slides), web reader.
+
+## Churay
+
+```sh
+# markdown editor (Llimphi desktop)
+cargo run --release -p pluma-app
+
+# notebook
+cargo run --release -p pluma-notebook-app
+
+# web reader (WASM)
+./scripts/build-gioser-web.sh
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows** — Llimphi natural apps.
+- **Wawa** — `pluma` kernel-pa apps-nin hina (`03_ukupacha/wawa/apps/pluma/`).
+- **Web** — `pluma-md-reader-web` markdown navegador ukhupi riqsichiq (kay sitio chayta usanqa).
+
+## Crateskuna
+
+Sumaq tabla [README.md](README.md)-pi. Familiakuna:
+
+- **Core + parser**: `pluma-core`, `pluma-cuerpo`, `pluma-md`, `pluma-md-reader-web`, `pluma-graph`, `pluma-semantic`, `pluma-align*`, `pluma-render-plan`, `pluma-store`.
+- **Transforms**: `pluma-transform`, `pluma-transform-llm`, `pluma-transform-tabla`.
+- **LLM**: `pluma-llm`, `pluma-llm-{core,anthropic,gemini,cohere,openai-compatible,mock}`.
+- **Editor**: `pluma-editor-{cuerpo,llimphi}`, `pluma-app`.
+- **Deck**: `pluma-deck-{core,web}`.
+- **Notebook**: `pluma-notebook-{core,store,exec,llimphi,graph-llimphi,app}` + `pluma-notebook-kernel-{python,wasm,llm,cosmos,dominium}`.
+
+## Yuyaykunaq
+
+- **LLM manan qillqaqchu; tikraqmi.** Mana "ch'iqaq qillqana modo"; sapanka llamayqa atómica mutación kutichin, runa allichaspa utaq mana munaspa.
+- Átomu IDmi cheqaq unidad: tikrakuna sutikunata waqaychan, ukhupi rikchakuna hawapi linkkuna ima.
+- Notebook kernelkuna **WASM-ñawpaq** (notebook sandbox kasqa).
diff --git a/00_unanchay/pluma/foreign-docx/Cargo.toml b/00_unanchay/pluma/foreign-docx/Cargo.toml
new file mode 100644
index 0000000..9f8df63
--- /dev/null
+++ b/00_unanchay/pluma/foreign-docx/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "foreign-docx"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma/foreign-docx — importa `.docx` (Word 2007+ y compatibles) como cuerpo madre del multilienzo. Un párrafo `<w:p>` del XML → un NarrativeAtom; runs `<w:t>` concatenados, formato (negrita/cursiva) descartado."
+
+[dependencies]
+zip = { version = "2", default-features = false, features = ["deflate"] }
+quick-xml = { workspace = true }
+thiserror = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+uuid = { workspace = true }
diff --git a/00_unanchay/pluma/foreign-docx/src/lib.rs b/00_unanchay/pluma/foreign-docx/src/lib.rs
new file mode 100644
index 0000000..9fb4d96
--- /dev/null
+++ b/00_unanchay/pluma/foreign-docx/src/lib.rs
@@ -0,0 +1,460 @@
+//! `foreign-docx` — importa archivos `.docx` (Office Open XML) como
+//! cuerpos madre del multilienzo de pluma.
+//!
+//! Un `.docx` es un zip que contiene `word/document.xml` con el cuerpo
+//! del documento serializado en XML. Este crate hace lo mínimo
+//! necesario: descomprime, abre `document.xml`, recorre los párrafos
+//! `<w:p>`, junta los runs `<w:t>` de cada uno, y produce un
+//! `NarrativeAtom` por párrafo.
+//!
+//! NO interpreta formato (negrita, cursiva, color), estilos, headers,
+//! footers, tablas, comments, ni elementos avanzados. La meta es
+//! ingestar el contenido legible — quien quiera fidelidad de formato
+//! debería trabajar el doc en su editor nativo, no traerlo a pluma.
+//!
+//! ## Ejemplo
+//!
+//! ```no_run
+//! use foreign_docx::parse_docx;
+//! let bytes = std::fs::read("informe.docx").unwrap();
+//! let imp = parse_docx(&bytes, "es", "informe.docx", 0).unwrap();
+//! // imp.cuerpo: Intencion::Original
+//! // imp.atoms: un NarrativeAtom por párrafo del .docx
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+use std::io::{Cursor, Read, Write};
+
+use quick_xml::events::Event;
+use quick_xml::reader::Reader;
+use thiserror::Error;
+use uuid::Uuid;
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+
+/// Resultado del import: el cuerpo madre + sus átomos. Misma shape que
+/// `pluma_md::DocumentoImportado` para que los demos puedan tratar
+/// ambos igual.
+#[derive(Debug)]
+pub struct DocumentoImportado {
+ pub cuerpo: Cuerpo,
+ pub atoms: Vec<NarrativeAtom>,
+}
+
+#[derive(Debug, Error)]
+pub enum DocxError {
+ #[error("no es un zip válido (.docx): {0}")]
+ Zip(#[from] zip::result::ZipError),
+ #[error("falta `word/document.xml` en el archivo")]
+ DocumentoFaltante,
+ #[error("lectura del archivo interno falló: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("XML malformado: {0}")]
+ Xml(#[from] quick_xml::Error),
+}
+
+/// Importa un `.docx` (bytes crudos del archivo, p. ej. devuelto por
+/// `std::fs::read`) como cuerpo Original. `branch_id`, `nombre` y
+/// `ahora` se anotan en `MetaCuerpo`.
+pub fn parse_docx(
+ bytes: &[u8],
+ branch_id: impl Into<String>,
+ nombre: impl Into<String>,
+ ahora: u64,
+) -> Result<DocumentoImportado, DocxError> {
+ let cursor = Cursor::new(bytes);
+ let mut zip = zip::ZipArchive::new(cursor)?;
+ let mut xml = String::new();
+ {
+ let mut entry = zip
+ .by_name("word/document.xml")
+ .map_err(|_| DocxError::DocumentoFaltante)?;
+ entry.read_to_string(&mut xml)?;
+ }
+ let parrafos = extraer_parrafos(&xml)?;
+
+ let branch = branch_id.into();
+ let mut cuerpo = Cuerpo::nuevo(branch.clone(), nombre, Intencion::Original, ahora);
+ let mut atoms = Vec::with_capacity(parrafos.len());
+ for texto in parrafos {
+ let texto = texto.trim();
+ if texto.is_empty() {
+ continue;
+ }
+ let atom = NarrativeAtom::new(texto.to_string(), &branch);
+ cuerpo.agregar(atom.id, ahora);
+ atoms.push(atom);
+ }
+ Ok(DocumentoImportado { cuerpo, atoms })
+}
+
+/// Parser SAX mínimo: cada `<w:p>` abre un buffer; cada `<w:t>` añade
+/// su texto; `</w:p>` cierra y empuja al output.
+///
+/// Reconocemos tanto `w:t` como `t` (algunos generadores omiten el
+/// prefijo; el namespace queda igual al ser el default del documento).
+/// Saltos `<w:br/>` se ignoran — quedan como espacio implícito entre
+/// los runs adyacentes.
+fn extraer_parrafos(xml: &str) -> Result<Vec<String>, quick_xml::Error> {
+ let mut reader = Reader::from_str(xml);
+ reader.trim_text(false);
+
+ let mut parrafos = Vec::new();
+ let mut buf = String::new();
+ let mut en_parrafo = false;
+ let mut en_text = false;
+
+ loop {
+ match reader.read_event() {
+ Ok(Event::Start(e)) => {
+ let name = e.name();
+ let local = nombre_local(name.as_ref());
+ if local == b"p" {
+ en_parrafo = true;
+ buf.clear();
+ } else if en_parrafo && local == b"t" {
+ en_text = true;
+ }
+ }
+ Ok(Event::End(e)) => {
+ let name = e.name();
+ let local = nombre_local(name.as_ref());
+ if local == b"p" {
+ parrafos.push(buf.clone());
+ buf.clear();
+ en_parrafo = false;
+ } else if local == b"t" {
+ en_text = false;
+ }
+ }
+ Ok(Event::Text(e)) => {
+ if en_parrafo && en_text {
+ let s = e.unescape()?;
+ buf.push_str(s.as_ref());
+ }
+ }
+ // Word usa `<w:br/>` para saltos de línea suaves dentro de
+ // un párrafo. Lo tratamos como espacio para no concatenar.
+ Ok(Event::Empty(e)) => {
+ let name = e.name();
+ let local = nombre_local(name.as_ref());
+ if en_parrafo && local == b"br" {
+ if !buf.ends_with(' ') && !buf.is_empty() {
+ buf.push(' ');
+ }
+ }
+ }
+ Ok(Event::Eof) => break,
+ Err(e) => return Err(e),
+ _ => {}
+ }
+ }
+ Ok(parrafos)
+}
+
+/// Exporta `cuerpo` + atoms como un `.docx` mínimo válido (zip con
+/// `[Content_Types].xml`, `_rels/.rels`, `word/_rels/document.xml.rels`
+/// y `word/document.xml`). Suficiente para que Word, LibreOffice y
+/// nuestro propio `parse_docx` lo abran sin quejarse.
+///
+/// Cada atom del `cuerpo.orden` se vuelca como un `<w:p>` con un solo
+/// `<w:r><w:t xml:space="preserve">…</w:t></w:r>`. Sin formato,
+/// estilos, headings (los prefijos `# `/`## ` quedan como texto crudo
+/// del párrafo — no se traducen a `pStyle="Heading1"` etc.). Lossy en
+/// formato es deliberado: la idea del exportador es entregar el
+/// contenido alineable, no recrear la apariencia del Word de turno.
+///
+/// Atoms ausentes del índice se saltan en silencio (igual semántica que
+/// [`pluma_md::to_md`]).
+pub fn write_docx(
+ cuerpo: &Cuerpo,
+ atoms: &HashMap<Uuid, NarrativeAtom>,
+) -> Result<Vec<u8>, DocxError> {
+ let mut buf = Vec::new();
+ {
+ let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+ let opts: zip::write::SimpleFileOptions = Default::default();
+
+ // [Content_Types].xml — declara el tipo MIME del documento.
+ w.start_file("[Content_Types].xml", opts)?;
+ w.write_all(CONTENT_TYPES_XML.as_bytes())?;
+
+ // _rels/.rels — el package relationship que apunta al document.
+ w.start_file("_rels/.rels", opts)?;
+ w.write_all(PACKAGE_RELS_XML.as_bytes())?;
+
+ // word/_rels/document.xml.rels — vacío pero presente, Word/LO
+ // lo esperan para abrir el archivo.
+ w.start_file("word/_rels/document.xml.rels", opts)?;
+ w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
+
+ // word/document.xml — el contenido.
+ let mut xml = String::from(DOCUMENT_HEAD);
+ for atom_id in &cuerpo.orden {
+ let Some(atom) = atoms.get(atom_id) else {
+ continue;
+ };
+ xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
+ xml.push_str(&escapar_xml(&atom.content));
+ xml.push_str("</w:t></w:r></w:p>");
+ }
+ // Si el cuerpo está vacío, igual escribimos un párrafo vacío —
+ // un .docx sin <w:p> a veces lo rechazan parsers estrictos.
+ if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
+ xml.push_str("<w:p/>");
+ }
+ xml.push_str(DOCUMENT_TAIL);
+
+ w.start_file("word/document.xml", opts)?;
+ w.write_all(xml.as_bytes())?;
+ w.finish()?;
+ }
+ Ok(buf)
+}
+
+/// Misma lógica que [`write_docx`] pero con índice por `&NarrativeAtom`
+/// — útil para callers que tienen prestados los atoms (mismo patrón
+/// que `pluma_md::to_md_borrow`).
+pub fn write_docx_borrow(
+ cuerpo: &Cuerpo,
+ atoms: &HashMap<Uuid, &NarrativeAtom>,
+) -> Result<Vec<u8>, DocxError> {
+ let mut buf = Vec::new();
+ {
+ let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+ let opts: zip::write::SimpleFileOptions = Default::default();
+
+ w.start_file("[Content_Types].xml", opts)?;
+ w.write_all(CONTENT_TYPES_XML.as_bytes())?;
+
+ w.start_file("_rels/.rels", opts)?;
+ w.write_all(PACKAGE_RELS_XML.as_bytes())?;
+
+ w.start_file("word/_rels/document.xml.rels", opts)?;
+ w.write_all(DOCUMENT_RELS_XML.as_bytes())?;
+
+ let mut xml = String::from(DOCUMENT_HEAD);
+ for atom_id in &cuerpo.orden {
+ let Some(atom) = atoms.get(atom_id) else {
+ continue;
+ };
+ xml.push_str("<w:p><w:r><w:t xml:space=\"preserve\">");
+ xml.push_str(&escapar_xml(&atom.content));
+ xml.push_str("</w:t></w:r></w:p>");
+ }
+ if cuerpo.orden.is_empty() || cuerpo.orden.iter().all(|id| !atoms.contains_key(id)) {
+ xml.push_str("<w:p/>");
+ }
+ xml.push_str(DOCUMENT_TAIL);
+
+ w.start_file("word/document.xml", opts)?;
+ w.write_all(xml.as_bytes())?;
+ w.finish()?;
+ }
+ Ok(buf)
+}
+
+const CONTENT_TYPES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+ <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
+ <Default Extension="xml" ContentType="application/xml"/>
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+</Types>"#;
+
+const PACKAGE_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+ <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
+</Relationships>"#;
+
+const DOCUMENT_RELS_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
+</Relationships>"#;
+
+const DOCUMENT_HEAD: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+ <w:body>"#;
+
+const DOCUMENT_TAIL: &str = r#"</w:body>
+</w:document>"#;
+
+/// Escapa los cinco caracteres XML que romperían el documento. Word es
+/// flexible con & y < dentro de `xml:space="preserve"`, pero los
+/// parsers strictos no.
+fn escapar_xml(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for c in s.chars() {
+ match c {
+ '&' => out.push_str("&"),
+ '<' => out.push_str("<"),
+ '>' => out.push_str(">"),
+ '"' => out.push_str("""),
+ '\'' => out.push_str("'"),
+ _ => out.push(c),
+ }
+ }
+ out
+}
+
+/// Quita el prefijo de namespace (`w:p` → `p`). Acepta tanto QName con
+/// prefijo (`w:p`) como sin (`p`). El default-ns del documento Word es
+/// el namespace `w`, pero hay docs ajenos sin prefijo — soportamos los
+/// dos.
+fn nombre_local(qname: &[u8]) -> &[u8] {
+ match qname.iter().position(|b| *b == b':') {
+ Some(i) => &qname[i + 1..],
+ None => qname,
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+ use std::io::Write;
+
+ /// Crea un `.docx` mínimo en memoria con los párrafos dados.
+ /// Genera solo el `[Content_Types].xml` mínimo, `_rels/.rels`,
+ /// `word/_rels/document.xml.rels` y `word/document.xml` con los
+ /// párrafos. Suficiente para que ZipArchive lo abra y nuestro
+ /// parser extraiga el contenido.
+ fn docx_de(parrafos: &[&str]) -> Vec<u8> {
+ let mut buf = Vec::new();
+ {
+ let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+ let opts: zip::write::SimpleFileOptions = Default::default();
+ // Content_Types mínimo. Word lo requiere para abrir el doc,
+ // pero nuestro parser no lo consulta — basta con que el zip
+ // tenga la entrada `word/document.xml`.
+ w.start_file("[Content_Types].xml", opts).unwrap();
+ w.write_all(
+ br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
+ <Default Extension="xml" ContentType="application/xml"/>
+ <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
+</Types>"#,
+ )
+ .unwrap();
+ // El documento.
+ let mut xml = String::from(
+ r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
+<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
+ <w:body>"#,
+ );
+ for p in parrafos {
+ xml.push_str(&format!(
+ "<w:p><w:r><w:t xml:space=\"preserve\">{}</w:t></w:r></w:p>",
+ p
+ ));
+ }
+ xml.push_str("</w:body></w:document>");
+ w.start_file("word/document.xml", opts).unwrap();
+ w.write_all(xml.as_bytes()).unwrap();
+ w.finish().unwrap();
+ }
+ buf
+ }
+
+ #[test]
+ fn parrafos_se_separan_en_atoms() {
+ let docx = docx_de(&["Primero.", "Segundo.", "Tercero."]);
+ let imp = parse_docx(&docx, "es", "test.docx", 1).unwrap();
+ assert_eq!(imp.atoms.len(), 3);
+ assert_eq!(imp.atoms[0].content.as_str(), "Primero.");
+ assert_eq!(imp.atoms[1].content.as_str(), "Segundo.");
+ assert_eq!(imp.atoms[2].content.as_str(), "Tercero.");
+ assert_eq!(imp.cuerpo.metadatos.intencion, Intencion::Original);
+ assert_eq!(imp.cuerpo.branch_id, "es");
+ assert_eq!(imp.cuerpo.orden.len(), 3);
+ }
+
+ #[test]
+ fn parrafos_vacios_se_omiten() {
+ let docx = docx_de(&["", "Solo este.", "", " "]);
+ let imp = parse_docx(&docx, "es", "x", 0).unwrap();
+ assert_eq!(imp.atoms.len(), 1);
+ assert_eq!(imp.atoms[0].content.as_str(), "Solo este.");
+ }
+
+ #[test]
+ fn archivo_sin_document_xml_devuelve_error() {
+ // Zip vacío.
+ let mut buf = Vec::new();
+ {
+ let mut w = zip::ZipWriter::new(Cursor::new(&mut buf));
+ w.start_file("hola.txt", zip::write::SimpleFileOptions::default())
+ .unwrap();
+ w.write_all(b"no soy docx").unwrap();
+ w.finish().unwrap();
+ }
+ match parse_docx(&buf, "es", "x", 0) {
+ Err(DocxError::DocumentoFaltante) => {}
+ otro => panic!("esperaba DocumentoFaltante, fue {otro:?}"),
+ }
+ }
+
+ #[test]
+ fn bytes_no_zip_devuelve_zip_error() {
+ let basura = b"esto no es un zip ni en broma";
+ assert!(matches!(
+ parse_docx(basura, "es", "x", 0),
+ Err(DocxError::Zip(_))
+ ));
+ }
+
+ #[test]
+ fn cuerpo_orden_y_atoms_referencian_los_mismos_uuids() {
+ let docx = docx_de(&["a", "b", "c"]);
+ let imp = parse_docx(&docx, "es", "x", 0).unwrap();
+ for (atom, uuid) in imp.atoms.iter().zip(imp.cuerpo.orden.iter()) {
+ assert_eq!(&atom.id, uuid);
+ }
+ }
+
+ fn cuerpo_y_atoms(textos: &[&str]) -> (Cuerpo, HashMap<Uuid, NarrativeAtom>) {
+ let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+ let mut map = HashMap::new();
+ for t in textos {
+ let atom = NarrativeAtom::new(*t, "es");
+ c.agregar(atom.id, 0);
+ map.insert(atom.id, atom);
+ }
+ (c, map)
+ }
+
+ #[test]
+ fn write_docx_roundtrip_preserva_orden_y_texto() {
+ let textos = vec!["Primero.", "Segundo párrafo.", "Tercero & último."];
+ let (c, atoms) = cuerpo_y_atoms(&textos);
+ let bytes = write_docx(&c, &atoms).unwrap();
+ // Releemos con nuestro parser — el camino más estricto: si
+ // parse_docx puede con lo que escribimos, Word puede también.
+ let imp = parse_docx(&bytes, "es", "roundtrip.docx", 0).unwrap();
+ assert_eq!(imp.atoms.len(), textos.len());
+ for (a, esperado) in imp.atoms.iter().zip(textos.iter()) {
+ assert_eq!(a.content.as_str(), *esperado);
+ }
+ }
+
+ #[test]
+ fn write_docx_cuerpo_vacio_genera_doc_minimo_valido() {
+ let c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+ let atoms: HashMap<Uuid, NarrativeAtom> = HashMap::new();
+ let bytes = write_docx(&c, &atoms).unwrap();
+ // Releer no debe fallar; sin atoms el resultado del parse es vacío.
+ let imp = parse_docx(&bytes, "es", "vacio.docx", 0).unwrap();
+ assert!(imp.atoms.is_empty());
+ }
+
+ #[test]
+ fn write_docx_escapa_caracteres_xml() {
+ let (c, atoms) = cuerpo_y_atoms(&["<x> & \"comillas\" 'simples'"]);
+ let bytes = write_docx(&c, &atoms).unwrap();
+ let imp = parse_docx(&bytes, "es", "esc.docx", 0).unwrap();
+ assert_eq!(imp.atoms.len(), 1);
+ // El parser des-escapa los entities — el roundtrip debe devolver
+ // el texto original.
+ assert_eq!(imp.atoms[0].content.as_str(), "<x> & \"comillas\" 'simples'");
+ }
+}
diff --git a/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml
new file mode 100644
index 0000000..c47e6a3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "pluma-align-embeddings"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — alineador de cuerpos por embeddings: produce hebras (CartaHebras) calculando similitud coseno entre los párrafos de dos cuerpos, vía cualquier rimay_verbo_core::Provider (mock determinista, BGE local, Cohere remoto)."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+async-trait = { workspace = true }
+anyhow = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-align = { path = "../pluma-align" }
+rimay-verbo-core = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
+rimay-verbo-mock = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-align-embeddings/LEEME.md b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md
new file mode 100644
index 0000000..dee9c34
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-align-embeddings
+
+> Alineamiento por embeddings para [pluma](../README.md).
+
+Refina el alineamiento naïve de [`pluma-align`](../pluma-align/README.md) usando similaridad coseno entre embeddings de párrafo. Consulta a [`rimay-verbo`](../../rimay/README.md) o a un mock; resuelve casos donde la heurística por longitud falla (textos reordenados, sinónimos).
+
+## API
+
+```rust
+use pluma_align_embeddings::alinear_con_embeddings;
+
+let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
+```
+
+## Deps
+
+- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
+- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align-embeddings/README.md b/00_unanchay/pluma/pluma-align-embeddings/README.md
new file mode 100644
index 0000000..da12c01
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/README.md
@@ -0,0 +1,19 @@
+# pluma-align-embeddings
+
+> Embeddings-based alignment for [pluma](../README.md).
+
+Refines the naïve [`pluma-align`](../pluma-align/README.md) using cosine similarity between paragraph embeddings. Queries [`rimay-verbo`](../../rimay/README.md) or a mock; solves cases where length heuristics fail (reordered text, synonyms, distant paraphrases).
+
+## API
+
+```rust
+use pluma_align_embeddings::alinear_con_embeddings;
+
+let pares = alinear_con_embeddings(&doc_a, &doc_b, &verbo).await?;
+```
+
+## Deps
+
+- [`pluma-align`](../pluma-align/README.md), [`pluma-core`](../pluma-core/README.md)
+- [`rimay-verbo-core`](../../rimay/rimay-verbo-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs
new file mode 100644
index 0000000..c84d867
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align-embeddings/src/lib.rs
@@ -0,0 +1,372 @@
+//! `pluma-align-embeddings` — alineador de cuerpos por embeddings.
+//!
+//! Toma dos cuerpos, calcula embeddings de cada párrafo vía un
+//! `rimay_verbo_core::Provider` (mock determinista, BGE local, Cohere
+//! remoto — quien implemente el rasgo) y produce una [`CartaHebras`]
+//! cuya fuerza es la similitud coseno entre los embeddings. La política
+//! de selección de pares la elige el caller.
+//!
+//! Este crate vive APARTE de `pluma-align` porque introduce async +
+//! dependencia a `rimay-verbo-core`. Mantener `pluma-align` sincrónico y
+//! agnóstico permite usarlo en contextos sin runtime async (UI thread,
+//! tests rápidos, wawa userspace).
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use anyhow::{Context, Result};
+use uuid::Uuid;
+
+use pluma_align::{Alineamiento, CartaHebras, OrigenAlineamiento};
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use rimay_verbo_core::Provider;
+
+/// Política con la que se eligen pares ganadores en la matriz NxM de
+/// similitudes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ModoAlineacion {
+ /// Para cada átomo del cuerpo A, registrar UNA hebra con el átomo de
+ /// B de mayor similitud (siempre que pase el umbral). Un átomo de B
+ /// puede recibir varias hebras — caso típico cuando la traducción
+ /// fusiona dos párrafos del original en uno.
+ MejorParaCadaA,
+ /// Greedy mutuo: registrar el par `(a, b)` solo si `a` es el mejor
+ /// candidato de `b` Y `b` es el mejor de `a`. Hebras 1↔1, más limpias.
+ /// Reduce las hebras y deja sin emparejar lo que es genuinamente
+ /// ambiguo — la UI muestra los huérfanos como "sin contraparte".
+ MutuoMejor,
+}
+
+/// Parámetros de la alineación. Defaults pensados para que un MockProvider
+/// (vectores aleatorios) NO produzca hebras espurias: umbral elevado.
+#[derive(Debug, Clone, Copy)]
+pub struct ParamsAlineacion {
+ /// Umbral mínimo de similitud coseno para registrar una hebra. Por
+ /// debajo, el par se descarta. Rango razonable con modelos reales:
+ /// 0.45–0.7. Para mock random, conviene >= 0.95 (efectivamente nada).
+ pub umbral_minimo: f32,
+ /// Política de selección.
+ pub modo: ModoAlineacion,
+}
+
+impl Default for ParamsAlineacion {
+ fn default() -> Self {
+ Self {
+ umbral_minimo: 0.55,
+ modo: ModoAlineacion::MutuoMejor,
+ }
+ }
+}
+
+/// Alinea dos cuerpos calculando embeddings de cada párrafo y emparejando
+/// según `params`. La carta resultante tiene `OrigenAlineamiento::Embeddings
+/// { modelo: provider.model_id().name, timestamp: ahora }`.
+///
+/// `ahora` es el timestamp (segundos UNIX) que el caller decide — el crate
+/// no lee el reloj, para mantener el flujo testeable.
+///
+/// Errores propagados: del provider, si falla la inferencia. Átomos
+/// referenciados que no existan en `atoms` se omiten en silencio (no
+/// son alineables si no hay texto).
+pub async fn alinear_por_embeddings(
+ cuerpo_a: &Cuerpo,
+ cuerpo_b: &Cuerpo,
+ atoms: &HashMap<Uuid, &NarrativeAtom>,
+ provider: &dyn Provider,
+ params: &ParamsAlineacion,
+ ahora: u64,
+) -> Result<CartaHebras> {
+ let (ids_a, textos_a) = recolectar_textos(cuerpo_a, atoms);
+ let (ids_b, textos_b) = recolectar_textos(cuerpo_b, atoms);
+
+ if ids_a.is_empty() || ids_b.is_empty() {
+ return Ok(CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id));
+ }
+
+ let emb_a = provider
+ .embed_batch(&textos_a)
+ .await
+ .context("embedding lote del cuerpo A falló")?;
+ let emb_b = provider
+ .embed_batch(&textos_b)
+ .await
+ .context("embedding lote del cuerpo B falló")?;
+
+ // Matriz NxM de similitudes coseno.
+ let n = emb_a.len();
+ let m = emb_b.len();
+ let mut sim = vec![0.0f32; n * m];
+ for i in 0..n {
+ for j in 0..m {
+ let s = emb_a[i].cosine(&emb_b[j]).context(
+ "similitud coseno cruzando modelos distintos — provider inconsistente",
+ )?;
+ sim[i * m + j] = s;
+ }
+ }
+
+ let modelo = provider.model_id().name.clone();
+ let origen = OrigenAlineamiento::Embeddings {
+ modelo,
+ timestamp: ahora,
+ };
+
+ let pares = match params.modo {
+ ModoAlineacion::MejorParaCadaA => mejores_por_fila(&sim, n, m, params.umbral_minimo),
+ ModoAlineacion::MutuoMejor => mutuos_mejor(&sim, n, m, params.umbral_minimo),
+ };
+
+ let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+ for (i, j, s) in pares {
+ carta.agregar(Alineamiento::nuevo(ids_a[i], ids_b[j], s, origen.clone()));
+ }
+ Ok(carta)
+}
+
+/// Recolecta (uuids_en_orden, textos_en_orden) de un cuerpo, omitiendo
+/// átomos no presentes en el índice.
+fn recolectar_textos(
+ cuerpo: &Cuerpo,
+ atoms: &HashMap<Uuid, &NarrativeAtom>,
+) -> (Vec<Uuid>, Vec<String>) {
+ let mut ids = Vec::with_capacity(cuerpo.orden.len());
+ let mut textos = Vec::with_capacity(cuerpo.orden.len());
+ for &id in &cuerpo.orden {
+ if let Some(atom) = atoms.get(&id) {
+ ids.push(id);
+ textos.push(atom.content.as_str().to_string());
+ }
+ }
+ (ids, textos)
+}
+
+/// Para cada fila `i` de la matriz, encuentra el `j` con mayor sim y lo
+/// emite si supera el umbral. Genera hasta N pares (uno por átomo de A).
+fn mejores_por_fila(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
+ let mut out = Vec::with_capacity(n);
+ for i in 0..n {
+ let mut mejor = (usize::MAX, f32::NEG_INFINITY);
+ for j in 0..m {
+ let s = sim[i * m + j];
+ if s > mejor.1 {
+ mejor = (j, s);
+ }
+ }
+ if mejor.0 != usize::MAX && mejor.1 >= umbral {
+ out.push((i, mejor.0, mejor.1));
+ }
+ }
+ out
+}
+
+/// Greedy mutuo: i↔j solo si i es el mejor candidato de j y j es el mejor
+/// de i. Garantiza 1↔1 y descarta pares ambiguos.
+fn mutuos_mejor(sim: &[f32], n: usize, m: usize, umbral: f32) -> Vec<(usize, usize, f32)> {
+ // Mejor j por cada i.
+ let mut mejor_de_a = vec![usize::MAX; n];
+ let mut mejor_de_a_s = vec![f32::NEG_INFINITY; n];
+ for i in 0..n {
+ for j in 0..m {
+ let s = sim[i * m + j];
+ if s > mejor_de_a_s[i] {
+ mejor_de_a_s[i] = s;
+ mejor_de_a[i] = j;
+ }
+ }
+ }
+ // Mejor i por cada j.
+ let mut mejor_de_b = vec![usize::MAX; m];
+ let mut mejor_de_b_s = vec![f32::NEG_INFINITY; m];
+ for j in 0..m {
+ for i in 0..n {
+ let s = sim[i * m + j];
+ if s > mejor_de_b_s[j] {
+ mejor_de_b_s[j] = s;
+ mejor_de_b[j] = i;
+ }
+ }
+ }
+ // Emisión: solo pares mutuos sobre umbral.
+ let mut out = Vec::new();
+ for i in 0..n {
+ let j = mejor_de_a[i];
+ if j == usize::MAX {
+ continue;
+ }
+ if mejor_de_b[j] == i && mejor_de_a_s[i] >= umbral {
+ out.push((i, j, mejor_de_a_s[i]));
+ }
+ }
+ out
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+ use pluma_align::OrigenAlineamiento;
+ use pluma_cuerpo::Intencion;
+ use rimay_verbo_mock::MockProvider;
+
+ fn cuerpo_con_atomos(
+ branch: &str,
+ intencion: Intencion,
+ textos: &[&str],
+ ) -> (Cuerpo, Vec<NarrativeAtom>) {
+ let mut c = Cuerpo::nuevo(branch, branch, intencion, 100);
+ let atoms: Vec<NarrativeAtom> =
+ textos.iter().map(|t| NarrativeAtom::new(*t, branch)).collect();
+ for a in &atoms {
+ c.agregar(a.id, 101);
+ }
+ (c, atoms)
+ }
+
+ fn indice<'a>(atoms: &'a [NarrativeAtom]) -> HashMap<Uuid, &'a NarrativeAtom> {
+ atoms.iter().map(|a| (a.id, a)).collect()
+ }
+
+ #[tokio::test]
+ async fn textos_identicos_dan_fuerza_uno() {
+ let (a, atoms_a) = cuerpo_con_atomos(
+ "a",
+ Intencion::Original,
+ &["alpha", "beta", "gamma"],
+ );
+ let (b, atoms_b) = cuerpo_con_atomos(
+ "b",
+ Intencion::Traduccion,
+ &["alpha", "beta", "gamma"],
+ );
+ let mut atoms_all = atoms_a;
+ atoms_all.extend(atoms_b);
+ let idx = indice(&atoms_all);
+
+ let provider = MockProvider::default();
+ // Mock es determinista por texto: misma cadena → mismo vector →
+ // coseno = 1. Subimos el umbral para asegurar que solo registra
+ // pares de coseno ≈ 1.
+ let params = ParamsAlineacion {
+ umbral_minimo: 0.99,
+ modo: ModoAlineacion::MutuoMejor,
+ };
+ let carta = alinear_por_embeddings(&a, &b, &idx, &provider, ¶ms, 7).await.unwrap();
+ assert_eq!(carta.hebras.len(), 3);
+ for h in &carta.hebras {
+ assert!(h.fuerza > 0.99);
+ match &h.origen {
+ OrigenAlineamiento::Embeddings { timestamp, .. } => assert_eq!(*timestamp, 7),
+ _ => panic!("origen debería ser Embeddings"),
+ }
+ }
+ }
+
+ #[tokio::test]
+ async fn textos_random_no_pasan_umbral_alto() {
+ let (a, atoms_a) = cuerpo_con_atomos(
+ "a",
+ Intencion::Original,
+ &["alfa beta gamma", "delta epsilon"],
+ );
+ let (b, atoms_b) = cuerpo_con_atomos(
+ "b",
+ Intencion::Traduccion,
+ &["lorem ipsum dolor", "consectetur adipiscing"],
+ );
+ let mut atoms_all = atoms_a;
+ atoms_all.extend(atoms_b);
+ let idx = indice(&atoms_all);
+
+ let provider = MockProvider::default();
+ // Umbral alto: el mock (vectores aleatorios entre textos no
+ // idénticos) no debe producir hebras.
+ let params = ParamsAlineacion {
+ umbral_minimo: 0.95,
+ modo: ModoAlineacion::MutuoMejor,
+ };
+ let carta = alinear_por_embeddings(&a, &b, &idx, &provider, ¶ms, 1).await.unwrap();
+ assert!(carta.hebras.is_empty());
+ }
+
+ #[tokio::test]
+ async fn cuerpo_vacio_devuelve_carta_vacia_sin_llamar_al_provider() {
+ // Si uno de los cuerpos está vacío, no hay nada que embeddear.
+ let (a, atoms_a) = cuerpo_con_atomos("a", Intencion::Original, &["solo a"]);
+ let b = Cuerpo::nuevo("b", "vacio", Intencion::Traduccion, 0);
+ let idx = indice(&atoms_a);
+ let provider = MockProvider::default();
+ let carta = alinear_por_embeddings(
+ &a,
+ &b,
+ &idx,
+ &provider,
+ &ParamsAlineacion::default(),
+ 1,
+ )
+ .await
+ .unwrap();
+ assert!(carta.hebras.is_empty());
+ assert_eq!(carta.cuerpo_a, Some(a.id));
+ assert_eq!(carta.cuerpo_b, Some(b.id));
+ }
+
+ #[tokio::test]
+ async fn mejor_para_cada_a_puede_apuntar_dos_a_uno() {
+ // Cuerpo A con 2 textos idénticos a UN texto de B.
+ let (a, atoms_a) = cuerpo_con_atomos(
+ "a",
+ Intencion::Original,
+ &["compartido", "compartido"],
+ );
+ let (b, atoms_b) = cuerpo_con_atomos(
+ "b",
+ Intencion::Traduccion,
+ &["compartido", "diferente"],
+ );
+ let mut atoms_all = atoms_a;
+ atoms_all.extend(atoms_b);
+ let idx = indice(&atoms_all);
+
+ let provider = MockProvider::default();
+ let params = ParamsAlineacion {
+ umbral_minimo: 0.99,
+ modo: ModoAlineacion::MejorParaCadaA,
+ };
+ let carta = alinear_por_embeddings(&a, &b, &idx, &provider, ¶ms, 1).await.unwrap();
+ // Las dos filas de A apuntan al mismo j=0 (texto "compartido" de B).
+ assert_eq!(carta.hebras.len(), 2);
+ let target = carta.hebras[0].atom_b;
+ assert_eq!(carta.hebras[1].atom_b, target);
+ }
+
+ #[tokio::test]
+ async fn mutuo_mejor_descarta_pares_ambiguos() {
+ // Dos textos de A idénticos a UN texto de B: con MutuoMejor, solo
+ // UN par mutuo gana (B prefiere su mejor único; la otra fila de A
+ // queda huérfana).
+ let (a, atoms_a) = cuerpo_con_atomos(
+ "a",
+ Intencion::Original,
+ &["compartido", "compartido"],
+ );
+ let (b, atoms_b) = cuerpo_con_atomos(
+ "b",
+ Intencion::Traduccion,
+ &["compartido", "diferente"],
+ );
+ let mut atoms_all = atoms_a;
+ atoms_all.extend(atoms_b);
+ let idx = indice(&atoms_all);
+
+ let provider = MockProvider::default();
+ let params = ParamsAlineacion {
+ umbral_minimo: 0.99,
+ modo: ModoAlineacion::MutuoMejor,
+ };
+ let carta = alinear_por_embeddings(&a, &b, &idx, &provider, ¶ms, 1).await.unwrap();
+ // Solo una hebra: el primer "compartido" de A con "compartido" de B
+ // (los dos j=0 candidatos empatan; el algoritmo se queda con el primer i).
+ assert_eq!(carta.hebras.len(), 1);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-align/Cargo.toml b/00_unanchay/pluma/pluma-align/Cargo.toml
new file mode 100644
index 0000000..a206b08
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-align"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — alineamientos entre cuerpos paralelos: las hebras que la UI pinta como barras de color entre columnas."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+postcard = { workspace = true }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
diff --git a/00_unanchay/pluma/pluma-align/LEEME.md b/00_unanchay/pluma/pluma-align/LEEME.md
new file mode 100644
index 0000000..6265dad
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/LEEME.md
@@ -0,0 +1,18 @@
+# pluma-align
+
+> Alineamiento texto–texto para [pluma](../README.md).
+
+Dados dos documentos (ej: original + traducción, draft + revisión), produce un mapping átomo–átomo usando heurística greedy por longitud + LCS sobre la secuencia. Resultado: `Vec<Par>` donde cada par puede ser `(a, b)`, `(a, ∅)`, `(∅, b)`. Base para diff visual y para apoyar las traducciones de [pluma-transform-llm](../pluma-transform-llm/README.md).
+
+## API
+
+```rust
+use pluma_align::alinear;
+
+let pares = alinear(&doc_a, &doc_b);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align/README.md b/00_unanchay/pluma/pluma-align/README.md
new file mode 100644
index 0000000..7943f4a
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/README.md
@@ -0,0 +1,18 @@
+# pluma-align
+
+> Text-text alignment for [pluma](../README.md).
+
+Given two documents (e.g., original + translation, draft + revision), produces atom-atom mapping using length-greedy heuristic + LCS over the sequence. Result: `Vec<Par>` where each pair can be `(a, b)`, `(a, ∅)`, `(∅, b)`. Foundation for visual diff and for backing the translations of [pluma-transform-llm](../pluma-transform-llm/README.md).
+
+## API
+
+```rust
+use pluma_align::alinear;
+
+let pares = alinear(&doc_a, &doc_b);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-align/src/lib.rs b/00_unanchay/pluma/pluma-align/src/lib.rs
new file mode 100644
index 0000000..a8d4ed8
--- /dev/null
+++ b/00_unanchay/pluma/pluma-align/src/lib.rs
@@ -0,0 +1,385 @@
+//! `pluma-align` — los alineamientos entre cuerpos paralelos.
+//!
+//! Un *alineamiento* enlaza un átomo de un cuerpo madre con un átomo de un
+//! cuerpo hija (o de cualquier cuerpo del haz: la noción no impone dirección).
+//! Llevan asociados una *fuerza* (qué tan correspondientes son los párrafos)
+//! y un *origen* (cómo se calculó: a mano, por embeddings o como subproducto
+//! de una transformación). La UI los pinta como *hebras*: barras de color
+//! verticales entre columnas, con saturación = fuerza y tipo de trazo = origen.
+//!
+//! Un átomo puede tener múltiples alineamientos:
+//! - 1↔1 — caso traducción típica;
+//! - 1↔N o N↔1 — un párrafo del original se traduce en dos del destino;
+//! - 0↔1 — un párrafo del destino sin contraparte en el original
+//! (texto añadido por el traductor);
+//! - 1↔0 — un párrafo del original que el destino eliminó.
+//!
+//! El crate no decide la política — solo modela. Las hebras 0↔X se representan
+//! como *ausencia* de alineamientos para ese átomo, no como un alineamiento
+//! degenerado: que no tenga hebra ES la información.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use uuid::Uuid;
+
+use pluma_cuerpo::Cuerpo;
+
+/// Cómo se produjo un alineamiento. La UI lo usa para distinguir hebras:
+/// continuas (Derivado), punteadas (Embeddings de baja confianza),
+/// trazos manuales con marca.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub enum OrigenAlineamiento {
+ /// Lo trazó un humano. La autoría queda anotada para auditoría.
+ Manual {
+ autor: String,
+ timestamp: u64,
+ },
+ /// Lo dedujo un calculador de embeddings (rimay/iniy). `modelo` identifica
+ /// la versión del calculador — si cambia, los alineamientos viejos pueden
+ /// requerir recálculo.
+ Embeddings {
+ modelo: String,
+ timestamp: u64,
+ },
+ /// Cae como subproducto de una transformación de `pluma-transform`. Cuando
+ /// `Identidad` deriva un cuerpo hija desde una madre, cada átomo
+ /// arrastra su contraparte 1:1 — esos son los alineamientos `Derivado`.
+ Derivado {
+ /// `Uuid` de la `Transformacion` (en `pluma-transform`) que lo emitió.
+ transformacion: Uuid,
+ timestamp: u64,
+ },
+}
+
+impl OrigenAlineamiento {
+ /// El instante en que este origen fue establecido. Útil para invalidar
+ /// alineamientos viejos en bloque ("todos los que tengan modelo X
+ /// anterior a hace una semana").
+ pub fn timestamp(&self) -> u64 {
+ match self {
+ OrigenAlineamiento::Manual { timestamp, .. } => *timestamp,
+ OrigenAlineamiento::Embeddings { timestamp, .. } => *timestamp,
+ OrigenAlineamiento::Derivado { timestamp, .. } => *timestamp,
+ }
+ }
+}
+
+/// Un alineamiento entre dos átomos de dos cuerpos. La dirección
+/// (`atom_a → atom_b`) no implica jerarquía — el grafo de cuerpos decide
+/// quién deriva de quién; aquí solo se anota el par. La UI pinta una sola
+/// hebra por par independientemente del orden.
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct Alineamiento {
+ /// Identidad estable del alineamiento — sobrevive a recálculos del par
+ /// (si el caller mantiene el mismo `Uuid`).
+ pub id: Uuid,
+ /// Átomo del cuerpo de la izquierda (el primer término del par).
+ pub atom_a: Uuid,
+ /// Átomo del cuerpo de la derecha.
+ pub atom_b: Uuid,
+ /// `[0.0, 1.0]` — qué tan correspondientes son los dos párrafos. `1.0` es
+ /// "el mismo párrafo" (o derivación directa); `0.0` no se almacena
+ /// (mejor no almacenar nada). La UI mapea esto a saturación de color.
+ pub fuerza: f32,
+ /// Cómo se calculó este alineamiento.
+ pub origen: OrigenAlineamiento,
+ /// `true` si el alineamiento sigue siendo válido frente al estado actual
+ /// de los cuerpos; `false` si el algoritmo lo marcó como stale tras una
+ /// edición. La UI lo usa para desaturar la hebra.
+ pub fresco: bool,
+}
+
+impl Alineamiento {
+ /// Crea un alineamiento nuevo con id aleatorio. La fuerza se sujeta al
+ /// intervalo `[0, 1]` — un input fuera de rango se acepta como saturado.
+ pub fn nuevo(
+ atom_a: Uuid,
+ atom_b: Uuid,
+ fuerza: f32,
+ origen: OrigenAlineamiento,
+ ) -> Self {
+ Self {
+ id: Uuid::new_v4(),
+ atom_a,
+ atom_b,
+ fuerza: fuerza.clamp(0.0, 1.0),
+ origen,
+ fresco: true,
+ }
+ }
+
+ /// `true` si este alineamiento toca el átomo dado, sea por `atom_a` o por
+ /// `atom_b`. Útil para listar todas las hebras incidentes a un párrafo.
+ pub fn toca(&self, atom_id: Uuid) -> bool {
+ self.atom_a == atom_id || self.atom_b == atom_id
+ }
+
+ /// Dado uno de los dos átomos del par, devuelve el otro. `None` si el
+ /// átomo no participa.
+ pub fn contraparte(&self, atom_id: Uuid) -> Option<Uuid> {
+ if self.atom_a == atom_id {
+ Some(self.atom_b)
+ } else if self.atom_b == atom_id {
+ Some(self.atom_a)
+ } else {
+ None
+ }
+ }
+}
+
+/// Una *carta de hebras* entre dos cuerpos: la colección de alineamientos
+/// que viven en ese par. Es la unidad que la UI consume: pasa por las hebras
+/// en orden y pinta una barra por cada una.
+///
+/// `cuerpo_a` y `cuerpo_b` se anotan para detectar al vuelo que la carta
+/// corresponde al par que la UI espera (evita confundir hebras de pares
+/// distintos). El crate no fuerza qué par es a/b vs b/a — el caller decide
+/// y se mantiene consistente.
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+pub struct CartaHebras {
+ pub cuerpo_a: Option<Uuid>,
+ pub cuerpo_b: Option<Uuid>,
+ pub hebras: Vec<Alineamiento>,
+}
+
+impl CartaHebras {
+ /// Una carta vacía, sin par anotado.
+ pub fn nueva() -> Self {
+ Self::default()
+ }
+
+ /// Anota el par al que pertenece esta carta. Útil al ingestarla.
+ pub fn con_par(mut self, cuerpo_a: Uuid, cuerpo_b: Uuid) -> Self {
+ self.cuerpo_a = Some(cuerpo_a);
+ self.cuerpo_b = Some(cuerpo_b);
+ self
+ }
+
+ /// Agrega una hebra. La carta no deduplica; eso es del caller (las hebras
+ /// son por id, no por pares — un par puede tener varias hebras de orígenes
+ /// distintos coexistiendo, p. ej. una Manual y una Embeddings de respaldo).
+ pub fn agregar(&mut self, hebra: Alineamiento) {
+ self.hebras.push(hebra);
+ }
+
+ /// Itera las hebras que tocan el átomo dado.
+ pub fn hebras_de(&self, atom_id: Uuid) -> impl Iterator<Item = &Alineamiento> {
+ self.hebras.iter().filter(move |h| h.toca(atom_id))
+ }
+
+ /// Marca como stale todas las hebras cuyo origen sea anterior a
+ /// `umbral_ts` — útil tras una edición sustancial de un cuerpo. Devuelve
+ /// cuántas se marcaron.
+ pub fn marcar_stale_anteriores_a(&mut self, umbral_ts: u64) -> usize {
+ let mut n = 0;
+ for h in self.hebras.iter_mut() {
+ if h.fresco && h.origen.timestamp() < umbral_ts {
+ h.fresco = false;
+ n += 1;
+ }
+ }
+ n
+ }
+
+ /// Serializa la carta a postcard.
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "carta :: serializacion fallida")
+ }
+
+ /// Reconstruye la carta desde postcard.
+ pub fn deserializar(bytes: &[u8]) -> Result<CartaHebras, &'static str> {
+ postcard::from_bytes::<CartaHebras>(bytes)
+ .map_err(|_| "carta :: deserializacion fallida")
+ }
+}
+
+// =============================================================================
+// Alineadores — estrategias concretas para producir CartaHebras
+// =============================================================================
+
+/// Alinea dos cuerpos posición-a-posición, hasta agotar el más corto. Es el
+/// alineador trivial: útil como baseline, como salida natural de una
+/// transformación `Identidad`, o cuando el usuario importa textos que sabe
+/// estructurados con la misma cantidad de párrafos. La `fuerza` es siempre
+/// 1.0 — el alineador no juzga similitud semántica, asume que la posición
+/// es verdad. Los párrafos sobrantes del cuerpo más largo quedan sin hebra
+/// (no se inventan alineamientos huérfanos).
+///
+/// `origen` se aplica idéntico a cada hebra producida.
+pub fn alinear_uno_a_uno(
+ cuerpo_a: &Cuerpo,
+ cuerpo_b: &Cuerpo,
+ origen: OrigenAlineamiento,
+) -> CartaHebras {
+ let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+ for (a, b) in cuerpo_a.orden.iter().zip(cuerpo_b.orden.iter()) {
+ carta.agregar(Alineamiento::nuevo(*a, *b, 1.0, origen.clone()));
+ }
+ carta
+}
+
+/// Alinea dos cuerpos a partir de una *tabla* explícita de pares
+/// `(atom_a, atom_b, fuerza)`. Es el camino para alineamientos manuales —
+/// el editor recibe los pares del usuario y los confirma de un golpe—.
+/// Los pares cuyos átomos no aparezcan en los respectivos cuerpos se
+/// descartan en silencio: alinear lo que no existe no tiene sentido.
+pub fn alinear_explicito(
+ cuerpo_a: &Cuerpo,
+ cuerpo_b: &Cuerpo,
+ pares: &[(Uuid, Uuid, f32)],
+ origen: OrigenAlineamiento,
+) -> CartaHebras {
+ // Indexar los cuerpos para chequeo de pertenencia O(1).
+ let en_a: HashMap<Uuid, ()> = cuerpo_a.orden.iter().map(|&id| (id, ())).collect();
+ let en_b: HashMap<Uuid, ()> = cuerpo_b.orden.iter().map(|&id| (id, ())).collect();
+ let mut carta = CartaHebras::nueva().con_par(cuerpo_a.id, cuerpo_b.id);
+ for &(a, b, fuerza) in pares {
+ if en_a.contains_key(&a) && en_b.contains_key(&b) {
+ carta.agregar(Alineamiento::nuevo(a, b, fuerza, origen.clone()));
+ }
+ }
+ carta
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+ use pluma_cuerpo::Intencion;
+
+ fn cuerpos_paralelos(n: usize) -> (Cuerpo, Cuerpo, Vec<Uuid>, Vec<Uuid>) {
+ let mut a = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
+ let mut b = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100);
+ // En este test no validamos consistencia: nos importa la alineación.
+ let ids_a: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
+ let ids_b: Vec<Uuid> = (0..n).map(|_| Uuid::new_v4()).collect();
+ for &id in &ids_a { a.agregar(id, 101); }
+ for &id in &ids_b { b.agregar(id, 101); }
+ (a, b, ids_a, ids_b)
+ }
+
+ #[test]
+ fn nuevo_alineamiento_clampea_fuerza() {
+ let h_alto = Alineamiento::nuevo(
+ Uuid::new_v4(), Uuid::new_v4(), 5.0,
+ OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
+ );
+ let h_bajo = Alineamiento::nuevo(
+ Uuid::new_v4(), Uuid::new_v4(), -0.3,
+ OrigenAlineamiento::Manual { autor: "yo".into(), timestamp: 1 },
+ );
+ assert_eq!(h_alto.fuerza, 1.0);
+ assert_eq!(h_bajo.fuerza, 0.0);
+ assert!(h_alto.fresco);
+ }
+
+ #[test]
+ fn contraparte_y_toca_funcionan_en_ambos_sentidos() {
+ let a = Uuid::new_v4();
+ let b = Uuid::new_v4();
+ let h = Alineamiento::nuevo(
+ a, b, 0.8,
+ OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 0 },
+ );
+ assert!(h.toca(a));
+ assert!(h.toca(b));
+ assert!(!h.toca(Uuid::new_v4()));
+ assert_eq!(h.contraparte(a), Some(b));
+ assert_eq!(h.contraparte(b), Some(a));
+ assert_eq!(h.contraparte(Uuid::new_v4()), None);
+ }
+
+ #[test]
+ fn alinear_uno_a_uno_empareja_hasta_el_mas_corto() {
+ let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
+ // Acortar b a 2 párrafos.
+ let mut b = b;
+ b.remover(ids_b[2], 102);
+ let carta = alinear_uno_a_uno(
+ &a, &b,
+ OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 200 },
+ );
+ assert_eq!(carta.hebras.len(), 2);
+ assert_eq!(carta.cuerpo_a, Some(a.id));
+ assert_eq!(carta.cuerpo_b, Some(b.id));
+ // Los pares son (ids_a[0], ids_b[0]) y (ids_a[1], ids_b[1]).
+ assert_eq!(carta.hebras[0].atom_a, ids_a[0]);
+ assert_eq!(carta.hebras[0].atom_b, ids_b[0]);
+ assert_eq!(carta.hebras[1].atom_a, ids_a[1]);
+ assert_eq!(carta.hebras[1].atom_b, ids_b[1]);
+ // Fuerza al máximo — es alineamiento posicional, no semántico.
+ assert!(carta.hebras.iter().all(|h| h.fuerza == 1.0));
+ // ids_a[2] queda huérfano: ninguna hebra lo toca.
+ assert!(carta.hebras_de(ids_a[2]).next().is_none());
+ }
+
+ #[test]
+ fn alinear_explicito_descarta_pares_ajenos() {
+ let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
+ let huerfano = Uuid::new_v4();
+ let pares = vec![
+ (ids_a[0], ids_b[0], 0.95),
+ (ids_a[1], ids_b[1], 0.80),
+ (huerfano, ids_b[0], 0.99), // este se descarta
+ (ids_a[0], huerfano, 0.50), // y este también
+ ];
+ let carta = alinear_explicito(
+ &a, &b, &pares,
+ OrigenAlineamiento::Manual { autor: "ana".into(), timestamp: 300 },
+ );
+ assert_eq!(carta.hebras.len(), 2);
+ assert!((carta.hebras[0].fuerza - 0.95).abs() < 1e-6);
+ assert!((carta.hebras[1].fuerza - 0.80).abs() < 1e-6);
+ }
+
+ #[test]
+ fn hebras_de_filtra_por_atomo_en_ambos_lados() {
+ let (a, b, ids_a, ids_b) = cuerpos_paralelos(3);
+ let carta = alinear_uno_a_uno(
+ &a, &b,
+ OrigenAlineamiento::Manual { autor: "x".into(), timestamp: 1 },
+ );
+ // Cada átomo de a participa en exactamente UNA hebra.
+ for &id in &ids_a {
+ assert_eq!(carta.hebras_de(id).count(), 1);
+ }
+ for &id in &ids_b {
+ assert_eq!(carta.hebras_de(id).count(), 1);
+ }
+ }
+
+ #[test]
+ fn marcar_stale_solo_toca_anteriores_al_umbral() {
+ let (a, b, ids_a, ids_b) = cuerpos_paralelos(2);
+ let mut carta = CartaHebras::nueva().con_par(a.id, b.id);
+ carta.agregar(Alineamiento::nuevo(
+ ids_a[0], ids_b[0], 1.0,
+ OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 100 },
+ ));
+ carta.agregar(Alineamiento::nuevo(
+ ids_a[1], ids_b[1], 1.0,
+ OrigenAlineamiento::Embeddings { modelo: "m1".into(), timestamp: 500 },
+ ));
+ let n = carta.marcar_stale_anteriores_a(300);
+ assert_eq!(n, 1);
+ assert!(!carta.hebras[0].fresco);
+ assert!(carta.hebras[1].fresco);
+
+ // Llamarlo de nuevo no vuelve a contar el ya stale.
+ let n2 = carta.marcar_stale_anteriores_a(300);
+ assert_eq!(n2, 0);
+ }
+
+ #[test]
+ fn roundtrip_postcard_de_carta() {
+ let (a, b, _, _) = cuerpos_paralelos(2);
+ let carta = alinear_uno_a_uno(
+ &a, &b,
+ OrigenAlineamiento::Derivado { transformacion: Uuid::new_v4(), timestamp: 7 },
+ );
+ let bytes = carta.serializar().unwrap();
+ let recuperada = CartaHebras::deserializar(&bytes).unwrap();
+ assert_eq!(recuperada, carta);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-app/Cargo.toml b/00_unanchay/pluma/pluma-app/Cargo.toml
new file mode 100644
index 0000000..db6e838
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/Cargo.toml
@@ -0,0 +1,46 @@
+[package]
+name = "pluma-app"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma-app — editor de escritura multilienzo: panel de documentos del sled, cuerpo_ide central editable, panel LLM con cycler de backends y derivación de hijas."
+
+[[bin]]
+name = "pluma-app"
+path = "src/main.rs"
+
+[dependencies]
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-md = { path = "../pluma-md" }
+foreign-docx = { path = "../foreign-docx" }
+pluma-align = { path = "../pluma-align" }
+pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
+pluma-editor-llimphi = { path = "../pluma-editor-llimphi" }
+pluma-store = { path = "../pluma-store" }
+pluma-transform = { path = "../pluma-transform" }
+pluma-transform-llm = { path = "../pluma-transform-llm" }
+pluma-llm = { path = "../pluma-llm" }
+pluma-llm-core = { path = "../pluma-llm-core" }
+pluma-llm-mock = { path = "../pluma-llm-mock" }
+llimphi-ui = { workspace = true }
+# Rail hospedado: pluma puede prestar sus secciones (Documentos/LLM/Buscar/Diff)
+# al rail de pata y colapsar sus columnas (PLUMA_DELEGATE_SIDEBAR).
+pata-host = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-button = { workspace = true }
+llimphi-widget-splitter = { workspace = true }
+llimphi-widget-list = { workspace = true }
+llimphi-widget-text-editor = { workspace = true }
+llimphi-widget-text-input = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-edit-menu = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+tokio = { workspace = true }
+uuid = { workspace = true }
+arboard = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-app/LEEME.md b/00_unanchay/pluma/pluma-app/LEEME.md
new file mode 100644
index 0000000..9a37324
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/LEEME.md
@@ -0,0 +1,16 @@
+# pluma-app
+
+> Binario del editor de [pluma](../README.md).
+
+Wrapper mínimo que arranca [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) con la `Config` cargada desde `wawa-config`. Sin lógica propia — todo vive en los crates de soporte.
+
+## Uso
+
+```sh
+cargo run --release -p pluma-app
+```
+
+## Deps
+
+- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-app/README.md b/00_unanchay/pluma/pluma-app/README.md
new file mode 100644
index 0000000..34496ff
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/README.md
@@ -0,0 +1,16 @@
+# pluma-app
+
+> Editor binary of [pluma](../README.md).
+
+Minimal wrapper that starts [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md) with the `Config` loaded from `wawa-config`. No logic of its own — everything lives in the supporting crates.
+
+## Usage
+
+```sh
+cargo run --release -p pluma-app
+```
+
+## Deps
+
+- [`pluma-editor-llimphi`](../pluma-editor-llimphi/README.md)
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-app/src/clipboard.rs b/00_unanchay/pluma/pluma-app/src/clipboard.rs
new file mode 100644
index 0000000..d5700d3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/clipboard.rs
@@ -0,0 +1,30 @@
+//! Puente al portapapeles del sistema vía `arboard`.
+
+use llimphi_widget_text_editor::Clipboard;
+
+/// Wrapper sobre `arboard::Clipboard`. Si el sistema no expone uno
+/// (headless CI, sin Wayland/X), `inner` queda en `None` y los métodos
+/// son no-op silenciosos — exactamente la semántica documentada del
+/// trait [`Clipboard`].
+pub(crate) struct ArboardClipboard {
+ inner: Option<arboard::Clipboard>,
+}
+
+impl ArboardClipboard {
+ pub(crate) fn new() -> Self {
+ Self {
+ inner: arboard::Clipboard::new().ok(),
+ }
+ }
+}
+
+impl Clipboard for ArboardClipboard {
+ fn get(&mut self) -> Option<String> {
+ self.inner.as_mut()?.get_text().ok()
+ }
+ fn set(&mut self, s: &str) {
+ if let Some(c) = self.inner.as_mut() {
+ let _ = c.set_text(s.to_owned());
+ }
+ }
+}
diff --git a/00_unanchay/pluma/pluma-app/src/init.rs b/00_unanchay/pluma/pluma-app/src/init.rs
new file mode 100644
index 0000000..b5a6416
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/init.rs
@@ -0,0 +1,149 @@
+//! Inicialización del modelo: apertura del sled, carga de cuerpos/atoms/
+//! cartas/transformaciones, sembrado del primer documento y autodetección
+//! del backend LLM.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
+use pluma_llm::{build_client, BackendKind, LlmConfig};
+use pluma_llm_core::ChatClient;
+use pluma_store::PlumaStore;
+use pluma_transform::Transformacion;
+use llimphi_widget_text_input::TextInputState;
+use uuid::Uuid;
+
+use crate::clipboard::ArboardClipboard;
+use crate::model::{Model, BACKENDS};
+use crate::util::{ahora_unix, ruta_sled};
+
+pub(crate) fn init_modelo() -> Model {
+ let path = ruta_sled();
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ let store = match PlumaStore::open(&path) {
+ Ok(s) => Arc::new(s),
+ Err(e) => panic!("pluma-app :: PlumaStore::open({path:?}) falló: {e:?}"),
+ };
+
+ let mut atoms: HashMap<Uuid, NarrativeAtom> = store
+ .iter_atoms()
+ .filter_map(|r| r.ok())
+ .map(|a| (a.id, a))
+ .collect();
+ let mut cuerpos: Vec<Cuerpo> = store.iter_cuerpos().filter_map(|r| r.ok()).collect();
+ let transformaciones: Vec<Transformacion> = store
+ .iter_transformaciones()
+ .filter_map(|r| r.ok())
+ .collect();
+ let cartas: Vec<CartaHebras> = store.iter_cartas().filter_map(|r| r.ok()).collect();
+
+ // Si el sled está vacío, sembrar un documento Original para que la
+ // ventana no esté muerta al primer arranque.
+ if cuerpos.is_empty() {
+ let ahora = ahora_unix();
+ let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es");
+ let mut cuerpo = Cuerpo::nuevo("es", "documento sin título", Intencion::Original, ahora);
+ cuerpo.agregar(atom.id, ahora);
+ let _ = store.put_atom(&atom);
+ let _ = store.put_cuerpo(&cuerpo);
+ let _ = store.flush();
+ atoms.insert(atom.id, atom);
+ cuerpos.push(cuerpo);
+ }
+
+ // Cuerpo activo = primer Original; si no hay, el primero a secas.
+ let activo = cuerpos
+ .iter()
+ .find(|c| !c.metadatos.intencion.es_derivada())
+ .map(|c| c.id)
+ .or_else(|| cuerpos.first().map(|c| c.id));
+
+ let ide = match activo {
+ Some(id) => {
+ let cuerpo = cuerpos.iter().find(|c| c.id == id).cloned().unwrap();
+ let idx: HashMap<Uuid, &NarrativeAtom> =
+ atoms.iter().map(|(k, v)| (*k, v)).collect();
+ CuerpoIde::from_cuerpo(&cuerpo, &idx)
+ }
+ None => CuerpoIde::nuevo_vacio(),
+ };
+
+ let (chat, backend_idx) = inicializar_backend();
+
+ Model {
+ store,
+ cuerpos,
+ atoms,
+ cartas,
+ transformaciones,
+ activo,
+ ide,
+ clipboard: ArboardClipboard::new(),
+ drag_accum: (0.0, 0.0),
+ chat,
+ backend_idx,
+ en_curso: false,
+ ultimo_error: None,
+ ultimo_status: "listo".to_string(),
+ path_input: TextInputState::new(),
+ path_focused: false,
+ find_input: TextInputState::new(),
+ find_visible: false,
+ find_matches: Vec::new(),
+ find_idx: 0,
+ diff_visible: false,
+ side_izq_w: 280.0,
+ side_der_w: 340.0,
+ menu_open: None,
+ menu_active: usize::MAX,
+ menu_anim: llimphi_motion::Tween::idle(1.0),
+ edit_menu: None,
+ edit_active: usize::MAX,
+ edit_anim: llimphi_motion::Tween::idle(1.0),
+ // Rail hospedado: leemos el opt-in acá; el HostClient se conecta en `init`
+ // (necesita el Handle). Las columnas arrancan visibles aunque se delegue —
+ // el usuario las colapsa desde el rail de pata.
+ delegated: std::env::var_os("PLUMA_DELEGATE_SIDEBAR").is_some(),
+ side_izq_visible: true,
+ side_der_visible: true,
+ _host: None,
+ }
+}
+
+/// Intenta el primer backend con env key configurada; si ninguno
+/// matchea, cae a Mock. Devuelve también el índice en `BACKENDS`.
+fn inicializar_backend() -> (Arc<dyn ChatClient>, usize) {
+ let preferencias: &[(BackendKind, &[&str])] = &[
+ (BackendKind::Anthropic, &["ANTHROPIC_API_KEY"]),
+ (BackendKind::Gemini, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]),
+ (BackendKind::DeepSeek, &["DEEPSEEK_API_KEY"]),
+ (BackendKind::Cohere, &["COHERE_API_KEY"]),
+ ];
+ for (kind, envs) in preferencias {
+ if envs.iter().any(|e| std::env::var(e).is_ok()) {
+ if let Ok(c) = build_client(&LlmConfig {
+ kind: *kind,
+ ..Default::default()
+ }) {
+ let idx = BACKENDS.iter().position(|k| k == kind).unwrap_or(0);
+ return (c, idx);
+ }
+ }
+ }
+ // Fallback: Mock (siempre construye).
+ let c = build_client(&LlmConfig {
+ kind: BackendKind::Mock,
+ ..Default::default()
+ })
+ .expect("Mock backend no debería fallar");
+ let idx = BACKENDS
+ .iter()
+ .position(|k| *k == BackendKind::Mock)
+ .unwrap_or(0);
+ (c, idx)
+}
diff --git a/00_unanchay/pluma/pluma-app/src/main.rs b/00_unanchay/pluma/pluma-app/src/main.rs
new file mode 100644
index 0000000..337ae91
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/main.rs
@@ -0,0 +1,200 @@
+//! `pluma-app` — editor de escritura multilienzo.
+//!
+//! Layout en tres columnas (splitters draggables):
+//!
+//! ```text
+//! ┌─────────────┬───────────────────────────┬───────────────┐
+//! │ documentos │ cuerpo_ide editable │ panel LLM │
+//! │ (lista de │ (cuerpo activo) │ - backend ▼ │
+//! │ cuerpos │ │ - botones LLM │
+//! │ del sled) │ │ - lista hijas │
+//! └─────────────┴───────────────────────────┴───────────────┘
+//! ```
+//!
+//! Persistencia automática en `~/.cache/gioser/pluma-app/pluma.sled`
+//! vía [`PlumaStore`]. Al primer arranque siembra un documento vacío
+//! para que la ventana no esté muerta. Tras ese punto, todo doc/atom/
+//! transformación/carta vive en sled.
+//!
+//! Atajos:
+//! - `Ctrl+S` guarda el cuerpo activo (diff buffer → atoms → sled).
+//! - `Ctrl+N` crea un documento Original nuevo.
+//! - `Ctrl+J` togglea la junction anterior al caret (zonas).
+//! - `Ctrl+Shift+]/[` saltan entre zonas.
+//!
+//! Botones del panel derecho dispara una transformación LLM sobre el
+//! cuerpo activo completo (Traducir → qu/en, Tono formal, Resumir 30p).
+//! La hija aparece como un cuerpo nuevo en la lista izquierda — click
+//! la activa.
+//!
+//! El crate está partido en módulos: `model` (Model+Msg+consts),
+//! `clipboard` (arboard), `util` (paths/etiquetas/reloj), `init`
+//! (apertura del sled + backend), `update` (lógica + LLM) y `view`
+//! (las tres columnas). Acá queda el `impl App` y el ruteo de teclado.
+
+mod clipboard;
+mod init;
+mod model;
+mod update;
+mod util;
+mod view;
+
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey};
+use llimphi_ui::View;
+
+use crate::init::init_modelo;
+use crate::model::{Model, Msg};
+use crate::update::actualizar;
+use crate::view::{vista, vista_overlay};
+
+fn main() {
+ llimphi_ui::run::<Pluma>();
+}
+
+struct Pluma;
+
+impl App for Pluma {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · editor multilienzo"
+ }
+
+ /// `app_id` Wayland: pata lo usa para correlacionar foco ↔ dientes hospedados.
+ fn app_id() -> Option<&'static str> {
+ Some("gioser.pluma")
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1600, 900)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ let mut m = init_modelo();
+ // Rail hospedado: si delega, publica sus secciones como dientes en pata.
+ if m.delegated {
+ let teeth = vec![
+ pata_host::HostedTooth::new(0, "folder", "Documentos"),
+ pata_host::HostedTooth::new(1, "tools", "LLM"),
+ pata_host::HostedTooth::new(2, "files", "Buscar"),
+ pata_host::HostedTooth::new(3, "tools", "Diff"),
+ ];
+ let h = handle.clone();
+ m._host = pata_host::HostClient::connect("gioser.pluma", "Pluma", teeth, move |id| {
+ h.dispatch(Msg::HostActivate(id))
+ });
+ }
+ m
+ }
+
+ fn update(model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ actualizar(model, msg, handle)
+ }
+
+ fn on_key(model: &Self::Model, event: &KeyEvent) -> Option<Self::Msg> {
+ if event.state != KeyState::Pressed {
+ return None;
+ }
+ // Menús abiertos: las flechas navegan y tienen prioridad sobre todo.
+ if let Some(mi) = model.menu_open {
+ let n = crate::update::menu_principal(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,
+ };
+ }
+ // Si el input de ruta tiene foco, las teclas van ahí — incluso
+ // Ctrl/Shift combos. Esc lo apaga; cualquier otra cosa edita.
+ if model.path_focused {
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::DefocusPath);
+ }
+ return Some(Msg::PathInputKey(event.clone()));
+ }
+ let ctrl = event.modifiers.ctrl || event.modifiers.meta;
+ let shift = event.modifiers.shift;
+ let alt = event.modifiers.alt;
+ // Alt+Flecha: mover el átomo bajo el caret. Lo capturamos antes
+ // que el editor para que no procese el evento como navegación.
+ if alt && !ctrl {
+ if matches!(&event.key, Key::Named(NamedKey::ArrowUp)) {
+ return Some(Msg::MoverAtomArriba);
+ }
+ if matches!(&event.key, Key::Named(NamedKey::ArrowDown)) {
+ return Some(Msg::MoverAtomAbajo);
+ }
+ }
+ // Find overlay capturado: Esc cierra, Enter/Shift+Enter ciclan
+ // matches, todo lo demás edita el query.
+ if model.find_visible {
+ if matches!(&event.key, Key::Named(NamedKey::Escape)) {
+ return Some(Msg::FindClose);
+ }
+ if matches!(&event.key, Key::Named(NamedKey::Enter)) {
+ return Some(if shift {
+ Msg::FindAnterior
+ } else {
+ Msg::FindSiguiente
+ });
+ }
+ // Ctrl+F otra vez cierra (atajo simétrico a abrir).
+ if ctrl {
+ if let Key::Character(s) = &event.key {
+ if s.eq_ignore_ascii_case("f") {
+ return Some(Msg::FindClose);
+ }
+ }
+ }
+ return Some(Msg::FindKey(event.clone()));
+ }
+ if ctrl {
+ if let Key::Character(s) = &event.key {
+ if s.eq_ignore_ascii_case("s") {
+ return Some(Msg::Guardar);
+ }
+ if s.eq_ignore_ascii_case("n") {
+ return Some(Msg::NuevoDoc);
+ }
+ if s.eq_ignore_ascii_case("f") {
+ return Some(Msg::FindToggle);
+ }
+ if s.eq_ignore_ascii_case("d") {
+ return Some(Msg::DiffToggle);
+ }
+ if shift && (s == "}" || s == "]") {
+ return Some(Msg::ZonaSiguiente);
+ }
+ if shift && (s == "{" || s == "[") {
+ return Some(Msg::ZonaAnterior);
+ }
+ if s.eq_ignore_ascii_case("j") {
+ return Some(Msg::ToglearFusion);
+ }
+ }
+ }
+ Some(Msg::EditorKey(event.clone()))
+ }
+
+ fn view(model: &Model) -> View<Msg> {
+ vista(model)
+ }
+
+ fn view_overlay(model: &Model) -> Option<View<Msg>> {
+ vista_overlay(model)
+ }
+}
diff --git a/00_unanchay/pluma/pluma-app/src/model.rs b/00_unanchay/pluma/pluma-app/src/model.rs
new file mode 100644
index 0000000..3f49c30
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/model.rs
@@ -0,0 +1,168 @@
+//! Modelo de la app y mensajes del bucle Elm.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use llimphi_ui::KeyEvent;
+use llimphi_widget_text_editor::{EditorMetrics, PointerEvent};
+use llimphi_widget_text_input::TextInputState;
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use pluma_editor_llimphi::cuerpo_ide::CuerpoIde;
+use pluma_llm::BackendKind;
+use pluma_llm_core::ChatClient;
+use pluma_store::PlumaStore;
+use pluma_transform::Transformacion;
+use uuid::Uuid;
+
+use crate::clipboard::ArboardClipboard;
+
+pub(crate) const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
+pub(crate) const VISIBLE_LINES: usize = 200;
+
+pub(crate) const BACKENDS: [BackendKind; 6] = [
+ BackendKind::Mock,
+ BackendKind::Gemini,
+ BackendKind::Anthropic,
+ BackendKind::DeepSeek,
+ BackendKind::Cohere,
+ BackendKind::Ollama,
+];
+
+#[derive(Clone, Debug)]
+pub(crate) enum Msg {
+ EditorKey(KeyEvent),
+ EditorPointer(PointerEvent),
+ AbrirDoc(Uuid),
+ NuevoDoc,
+ Guardar,
+ PathInputKey(KeyEvent),
+ FocusPath,
+ DefocusPath,
+ AbrirArchivo,
+ ExportarMd,
+ FindToggle,
+ FindKey(KeyEvent),
+ FindSiguiente,
+ FindAnterior,
+ FindClose,
+ DiffToggle,
+ /// Rail hospedado: pata reenvió un clic en un diente prestado (0=Documentos,
+ /// 1=LLM, 2=Buscar, 3=Diff). Togglea esa sección.
+ HostActivate(u32),
+ MoverAtomArriba,
+ MoverAtomAbajo,
+ TocarMadre,
+ RegenerarStale,
+ ToglearFusion,
+ ZonaSiguiente,
+ ZonaAnterior,
+ CicloBackend,
+ PedirTraducir(String),
+ PedirTono(String),
+ PedirResumir(Option<u32>),
+ LlmListo {
+ hija: Cuerpo,
+ atoms_nuevos: Vec<NarrativeAtom>,
+ carta: CartaHebras,
+ transformacion: Transformacion,
+ },
+ LlmError(String),
+ ResizeIzq(f32),
+ ResizeDer(f32),
+
+ // --- Menú principal + menú de edición contextual ---
+ /// Abre/cierra un dropdown del menú principal (índice del menú raíz).
+ MenuOpen(Option<usize>),
+ /// Comando string del menú principal (rebota desde `on_command`).
+ 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,
+ /// Right-click: abre el menú de edición anclado en (x, y) de ventana.
+ EditMenuOpen(f32, f32),
+ /// Acción elegida en el menú de edición contextual.
+ EditMenuAction(llimphi_widget_edit_menu::EditAction),
+ /// Cierra cualquier menú abierto (dropdown o edición).
+ CloseMenus,
+}
+
+pub(crate) struct Model {
+ pub(crate) store: Arc<PlumaStore>,
+ pub(crate) cuerpos: Vec<Cuerpo>,
+ pub(crate) atoms: HashMap<Uuid, NarrativeAtom>,
+ pub(crate) cartas: Vec<CartaHebras>,
+ pub(crate) transformaciones: Vec<Transformacion>,
+ /// `id` del `Cuerpo` activo (el que se ve en `ide`). `None` sólo si
+ /// la lista de cuerpos está vacía — el init siembra uno para evitarlo.
+ pub(crate) activo: Option<Uuid>,
+ pub(crate) ide: CuerpoIde,
+ pub(crate) clipboard: ArboardClipboard,
+ pub(crate) drag_accum: (f32, f32),
+
+ pub(crate) chat: Arc<dyn ChatClient>,
+ pub(crate) backend_idx: usize,
+ pub(crate) en_curso: bool,
+ pub(crate) ultimo_error: Option<String>,
+ pub(crate) ultimo_status: String,
+
+ /// Ruta del archivo a abrir/exportar — input compartido.
+ /// Se interpreta según qué botón clickea el usuario.
+ pub(crate) path_input: TextInputState,
+ /// Cuando es `true`, las teclas del usuario van al `path_input` en
+ /// vez del editor. Click sobre el input lo enciende; Esc, o un
+ /// click fuera (en realidad, sólo Esc) lo apaga.
+ pub(crate) path_focused: bool,
+
+ /// Find-in-page sobre el cuerpo activo. `Ctrl+F` muestra el overlay
+ /// y lo enfoca; Esc lo cierra; Enter/Shift+Enter cyclan matches.
+ pub(crate) find_input: TextInputState,
+ pub(crate) find_visible: bool,
+ pub(crate) find_matches: Vec<(usize, usize)>,
+ pub(crate) find_idx: usize,
+
+ /// Cuando es `true` y el cuerpo activo es una hija, el centro
+ /// muestra la madre y la hija lado a lado con las hebras pintadas
+ /// (read-only). Cuando el activo es Original o no se encuentra la
+ /// madre, el flag igual existe pero la vista cae al cuerpo_ide
+ /// normal con un cartel.
+ pub(crate) diff_visible: bool,
+
+ pub(crate) side_izq_w: f32,
+ pub(crate) side_der_w: f32,
+
+ /// Índice del menú raíz cuyo dropdown está abierto (`None` = cerrado).
+ pub(crate) menu_open: Option<usize>,
+ /// Fila resaltada por teclado en el menú principal (`usize::MAX` = ninguna).
+ pub(crate) menu_active: usize,
+ /// Animación de aparición/swap del dropdown del menú principal (0→1).
+ pub(crate) menu_anim: llimphi_motion::Tween<f32>,
+ /// Ancla (x, y) en coords de ventana del menú de edición contextual,
+ /// o `None` si no está abierto.
+ pub(crate) edit_menu: Option<(f32, f32)>,
+ /// Fila resaltada por teclado en el menú de edición (`usize::MAX` = ninguna).
+ pub(crate) edit_active: usize,
+ /// Animación de aparición del menú de edición (0→1).
+ pub(crate) edit_anim: llimphi_motion::Tween<f32>,
+
+ // --- Rail hospedado (sidebar delegado a pata) ---
+ /// `true` si pluma delega su sidebar a pata (`PLUMA_DELEGATE_SIDEBAR`): sus
+ /// secciones aparecen como dientes en el rail de pata cuando tiene foco, y las
+ /// columnas laterales se pueden colapsar (editor a pantalla completa).
+ pub(crate) delegated: bool,
+ /// Visibilidad de la columna de Documentos (sólo aplica en modo delegado).
+ pub(crate) side_izq_visible: bool,
+ /// Visibilidad de la columna LLM (sólo aplica en modo delegado).
+ pub(crate) side_der_visible: bool,
+ /// Cliente del rail hospedado; sólo se retiene (las activaciones llegan por
+ /// callback). `_` evita el lint de campo sin leer.
+ pub(crate) _host: Option<pata_host::HostClient>,
+}
diff --git a/00_unanchay/pluma/pluma-app/src/update.rs b/00_unanchay/pluma/pluma-app/src/update.rs
new file mode 100644
index 0000000..4a0bed4
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/update.rs
@@ -0,0 +1,1004 @@
+//! Lógica de actualización del bucle Elm: el `match` central, las
+//! mutaciones del modelo (abrir/crear/guardar/mover/regenerar), el
+//! find-in-page, y el trabajo LLM lanzado en un thread aparte.
+
+use std::collections::HashMap;
+
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_ui::Handle;
+use llimphi_widget_edit_menu::{self as editmenu, EditAction, EditFlags};
+use llimphi_widget_text_editor::PointerEvent;
+use pluma_align::CartaHebras;
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_cuerpo::CambioAtom;
+use pluma_llm::{build_client, LlmConfig};
+use pluma_transform::{TipoTransformacion, Transformacion};
+use pluma_transform_llm::{EjecutorResumirLlm, EjecutorTonoLlm, EjecutorTraducirLlm};
+use uuid::Uuid;
+
+use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES};
+use crate::util::{ahora_unix, etiqueta_backend, expandir_ruta, extension_lower};
+
+pub(crate) fn actualizar(mut model: Model, msg: Msg, handle: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::EditorKey(ev) => {
+ let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
+ }
+ Msg::EditorPointer(ev) => {
+ let scroll = model.ide.state.scroll_offset;
+ match ev {
+ PointerEvent::Click { x, y } => {
+ model.drag_accum = (0.0, 0.0);
+ let (line, col) = METRICS.screen_to_pos(x, y, scroll);
+ model.ide.set_caret(line, col);
+ }
+ PointerEvent::Drag {
+ initial_x,
+ initial_y,
+ dx,
+ dy,
+ } => {
+ model.drag_accum.0 += dx;
+ model.drag_accum.1 += dy;
+ let cx = initial_x + model.drag_accum.0;
+ let cy = initial_y + model.drag_accum.1;
+ let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
+ model.ide.state.extend_selection_to(line, col);
+ }
+ }
+ }
+ Msg::AbrirDoc(id) => {
+ cambiar_activo(&mut model, id);
+ }
+ Msg::NuevoDoc => {
+ crear_doc_nuevo(&mut model);
+ }
+ Msg::Guardar => {
+ guardar_activo(&mut model);
+ }
+ Msg::PathInputKey(ev) => {
+ model.path_input.apply_key(&ev);
+ }
+ Msg::FocusPath => {
+ model.path_focused = true;
+ }
+ Msg::DefocusPath => {
+ model.path_focused = false;
+ }
+ Msg::AbrirArchivo => {
+ model.path_focused = false;
+ abrir_archivo(&mut model);
+ }
+ Msg::ExportarMd => {
+ model.path_focused = false;
+ exportar_md(&mut model);
+ }
+ Msg::FindToggle => {
+ model.find_visible = !model.find_visible;
+ if model.find_visible {
+ recomputar_matches(&mut model);
+ if !model.find_matches.is_empty() {
+ saltar_a_match(&mut model);
+ }
+ }
+ }
+ Msg::FindKey(ev) => {
+ model.find_input.apply_key(&ev);
+ recomputar_matches(&mut model);
+ if !model.find_matches.is_empty() {
+ saltar_a_match(&mut model);
+ }
+ }
+ Msg::FindSiguiente => {
+ if model.find_matches.is_empty() {
+ return model;
+ }
+ model.find_idx = (model.find_idx + 1) % model.find_matches.len();
+ saltar_a_match(&mut model);
+ }
+ Msg::FindAnterior => {
+ if model.find_matches.is_empty() {
+ return model;
+ }
+ let n = model.find_matches.len();
+ model.find_idx = (model.find_idx + n - 1) % n;
+ saltar_a_match(&mut model);
+ }
+ Msg::FindClose => {
+ model.find_visible = false;
+ }
+ Msg::DiffToggle => {
+ model.diff_visible = !model.diff_visible;
+ }
+ // Rail hospedado: pata reenvió un diente. 0/1 colapsan las columnas
+ // (editor a pantalla completa), 2/3 togglean Buscar/Diff (su misma lógica).
+ Msg::HostActivate(id) => match id {
+ 0 => model.side_izq_visible = !model.side_izq_visible,
+ 1 => model.side_der_visible = !model.side_der_visible,
+ 2 => {
+ model.find_visible = !model.find_visible;
+ if model.find_visible {
+ recomputar_matches(&mut model);
+ if !model.find_matches.is_empty() {
+ saltar_a_match(&mut model);
+ }
+ }
+ }
+ 3 => model.diff_visible = !model.diff_visible,
+ _ => {}
+ },
+ Msg::MoverAtomArriba => {
+ mover_atom_caret(&mut model, -1);
+ }
+ Msg::MoverAtomAbajo => {
+ mover_atom_caret(&mut model, 1);
+ }
+ Msg::TocarMadre => {
+ tocar_madre(&mut model);
+ }
+ Msg::RegenerarStale => {
+ regenerar_siguiente_stale(&mut model, handle);
+ }
+ Msg::ToglearFusion => {
+ if let Some(idx) = model.ide.junction_antes_del_caret() {
+ model.ide.togglear_junction(idx);
+ }
+ }
+ Msg::ZonaSiguiente => {
+ model.ide.ir_a_zona_siguiente();
+ model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+ }
+ Msg::ZonaAnterior => {
+ model.ide.ir_a_zona_anterior();
+ model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+ }
+ Msg::CicloBackend => {
+ cycle_backend(&mut model);
+ }
+ Msg::PedirTraducir(lengua) => {
+ lanzar(&mut model, handle, TrabajoLlm::Traducir(lengua));
+ }
+ Msg::PedirTono(etiqueta) => {
+ lanzar(&mut model, handle, TrabajoLlm::Tono(etiqueta));
+ }
+ Msg::PedirResumir(palabras) => {
+ lanzar(&mut model, handle, TrabajoLlm::Resumir(palabras));
+ }
+ Msg::LlmListo {
+ hija,
+ atoms_nuevos,
+ carta,
+ transformacion,
+ } => {
+ recibir_hija(&mut model, hija, atoms_nuevos, carta, transformacion);
+ }
+ Msg::LlmError(s) => {
+ eprintln!("pluma-app :: error LLM: {s}");
+ model.ultimo_error = Some(s);
+ model.en_curso = false;
+ }
+ Msg::ResizeIzq(dx) => {
+ model.side_izq_w = (model.side_izq_w + dx).clamp(160.0, 420.0);
+ }
+ Msg::ResizeDer(dx) => {
+ // El divisor está del lado izquierdo de la columna derecha:
+ // dx>0 = divisor a la derecha = panel der encoge.
+ model.side_der_w = (model.side_der_w - dx).clamp(220.0, 520.0);
+ }
+
+ // --- Menú principal + menú de edición contextual ---
+ 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(handle, motion::FAST, || Msg::MenuTick);
+ }
+ }
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.menu_active = usize::MAX;
+ model.edit_menu = None;
+ model.edit_active = usize::MAX;
+ }
+ 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(handle, motion::FAST, || Msg::MenuTick);
+ }
+ Msg::EditMenuAction(action) => {
+ return aplicar_edit_menu(model, action);
+ }
+ Msg::MenuCommand(cmd) => {
+ return ejecutar_menu_command(model, cmd, handle);
+ }
+ Msg::MenuNav(dir) => {
+ if let Some(mi) = model.menu_open {
+ let menu = menu_principal(&model);
+ model.menu_active =
+ llimphi_widget_menubar::menubar_nav(&menu, mi, model.menu_active, dir);
+ }
+ }
+ Msg::MenuActivate => {
+ if let Some(mi) = model.menu_open {
+ let menu = menu_principal(&model);
+ if let Some(cmd) =
+ llimphi_widget_menubar::menubar_command_at(&menu, mi, model.menu_active)
+ {
+ return ejecutar_menu_command(model, cmd, handle);
+ }
+ }
+ }
+ Msg::MenuTick => {}
+ Msg::EditNav(dir) => {
+ let flags = EditFlags::from_editor(&model.ide.state, false);
+ model.edit_active = editmenu::edit_menu_step(flags, model.edit_active, dir);
+ }
+ Msg::EditActivate => {
+ let flags = EditFlags::from_editor(&model.ide.state, false);
+ if let Some(action) = editmenu::edit_menu_action_at(flags, model.edit_active) {
+ return aplicar_edit_menu(model, action);
+ }
+ }
+ }
+ model
+}
+
+/// Construye el menú principal de pluma reflejando el estado actual: los
+/// ítems de Editar quedan grises cuando no hay selección (Cortar/Copiar) o
+/// historial (Deshacer/Rehacer). El editor focuseado es el `cuerpo_ide`
+/// central (único editor de texto rico de la app).
+pub(crate) fn menu_principal(model: &Model) -> app_bus::AppMenu {
+ use app_bus::{AppMenu, Menu, MenuItem};
+
+ let ed = &model.ide.state;
+ let has_sel = ed.has_selection();
+ let can_undo = ed.can_undo();
+ let can_redo = ed.can_redo();
+
+ 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 paste = MenuItem::new("Pegar", "edit.paste").shortcut("Ctrl+V");
+ let sel_all = MenuItem::new("Seleccionar todo", "edit.selectall")
+ .shortcut("Ctrl+A")
+ .separated();
+
+ // El botón de regenerar stale sólo tiene sentido si hay alguna hija
+ // stale del activo — lo grisamos cuando no.
+ let mut regen = MenuItem::new("Regenerar stale", "llm.regen");
+ if contar_stale_del_activo(model) == 0 {
+ regen = regen.disabled();
+ }
+
+ AppMenu::new()
+ .menu(
+ Menu::new("Archivo")
+ .item(MenuItem::new("Nuevo documento", "file.nuevo").shortcut("Ctrl+N"))
+ .item(MenuItem::new("Guardar", "file.guardar").shortcut("Ctrl+S"))
+ .item(MenuItem::new("Abrir archivo (ruta)", "file.abrir").separated())
+ .item(MenuItem::new("Exportar (md/docx)", "file.exportar")),
+ )
+ .menu(
+ Menu::new("Editar")
+ .item(undo)
+ .item(redo)
+ .item(cut)
+ .item(copy)
+ .item(paste)
+ .item(sel_all),
+ )
+ .menu(
+ Menu::new("Buscar")
+ .item(MenuItem::new("Buscar en documento", "search.find").shortcut("Ctrl+F")),
+ )
+ .menu(
+ Menu::new("Multilienzo")
+ .item(MenuItem::new("Diff madre ↔ hija", "mult.diff").shortcut("Ctrl+D"))
+ .item(MenuItem::new("Togglear fusión (zona)", "mult.fusion").shortcut("Ctrl+J"))
+ .item(MenuItem::new("Zona siguiente", "mult.zona_sig").separated())
+ .item(MenuItem::new("Zona anterior", "mult.zona_ant")),
+ )
+ .menu(
+ Menu::new("LLM")
+ .item(MenuItem::new("Ciclar backend", "llm.backend"))
+ .item(MenuItem::new("Traducir → qu", "llm.trad_qu"))
+ .item(MenuItem::new("Traducir → en", "llm.trad_en"))
+ .item(MenuItem::new("Tono formal", "llm.tono"))
+ .item(MenuItem::new("Resumir 30p", "llm.resumir"))
+ .item(MenuItem::new("Tocar madre", "llm.tocar").separated())
+ .item(regen),
+ )
+ .menu(
+ Menu::new("Ayuda")
+ .item(MenuItem::new("pluma · editor multilienzo", "help.about").disabled()),
+ )
+}
+
+/// Traduce el `command` string del menú principal al `Msg` real de la app
+/// y lo aplica. Cierra el dropdown antes de actuar. Los comandos `edit.*`
+/// se enrutan al menú de edición sobre el `cuerpo_ide`.
+fn ejecutar_menu_command(mut model: Model, command: String, handle: &Handle<Msg>) -> Model {
+ model.menu_open = None;
+ let target = match command.as_str() {
+ "file.nuevo" => Some(Msg::NuevoDoc),
+ "file.guardar" => Some(Msg::Guardar),
+ "file.abrir" => Some(Msg::AbrirArchivo),
+ "file.exportar" => Some(Msg::ExportarMd),
+ "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)),
+ "search.find" => Some(Msg::FindToggle),
+ "mult.diff" => Some(Msg::DiffToggle),
+ "mult.fusion" => Some(Msg::ToglearFusion),
+ "mult.zona_sig" => Some(Msg::ZonaSiguiente),
+ "mult.zona_ant" => Some(Msg::ZonaAnterior),
+ "llm.backend" => Some(Msg::CicloBackend),
+ "llm.trad_qu" => Some(Msg::PedirTraducir("qu".into())),
+ "llm.trad_en" => Some(Msg::PedirTraducir("en".into())),
+ "llm.tono" => Some(Msg::PedirTono("formal".into())),
+ "llm.resumir" => Some(Msg::PedirResumir(Some(30))),
+ "llm.tocar" => Some(Msg::TocarMadre),
+ "llm.regen" => Some(Msg::RegenerarStale),
+ _ => None,
+ };
+ match target {
+ Some(msg) => actualizar(model, msg, handle),
+ None => model,
+ }
+}
+
+/// Aplica una acción del menú de edición al `EditorState` del cuerpo_ide,
+/// reusando `editmenu::apply` (mismo camino que las teclas de edición).
+/// Cierra el menú. Como `apply_key_with_clipboard`, no necesita marcar
+/// dirty manual: el `CuerpoIde` deriva el pendiente_sync de su `edit_seq`.
+fn aplicar_edit_menu(mut model: Model, action: EditAction) -> Model {
+ model.edit_menu = None;
+ let _ = llimphi_widget_edit_menu::apply(&mut model.ide.state, action, &mut model.clipboard);
+ model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+ model
+}
+
+fn cambiar_activo(model: &mut Model, id: Uuid) {
+ if model.activo == Some(id) {
+ return;
+ }
+ let cuerpo = match model.cuerpos.iter().find(|c| c.id == id) {
+ Some(c) => c.clone(),
+ None => return,
+ };
+ model.activo = Some(id);
+ let idx: HashMap<Uuid, &NarrativeAtom> =
+ model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+ model.ide.recargar(&cuerpo, &idx);
+ model.ultimo_status = format!("doc: {}", cuerpo.metadatos.nombre_legible);
+}
+
+fn crear_doc_nuevo(model: &mut Model) {
+ let ahora = ahora_unix();
+ let n = model
+ .cuerpos
+ .iter()
+ .filter(|c| !c.metadatos.intencion.es_derivada())
+ .count()
+ + 1;
+ let atom = NarrativeAtom::new("Empieza a escribir aquí…", "es");
+ let mut cuerpo = Cuerpo::nuevo(
+ format!("es-{n}"),
+ format!("doc #{n} sin título"),
+ Intencion::Original,
+ ahora,
+ );
+ cuerpo.agregar(atom.id, ahora);
+ let _ = model.store.put_atom(&atom);
+ let _ = model.store.put_cuerpo(&cuerpo);
+ let _ = model.store.flush();
+ let id = cuerpo.id;
+ model.atoms.insert(atom.id, atom);
+ model.cuerpos.push(cuerpo);
+ cambiar_activo(model, id);
+ model.ultimo_status = format!("doc #{n} creado");
+}
+
+fn guardar_activo(model: &mut Model) {
+ let Some(activo_id) = model.activo else {
+ model.ultimo_status = "sin doc activo".into();
+ return;
+ };
+ let idx: HashMap<Uuid, &NarrativeAtom> =
+ model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+ let cambios = model.ide.diff(&idx);
+ drop(idx);
+
+ if cambios.is_empty() {
+ model.ultimo_status = "sin cambios".into();
+ return;
+ }
+
+ let mut creados: Vec<Uuid> = Vec::new();
+ let branch_id = model
+ .cuerpos
+ .iter()
+ .find(|c| c.id == activo_id)
+ .map(|c| c.branch_id.clone())
+ .unwrap_or_else(|| "es".to_string());
+
+ for c in &cambios {
+ match c {
+ CambioAtom::Mutar { id, texto_nuevo } => {
+ if let Some(a) = model.atoms.get_mut(id) {
+ a.set_content(texto_nuevo.as_str());
+ let _ = model.store.put_atom(a);
+ }
+ }
+ CambioAtom::Crear { texto, posicion: _ } => {
+ let atom = NarrativeAtom::new(texto.as_str(), &branch_id);
+ let id = atom.id;
+ let _ = model.store.put_atom(&atom);
+ model.atoms.insert(id, atom);
+ creados.push(id);
+ }
+ CambioAtom::Eliminar { id } => {
+ model.atoms.remove(id);
+ // El sled mantiene el atom histórico — no lo borramos
+ // del backend porque hijas/cartas pueden seguir apuntando
+ // a él. La memoria local sí lo descarta.
+ }
+ }
+ }
+
+ model.ide.aplicar_cambios(&cambios, &creados);
+
+ // Reconstruir `cuerpo.orden` con el orden nuevo del IDE.
+ let nuevo_orden: Vec<Uuid> = model.ide.editor_cuerpo.atom_ids.clone();
+ if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+ let ahora = c.metadatos.modificado_en.saturating_add(1);
+ let viejo: Vec<Uuid> = c.orden.clone();
+ for id in &viejo {
+ let _ = c.remover(*id, ahora);
+ }
+ for id in &nuevo_orden {
+ c.agregar(*id, ahora);
+ }
+ let _ = model.store.put_cuerpo(c);
+ }
+ let _ = model.store.flush();
+
+ let n_mut = cambios
+ .iter()
+ .filter(|c| matches!(c, CambioAtom::Mutar { .. }))
+ .count();
+ let n_new = creados.len();
+ let n_del = cambios
+ .iter()
+ .filter(|c| matches!(c, CambioAtom::Eliminar { .. }))
+ .count();
+ model.ultimo_status = format!("guardado: {n_mut} mut · {n_new} crear · {n_del} del");
+}
+
+/// Recalcula las posiciones (línea, col) donde aparece el query en el
+/// buffer actual. Búsqueda case-insensitive, substring. Llamarlo cada
+/// vez que el query o el texto cambian. Reset de `find_idx` al primer
+/// match cuando hay alguno; lo deja en 0 si no hay (consistente con
+/// "0 de 0"), pero la UI no salta si está vacío.
+fn recomputar_matches(model: &mut Model) {
+ let query = model.find_input.text();
+ if query.is_empty() {
+ model.find_matches.clear();
+ model.find_idx = 0;
+ return;
+ }
+ let q_lower = query.to_lowercase();
+ let mut matches: Vec<(usize, usize)> = Vec::new();
+ let texto = model.ide.texto_buffer();
+ for (line_idx, linea) in texto.lines().enumerate() {
+ let l_lower = linea.to_lowercase();
+ let mut start = 0;
+ while let Some(pos) = l_lower[start..].find(&q_lower) {
+ let col = start + pos;
+ matches.push((line_idx, col));
+ start = col + q_lower.len().max(1);
+ if start >= l_lower.len() {
+ break;
+ }
+ }
+ }
+ model.find_matches = matches;
+ if model.find_idx >= model.find_matches.len() {
+ model.find_idx = 0;
+ }
+}
+
+fn saltar_a_match(model: &mut Model) {
+ let Some(&(line, col)) = model.find_matches.get(model.find_idx) else {
+ return;
+ };
+ model.ide.set_caret(line, col);
+ model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+}
+
+/// Avanza `modificado_en` del cuerpo activo a la hora actual. Cualquier
+/// hija derivada cuyo `regenerada_en` sea anterior se vuelve `es_stale`
+/// y aparece en el botón «regenerar stale (N)». Caso de uso típico:
+/// editaste la madre sin querer recordar todos los detalles y querés
+/// invalidar las derivadas para que vuelvan a salir del LLM.
+fn tocar_madre(model: &mut Model) {
+ let Some(activo_id) = model.activo else {
+ model.ultimo_status = "sin doc activo".into();
+ return;
+ };
+ let ahora = ahora_unix();
+ if let Some(c) = model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+ c.metadatos.modificado_en = ahora;
+ let _ = model.store.put_cuerpo(c);
+ }
+ let _ = model.store.flush();
+ let n = contar_stale_del_activo(model);
+ model.ultimo_status = format!("madre tocada — {n} hija(s) ahora stale");
+ model.ultimo_error = None;
+}
+
+pub(crate) fn contar_stale_del_activo(model: &Model) -> usize {
+ let Some(activo_id) = model.activo else {
+ return 0;
+ };
+ let Some(madre) = model.cuerpos.iter().find(|c| c.id == activo_id) else {
+ return 0;
+ };
+ let modif = madre.metadatos.modificado_en;
+ model
+ .cuerpos
+ .iter()
+ .filter(|c| {
+ c.metadatos.derivado_de == Some(activo_id) && c.es_derivado() && c.es_stale(modif)
+ })
+ .count()
+}
+
+/// Encuentra la primera hija del activo que sea stale, busca la
+/// `Transformacion` original registrada (madre==activo, hija==hija_id),
+/// traduce su `TipoTransformacion` a un `TrabajoLlm`, y lo lanza con la
+/// madre actualizada — el ejecutor produce una hija nueva fresca; la
+/// vieja queda en el modelo (sigue visible por si querés diff). Lo
+/// hacemos hija-por-hija (no batch) para que el progreso sea visible
+/// y un error no aborte todas.
+fn regenerar_siguiente_stale(model: &mut Model, handle: &Handle<Msg>) {
+ if model.en_curso {
+ model.ultimo_status = "LLM ocupado — esperá".into();
+ return;
+ }
+ let Some(activo_id) = model.activo else {
+ model.ultimo_status = "sin doc activo".into();
+ return;
+ };
+ let madre_modif = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+ Some(c) => c.metadatos.modificado_en,
+ None => return,
+ };
+ let hija_id_opt = model
+ .cuerpos
+ .iter()
+ .find(|c| {
+ c.metadatos.derivado_de == Some(activo_id)
+ && c.es_derivado()
+ && c.es_stale(madre_modif)
+ })
+ .map(|c| c.id);
+ let Some(hija_id) = hija_id_opt else {
+ model.ultimo_status = "no hay hijas stale — tocar madre primero".into();
+ return;
+ };
+ // Buscar la Transformacion original. Prioridad: la del store (en
+ // memoria está cargada al iniciar; el sled es la fuente de verdad).
+ let tipo = model
+ .transformaciones
+ .iter()
+ .find(|t| t.madre == activo_id && t.hija == hija_id)
+ .map(|t| t.tipo.clone());
+ let Some(tipo) = tipo else {
+ model.ultimo_status = format!(
+ "no se halló transformación para regenerar {hija_id} — falta historial"
+ );
+ return;
+ };
+ let Some(trabajo) = trabajo_de_tipo(&tipo) else {
+ model.ultimo_status = format!("tipo {tipo:?} no es regenerable automáticamente");
+ return;
+ };
+ lanzar(model, handle, trabajo);
+}
+
+/// Traduce un `TipoTransformacion` persistido al `TrabajoLlm` que
+/// `lanzar` sabe correr. `Identidad`/`Reescribir`/`Custom` no son
+/// auto-regenerables — Reescribir necesita prompt humano, Custom Rhai,
+/// Identidad no aporta nada nuevo.
+fn trabajo_de_tipo(t: &TipoTransformacion) -> Option<TrabajoLlm> {
+ match t {
+ TipoTransformacion::Traducir { lengua_destino } => {
+ Some(TrabajoLlm::Traducir(lengua_destino.clone()))
+ }
+ TipoTransformacion::Tono { etiqueta } => Some(TrabajoLlm::Tono(etiqueta.clone())),
+ TipoTransformacion::Resumir { palabras_objetivo } => {
+ Some(TrabajoLlm::Resumir(*palabras_objetivo))
+ }
+ _ => None,
+ }
+}
+
+/// Mueve el átomo donde está el caret una posición arriba (`delta=-1`)
+/// o abajo (`delta=1`). Sincroniza el buffer al modelo antes de
+/// reordenar (para no perder ediciones pendientes), muta `cuerpo.orden`,
+/// persiste, y recarga el IDE — junctions resetean a separadores (es
+/// el costo del reorder; el usuario las re-fusiona si las quería).
+/// El caret queda en la primera línea del átomo movido.
+fn mover_atom_caret(model: &mut Model, delta: i32) {
+ let Some(activo_id) = model.activo else {
+ return;
+ };
+ // Sincroniza pendientes para no perderlos al recargar.
+ guardar_activo(model);
+
+ let (caret_line, _) = model.ide.caret();
+ let Some(atom_id) = model.ide.atom_id_en_linea(caret_line) else {
+ return;
+ };
+ let cuerpo = match model.cuerpos.iter_mut().find(|c| c.id == activo_id) {
+ Some(c) => c,
+ None => return,
+ };
+ let n = cuerpo.orden.len();
+ if n < 2 {
+ return;
+ }
+ let i = match cuerpo.orden.iter().position(|x| *x == atom_id) {
+ Some(i) => i,
+ None => return,
+ };
+ let j = if delta < 0 {
+ if i == 0 {
+ return;
+ }
+ i - 1
+ } else {
+ if i + 1 >= n {
+ return;
+ }
+ i + 1
+ };
+ cuerpo.orden.swap(i, j);
+ cuerpo.metadatos.modificado_en = cuerpo.metadatos.modificado_en.saturating_add(1);
+ let _ = model.store.put_cuerpo(cuerpo);
+ let _ = model.store.flush();
+
+ // Recargar el IDE con el orden nuevo. Snapshot la cuerpo data
+ // primero para evitar el borrow simultáneo del index.
+ let cuerpo_clon = cuerpo.clone();
+ // Liberamos el préstamo mutable de `model.cuerpos` antes de
+ // tomar uno inmutable de `model.atoms` para construir el índice.
+ let _ = cuerpo;
+ let idx: HashMap<Uuid, &NarrativeAtom> =
+ model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+ model.ide.recargar(&cuerpo_clon, &idx);
+ drop(idx);
+
+ // Posicionar el caret al inicio del átomo movido. Su nuevo idx es
+ // `j`; sumamos lineas anteriores (cada atom = 1 + atoms_extra_lineas
+ // + separador). Más simple: usar posicion_de_atom.
+ if let Some((line, col)) = model.ide.posicion_de_atom(atom_id) {
+ model.ide.set_caret(line, col);
+ model.ide.state.ensure_caret_visible(VISIBLE_LINES);
+ }
+
+ model.ultimo_status = format!(
+ "atom movido {}",
+ if delta < 0 { "↑" } else { "↓" }
+ );
+ model.ultimo_error = None;
+}
+
+fn abrir_archivo(model: &mut Model) {
+ let path_raw = model.path_input.text().trim().to_string();
+ if path_raw.is_empty() {
+ model.ultimo_error = Some("ruta vacía".into());
+ return;
+ }
+ let path = expandir_ruta(&path_raw);
+ let bytes = match std::fs::read(&path) {
+ Ok(b) => b,
+ Err(e) => {
+ model.ultimo_error = Some(format!("leyendo {path:?}: {e}"));
+ return;
+ }
+ };
+ let nombre = path
+ .file_name()
+ .map(|s| s.to_string_lossy().into_owned())
+ .unwrap_or_else(|| "archivo".to_string());
+ let ahora = ahora_unix();
+
+ let importado = if extension_lower(&path) == Some("docx".to_string()) {
+ match foreign_docx::parse_docx(&bytes, "es", nombre.clone(), ahora) {
+ Ok(imp) => (imp.cuerpo, imp.atoms),
+ Err(e) => {
+ model.ultimo_error = Some(format!("parse_docx {nombre}: {e:?}"));
+ return;
+ }
+ }
+ } else if extension_lower(&path) == Some("md".to_string())
+ || extension_lower(&path) == Some("markdown".to_string())
+ || extension_lower(&path) == Some("txt".to_string())
+ {
+ let texto = match std::str::from_utf8(&bytes) {
+ Ok(s) => s.to_string(),
+ Err(e) => {
+ model.ultimo_error = Some(format!("{nombre} no es UTF-8: {e}"));
+ return;
+ }
+ };
+ let imp = pluma_md::parse_md(&texto, "es", nombre.clone(), ahora);
+ (imp.cuerpo, imp.atoms)
+ } else {
+ model.ultimo_error = Some(format!(
+ "extensión no soportada en {nombre} — usá .md o .docx"
+ ));
+ return;
+ };
+
+ let (cuerpo, atoms_nuevos) = importado;
+ if atoms_nuevos.is_empty() {
+ model.ultimo_error = Some(format!("{nombre} no produjo átomos"));
+ return;
+ }
+ for a in &atoms_nuevos {
+ let _ = model.store.put_atom(a);
+ model.atoms.insert(a.id, a.clone());
+ }
+ let _ = model.store.put_cuerpo(&cuerpo);
+ let _ = model.store.flush();
+ let id = cuerpo.id;
+ let n = atoms_nuevos.len();
+ model.cuerpos.push(cuerpo);
+ model.ultimo_status = format!("abierto «{nombre}»: {n} átomos");
+ model.ultimo_error = None;
+ cambiar_activo(model, id);
+}
+
+fn exportar_md(model: &mut Model) {
+ let Some(activo_id) = model.activo else {
+ model.ultimo_error = Some("sin doc activo".into());
+ return;
+ };
+ let path_raw = model.path_input.text().trim().to_string();
+ if path_raw.is_empty() {
+ model.ultimo_error = Some("ruta vacía".into());
+ return;
+ }
+ let path = expandir_ruta(&path_raw);
+ let Some(cuerpo) = model.cuerpos.iter().find(|c| c.id == activo_id) else {
+ model.ultimo_error = Some("doc activo desapareció".into());
+ return;
+ };
+
+ let ext = extension_lower(&path).unwrap_or_default();
+ let bytes: Vec<u8> = if ext == "docx" {
+ match foreign_docx::write_docx(cuerpo, &model.atoms) {
+ Ok(b) => b,
+ Err(e) => {
+ model.ultimo_error = Some(format!("write_docx: {e}"));
+ return;
+ }
+ }
+ } else if ext.is_empty() || ext == "md" || ext == "markdown" || ext == "txt" {
+ let md = pluma_md::to_md(cuerpo, &model.atoms);
+ if md.is_empty() {
+ model.ultimo_error = Some("doc vacío — nada que exportar".into());
+ return;
+ }
+ md.into_bytes()
+ } else {
+ model.ultimo_error = Some(format!(
+ "extensión .{ext} no soportada — usá .md o .docx"
+ ));
+ return;
+ };
+
+ if let Some(parent) = path.parent() {
+ let _ = std::fs::create_dir_all(parent);
+ }
+ match std::fs::write(&path, &bytes) {
+ Ok(()) => {
+ model.ultimo_status = format!(
+ "exportado «{}» a {} ({} bytes)",
+ cuerpo.metadatos.nombre_legible,
+ path.display(),
+ bytes.len(),
+ );
+ model.ultimo_error = None;
+ }
+ Err(e) => {
+ model.ultimo_error = Some(format!("escribiendo {path:?}: {e}"));
+ }
+ }
+}
+
+fn cycle_backend(model: &mut Model) {
+ let total = BACKENDS.len();
+ for step in 1..=total {
+ let try_idx = (model.backend_idx + step) % total;
+ let kind = BACKENDS[try_idx];
+ match build_client(&LlmConfig {
+ kind,
+ ..Default::default()
+ }) {
+ Ok(c) => {
+ model.chat = c;
+ model.backend_idx = try_idx;
+ model.ultimo_status = format!("backend → {}", etiqueta_backend(kind));
+ model.ultimo_error = None;
+ return;
+ }
+ Err(e) => {
+ model.ultimo_error = Some(format!("backend {kind:?}: {e}"));
+ }
+ }
+ }
+ // Si todos fallaron (no debería: Mock siempre funciona), no-op.
+}
+
+fn recibir_hija(
+ model: &mut Model,
+ hija: Cuerpo,
+ atoms_nuevos: Vec<NarrativeAtom>,
+ carta: CartaHebras,
+ transformacion: Transformacion,
+) {
+ for a in &atoms_nuevos {
+ let _ = model.store.put_atom(a);
+ model.atoms.insert(a.id, a.clone());
+ }
+ let _ = model.store.put_cuerpo(&hija);
+ let _ = model.store.put_carta(&carta);
+ let _ = model.store.put_transformacion(&transformacion);
+ let _ = model.store.flush();
+ let hija_id = hija.id;
+ let nombre = hija.metadatos.nombre_legible.clone();
+ model.cuerpos.push(hija);
+ model.cartas.push(carta);
+ model.transformaciones.push(transformacion);
+ model.en_curso = false;
+ model.ultimo_status = format!("hija «{nombre}» derivada");
+ cambiar_activo(model, hija_id);
+}
+
+// ---------------------------------------------------------------------
+// Trabajo LLM
+// ---------------------------------------------------------------------
+
+pub(crate) enum TrabajoLlm {
+ Traducir(String),
+ Tono(String),
+ Resumir(Option<u32>),
+}
+
+fn lanzar(model: &mut Model, handle: &Handle<Msg>, trabajo: TrabajoLlm) {
+ if model.en_curso {
+ return;
+ }
+ let Some(activo_id) = model.activo else {
+ model.ultimo_status = "sin doc activo".into();
+ return;
+ };
+ // Sincronizar antes de transformar — si el usuario tipeó sin Ctrl+S,
+ // queremos que el LLM vea el texto editado.
+ guardar_activo(model);
+
+ let madre = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+ Some(c) => c.clone(),
+ None => {
+ model.ultimo_error = Some("doc activo desapareció".into());
+ return;
+ }
+ };
+ if madre.orden.is_empty() {
+ model.ultimo_status = "madre vacía — nada que transformar".into();
+ return;
+ }
+
+ let atoms_owned: Vec<NarrativeAtom> = model.atoms.values().cloned().collect();
+ let chat = model.chat.clone();
+ let h = handle.clone();
+ let ahora = ahora_unix();
+
+ model.en_curso = true;
+ model.ultimo_error = None;
+ model.ultimo_status = format!("LLM en curso ({} backend)", etiqueta_backend(BACKENDS[model.backend_idx]));
+
+ handle.spawn(move || {
+ let rt = match tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ {
+ Ok(rt) => rt,
+ Err(e) => return Msg::LlmError(format!("runtime tokio: {e}")),
+ };
+ let idx: HashMap<Uuid, &NarrativeAtom> =
+ atoms_owned.iter().map(|a| (a.id, a)).collect();
+
+ let resultado = rt.block_on(async {
+ match trabajo {
+ TrabajoLlm::Traducir(lengua) => {
+ let ej = EjecutorTraducirLlm::from_arc(chat, lengua.clone());
+ let t = Transformacion::nueva(
+ madre.id,
+ Uuid::new_v4(),
+ TipoTransformacion::Traducir {
+ lengua_destino: lengua,
+ },
+ "ui",
+ ahora,
+ );
+ ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+ .await
+ .map(|p| (p, t))
+ }
+ TrabajoLlm::Tono(etiq) => {
+ let ej = EjecutorTonoLlm::from_arc(chat, etiq.clone());
+ let t = Transformacion::nueva(
+ madre.id,
+ Uuid::new_v4(),
+ TipoTransformacion::Tono { etiqueta: etiq },
+ "ui",
+ ahora,
+ );
+ ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+ .await
+ .map(|p| (p, t))
+ }
+ TrabajoLlm::Resumir(palabras) => {
+ let ej = EjecutorResumirLlm::from_arc(chat, palabras);
+ let t = Transformacion::nueva(
+ madre.id,
+ Uuid::new_v4(),
+ TipoTransformacion::Resumir {
+ palabras_objetivo: palabras,
+ },
+ "ui",
+ ahora,
+ );
+ ej.aplicar_con_atoms(&t, &madre, &idx, ahora)
+ .await
+ .map(|p| (p, t))
+ }
+ }
+ });
+
+ let _ = h;
+ match resultado {
+ Ok((prod, transformacion)) => Msg::LlmListo {
+ hija: prod.hija,
+ atoms_nuevos: prod.atoms_nuevos,
+ carta: prod.carta,
+ transformacion,
+ },
+ Err(e) => Msg::LlmError(format!("{e:?}")),
+ }
+ });
+}
diff --git a/00_unanchay/pluma/pluma-app/src/util.rs b/00_unanchay/pluma/pluma-app/src/util.rs
new file mode 100644
index 0000000..6967472
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/util.rs
@@ -0,0 +1,101 @@
+//! Helpers puros: rutas, expansión de `~`, etiquetas legibles de
+//! backend/intención/transformación, y el reloj unix.
+
+use std::path::{Path, PathBuf};
+
+use pluma_cuerpo::Intencion;
+use pluma_llm::BackendKind;
+use pluma_transform::TipoTransformacion;
+
+pub(crate) fn expandir_ruta(raw: &str) -> PathBuf {
+ if let Some(rest) = raw.strip_prefix("~/") {
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home).join(rest);
+ }
+ } else if raw == "~" {
+ if let Ok(home) = std::env::var("HOME") {
+ return PathBuf::from(home);
+ }
+ }
+ PathBuf::from(raw)
+}
+
+pub(crate) fn extension_lower(p: &Path) -> Option<String> {
+ p.extension().map(|e| e.to_string_lossy().to_lowercase())
+}
+
+pub(crate) fn etiqueta_backend(k: BackendKind) -> &'static str {
+ match k {
+ BackendKind::Mock => "mock",
+ BackendKind::Gemini => "gemini",
+ BackendKind::Anthropic => "anthropic",
+ BackendKind::DeepSeek => "deepseek",
+ BackendKind::Cohere => "cohere",
+ BackendKind::Ollama => "ollama",
+ }
+}
+
+pub(crate) fn etiqueta_intencion(i: &Intencion) -> String {
+ match i {
+ Intencion::Original => "original".into(),
+ Intencion::Traduccion => "traducción".into(),
+ Intencion::Tono { etiqueta } => format!("tono {etiqueta}"),
+ Intencion::Resumen {
+ palabras_objetivo: Some(n),
+ } => format!("resumen ≈{n}p"),
+ Intencion::Resumen {
+ palabras_objetivo: None,
+ } => "resumen".into(),
+ Intencion::Reescritura { .. } => "reescritura".into(),
+ Intencion::Anotacion => "anotación".into(),
+ Intencion::Custom { kind } => kind.clone(),
+ }
+}
+
+pub(crate) fn etiqueta_tipo(t: &TipoTransformacion) -> String {
+ match t {
+ TipoTransformacion::Identidad => "identidad".into(),
+ TipoTransformacion::Traducir { lengua_destino } => format!("traducir → {lengua_destino}"),
+ TipoTransformacion::Tono { etiqueta } => format!("tono {etiqueta}"),
+ TipoTransformacion::Resumir {
+ palabras_objetivo: Some(n),
+ } => format!("resumir ≈{n}p"),
+ TipoTransformacion::Resumir {
+ palabras_objetivo: None,
+ } => "resumir".into(),
+ TipoTransformacion::Reescribir { .. } => "reescribir".into(),
+ TipoTransformacion::Custom { kind, .. } => kind.clone(),
+ }
+}
+
+pub(crate) fn recortar(s: &str, max: usize) -> String {
+ if s.chars().count() <= max {
+ s.to_string()
+ } else {
+ let mut o: String = s.chars().take(max.saturating_sub(1)).collect();
+ o.push('…');
+ o
+ }
+}
+
+pub(crate) fn ruta_sled() -> PathBuf {
+ if let Ok(p) = std::env::var("PLUMA_APP_SLED") {
+ return PathBuf::from(p);
+ }
+ let base = std::env::var("XDG_CACHE_HOME")
+ .map(PathBuf::from)
+ .unwrap_or_else(|_| {
+ std::env::var("HOME")
+ .map(|h| PathBuf::from(h).join(".cache"))
+ .unwrap_or_else(|_| PathBuf::from(".cache"))
+ });
+ base.join("gioser").join("pluma-app").join("pluma.sled")
+}
+
+pub(crate) fn ahora_unix() -> u64 {
+ use std::time::{SystemTime, UNIX_EPOCH};
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
diff --git a/00_unanchay/pluma/pluma-app/src/view.rs b/00_unanchay/pluma/pluma-app/src/view.rs
new file mode 100644
index 0000000..0267f1c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-app/src/view.rs
@@ -0,0 +1,821 @@
+//! Vistas: las tres columnas (documentos · editor/diff · panel LLM), la
+//! barra de status, el overlay de find, y las secciones de hijas/historial.
+
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{auto, length, percent, FlexDirection, Size, Style},
+ AlignItems, Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_theme::Theme;
+use llimphi_ui::{DragPhase, View};
+use llimphi_widget_button::{button_view, ButtonPalette};
+use llimphi_widget_list::{list_view, ListPalette, ListRow, ListSpec};
+use llimphi_widget_splitter::{splitter_two, Direction, PaneSize, SplitterPalette};
+use llimphi_widget_context_menu::{context_menu_view_ex, ContextMenuExtras};
+use llimphi_widget_edit_menu::{self as editmenu, EditFlags};
+use llimphi_widget_menubar::{
+ menubar_overlay_animated, menubar_view, MenuBarSpec, DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_widget_text_editor::{EditorPalette as TEPalette, Language};
+use llimphi_widget_text_input::{text_input_view, TextInputPalette};
+use pluma_align::CartaHebras;
+use pluma_cuerpo::Cuerpo;
+use pluma_editor_llimphi::cuerpo_ide::cuerpo_ide_view;
+use pluma_editor_llimphi::multilienzo::{
+ multilienzo_view, IndiceAtoms, MultilienzoConfig, PaletaHebras,
+};
+use pluma_editor_llimphi::Palette as MultPalette;
+use pluma_transform::Transformacion;
+use uuid::Uuid;
+
+use crate::model::{Model, Msg, BACKENDS, METRICS, VISIBLE_LINES};
+use crate::update::{contar_stale_del_activo, menu_principal};
+use crate::util::{etiqueta_backend, etiqueta_intencion, etiqueta_tipo, recortar};
+
+/// Tamaño de ventana del init — usado como viewport para clampear los
+/// dropdowns del menú (la app no trackea el tamaño real hoy).
+const VIEWPORT: (f32, f32) = (1600.0, 900.0);
+
+/// Arma el `MenuBarSpec` compartido entre `menubar_view` (barra) y
+/// `menubar_overlay` (dropdown). El `menu` se construye afuera y se
+/// presta por referencia para no clonarlo dos veces.
+fn menubar_spec<'a>(
+ menu: &'a app_bus::AppMenu,
+ model: &Model,
+ theme: &'a Theme,
+) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme,
+ viewport: VIEWPORT,
+ height: MENU_H,
+ on_open: std::sync::Arc::new(Msg::MenuOpen),
+ on_command: std::sync::Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+pub(crate) fn vista(model: &Model) -> View<Msg> {
+ let theme = Theme::dark();
+ let editor_palette = TEPalette::default();
+ let splitter_palette = SplitterPalette::from_theme(&theme);
+
+ let menu = menu_principal(model);
+ let menubar = menubar_view(&menubar_spec(&menu, model, &theme));
+ let status = barra_status(model, &theme);
+ let panel_centro = panel_editor(model, &editor_palette);
+
+ // En modo delegado (sidebar prestado a pata) las columnas laterales se pueden
+ // colapsar desde el rail de pata → editor a pantalla completa ("puro canvas").
+ // Sin delegar, ambas van siempre (comportamiento original).
+ let show_izq = !model.delegated || model.side_izq_visible;
+ let show_der = !model.delegated || model.side_der_visible;
+
+ // Splitter anidado: izq | (centro | der). Cada lado oculto se saca del árbol
+ // (y con él su splitter), no sólo se esconde.
+ let centro_der = if show_der {
+ splitter_two(
+ Direction::Row,
+ panel_centro,
+ PaneSize::Flex,
+ panel_llm(model, &theme),
+ PaneSize::Fixed(model.side_der_w),
+ |phase, dx| match phase {
+ DragPhase::Move => Some(Msg::ResizeDer(dx)),
+ DragPhase::End => None,
+ },
+ &splitter_palette,
+ )
+ } else {
+ panel_centro
+ };
+ let body = if show_izq {
+ splitter_two(
+ Direction::Row,
+ panel_documentos(model, &theme),
+ PaneSize::Fixed(model.side_izq_w),
+ centro_der,
+ PaneSize::Flex,
+ |phase, dx| match phase {
+ DragPhase::Move => Some(Msg::ResizeIzq(dx)),
+ DragPhase::End => None,
+ },
+ &splitter_palette,
+ )
+ } else {
+ centro_der
+ };
+
+ // El right-click se engancha en la raíz (origen 0,0 → las coords
+ // locales que llegan al handler ya son de ventana) y abre el menú de
+ // edición sobre el cuerpo_ide. La barra de menú va de primer hijo.
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_app)
+ .on_right_click_at(|x, y, _w, _h| Some(Msg::EditMenuOpen(x, y)))
+ .children(vec![menubar, status, body])
+}
+
+/// Overlay flotante: el menú de edición contextual tiene prioridad; si no
+/// está abierto, cae al dropdown del menú principal. La app no tenía otros
+/// popups flotantes (find es inline), así que estos dos son todo.
+pub(crate) fn vista_overlay(model: &Model) -> Option<View<Msg>> {
+ let theme = Theme::dark();
+ if let Some((x, y)) = model.edit_menu {
+ let flags = EditFlags::from_editor(&model.ide.state, false);
+ let mut spec = editmenu::edit_context_menu(
+ (x, y),
+ VIEWPORT,
+ &theme,
+ flags,
+ Msg::EditMenuAction,
+ Msg::CloseMenus,
+ );
+ spec.active = model.edit_active;
+ return Some(context_menu_view_ex(
+ spec,
+ ContextMenuExtras {
+ appear: model.edit_anim.value(),
+ ..Default::default()
+ },
+ ));
+ }
+ let menu = menu_principal(model);
+ menubar_overlay_animated(
+ &menubar_spec(&menu, model, &theme),
+ model.menu_active,
+ model.menu_anim.value(),
+ )
+}
+
+fn barra_status(model: &Model, theme: &Theme) -> View<Msg> {
+ let nombre = model
+ .activo
+ .and_then(|id| model.cuerpos.iter().find(|c| c.id == id))
+ .map(|c| c.metadatos.nombre_legible.clone())
+ .unwrap_or_else(|| "(sin doc)".to_string());
+ let zona = model.ide.zona_del_caret();
+ let n_zonas = model.ide.n_zonas();
+ let backend = etiqueta_backend(BACKENDS[model.backend_idx]);
+ let estado = if model.en_curso {
+ "⏳"
+ } else if model.ultimo_error.is_some() {
+ "⚠"
+ } else {
+ "○"
+ };
+ let texto = format!(
+ "pluma · {nombre} · zona {zona}/{n_zonas} · backend {backend} · {estado} {}",
+ model.ultimo_status
+ );
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(30.0_f32),
+ },
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(0.0_f32),
+ bottom: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .text_aligned(texto, 12.0, theme.fg_text, Alignment::Start)
+}
+
+fn panel_documentos(model: &Model, theme: &Theme) -> View<Msg> {
+ let palette_btn = ButtonPalette::from_theme(theme);
+ let palette_list = ListPalette::from_theme(theme);
+
+ // Originales primero, luego derivadas — el orden en la lista es
+ // estable porque clonamos `model.cuerpos` y particionamos.
+ let mut originales: Vec<&Cuerpo> = Vec::new();
+ let mut derivadas: Vec<&Cuerpo> = Vec::new();
+ for c in &model.cuerpos {
+ if c.metadatos.intencion.es_derivada() {
+ derivadas.push(c);
+ } else {
+ originales.push(c);
+ }
+ }
+
+ let mut rows: Vec<ListRow<Msg>> = Vec::new();
+ for c in originales.iter().chain(derivadas.iter()) {
+ let prefijo = if c.metadatos.intencion.es_derivada() {
+ " ↳ "
+ } else {
+ "■ "
+ };
+ let label = format!(
+ "{prefijo}{} · {}",
+ c.metadatos.nombre_legible, c.branch_id
+ );
+ rows.push(ListRow {
+ label,
+ selected: model.activo == Some(c.id),
+ on_click: Msg::AbrirDoc(c.id),
+ });
+ }
+
+ let n = rows.len();
+ let lista = list_view(ListSpec {
+ rows,
+ total: n,
+ caption: Some(format!("{n} documentos")),
+ truncated_hint: None,
+ row_height: 22.0,
+ palette: palette_list,
+ });
+
+ let boton_nuevo = button_view::<Msg>("+ nuevo doc (Ctrl+N)", &palette_btn, Msg::NuevoDoc);
+ let boton_guardar = button_view::<Msg>("💾 guardar (Ctrl+S)", &palette_btn, Msg::Guardar);
+
+ let header = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned("DOCUMENTOS".to_string(), 10.0, theme.fg_muted, Alignment::Start);
+
+ let archivo = seccion_archivo(model, theme);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .clip(true)
+ .children(vec![header, boton_nuevo, boton_guardar, archivo, lista])
+}
+
+fn seccion_archivo(model: &Model, theme: &Theme) -> View<Msg> {
+ let palette_btn = ButtonPalette::from_theme(theme);
+ let palette_input = TextInputPalette::from_theme(theme);
+
+ let header = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(18.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ "ARCHIVO".to_string(),
+ 10.0,
+ theme.fg_muted,
+ Alignment::Start,
+ );
+
+ let input = text_input_view::<Msg>(
+ &model.path_input,
+ "ruta .md o .docx (Esc para salir)",
+ model.path_focused,
+ &palette_input,
+ Msg::FocusPath,
+ );
+
+ let fila_botones = View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(28.0_f32),
+ },
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ button_view::<Msg>("📂 abrir", &palette_btn, Msg::AbrirArchivo),
+ button_view::<Msg>("⬆ exportar (md/docx)", &palette_btn, Msg::ExportarMd),
+ ]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(82.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![header, input, fila_botones])
+}
+
+fn panel_editor(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
+ let cuerpo_central: View<Msg> = if model.diff_visible {
+ vista_diff(model, palette_editor)
+ } else {
+ cuerpo_ide_view::<Msg>(
+ &model.ide,
+ palette_editor,
+ METRICS,
+ VISIBLE_LINES,
+ Language::Plain,
+ |ev| Some(Msg::EditorPointer(ev)),
+ )
+ };
+
+ let mut hijos: Vec<View<Msg>> = Vec::new();
+ if model.find_visible {
+ hijos.push(barra_find(model));
+ }
+ hijos.push(cuerpo_central);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(8.0_f32),
+ bottom: length(8.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(palette_editor.bg)
+ .clip(true)
+ .children(hijos)
+}
+
+fn vista_diff(model: &Model, palette_editor: &TEPalette) -> View<Msg> {
+ // Resolver activo + madre. Si activo no es derivado o la madre no
+ // se encuentra, mostramos un cartel y volvemos a `cuerpo_ide_view`.
+ let theme = Theme::dark();
+ let activo_id = match model.activo {
+ Some(id) => id,
+ None => return cartel_diff("sin doc activo", palette_editor),
+ };
+ let activo = match model.cuerpos.iter().find(|c| c.id == activo_id) {
+ Some(c) => c,
+ None => return cartel_diff("activo no encontrado", palette_editor),
+ };
+ let madre_id = match activo.metadatos.derivado_de {
+ Some(id) => id,
+ None => {
+ // Activo es Original — fallback al editor normal con cartel.
+ return View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![
+ cartel_diff(
+ "este cuerpo es Original — no tiene madre con que diffear (Ctrl+D para cerrar)",
+ palette_editor,
+ ),
+ cuerpo_ide_view::<Msg>(
+ &model.ide,
+ palette_editor,
+ METRICS,
+ VISIBLE_LINES,
+ Language::Plain,
+ |ev| Some(Msg::EditorPointer(ev)),
+ ),
+ ]);
+ }
+ };
+ let madre = match model.cuerpos.iter().find(|c| c.id == madre_id) {
+ Some(c) => c,
+ None => return cartel_diff(
+ "madre referenciada no está en el sled — ¿borrada?",
+ palette_editor,
+ ),
+ };
+
+ // Buscar la carta de hebras entre estos dos. `pluma_align::CartaHebras`
+ // anota su par; consideramos cualquier orden.
+ let carta = model.cartas.iter().find(|c| {
+ (c.cuerpo_a == Some(madre.id) && c.cuerpo_b == Some(activo.id))
+ || (c.cuerpo_a == Some(activo.id) && c.cuerpo_b == Some(madre.id))
+ });
+
+ let cuerpos_ref: Vec<&Cuerpo> = vec![madre, activo];
+ let cartas_ref: Vec<Option<&CartaHebras>> = vec![carta];
+ let atoms_idx: IndiceAtoms = model.atoms.iter().map(|(k, v)| (*k, v)).collect();
+ let cfg = MultilienzoConfig::default();
+ let paleta_hebras = PaletaHebras::default();
+ let palette_mult = MultPalette::from_theme(&theme);
+
+ let mult = multilienzo_view::<Msg>(
+ &cuerpos_ref,
+ &atoms_idx,
+ &cartas_ref,
+ &cfg,
+ &paleta_hebras,
+ &palette_mult,
+ );
+
+ let header_text = format!(
+ "DIFF · madre «{}» ↔ hija «{}» ({})",
+ madre.metadatos.nombre_legible,
+ activo.metadatos.nombre_legible,
+ if carta.is_some() {
+ "con hebras"
+ } else {
+ "sin carta — hebras no disponibles"
+ },
+ );
+ let header = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(header_text, 11.0, theme.fg_muted, Alignment::Start);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(4.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![header, mult])
+}
+
+fn cartel_diff(texto: &str, palette_editor: &TEPalette) -> View<Msg> {
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(40.0_f32),
+ },
+ padding: Rect {
+ left: length(12.0_f32),
+ right: length(12.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ texto.to_string(),
+ 12.0,
+ palette_editor.fg_line_number,
+ Alignment::Start,
+ )
+}
+
+fn barra_find(model: &Model) -> View<Msg> {
+ let theme = Theme::dark();
+ let palette_input = TextInputPalette::from_theme(&theme);
+ let palette_btn = ButtonPalette::from_theme(&theme);
+
+ let input = text_input_view::<Msg>(
+ &model.find_input,
+ "buscar (Enter siguiente · Shift+Enter previo · Esc cerrar)",
+ true, // find_visible implica que tiene foco
+ &palette_input,
+ Msg::FindToggle, // click en el input no cambia foco — siempre vivo
+ );
+
+ let total = model.find_matches.len();
+ let pos = if total == 0 {
+ 0
+ } else {
+ model.find_idx + 1
+ };
+ let counter = View::new(Style {
+ size: Size {
+ width: length(80.0_f32),
+ height: length(34.0_f32),
+ },
+ padding: Rect {
+ left: length(8.0_f32),
+ right: length(8.0_f32),
+ top: length(8.0_f32),
+ bottom: length(0.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned(
+ format!("{pos}/{total}"),
+ 12.0,
+ theme.fg_muted,
+ Alignment::Center,
+ );
+
+ let prev = button_view::<Msg>("◀", &palette_btn, Msg::FindAnterior);
+ let next = button_view::<Msg>("▶", &palette_btn, Msg::FindSiguiente);
+ let cerrar = button_view::<Msg>("✕", &palette_btn, Msg::FindClose);
+
+ let input_wrap = View::new(Style {
+ flex_grow: 1.0,
+ flex_shrink: 1.0,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(34.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![input]);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Row,
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(40.0_f32),
+ },
+ gap: Size {
+ width: length(6.0_f32),
+ height: length(0.0_f32),
+ },
+ align_items: Some(AlignItems::Center),
+ ..Default::default()
+ })
+ .children(vec![input_wrap, counter, prev, next, cerrar])
+}
+
+fn panel_llm(model: &Model, theme: &Theme) -> View<Msg> {
+ let palette_btn_activo = ButtonPalette::from_theme(theme);
+ let palette_btn_off = ButtonPalette {
+ bg: Color::from_rgba8(60, 60, 60, 255),
+ bg_hover: Color::from_rgba8(60, 60, 60, 255),
+ fg: Color::from_rgba8(140, 140, 140, 255),
+ radius: palette_btn_activo.radius,
+ };
+ let pal = if model.en_curso {
+ &palette_btn_off
+ } else {
+ &palette_btn_activo
+ };
+ let pal_backend = &palette_btn_activo;
+
+ let etiqueta_back = format!(
+ "🔀 backend: {}",
+ etiqueta_backend(BACKENDS[model.backend_idx])
+ );
+ let cycler = button_view::<Msg>(&etiqueta_back, pal_backend, Msg::CicloBackend);
+
+ let etiqueta_diff = if model.diff_visible {
+ "↔ diff: ON (Ctrl+D)"
+ } else {
+ "↔ diff: off (Ctrl+D)"
+ };
+ let diff_btn = button_view::<Msg>(etiqueta_diff, pal_backend, Msg::DiffToggle);
+
+ let header = View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(20.0_f32),
+ },
+ padding: Rect {
+ left: length(4.0_f32),
+ right: length(4.0_f32),
+ top: length(2.0_f32),
+ bottom: length(2.0_f32),
+ },
+ ..Default::default()
+ })
+ .text_aligned("LLM".to_string(), 10.0, theme.fg_muted, Alignment::Start);
+
+ let mk = |label: &str, m: Msg| button_view::<Msg>(label, pal, m);
+ let botones: Vec<View<Msg>> = vec![
+ mk("→ traducir qu", Msg::PedirTraducir("qu".into())),
+ mk("→ traducir en", Msg::PedirTraducir("en".into())),
+ mk("✎ tono formal", Msg::PedirTono("formal".into())),
+ mk("✂ resumir 30p", Msg::PedirResumir(Some(30))),
+ ];
+
+ let n_stale = contar_stale_del_activo(model);
+ let label_regen = if n_stale > 0 {
+ format!("⟳ regenerar stale ({n_stale})")
+ } else {
+ "⟳ regenerar stale (0)".to_string()
+ };
+ let tocar_btn = button_view::<Msg>("⏰ tocar madre", pal, Msg::TocarMadre);
+ let regen_btn = button_view::<Msg>(&label_regen, pal, Msg::RegenerarStale);
+
+ // Lista de hijas del cuerpo activo — para abrirlas con click.
+ let hijas_seccion = seccion_hijas(model, theme);
+
+ let mut hijos: Vec<View<Msg>> = Vec::new();
+ hijos.push(header);
+ hijos.push(cycler);
+ hijos.push(diff_btn);
+ hijos.extend(botones);
+ hijos.push(tocar_btn);
+ hijos.push(regen_btn);
+ hijos.push(divider(theme));
+ hijos.push(hijas_seccion);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: percent(1.0_f32),
+ },
+ padding: Rect {
+ left: length(10.0_f32),
+ right: length(10.0_f32),
+ top: length(10.0_f32),
+ bottom: length(10.0_f32),
+ },
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.bg_panel)
+ .clip(true)
+ .children(hijos)
+}
+
+fn seccion_hijas(model: &Model, theme: &Theme) -> View<Msg> {
+ let palette_list = ListPalette::from_theme(theme);
+ let activo = model.activo;
+
+ let hijas: Vec<&Cuerpo> = model
+ .cuerpos
+ .iter()
+ .filter(|c| {
+ c.metadatos.intencion.es_derivada() && c.metadatos.derivado_de == activo
+ })
+ .collect();
+
+ let mut rows: Vec<ListRow<Msg>> = Vec::new();
+ for h in &hijas {
+ let label = format!("• {} · {}", h.branch_id, etiqueta_intencion(&h.metadatos.intencion));
+ rows.push(ListRow {
+ label,
+ selected: false,
+ on_click: Msg::AbrirDoc(h.id),
+ });
+ }
+
+ let n = rows.len();
+ let lista = list_view(ListSpec {
+ rows,
+ total: n,
+ caption: Some(format!("hijas: {n}")),
+ truncated_hint: None,
+ row_height: 20.0,
+ palette: palette_list,
+ });
+
+ let historial = seccion_historial(model, theme);
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: auto(),
+ },
+ flex_grow: 1.0,
+ gap: Size {
+ width: length(0.0_f32),
+ height: length(6.0_f32),
+ },
+ ..Default::default()
+ })
+ .children(vec![lista, divider(theme), historial])
+}
+
+fn seccion_historial(model: &Model, theme: &Theme) -> View<Msg> {
+ let palette_list = ListPalette::from_theme(theme);
+
+ // Index para resolver Uuid → Cuerpo, cuerpo.metadatos.nombre_legible.
+ let cuerpo_de = |id: Uuid| model.cuerpos.iter().find(|c| c.id == id);
+
+ // Transformaciones del cuerpo activo: ya sea como madre o como hija.
+ // Lo más útil al usuario suele ser "todo lo que pasó alrededor de
+ // este doc" — así una hija de cuya madre vengo, lo veo.
+ let activo = model.activo;
+ let mut filtradas: Vec<&Transformacion> = model
+ .transformaciones
+ .iter()
+ .filter(|t| match activo {
+ Some(id) => t.madre == id || t.hija == id,
+ None => true,
+ })
+ .collect();
+ // Más recientes arriba.
+ filtradas.sort_by(|a, b| b.creada_en.cmp(&a.creada_en));
+
+ let mut rows: Vec<ListRow<Msg>> = Vec::new();
+ for t in &filtradas {
+ let madre = cuerpo_de(t.madre)
+ .map(|c| c.metadatos.nombre_legible.as_str())
+ .unwrap_or("?");
+ let hija = cuerpo_de(t.hija)
+ .map(|c| c.metadatos.nombre_legible.as_str())
+ .unwrap_or("?");
+ let tipo = etiqueta_tipo(&t.tipo);
+ // Truncar nombres largos para que la fila no se rompa visual.
+ let label = format!(
+ "{} → {} · {}",
+ recortar(madre, 18),
+ recortar(hija, 18),
+ tipo,
+ );
+ rows.push(ListRow {
+ label,
+ selected: false,
+ on_click: Msg::AbrirDoc(t.hija),
+ });
+ }
+
+ let n = rows.len();
+ let lista = list_view(ListSpec {
+ rows,
+ total: n,
+ caption: Some(if activo.is_some() {
+ format!("historial activo: {n}")
+ } else {
+ format!("historial: {n}")
+ }),
+ truncated_hint: None,
+ row_height: 20.0,
+ palette: palette_list,
+ });
+
+ View::new(Style {
+ flex_direction: FlexDirection::Column,
+ size: Size {
+ width: percent(1.0_f32),
+ height: auto(),
+ },
+ ..Default::default()
+ })
+ .children(vec![lista])
+}
+
+fn divider(theme: &Theme) -> View<Msg> {
+ View::new(Style {
+ size: Size {
+ width: percent(1.0_f32),
+ height: length(1.0_f32),
+ },
+ ..Default::default()
+ })
+ .fill(theme.border)
+}
diff --git a/00_unanchay/pluma/pluma-core/Cargo.toml b/00_unanchay/pluma/pluma-core/Cargo.toml
new file mode 100644
index 0000000..9ace970
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "pluma-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma-app — átomo narrativo (NarrativeAtom) + estado de coherencia. Tipos puros del editor DAG, agnósticos de UI."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+sha2 = { workspace = true }
+serde = { workspace = true, features = ["rc"] }
diff --git a/00_unanchay/pluma/pluma-core/LEEME.md b/00_unanchay/pluma/pluma-core/LEEME.md
new file mode 100644
index 0000000..f24acb9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-core
+
+> Modelo de documento de [pluma](../README.md): átomos, grafo, ids.
+
+`Atomo` = unidad mínima (un párrafo, una celda, un bloque de código) con `Uuid` estable. `Documento` es un DAG de átomos con orden lineal por default + referencias laterales (links, tags). Las mutaciones se aplican como `CambioAtom { Crear, Mutar, Eliminar }`.
+
+## API
+
+```rust
+use pluma_core::{Documento, Atomo, Uuid};
+
+let mut doc = Documento::new();
+let id = doc.crear_atomo("texto")?;
+```
+
+## Deps
+
+- `serde`, [`uuid`](https://crates.io/crates/uuid)
+- Cero deps gráficas / network
diff --git a/00_unanchay/pluma/pluma-core/README.md b/00_unanchay/pluma/pluma-core/README.md
new file mode 100644
index 0000000..c053dde
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/README.md
@@ -0,0 +1,19 @@
+# pluma-core
+
+> Document model of [pluma](../README.md): atoms, graph, ids.
+
+`Atomo` = minimal unit (a paragraph, a cell, a code block) with stable `Uuid`. `Documento` is a DAG of atoms with default linear order + lateral references (links, tags). Mutations apply as `CambioAtom { Crear, Mutar, Eliminar }`.
+
+## API
+
+```rust
+use pluma_core::{Documento, Atomo, Uuid};
+
+let mut doc = Documento::new();
+let id = doc.crear_atomo("text")?;
+```
+
+## Deps
+
+- `serde`, [`uuid`](https://crates.io/crates/uuid)
+- Zero graphics / network deps
diff --git a/00_unanchay/pluma/pluma-core/src/lib.rs b/00_unanchay/pluma/pluma-core/src/lib.rs
new file mode 100644
index 0000000..b6d0ad9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-core/src/lib.rs
@@ -0,0 +1,138 @@
+//! `pluma_app-core` — el átomo narrativo y su estado de coherencia.
+//!
+//! Tipos puros del editor DAG de pluma_app: sin UI, sin storage, sin red. El
+//! documento es un grafo de [`NarrativeAtom`]s; cada átomo comparte su
+//! texto vía `Arc<String>` para que ramificar una línea temporal sea
+//! O(1) (structural sharing).
+//!
+//! Invariante: `content_hash` siempre corresponde a `content` —
+//! ver [`NarrativeAtom::hash_matches`].
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::sync::Arc;
+use uuid::Uuid;
+
+/// Estado de coherencia lógica de un átomo dentro del grafo narrativo.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CoherenceState {
+ /// Consistente con sus dependencias.
+ Valid,
+ /// En conflicto: una dependencia cambió y lo contradice.
+ InConflict { origin: Uuid, reason: String },
+ /// Marcado para re-evaluación (una dependencia mutó; falta verificar).
+ PendingEvaluation,
+}
+
+/// Un átomo narrativo: la unidad atómica del documento.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NarrativeAtom {
+ pub id: Uuid,
+ /// SHA-256 del contenido — verifica integridad de toda mutación.
+ pub content_hash: [u8; 32],
+ /// Texto compartido. Clonar una rama no duplica el texto.
+ pub content: Arc<String>,
+ /// Concepto → intensidad. Lo puebla `pluma_app-semantic`.
+ pub semantic_vectors: HashMap<String, f32>,
+ /// Átomos prerrequisito (sus "padres" lógicos).
+ pub dependencies: Vec<Uuid>,
+ /// Identificador de la rama / línea temporal.
+ pub branch_id: String,
+ pub coherence: CoherenceState,
+}
+
+impl NarrativeAtom {
+ /// Crea un átomo nuevo con id aleatorio. Hashea el contenido.
+ pub fn new(content: impl Into<String>, branch_id: impl Into<String>) -> Self {
+ let content = content.into();
+ let content_hash = sha256(content.as_bytes());
+ Self {
+ id: Uuid::new_v4(),
+ content_hash,
+ content: Arc::new(content),
+ semantic_vectors: HashMap::new(),
+ dependencies: Vec::new(),
+ branch_id: branch_id.into(),
+ coherence: CoherenceState::Valid,
+ }
+ }
+
+ /// Declara una dependencia (prerrequisito lógico).
+ pub fn depends_on(mut self, dep: Uuid) -> Self {
+ if !self.dependencies.contains(&dep) {
+ self.dependencies.push(dep);
+ }
+ self
+ }
+
+ /// Reemplaza el contenido: re-hashea y vuelve a `PendingEvaluation`
+ /// (toda mutación exige re-verificar la coherencia).
+ pub fn set_content(&mut self, content: impl Into<String>) {
+ let content = content.into();
+ self.content_hash = sha256(content.as_bytes());
+ self.content = Arc::new(content);
+ self.coherence = CoherenceState::PendingEvaluation;
+ }
+
+ /// `true` si `content_hash` corresponde al `content` actual.
+ /// El editor valida esto en toda mutación de texto.
+ pub fn hash_matches(&self) -> bool {
+ sha256(self.content.as_bytes()) == self.content_hash
+ }
+}
+
+/// SHA-256 de un buffer de bytes.
+pub fn sha256(bytes: &[u8]) -> [u8; 32] {
+ use sha2::{Digest, Sha256};
+ let mut h = Sha256::new();
+ h.update(bytes);
+ h.finalize().into()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn new_atom_is_valid_with_matching_hash() {
+ let a = NarrativeAtom::new("había una vez", "main");
+ assert_eq!(a.coherence, CoherenceState::Valid);
+ assert!(a.hash_matches());
+ assert_eq!(a.branch_id, "main");
+ }
+
+ #[test]
+ fn set_content_rehashes_and_marks_pending() {
+ let mut a = NarrativeAtom::new("v1", "main");
+ let h1 = a.content_hash;
+ a.set_content("v2 distinto");
+ assert_ne!(a.content_hash, h1);
+ assert!(a.hash_matches());
+ assert_eq!(a.coherence, CoherenceState::PendingEvaluation);
+ }
+
+ #[test]
+ fn branch_shares_content_arc() {
+ let a = NarrativeAtom::new("texto largo compartido", "main");
+ let b = a.clone();
+ // Clonar la rama NO duplica el String — comparten el Arc.
+ assert!(Arc::ptr_eq(&a.content, &b.content));
+ }
+
+ #[test]
+ fn depends_on_dedups() {
+ let d = Uuid::new_v4();
+ let a = NarrativeAtom::new("x", "main").depends_on(d).depends_on(d);
+ assert_eq!(a.dependencies.len(), 1);
+ }
+
+ #[test]
+ fn tampered_content_fails_hash_check() {
+ let mut a = NarrativeAtom::new("original", "main");
+ // Forzar desincronización (lo que el editor debe detectar).
+ a.content = Arc::new("manipulado".to_string());
+ assert!(!a.hash_matches());
+ }
+}
diff --git a/00_unanchay/pluma/pluma-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml
new file mode 100644
index 0000000..5565fb9
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-cuerpo"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — modelo de Cuerpo (lienzo): secuencia ordenada de párrafos del mismo documento bajo una mirada (idioma, tono, resumen, derivación). Hueso del paralelismo multilienzo."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+postcard = { workspace = true }
+pluma-core = { path = "../pluma-core" }
diff --git a/00_unanchay/pluma/pluma-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-cuerpo/LEEME.md
new file mode 100644
index 0000000..22888b3
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/LEEME.md
@@ -0,0 +1,19 @@
+# pluma-cuerpo
+
+> Texto del documento como secuencia de átomos para [pluma](../README.md).
+
+`Cuerpo` es la vista lineal del documento: lista ordenada de `Atomo` ids, con su texto concatenado por un separador (default `"\n\n"`). Sirve como "vista plana" para el editor y para serialización a Markdown.
+
+## API
+
+```rust
+use pluma_cuerpo::Cuerpo;
+
+let cuerpo = Cuerpo::from_doc(&doc);
+let texto = cuerpo.como_string();
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-cuerpo/README.md b/00_unanchay/pluma/pluma-cuerpo/README.md
new file mode 100644
index 0000000..45ee620
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/README.md
@@ -0,0 +1,19 @@
+# pluma-cuerpo
+
+> Document text as a sequence of atoms for [pluma](../README.md).
+
+`Cuerpo` is the linear view of the document: ordered list of `Atomo` ids with their text concatenated by a separator (default `"\n\n"`). Used as the "flat view" for the editor and for Markdown serialization.
+
+## API
+
+```rust
+use pluma_cuerpo::Cuerpo;
+
+let cuerpo = Cuerpo::from_doc(&doc);
+let text = cuerpo.como_string();
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs
new file mode 100644
index 0000000..ce96b8b
--- /dev/null
+++ b/00_unanchay/pluma/pluma-cuerpo/src/lib.rs
@@ -0,0 +1,379 @@
+//! `pluma-cuerpo` — el *cuerpo* (lienzo) de un documento multivista.
+//!
+//! Un documento pluma deja de ser una sola secuencia lineal de párrafos: es un
+//! *haz* de cuerpos. Cada cuerpo recorre el mismo material desde una mirada
+//! distinta — el original en español, su traducción al quechua, el resumen
+//! en inglés, el comentario crítico, la versión "tono infantil". Todos viven
+//! en paralelo, sincronizados por alineamientos párrafo-a-párrafo
+//! (ver `pluma-align`).
+//!
+//! Este crate define solo el cuerpo: una colección ordenada de [`Uuid`]s de
+//! `NarrativeAtom`s, con metadatos que explican qué *intención* tiene este
+//! cuerpo dentro del haz, y de qué cuerpo madre deriva si es derivado.
+//!
+//! No define la UI ni la alineación entre cuerpos. Esos viven en
+//! `pluma-editor-llimphi` y `pluma-align` respectivamente.
+
+#![forbid(unsafe_code)]
+
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// La identidad de una lengua dentro de un cuerpo: un código corto estilo
+/// ISO 639 (`"es"`, `"qu"`, `"en"`). No se restringe la enumeracion para no
+/// encerrarnos en un catalogo fijo; el campo es un `String`, y `pluma-localize`
+/// (o `rimay-localize`) decide qué significa cada código.
+pub type Lengua = String;
+
+/// La razón por la que existe un cuerpo dentro del haz. Si es `Original`, el
+/// cuerpo no deriva de ningún otro — es la madre. Cualquier otra variante
+/// implica que existe un `derivado_de` apuntando a un cuerpo madre.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Intencion {
+ /// Cuerpo "madre" del haz — no deriva de ningún otro.
+ Original,
+ /// Traducción de la madre a otra lengua.
+ Traduccion,
+ /// Reescritura con un tono distinto (formal, casual, técnico, infantil…).
+ /// La `etiqueta` es libre — la UI la presenta como label de la columna.
+ Tono { etiqueta: String },
+ /// Resumen de la madre, con un objetivo opcional de palabras.
+ Resumen { palabras_objetivo: Option<u32> },
+ /// Reescritura libre dictada por un prompt humano.
+ Reescritura { prompt: String },
+ /// Comentario crítico, glosa o anotación marginal — cada átomo del cuerpo
+ /// anotación se alinea al átomo del cuerpo madre que comenta.
+ Anotacion,
+ /// Cualquier otra cosa que la app quiera categorizar — la etiqueta es libre.
+ Custom { kind: String },
+}
+
+impl Intencion {
+ /// `true` si esta intención implica que el cuerpo deriva de otro. Solo
+ /// `Original` es la excepción: vive solo.
+ pub fn es_derivada(&self) -> bool {
+ !matches!(self, Intencion::Original)
+ }
+}
+
+/// Metadatos descriptivos de un cuerpo: lo que le explica al usuario y al
+/// sistema qué representa este cuerpo dentro del haz.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct MetaCuerpo {
+ /// Nombre legible — el rótulo que la UI muestra en la cabecera de la
+ /// columna. Libre. Ejemplos: `"es"`, `"qu (Cuzco)"`, `"borrador 2"`,
+ /// `"comentario de Ana"`.
+ pub nombre_legible: String,
+ /// Lengua del cuerpo, si aplica. Útil para encadenar con `rimay-localize`,
+ /// motores de embeddings y exportadores. `None` para cuerpos sin lengua
+ /// específica (notas formales, listas de tareas, etc.).
+ pub lengua: Option<Lengua>,
+ /// Por qué existe este cuerpo dentro del haz.
+ pub intencion: Intencion,
+ /// Cuerpo madre del que deriva, si lo hay. Si `intencion` no es
+ /// `Original`, este campo debería estar `Some`; el constructor lo respeta,
+ /// y `valida_consistencia` lo verifica.
+ pub derivado_de: Option<Uuid>,
+ /// Marca temporal (segundos UNIX) del estado de la madre cuando este
+ /// cuerpo se regeneró por última vez. Si la madre cambia después de este
+ /// timestamp, el cuerpo queda *stale* — la UI lo pinta con la hebra
+ /// desaturada. `None` mientras el cuerpo nunca se haya regenerado o sea
+ /// `Original`.
+ pub fresco_hasta: Option<u64>,
+ /// Instante de creación (segundos UNIX). Se fija en el constructor.
+ pub creado_en: u64,
+ /// Instante de la última modificación de la estructura del cuerpo
+ /// (inserciones, removidos, reordenamientos). NO se actualiza cuando
+ /// cambia el contenido de un átomo — eso lo gestiona el átomo.
+ pub modificado_en: u64,
+}
+
+/// El cuerpo (lienzo) en sí: una secuencia ORDENADA de identidades de
+/// `NarrativeAtom`s. La identidad estable de un párrafo es su `Uuid`; los
+/// alineamientos entre cuerpos hablan en términos de esos `Uuid`s, no de
+/// posiciones — así un párrafo movido dentro del cuerpo no rompe sus hebras.
+///
+/// El cuerpo NO posee los átomos: solo los referencia. La posesión vive en el
+/// `NarrativeGraph` (en `pluma-graph`). Esa separación permite que distintos
+/// cuerpos compartan átomos (un párrafo idéntico en madre e hija — caso
+/// común tras `Intencion::Identidad`) sin duplicación.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Cuerpo {
+ /// Identidad del cuerpo. Estable: ni una traducción ni un renombre lo
+ /// cambian. Es el ancla a la que los alineamientos apuntan.
+ pub id: Uuid,
+ /// Identificador de rama, en el sentido que ya entiende `pluma-core`. Por
+ /// convención: `"es"`, `"qu"`, `"borrador-2"`, `"resumen-en"`. La rama da
+ /// también la identidad del `branch_id` que los `NarrativeAtom`s
+ /// referencian.
+ pub branch_id: String,
+ /// Metadatos del cuerpo.
+ pub metadatos: MetaCuerpo,
+ /// Orden de presentación de los párrafos del cuerpo. Cada `Uuid` debe
+ /// existir en el `NarrativeGraph` que aloja al documento; el cuerpo no
+ /// lo valida (no conoce el grafo), eso queda para el caller.
+ pub orden: Vec<Uuid>,
+}
+
+impl Cuerpo {
+ /// Crea un cuerpo vacío con la identidad y los metadatos esenciales.
+ /// `ahora` es el timestamp (segundos UNIX) que el caller decide — el
+ /// crate no toca el reloj para mantenerse `no_std`-amigable y testable.
+ pub fn nuevo(
+ branch_id: impl Into<String>,
+ nombre_legible: impl Into<String>,
+ intencion: Intencion,
+ ahora: u64,
+ ) -> Self {
+ Self {
+ id: Uuid::new_v4(),
+ branch_id: branch_id.into(),
+ metadatos: MetaCuerpo {
+ nombre_legible: nombre_legible.into(),
+ lengua: None,
+ intencion,
+ derivado_de: None,
+ fresco_hasta: None,
+ creado_en: ahora,
+ modificado_en: ahora,
+ },
+ orden: Vec::new(),
+ }
+ }
+
+ /// Marca este cuerpo como derivado de `madre`, fijando también el
+ /// timestamp de frescura con el que se regeneró por primera vez. Útil al
+ /// crear cuerpos via `pluma-transform`.
+ pub fn deriva_de(mut self, madre: Uuid, fresco_hasta: u64) -> Self {
+ self.metadatos.derivado_de = Some(madre);
+ self.metadatos.fresco_hasta = Some(fresco_hasta);
+ self
+ }
+
+ /// Anota la lengua del cuerpo. Útil al encadenar — `Cuerpo::nuevo(...).con_lengua("qu")`.
+ pub fn con_lengua(mut self, lengua: impl Into<Lengua>) -> Self {
+ self.metadatos.lengua = Some(lengua.into());
+ self
+ }
+
+ /// Agrega un átomo al final del cuerpo. Actualiza `modificado_en`. No
+ /// verifica duplicados: un mismo átomo PUEDE aparecer dos veces si el
+ /// caller lo necesita (caso raro, pero no se prohíbe — el cuerpo es
+ /// agnóstico).
+ pub fn agregar(&mut self, atom_id: Uuid, ahora: u64) {
+ self.orden.push(atom_id);
+ self.metadatos.modificado_en = ahora;
+ }
+
+ /// Inserta un átomo en `indice`. Si `indice > len`, se inserta al final.
+ pub fn insertar(&mut self, indice: usize, atom_id: Uuid, ahora: u64) {
+ let pos = indice.min(self.orden.len());
+ self.orden.insert(pos, atom_id);
+ self.metadatos.modificado_en = ahora;
+ }
+
+ /// Remueve el primer átomo con `atom_id`. Devuelve `true` si removió.
+ pub fn remover(&mut self, atom_id: Uuid, ahora: u64) -> bool {
+ if let Some(pos) = self.orden.iter().position(|&id| id == atom_id) {
+ self.orden.remove(pos);
+ self.metadatos.modificado_en = ahora;
+ true
+ } else {
+ false
+ }
+ }
+
+ /// Mueve la primera ocurrencia de `atom_id` al nuevo índice. Devuelve
+ /// `true` si efectuó el cambio. Un movimiento al mismo índice es no-op
+ /// y devuelve `true`. Si `nuevo_indice > len-1`, se mueve al final.
+ pub fn mover(&mut self, atom_id: Uuid, nuevo_indice: usize, ahora: u64) -> bool {
+ let Some(actual) = self.orden.iter().position(|&id| id == atom_id) else {
+ return false;
+ };
+ let destino = nuevo_indice.min(self.orden.len().saturating_sub(1));
+ if actual == destino {
+ return true;
+ }
+ let id = self.orden.remove(actual);
+ self.orden.insert(destino, id);
+ self.metadatos.modificado_en = ahora;
+ true
+ }
+
+ /// Posición del primer átomo con `atom_id`, si existe en el cuerpo.
+ pub fn posicion(&self, atom_id: Uuid) -> Option<usize> {
+ self.orden.iter().position(|&id| id == atom_id)
+ }
+
+ /// `true` si el cuerpo deriva de otro (su intención no es `Original`).
+ pub fn es_derivado(&self) -> bool {
+ self.metadatos.intencion.es_derivada()
+ }
+
+ /// `true` si la madre cambió después de `fresco_hasta`. Si no es derivado
+ /// o nunca se regeneró, devuelve `false` (no aplica el concepto).
+ pub fn es_stale(&self, modificado_madre_en: u64) -> bool {
+ match self.metadatos.fresco_hasta {
+ Some(ts) => modificado_madre_en > ts,
+ None => false,
+ }
+ }
+
+ /// Marca el cuerpo como recién regenerado: `fresco_hasta = ahora`.
+ pub fn marcar_fresco(&mut self, ahora: u64) {
+ self.metadatos.fresco_hasta = Some(ahora);
+ self.metadatos.modificado_en = ahora;
+ }
+
+ /// Verifica la consistencia interna del cuerpo. Útil al cargar de disco o
+ /// tras transformaciones que prometen no romper invariantes. Reglas:
+ ///
+ /// - Si `intencion.es_derivada()`, `derivado_de` debe ser `Some`.
+ /// - Si `intencion == Original`, `derivado_de` debe ser `None`.
+ /// - `modificado_en >= creado_en`.
+ pub fn valida_consistencia(&self) -> Result<(), &'static str> {
+ let m = &self.metadatos;
+ if m.intencion.es_derivada() && m.derivado_de.is_none() {
+ return Err("cuerpo :: intencion derivada sin `derivado_de`");
+ }
+ if !m.intencion.es_derivada() && m.derivado_de.is_some() {
+ return Err("cuerpo :: intencion Original con `derivado_de` no nulo");
+ }
+ if m.modificado_en < m.creado_en {
+ return Err("cuerpo :: `modificado_en` anterior a `creado_en`");
+ }
+ Ok(())
+ }
+
+ /// Serializa el cuerpo a su forma binaria `postcard` — el codec ya
+ /// canónico del workspace (lo usa `format`/`akasha` en wawa).
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "cuerpo :: serializacion fallida")
+ }
+
+ /// Reconstruye un cuerpo desde su forma binaria.
+ pub fn deserializar(bytes: &[u8]) -> Result<Cuerpo, &'static str> {
+ postcard::from_bytes::<Cuerpo>(bytes)
+ .map_err(|_| "cuerpo :: deserializacion fallida")
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+
+ fn ahora_test() -> u64 {
+ 1_716_724_800
+ }
+
+ #[test]
+ fn nuevo_es_consistente_y_vacio() {
+ let c = Cuerpo::nuevo("es", "es (original)", Intencion::Original, ahora_test());
+ assert!(c.orden.is_empty());
+ assert!(!c.es_derivado());
+ assert!(c.metadatos.derivado_de.is_none());
+ assert_eq!(c.metadatos.creado_en, c.metadatos.modificado_en);
+ c.valida_consistencia().unwrap();
+ }
+
+ #[test]
+ fn derivado_exige_madre() {
+ // Construir un cuerpo derivado sin madre — debe detectarse.
+ let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, ahora_test());
+ assert!(c.es_derivado());
+ assert!(c.valida_consistencia().is_err());
+
+ c.metadatos.derivado_de = Some(Uuid::new_v4());
+ c.valida_consistencia().unwrap();
+ }
+
+ #[test]
+ fn original_no_puede_tener_madre() {
+ let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, ahora_test());
+ c.metadatos.derivado_de = Some(Uuid::new_v4());
+ assert!(c.valida_consistencia().is_err());
+ }
+
+ #[test]
+ fn agregar_insertar_remover_mover_actualiza_modificado() {
+ let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 100);
+ let a = Uuid::new_v4();
+ let b = Uuid::new_v4();
+ let d = Uuid::new_v4();
+ c.agregar(a, 101);
+ c.agregar(b, 102);
+ c.insertar(1, d, 103);
+ // orden: [a, d, b]
+ assert_eq!(c.orden, vec![a, d, b]);
+ assert_eq!(c.metadatos.modificado_en, 103);
+ assert_eq!(c.posicion(d), Some(1));
+
+ // Mover b al inicio.
+ assert!(c.mover(b, 0, 104));
+ assert_eq!(c.orden, vec![b, a, d]);
+ assert_eq!(c.metadatos.modificado_en, 104);
+
+ // Remover a.
+ assert!(c.remover(a, 105));
+ assert_eq!(c.orden, vec![b, d]);
+
+ // Remover algo que no está.
+ assert!(!c.remover(Uuid::new_v4(), 106));
+ // El timestamp NO se mueve si no removió.
+ assert_eq!(c.metadatos.modificado_en, 105);
+ }
+
+ #[test]
+ fn insertar_mas_alla_del_final_aterriza_al_final() {
+ let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+ let a = Uuid::new_v4();
+ let b = Uuid::new_v4();
+ c.agregar(a, 1);
+ c.insertar(99, b, 2);
+ assert_eq!(c.orden, vec![a, b]);
+ }
+
+ #[test]
+ fn stale_solo_aplica_si_madre_se_modifico_despues() {
+ let c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
+ .deriva_de(Uuid::new_v4(), 200);
+ // La madre no se ha tocado desde la regeneración.
+ assert!(!c.es_stale(150));
+ assert!(!c.es_stale(200));
+ // La madre cambió DESPUES.
+ assert!(c.es_stale(201));
+ }
+
+ #[test]
+ fn marcar_fresco_actualiza_ambos_timestamps() {
+ let mut c = Cuerpo::nuevo("qu", "qu", Intencion::Traduccion, 100)
+ .deriva_de(Uuid::new_v4(), 100);
+ c.marcar_fresco(500);
+ assert_eq!(c.metadatos.fresco_hasta, Some(500));
+ assert_eq!(c.metadatos.modificado_en, 500);
+ }
+
+ #[test]
+ fn roundtrip_postcard_es_simetrico() {
+ let mut c = Cuerpo::nuevo("qu", "quechua del Cuzco", Intencion::Traduccion, 1000)
+ .deriva_de(Uuid::new_v4(), 1000)
+ .con_lengua("qu");
+ c.agregar(Uuid::new_v4(), 1001);
+ c.agregar(Uuid::new_v4(), 1002);
+ let bytes = c.serializar().unwrap();
+ let recuperado = Cuerpo::deserializar(&bytes).unwrap();
+ assert_eq!(recuperado, c);
+ recuperado.valida_consistencia().unwrap();
+ }
+
+ #[test]
+ fn intencion_es_derivada_distingue_original_de_lo_demas() {
+ assert!(!Intencion::Original.es_derivada());
+ assert!(Intencion::Traduccion.es_derivada());
+ assert!(Intencion::Tono { etiqueta: "formal".into() }.es_derivada());
+ assert!(Intencion::Resumen { palabras_objetivo: Some(200) }.es_derivada());
+ assert!(Intencion::Reescritura { prompt: "p".into() }.es_derivada());
+ assert!(Intencion::Anotacion.es_derivada());
+ assert!(Intencion::Custom { kind: "x".into() }.es_derivada());
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-app/Cargo.toml b/00_unanchay/pluma/pluma-deck-app/Cargo.toml
new file mode 100644
index 0000000..e212a09
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-app/Cargo.toml
@@ -0,0 +1,28 @@
+[package]
+name = "pluma-deck-app"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "App de presentaciones espaciales (modo Recorrido, tipo Prezi): abre .deck/.md, presenta, autorea y guarda. Frontend Llimphi sobre pluma-deck-core."
+
+[[bin]]
+name = "pluma-deck"
+path = "src/main.rs"
+
+[dependencies]
+# Modelo + cámara + ruta + autoría + persistencia (postcard) + adaptador pluma.
+pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
+# Frontend Llimphi (pintura + vistas presentar/editar).
+pluma-deck-recorrido-llimphi = { path = "../pluma-deck-recorrido-llimphi" }
+# Para abrir documentos markdown reales → Recorrido vía el adaptador del core.
+pluma-md = { path = "../pluma-md" }
+llimphi-ui = { workspace = true }
+# Barra de menú principal + menú contextual sobre el marco seleccionado.
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-deck-app/src/main.rs b/00_unanchay/pluma/pluma-deck-app/src/main.rs
new file mode 100644
index 0000000..e632cb0
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-app/src/main.rs
@@ -0,0 +1,706 @@
+//! `pluma-deck` — app de **presentaciones espaciales** (modo Recorrido, tipo Prezi).
+//!
+//! Unifica en un solo binario lo que antes vivía repartido en demos: abrir un
+//! documento, **presentarlo** (la cámara vuela por la ruta) y **autorearlo**
+//! (mover/crear/borrar/rotar marcos), con guardar/cargar en el formato nativo.
+//! Toda la lógica vive en `pluma-deck-core` (cámara, ruta, autoría, persistencia,
+//! adaptador pluma); aquí sólo se cablean eventos y se elige la vista por modo.
+//!
+//! Uso:
+//! `cargo run -p pluma-deck-app -- [archivo.deck | archivo.md]`
+//! - `*.deck` → carga el recorrido binario (postcard) y guarda sobre él.
+//! - `*.md` → importa markdown como recorrido (guarda en `recorrido.deck`).
+//! - sin arg → recorrido de bienvenida (guarda en `recorrido.deck`).
+//!
+//! Controles comunes: **flechas / Espacio / Enter** vuela por la ruta ·
+//! **Home/Esc** vista general · **p** modo presentador (autoplay) · **rueda**
+//! zoom-a-cursor · **Tab** alterna presentar/editar · **Ctrl+S / Ctrl+O**
+//! guarda / carga · **Ctrl+Z / Ctrl+Shift+Z** deshace / rehace autoría. En
+//! **editar**: arrastrar mueve/selecciona un marco (o panea el vacío), **n**
+//! crea, **Supr** elimina, **[ ]** rota. En **presentar**: arrastrar panea libre.
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, FlexDirection, Size, Style};
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use llimphi_widget_context_menu::{
+ context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+ menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+ DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_motion::{animate, motion, Tween};
+use pluma_deck_core::adaptador::recorrido_desde_atomos;
+use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, recorrido_view_editor, ZOOM_BASE};
+
+use app_bus::{AppMenu, Menu, MenuItem};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1200.0, h: 760.0 };
+
+#[derive(Clone, Copy, PartialEq)]
+enum Modo {
+ Presentar,
+ Editar,
+}
+
+#[derive(Clone)]
+enum Msg {
+ Zoom { mult: f64, cursor: (f32, f32) },
+ /// Pan libre (modo presentar): delta de pantalla.
+ Pan { dx: f32, dy: f32 },
+ /// Arrastre de autoría (modo editar): delta + posición del press.
+ Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
+ FinArrastre,
+ NuevoMarco,
+ Eliminar,
+ Rotar(f64),
+ Deshacer,
+ Rehacer,
+ Guardar,
+ Cargar,
+ ToggleModo,
+ VistaGeneral,
+ ToggleAutoplay,
+ Siguiente,
+ Anterior,
+ Tick,
+ /// Cicla el tema del chrome (barra de menú / overlays).
+ CambiarTema,
+ /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cierra).
+ MenuOpen(Option<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Navegación de teclado en el dropdown del menú principal (±1 fila).
+ MenuNav(i32),
+ /// Enter sobre la fila activa del menú principal.
+ MenuActivate,
+ /// Tick de re-render para la animación de aparición del dropdown.
+ MenuTick,
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click en el lienzo → menú contextual anclado en `(x, y)` de
+ /// ventana. En modo editar selecciona el marco bajo el cursor (si lo hay).
+ ContextMenuOpen(f32, f32),
+}
+
+struct Model {
+ rec: Recorrido,
+ state: RecorridoState,
+ autoplay: Autoplay,
+ modo: Modo,
+ seleccionado: Option<u64>,
+ /// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
+ arrastrando: Option<Option<u64>>,
+ /// Destino de Ctrl+S (postcard).
+ guardar_en: PathBuf,
+ /// Undo/redo de autoría (snapshots del recorrido).
+ historial: Historial<Recorrido>,
+ /// Tema del chrome (barra de menú / overlays). El lienzo usa su paleta propia.
+ theme: Theme,
+ /// Barra de menú principal: índice del menú raíz abierto (`None` cerrado).
+ menu_open: Option<usize>,
+ /// Fila activa (resaltada por teclado) del dropdown del menú principal.
+ menu_active: usize,
+ /// Animación de aparición/swap del dropdown del menú principal (0→1).
+ menu_anim: Tween<f32>,
+ /// Menú contextual del lienzo: `(x, y)` ancla en ventana. `None` cerrado.
+ context_menu: Option<(f32, f32)>,
+}
+
+/// Recorrido de bienvenida cuando no se pasa archivo.
+fn bienvenida() -> Recorrido {
+ let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+ titulo: Some(t.into()),
+ parrafos: ps.iter().map(|s| s.to_string()).collect(),
+ };
+ Recorrido::en_rejilla(
+ vec![
+ slide("pluma · deck", &["Presentaciones espaciales tipo Prezi.", "Pasá un .deck o un .md, o autoreá desde cero."]),
+ slide("Presentar", &["Flechas / Espacio vuelan por la ruta.", "Home/Esc: vista general. p: autoplay."]),
+ slide("Editar (Tab)", &["Arrastrá para mover/seleccionar un marco.", "n: nuevo Supr: borrar [ ]: rotar."]),
+ slide("Guardar", &["Ctrl+S guarda, Ctrl+O carga.", "Formato nativo postcard (.deck)."]),
+ ],
+ RejillaOpts { cols: 2, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 },
+ )
+}
+
+/// Abre un archivo como Recorrido: `.md` se importa con el adaptador pluma;
+/// cualquier otro se intenta como `.deck` binario. `None` si falla.
+fn abrir(ruta: &str) -> Option<Recorrido> {
+ let bytes = std::fs::read(ruta).ok()?;
+ if ruta.ends_with(".md") {
+ let md = String::from_utf8(bytes).ok()?;
+ let doc = pluma_md::parse_md(&md, "es", "deck", 0);
+ Some(recorrido_desde_atomos(&doc.atoms, RejillaOpts::default()))
+ } else {
+ Recorrido::deserializar(&bytes).ok()
+ }
+}
+
+struct Deck;
+
+impl App for Deck {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · deck — presentaciones espaciales (Tab: presentar/editar)"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1200, 760)
+ }
+
+ fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+ // Resuelve el recorrido inicial y el destino de guardado según el arg.
+ let (rec, guardar_en) = match std::env::args().nth(1) {
+ Some(p) if p.ends_with(".deck") => {
+ let r = abrir(&p).unwrap_or_else(bienvenida);
+ (r, PathBuf::from(p))
+ }
+ // .md u otro: importa si puede, pero guarda en un .deck para no
+ // sobrescribir el fuente con binario.
+ Some(p) => (abrir(&p).unwrap_or_else(bienvenida), PathBuf::from("recorrido.deck")),
+ None => (bienvenida(), PathBuf::from("recorrido.deck")),
+ };
+ let mut state = RecorridoState::new();
+ state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+ handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+ Model {
+ rec,
+ state,
+ autoplay: Autoplay::default(),
+ modo: Modo::Presentar,
+ seleccionado: None,
+ arrastrando: None,
+ guardar_en,
+ historial: Historial::new(64),
+ theme: Theme::dark(),
+ menu_open: None,
+ menu_active: usize::MAX,
+ menu_anim: Tween::idle(1.0),
+ context_menu: None,
+ }
+ }
+
+ fn update(mut model: Self::Model, msg: Self::Msg, handle: &Handle<Self::Msg>) -> Self::Model {
+ let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+ match msg {
+ Msg::Zoom { mult, cursor } => {
+ model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+ }
+ Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+ Msg::Arrastre { dx, dy, lx, ly } => {
+ let modo = match model.arrastrando {
+ Some(m) => m,
+ None => {
+ let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
+ let m = model.rec.marco_en_punto(world);
+ model.arrastrando = Some(m);
+ if m.is_some() {
+ model.seleccionado = m;
+ // Una instantánea por arrastre (al agarrar el marco),
+ // no por cada move — undo revierte el movimiento entero.
+ model.historial.registrar(&model.rec);
+ }
+ m
+ }
+ };
+ match modo {
+ Some(id) => {
+ let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
+ model.rec.mover_marco(id, wdx, wdy);
+ }
+ None => model.state.arrastrar_delta(dx as f64, dy as f64),
+ }
+ }
+ Msg::FinArrastre => model.arrastrando = None,
+ Msg::NuevoMarco => {
+ model.historial.registrar(&model.rec);
+ let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
+ let (cx, cy) = model.state.camara.centro;
+ let (w, h) = (520.0, 320.0);
+ model.rec.agregar_marco(Marco::new(
+ id,
+ Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
+ ContenidoMarco::Texto { titulo: Some(format!("marco {id}")), parrafos: vec![] },
+ ));
+ model.rec.pasos.push(id);
+ model.seleccionado = Some(id);
+ }
+ Msg::Eliminar => {
+ if let Some(id) = model.seleccionado.take() {
+ model.historial.registrar(&model.rec);
+ model.rec.eliminar_marco(id);
+ let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+ model.state.saltar_a_paso(&model.rec, idx, panel);
+ }
+ }
+ Msg::Rotar(d) => {
+ if let Some(id) = model.seleccionado {
+ model.historial.registrar(&model.rec);
+ model.rec.rotar_marco(id, d);
+ }
+ }
+ Msg::Deshacer | Msg::Rehacer => {
+ let nuevo = match msg {
+ Msg::Deshacer => model.historial.deshacer(&model.rec),
+ _ => model.historial.rehacer(&model.rec),
+ };
+ if let Some(rec) = nuevo {
+ model.rec = rec;
+ // La selección puede haber dejado de existir; el paso se clampa.
+ if model.seleccionado.map_or(false, |id| model.rec.marco(id).is_none()) {
+ model.seleccionado = None;
+ }
+ let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+ model.state.saltar_a_paso(&model.rec, idx, panel);
+ }
+ }
+ Msg::Guardar => match model.rec.serializar() {
+ Ok(bytes) => {
+ let _ = std::fs::write(&model.guardar_en, &bytes);
+ eprintln!("guardado {} ({} bytes)", model.guardar_en.display(), bytes.len());
+ }
+ Err(e) => eprintln!("error al guardar: {e}"),
+ },
+ Msg::Cargar => {
+ let r = std::fs::read(&model.guardar_en)
+ .map_err(|_| "no se pudo leer")
+ .and_then(|b| Recorrido::deserializar(&b));
+ match r {
+ Ok(rec) => {
+ model.rec = rec;
+ model.seleccionado = None;
+ model.state.saltar_a_paso(&model.rec, 0, panel);
+ eprintln!("cargado {}", model.guardar_en.display());
+ }
+ Err(e) => eprintln!("error al cargar: {e}"),
+ }
+ }
+ Msg::ToggleModo => {
+ model.modo = match model.modo {
+ Modo::Presentar => Modo::Editar,
+ Modo::Editar => Modo::Presentar,
+ };
+ model.autoplay.pausa();
+ eprintln!("modo: {}", if model.modo == Modo::Editar { "editar" } else { "presentar" });
+ }
+ Msg::VistaGeneral => {
+ model.state.vista_general(&model.rec, panel);
+ }
+ Msg::ToggleAutoplay => {
+ model.autoplay.toggle();
+ }
+ Msg::Siguiente => {
+ model.state.siguiente(&model.rec, panel);
+ }
+ Msg::Anterior => {
+ model.state.anterior(&model.rec, panel);
+ }
+ Msg::Tick => {
+ model.state.avanzar(1.0 / 60.0);
+ model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
+ }
+ Msg::CambiarTema => {
+ model.theme = Theme::next_after(model.theme.name);
+ }
+ Msg::MenuOpen(which) => {
+ model.menu_open = which;
+ model.menu_active = usize::MAX;
+ // Abrir un menú raíz cierra cualquier contextual.
+ model.context_menu = None;
+ // Animación de aparición/swap: cada vez que se abre (o se
+ // cambia de) menú, el dropdown se funde+desliza de nuevo.
+ if which.is_some() {
+ model.menu_anim = Tween::new(0.0, 1.0, motion::FAST, motion::ease_out_cubic);
+ animate(handle, motion::FAST, || Msg::MenuTick);
+ }
+ }
+ 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 Deck::update(model, Msg::MenuCommand(cmd), handle);
+ }
+ }
+ }
+ Msg::MenuTick => {}
+ Msg::CloseMenus => {
+ model.menu_open = None;
+ model.menu_active = usize::MAX;
+ model.context_menu = None;
+ }
+ Msg::MenuCommand(cmd) => {
+ model.menu_open = None;
+ model.menu_active = usize::MAX;
+ if let Some(next) = comando_a_msg(&cmd) {
+ return Deck::update(model, next, handle);
+ }
+ // Comandos sin Msg directo (salir / no-op).
+ if cmd == "file.quit" {
+ std::process::exit(0);
+ }
+ }
+ Msg::ContextMenuOpen(x, y) => {
+ model.menu_open = None;
+ // En editar, el right-click también selecciona el marco bajo el
+ // cursor (si lo hay) para que las acciones del menú apliquen a él.
+ if model.modo == Modo::Editar {
+ let world = model.state.camara.screen_to_world((x as f64, y as f64), panel);
+ if let Some(id) = model.rec.marco_en_punto(world) {
+ model.seleccionado = Some(id);
+ }
+ }
+ model.context_menu = Some((x, y));
+ }
+ }
+ model
+ }
+
+ fn view(model: &Self::Model) -> View<Self::Msg> {
+ let menu = app_menu(model);
+ let menubar = menubar_view(&menubar_spec(&menu, model));
+
+ let lienzo = match model.modo {
+ Modo::Editar => recorrido_view_editor(&model.rec, &model.state, model.seleccionado)
+ .draggable_at(|phase, dx, dy, lx, ly| match phase {
+ DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
+ DragPhase::End => Some(Msg::FinArrastre),
+ }),
+ Modo::Presentar => recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Pan { dx, dy }),
+ DragPhase::End => None,
+ }),
+ };
+
+ // Column raíz: barra de menú arriba + lienzo a pantalla completa debajo.
+ // El right-click va en la RAÍZ (origen 0,0 ⇒ local == ventana) para anclar
+ // el menú contextual en coords de ventana, igual que el panel del lienzo.
+ 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::ContextMenuOpen(x, y)))
+ .children(vec![menubar, lienzo])
+ }
+
+ fn view_overlay(model: &Self::Model) -> Option<View<Self::Msg>> {
+ // Menú contextual del lienzo tiene prioridad sobre el dropdown principal.
+ if let Some((x, y)) = model.context_menu {
+ let viewport = viewport_of(model);
+ // Acciones según el modo. En editar, sobre el marco seleccionado;
+ // en presentar, navegación. Sólo comandos que mapean a Msg reales.
+ let (header, items, on_pick): (String, Vec<ContextMenuItem>, Arc<dyn Fn(usize) -> Msg + Send + Sync>) =
+ if model.modo == Modo::Editar {
+ let hay_sel = model.seleccionado.is_some();
+ // Las acciones sobre marco se deshabilitan (gris) sin selección.
+ let opt = |it: ContextMenuItem| if hay_sel { it } else { it.disabled() };
+ let rotar_l = opt(ContextMenuItem::action("Rotar ⟲"));
+ let rotar_r = opt(ContextMenuItem::action("Rotar ⟳"));
+ let borrar = opt(ContextMenuItem::action("Eliminar marco").destructive());
+ let items = vec![
+ ContextMenuItem::action("Nuevo marco"),
+ rotar_l,
+ rotar_r,
+ ContextMenuItem::separator(),
+ borrar,
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
+ 0 => Msg::NuevoMarco,
+ 1 => Msg::Rotar(-0.08),
+ 2 => Msg::Rotar(0.08),
+ 4 => Msg::Eliminar,
+ _ => Msg::CloseMenus,
+ });
+ ("Editar marco".to_string(), items, on_pick)
+ } else {
+ let items = vec![
+ ContextMenuItem::action("Siguiente"),
+ ContextMenuItem::action("Anterior"),
+ ContextMenuItem::action("Vista general"),
+ ContextMenuItem::separator(),
+ ContextMenuItem::action("Presentar (autoplay)"),
+ ];
+ let on_pick: Arc<dyn Fn(usize) -> Msg + Send + Sync> = Arc::new(|i: usize| match i {
+ 0 => Msg::Siguiente,
+ 1 => Msg::Anterior,
+ 2 => Msg::VistaGeneral,
+ 4 => Msg::ToggleAutoplay,
+ _ => Msg::CloseMenus,
+ });
+ ("Presentar".to_string(), items, on_pick)
+ };
+ return Some(context_menu_view(ContextMenuSpec {
+ anchor: (x, y),
+ viewport,
+ header: Some(header),
+ items,
+ active: usize::MAX,
+ on_pick,
+ on_dismiss: Msg::CloseMenus,
+ palette: ContextMenuPalette::from_theme(&model.theme),
+ }));
+ }
+ // Si no, el dropdown del menú principal (con nav por teclado + animación).
+ let menu = app_menu(model);
+ menubar_overlay_animated(
+ &menubar_spec(&menu, model),
+ model.menu_active,
+ model.menu_anim.value(),
+ )
+ }
+
+ fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+ let panel = panel_actual()?;
+ if !dentro(panel, cursor.0, cursor.1) {
+ return None;
+ }
+ Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+ }
+
+ fn on_key(model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+ if ev.state != KeyState::Pressed {
+ return None;
+ }
+ // Menú principal abierto: las flechas navegan. ←/→ cambian de menú
+ // raíz (con wrap), ↑/↓ mueven la fila activa, Enter ejecuta, Esc
+ // cierra. Tiene prioridad sobre todo lo demás.
+ if let Some(mi) = model.menu_open {
+ let n = app_menu(model).menus.len().max(1);
+ return match &ev.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,
+ };
+ }
+ // Guardar/cargar en cualquier modo.
+ if ev.modifiers.ctrl {
+ return match &ev.key {
+ Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
+ Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
+ // Ctrl+Z deshace; Ctrl+Shift+Z o Ctrl+Y rehacen.
+ Key::Character(c) if c.eq_ignore_ascii_case("z") => {
+ Some(if ev.modifiers.shift { Msg::Rehacer } else { Msg::Deshacer })
+ }
+ Key::Character(c) if c.eq_ignore_ascii_case("y") => Some(Msg::Rehacer),
+ _ => None,
+ };
+ }
+ // Comunes a ambos modos.
+ match &ev.key {
+ Key::Named(NamedKey::Tab) => return Some(Msg::ToggleModo),
+ Key::Named(NamedKey::Home | NamedKey::Escape) => return Some(Msg::VistaGeneral),
+ Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+ return Some(Msg::Siguiente)
+ }
+ Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => return Some(Msg::Anterior),
+ Key::Character(c) if c.eq_ignore_ascii_case("p") => return Some(Msg::ToggleAutoplay),
+ _ => {}
+ }
+ // Sólo en modo editar.
+ if model.modo == Modo::Editar {
+ return match &ev.key {
+ Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
+ Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
+ Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
+ Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
+ _ => None,
+ };
+ }
+ None
+ }
+}
+
+/// Pila de undo/redo genérica para autoría. Antes de cada cambio se `registrar`a
+/// el estado previo; `deshacer`/`rehacer` mueven el estado entre pasado y futuro.
+/// Acotada a `max` entradas (descarta las más viejas).
+struct Historial<T> {
+ pasado: Vec<T>,
+ futuro: Vec<T>,
+ max: usize,
+}
+
+impl<T: Clone> Historial<T> {
+ fn new(max: usize) -> Self {
+ Self { pasado: Vec::new(), futuro: Vec::new(), max: max.max(1) }
+ }
+
+ /// Registra `actual` (estado **previo** al cambio que está por aplicarse) y
+ /// limpia el redo — una rama nueva invalida los rehacer pendientes.
+ fn registrar(&mut self, actual: &T) {
+ self.pasado.push(actual.clone());
+ if self.pasado.len() > self.max {
+ self.pasado.remove(0);
+ }
+ self.futuro.clear();
+ }
+
+ /// Deshace: devuelve el último estado registrado y manda `actual` al futuro.
+ fn deshacer(&mut self, actual: &T) -> Option<T> {
+ let prev = self.pasado.pop()?;
+ self.futuro.push(actual.clone());
+ Some(prev)
+ }
+
+ /// Rehace: devuelve el último estado deshecho y manda `actual` al pasado.
+ fn rehacer(&mut self, actual: &T) -> Option<T> {
+ let next = self.futuro.pop()?;
+ self.pasado.push(actual.clone());
+ Some(next)
+ }
+}
+
+/// Viewport para clampear overlays. El deck no trackea el resize, así que
+/// usamos el tamaño inicial — basta para anclar los menús dentro de pantalla.
+fn viewport_of(_model: &Model) -> (f32, f32) {
+ let (w, h) = Deck::initial_size();
+ (w as f32, h as f32)
+}
+
+/// Arma el `MenuBarSpec` compartido por `menubar_view` y `menubar_overlay`.
+fn menubar_spec<'a>(menu: &'a AppMenu, model: &'a Model) -> MenuBarSpec<'a, Msg> {
+ MenuBarSpec {
+ menu,
+ open: model.menu_open,
+ theme: &model.theme,
+ viewport: viewport_of(model),
+ height: MENU_H,
+ on_open: Arc::new(Msg::MenuOpen),
+ on_command: Arc::new(|c: &str| Msg::MenuCommand(c.to_string())),
+ }
+}
+
+/// Menú principal del deck. Sólo comandos que mapean a acciones reales del
+/// `update`. "Editar" (Slide) aparece siempre porque la autoría no necesita
+/// foco de texto; sus ítems se deshabilitan según el modo / selección reales.
+fn app_menu(model: &Model) -> AppMenu {
+ let editar = model.modo == Modo::Editar;
+ let hay_sel = model.seleccionado.is_some();
+ // Helper: ítem habilitado/deshabilitado según condición real.
+ let item = |label: &str, cmd: &str, on: bool| {
+ let it = MenuItem::new(label, cmd);
+ if on { it } else { it.disabled() }
+ };
+ AppMenu::new()
+ .menu(
+ Menu::new("Archivo")
+ .item(MenuItem::new("Guardar", "file.save").shortcut("Ctrl+S"))
+ .item(MenuItem::new("Cargar", "file.open").shortcut("Ctrl+O")),
+ )
+ .menu(
+ Menu::new("Editar")
+ .item(MenuItem::new("Deshacer", "edit.undo").shortcut("Ctrl+Z"))
+ .item(MenuItem::new("Rehacer", "edit.redo").shortcut("Ctrl+Shift+Z").separated())
+ .item(item("Rotar ⟲", "edit.rotar_l", editar && hay_sel))
+ .item(item("Rotar ⟳", "edit.rotar_r", editar && hay_sel)),
+ )
+ .menu(
+ Menu::new("Slide")
+ .item(item("Nuevo marco", "slide.nuevo", editar).shortcut("n"))
+ .item(item("Eliminar marco", "slide.eliminar", editar && hay_sel).shortcut("Supr"))
+ .item(MenuItem::new("Siguiente", "slide.siguiente").shortcut("→").separated())
+ .item(MenuItem::new("Anterior", "slide.anterior").shortcut("←")),
+ )
+ .menu(
+ Menu::new("Ver")
+ .item(MenuItem::new(
+ if editar { "Presentar (Tab)" } else { "Editar (Tab)" },
+ "view.modo",
+ ))
+ .item(MenuItem::new("Vista general", "view.general").shortcut("Home"))
+ .item(MenuItem::new("Autoplay", "view.autoplay").shortcut("p"))
+ .item(MenuItem::new("Cambiar tema", "view.theme").separated()),
+ )
+ .menu(Menu::new("Ayuda").item(MenuItem::new("Acerca de", "help.about")))
+}
+
+/// Traduce un command id del menú principal al `Msg` real del deck. `None` para
+/// los que no tienen Msg directo (`file.quit` se maneja aparte; `help.about` no-op).
+fn comando_a_msg(cmd: &str) -> Option<Msg> {
+ Some(match cmd {
+ "file.save" => Msg::Guardar,
+ "file.open" => Msg::Cargar,
+ "edit.undo" => Msg::Deshacer,
+ "edit.redo" => Msg::Rehacer,
+ "edit.rotar_l" => Msg::Rotar(-0.08),
+ "edit.rotar_r" => Msg::Rotar(0.08),
+ "slide.nuevo" => Msg::NuevoMarco,
+ "slide.eliminar" => Msg::Eliminar,
+ "slide.siguiente" => Msg::Siguiente,
+ "slide.anterior" => Msg::Anterior,
+ "view.modo" => Msg::ToggleModo,
+ "view.general" => Msg::VistaGeneral,
+ "view.autoplay" => Msg::ToggleAutoplay,
+ "view.theme" => Msg::CambiarTema,
+ _ => return None,
+ })
+}
+
+fn main() {
+ llimphi_ui::run::<Deck>();
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::Historial;
+
+ #[test]
+ fn deshacer_rehacer_round_trip() {
+ let mut h = Historial::new(10);
+ // estado: 0 → (registrar 0) → 1 → (registrar 1) → 2
+ h.registrar(&0);
+ h.registrar(&1);
+ let mut actual = 2;
+ // deshacer dos veces: 2→1→0
+ actual = h.deshacer(&actual).unwrap();
+ assert_eq!(actual, 1);
+ actual = h.deshacer(&actual).unwrap();
+ assert_eq!(actual, 0);
+ assert!(h.deshacer(&actual).is_none(), "sin más pasado");
+ // rehacer dos veces: 0→1→2
+ actual = h.rehacer(&actual).unwrap();
+ assert_eq!(actual, 1);
+ actual = h.rehacer(&actual).unwrap();
+ assert_eq!(actual, 2);
+ assert!(h.rehacer(&actual).is_none(), "sin más futuro");
+ }
+
+ #[test]
+ fn registrar_tras_deshacer_limpia_el_futuro() {
+ let mut h = Historial::new(10);
+ h.registrar(&0);
+ let actual = h.deshacer(&1).unwrap(); // actual ahora = 0, futuro = [1]
+ assert_eq!(actual, 0);
+ h.registrar(&actual); // rama nueva: el futuro se descarta
+ assert!(h.rehacer(&actual).is_none());
+ }
+
+ #[test]
+ fn respeta_el_tope_descartando_lo_mas_viejo() {
+ let mut h = Historial::new(2);
+ h.registrar(&1);
+ h.registrar(&2);
+ h.registrar(&3); // descarta el 1
+ assert_eq!(h.deshacer(&99), Some(3));
+ assert_eq!(h.deshacer(&3), Some(2));
+ assert!(h.deshacer(&2).is_none());
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/Cargo.toml b/00_unanchay/pluma/pluma-deck-core/Cargo.toml
new file mode 100644
index 0000000..d745206
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-deck-core"
+description = "Vista — máquina de estados agnóstica para deck horizontal con snap. Sin dependencias web/DOM."
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[features]
+# Adaptador opcional pluma → Recorrido. El core sigue agnóstico por defecto
+# (cero dependencias); activar `pluma` trae el puente desde el modelo de
+# documento de pluma (NarrativeAtom / Cuerpo) hacia los slides del Recorrido.
+pluma = ["dep:pluma-core", "dep:pluma-cuerpo", "dep:uuid"]
+# Persistencia del Recorrido en el codec nativo del workspace (postcard, el
+# mismo de format/akasha/pluma-cuerpo). Deriva serde sobre el modelo de datos
+# (Rect/Camara/Marco/ContenidoMarco/Recorrido) + helpers serializar/deserializar.
+serde = ["dep:serde", "dep:postcard"]
+
+[dependencies]
+pluma-core = { path = "../pluma-core", optional = true }
+pluma-cuerpo = { path = "../pluma-cuerpo", optional = true }
+uuid = { workspace = true, optional = true }
+serde = { workspace = true, optional = true, features = ["derive"] }
+postcard = { workspace = true, optional = true }
diff --git a/00_unanchay/pluma/pluma-deck-core/LEEME.md b/00_unanchay/pluma/pluma-deck-core/LEEME.md
new file mode 100644
index 0000000..dad3c38
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/LEEME.md
@@ -0,0 +1,18 @@
+# pluma-deck-core
+
+> Deck (slides) sobre [pluma](../README.md).
+
+Modelo de slides: un `Deck` es una secuencia de `Slide`, cada slide es un subgrafo del documento + layout hint (full · split · cover). Sin UI ni render. Sirve como capa de organización para presentaciones cuya fuente sigue siendo el doc pluma.
+
+## API
+
+```rust
+use pluma_deck_core::{Deck, Slide};
+
+let deck = Deck::from_doc(&doc, &slide_breaks);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`
diff --git a/00_unanchay/pluma/pluma-deck-core/README.md b/00_unanchay/pluma/pluma-deck-core/README.md
new file mode 100644
index 0000000..e96de85
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/README.md
@@ -0,0 +1,18 @@
+# pluma-deck-core
+
+> Deck (slides) on [pluma](../README.md).
+
+Slide model: a `Deck` is a sequence of `Slide`, each slide is a subgraph of the document + layout hint (full · split · cover). No UI or render. Acts as an organization layer for presentations whose source remains the pluma doc.
+
+## API
+
+```rust
+use pluma_deck_core::{Deck, Slide};
+
+let deck = Deck::from_doc(&doc, &slide_breaks);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`
diff --git a/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs
new file mode 100644
index 0000000..69e4bff
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/adaptador.rs
@@ -0,0 +1,141 @@
+//! Adaptador opcional **pluma → Recorrido** (feature `pluma`).
+//!
+//! Puente entre el modelo de documento de pluma ([`NarrativeAtom`] /
+//! [`Cuerpo`]) y los slides agnósticos de [`crate::ContenidoMarco`]. Promovido
+//! desde el glue que vivía inline en el example `recorrido_md_demo` — ahora es
+//! reusable y testeado. El core sigue agnóstico: este módulo sólo compila con
+//! la feature `pluma` activa; sin ella, `pluma-deck-core` no conoce pluma.
+//!
+//! Regla de agrupación: un átomo cuyo texto arranca con `#`+espacio (encabezado
+//! markdown) abre un slide nuevo cuyo título es ese encabezado; los demás átomos
+//! son párrafos del slide actual. Es el mismo criterio que `pluma-md` usa al
+//! emitir un átomo por bloque.
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+use uuid::Uuid;
+
+use crate::{ContenidoMarco, Recorrido, RejillaOpts};
+
+/// Cierra el slide en construcción (si tiene algo) y lo empuja a `slides`.
+fn empujar(slides: &mut Vec<ContenidoMarco>, titulo: &mut Option<String>, parrafos: &mut Vec<String>) {
+ if titulo.is_some() || !parrafos.is_empty() {
+ slides.push(ContenidoMarco::Texto {
+ titulo: titulo.take(),
+ parrafos: std::mem::take(parrafos),
+ });
+ }
+}
+
+/// Agrupa una secuencia de textos (en orden de documento) en slides. Un texto
+/// que arranca con `#`+espacio abre un slide nuevo (su título = el encabezado
+/// sin los `#`); los textos vacíos se ignoran; el resto son párrafos del slide
+/// actual. Es la pieza pura — `recorrido_desde_*` la alimentan.
+pub fn slides_desde_textos<'a>(textos: impl IntoIterator<Item = &'a str>) -> Vec<ContenidoMarco> {
+ let mut slides = Vec::new();
+ let mut titulo: Option<String> = None;
+ let mut parrafos: Vec<String> = Vec::new();
+ for c in textos {
+ let hashes = c.chars().take_while(|&ch| ch == '#').count();
+ let es_encabezado = hashes > 0 && c[hashes..].starts_with(' ');
+ if es_encabezado {
+ empujar(&mut slides, &mut titulo, &mut parrafos);
+ titulo = Some(c[hashes..].trim().to_string());
+ } else if !c.trim().is_empty() {
+ parrafos.push(c.to_string());
+ }
+ }
+ empujar(&mut slides, &mut titulo, &mut parrafos);
+ slides
+}
+
+/// Recorrido desde una secuencia de átomos en **orden de documento** (un
+/// encabezado abre slide). `opts` controla el auto-layout en rejilla.
+pub fn recorrido_desde_atomos(atomos: &[NarrativeAtom], opts: RejillaOpts) -> Recorrido {
+ Recorrido::en_rejilla(slides_desde_textos(atomos.iter().map(|a| a.content.as_str())), opts)
+}
+
+/// Recorrido desde un [`Cuerpo`]: recorre `cuerpo.orden` resolviendo cada `Uuid`
+/// a su átomo con `resolver` (el cuerpo no conoce el grafo, lo resuelve el
+/// caller); los ids que no resuelven se omiten. Así un lienzo del haz multilienzo
+/// alimenta una presentación sin que el core conozca el `NarrativeGraph`.
+pub fn recorrido_desde_cuerpo<'a>(
+ cuerpo: &Cuerpo,
+ resolver: impl Fn(&Uuid) -> Option<&'a NarrativeAtom>,
+ opts: RejillaOpts,
+) -> Recorrido {
+ let textos = cuerpo.orden.iter().filter_map(|id| resolver(id)).map(|a| a.content.as_str());
+ Recorrido::en_rejilla(slides_desde_textos(textos), opts)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pluma_cuerpo::Intencion;
+
+ fn atom(c: &str) -> NarrativeAtom {
+ NarrativeAtom::new(c, "es")
+ }
+
+ fn como_texto(c: &ContenidoMarco) -> (Option<&str>, &[String]) {
+ match c {
+ ContenidoMarco::Texto { titulo, parrafos } => (titulo.as_deref(), parrafos.as_slice()),
+ _ => panic!("se esperaba ContenidoMarco::Texto"),
+ }
+ }
+
+ #[test]
+ fn slides_agrupan_por_encabezado() {
+ let textos = ["# Título A", "p1", "", "p2", "## Título B", "p3"];
+ let slides = slides_desde_textos(textos.iter().copied());
+ assert_eq!(slides.len(), 2);
+ let (t0, p0) = como_texto(&slides[0]);
+ assert_eq!(t0, Some("Título A"));
+ assert_eq!(p0, &["p1".to_string(), "p2".to_string()]); // el vacío se ignora
+ let (t1, p1) = como_texto(&slides[1]);
+ assert_eq!(t1, Some("Título B"));
+ assert_eq!(p1, &["p3".to_string()]);
+ }
+
+ #[test]
+ fn parrafos_sueltos_sin_encabezado_forman_un_slide_sin_titulo() {
+ let slides = slides_desde_textos(["solo párrafo", "y otro"].iter().copied());
+ assert_eq!(slides.len(), 1);
+ let (t, p) = como_texto(&slides[0]);
+ assert_eq!(t, None);
+ assert_eq!(p.len(), 2);
+ }
+
+ #[test]
+ fn recorrido_desde_atomos_rutea_en_orden_de_lectura() {
+ let atomos = vec![atom("# Uno"), atom("cuerpo de uno"), atom("# Dos")];
+ let rec = recorrido_desde_atomos(&atomos, RejillaOpts::default());
+ assert_eq!(rec.marcos.len(), 2);
+ assert_eq!(rec.pasos, vec![1, 2]);
+ }
+
+ #[test]
+ fn recorrido_desde_cuerpo_resuelve_el_orden_y_omite_faltantes() {
+ let a = atom("# Hola");
+ let b = atom("mundo");
+ let mut cuerpo = Cuerpo::nuevo("es", "doc", Intencion::Original, 0);
+ cuerpo.agregar(a.id, 0);
+ cuerpo.agregar(b.id, 0);
+ cuerpo.agregar(Uuid::new_v4(), 0); // id huérfano: no resuelve
+ let resolver = |id: &Uuid| {
+ if *id == a.id {
+ Some(&a)
+ } else if *id == b.id {
+ Some(&b)
+ } else {
+ None
+ }
+ };
+ let rec = recorrido_desde_cuerpo(&cuerpo, resolver, RejillaOpts::default());
+ // Un solo slide: "Hola" con el párrafo "mundo"; el huérfano se omitió.
+ assert_eq!(rec.marcos.len(), 1);
+ let (t, p) = como_texto(&rec.marcos[0].contenido);
+ assert_eq!(t, Some("Hola"));
+ assert_eq!(p, &["mundo".to_string()]);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/camara.rs b/00_unanchay/pluma/pluma-deck-core/src/camara.rs
new file mode 100644
index 0000000..6e32c5c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/camara.rs
@@ -0,0 +1,257 @@
+//! Cámara 2D para el modo `Recorrido` (presentación espacial, tipo Prezi).
+//!
+//! Matemática agnóstica del lienzo infinito: convierte entre coordenadas de
+//! *mundo* (donde viven los marcos) y coordenadas de *pantalla* (px del panel),
+//! con zoom + paneo + giro. Es la versión extraída y generalizada del zoom/pan
+//! que `tullpu-app-llimphi` resolvió inline (`factor_zoom`/`pan`/zoom-a-cursor):
+//! aquí vive como tipo reusable, sin render ni DOM.
+//!
+//! Convención: `centro` es el punto de mundo que cae en el centro del panel;
+//! `zoom` es el factor mundo→px (zoom 2.0 ⇒ 1 unidad de mundo mide 2 px);
+//! `rot_rad` gira la vista (la pantalla rota `-rot` respecto al mundo, de modo
+//! que un marco con `rot_rad` propio se ve recto cuando la cámara lo iguala).
+
+/// Clamp inferior del zoom — más allá la presentación se pierde en el infinito.
+pub const ZOOM_MIN: f64 = 0.02;
+/// Clamp superior del zoom — más allá se pixela el contenido.
+pub const ZOOM_MAX: f64 = 64.0;
+/// Fracción del panel que ocupa un marco al hacer `fit_marco` (deja aire).
+pub const FIT_MARGEN: f64 = 0.9;
+
+/// Rectángulo axis-aligned. Sirve tanto para el panel (px de pantalla) como
+/// para el `rect` de un marco (coords de mundo).
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Rect {
+ pub x: f64,
+ pub y: f64,
+ pub w: f64,
+ pub h: f64,
+}
+
+impl Rect {
+ pub fn new(x: f64, y: f64, w: f64, h: f64) -> Self {
+ Self { x, y, w, h }
+ }
+
+ /// Centro geométrico.
+ pub fn centro(&self) -> (f64, f64) {
+ (self.x + self.w * 0.5, self.y + self.h * 0.5)
+ }
+}
+
+/// Función de suavizado para la interpolación entre pasos del recorrido.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub enum Ease {
+ /// Velocidad constante.
+ Lineal,
+ /// Arranca y frena suave (`smoothstep`). Es el sabor "vuelo Prezi".
+ #[default]
+ SuaveInOut,
+}
+
+impl Ease {
+ /// Mapea `t ∈ [0,1]` a la curva elegida (también en `[0,1]`).
+ pub fn aplicar(self, t: f64) -> f64 {
+ let t = t.clamp(0.0, 1.0);
+ match self {
+ Ease::Lineal => t,
+ Ease::SuaveInOut => t * t * (3.0 - 2.0 * t),
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Camara {
+ /// Punto de mundo que cae en el centro del panel.
+ pub centro: (f64, f64),
+ /// Factor mundo→px. Siempre dentro de `[ZOOM_MIN, ZOOM_MAX]`.
+ pub zoom: f64,
+ /// Giro de la vista en radianes.
+ pub rot_rad: f64,
+}
+
+impl Default for Camara {
+ fn default() -> Self {
+ Self { centro: (0.0, 0.0), zoom: 1.0, rot_rad: 0.0 }
+ }
+}
+
+impl Camara {
+ pub fn new(centro: (f64, f64), zoom: f64, rot_rad: f64) -> Self {
+ Self { centro, zoom: zoom.clamp(ZOOM_MIN, ZOOM_MAX), rot_rad }
+ }
+
+ /// Mundo → pantalla. `panel` son los px del viewport donde se pinta.
+ pub fn world_to_screen(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
+ let (cx, cy) = panel.centro();
+ let dx = p.0 - self.centro.0;
+ let dy = p.1 - self.centro.1;
+ // La pantalla rota -rot respecto al mundo.
+ let (s, c) = (-self.rot_rad).sin_cos();
+ let rx = dx * c - dy * s;
+ let ry = dx * s + dy * c;
+ (cx + rx * self.zoom, cy + ry * self.zoom)
+ }
+
+ /// Pantalla → mundo. Inversa exacta de [`world_to_screen`](Self::world_to_screen).
+ pub fn screen_to_world(&self, p: (f64, f64), panel: Rect) -> (f64, f64) {
+ let (cx, cy) = panel.centro();
+ let sx = (p.0 - cx) / self.zoom;
+ let sy = (p.1 - cy) / self.zoom;
+ let (s, c) = self.rot_rad.sin_cos();
+ let wx = sx * c - sy * s;
+ let wy = sx * s + sy * c;
+ (self.centro.0 + wx, self.centro.1 + wy)
+ }
+
+ /// Wheel zoom anclado al cursor: el punto de mundo bajo `cursor` queda
+ /// fijo en pantalla tras escalar por `mult` (`mult>1` acerca). Réplica del
+ /// zoom-a-cursor de tullpu. `panel`/`cursor` en px de pantalla.
+ pub fn zoom_a_cursor(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
+ let ancla = self.screen_to_world(cursor, panel);
+ self.zoom = (self.zoom * mult).clamp(ZOOM_MIN, ZOOM_MAX);
+ // Recolocar `centro` para que `ancla` vuelva a caer bajo `cursor`.
+ let (cx, cy) = panel.centro();
+ let sx = (cursor.0 - cx) / self.zoom;
+ let sy = (cursor.1 - cy) / self.zoom;
+ let (s, c) = self.rot_rad.sin_cos();
+ let wx = sx * c - sy * s;
+ let wy = sx * s + sy * c;
+ self.centro = (ancla.0 - wx, ancla.1 - wy);
+ }
+
+ /// Convierte un delta de pantalla (px) en su delta de mundo equivalente,
+ /// deshaciendo zoom + giro. Útil para mover un objeto siguiendo al cursor
+ /// (`objeto += delta`) o para panear (`centro -= delta`).
+ pub fn delta_pantalla_a_mundo(&self, dx: f64, dy: f64) -> (f64, f64) {
+ let (s, c) = self.rot_rad.sin_cos();
+ ((dx * c - dy * s) / self.zoom, (dx * s + dy * c) / self.zoom)
+ }
+
+ /// Paneo: arrastra el contenido `(dx, dy)` px de pantalla. El punto de
+ /// mundo bajo el cursor sigue al dedo.
+ pub fn pan(&mut self, dx: f64, dy: f64) {
+ let (wx, wy) = self.delta_pantalla_a_mundo(dx, dy);
+ self.centro = (self.centro.0 - wx, self.centro.1 - wy);
+ }
+
+ /// Cámara que centra y encuadra (`contain`) `marco` en `panel`, igualando
+ /// su giro para que se vea recto. Deja `FIT_MARGEN` de aire.
+ pub fn fit(marco: Rect, marco_rot_rad: f64, panel: Rect) -> Camara {
+ let zw = if marco.w > 0.0 { panel.w / marco.w } else { 1.0 };
+ let zh = if marco.h > 0.0 { panel.h / marco.h } else { 1.0 };
+ let zoom = (zw.min(zh) * FIT_MARGEN).clamp(ZOOM_MIN, ZOOM_MAX);
+ Camara { centro: marco.centro(), zoom, rot_rad: marco_rot_rad }
+ }
+
+ /// Interpola dos cámaras. El zoom se mezcla en **espacio logarítmico**
+ /// (un acercamiento percibido constante — el "vuelo" suave de Prezi);
+ /// centro y giro, linealmente. `t` se pasa por `ease` antes de mezclar.
+ pub fn interpolar(a: &Camara, b: &Camara, t: f64, ease: Ease) -> Camara {
+ let u = ease.aplicar(t);
+ let lerp = |x: f64, y: f64| x + (y - x) * u;
+ Camara {
+ centro: (lerp(a.centro.0, b.centro.0), lerp(a.centro.1, b.centro.1)),
+ zoom: (a.zoom.ln() * (1.0 - u) + b.zoom.ln() * u).exp(),
+ rot_rad: lerp(a.rot_rad, b.rot_rad),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+ fn aprox(a: (f64, f64), b: (f64, f64)) {
+ assert!((a.0 - b.0).abs() < 1e-6 && (a.1 - b.1).abs() < 1e-6, "{a:?} != {b:?}");
+ }
+
+ #[test]
+ fn centro_cae_en_centro_de_panel() {
+ let cam = Camara::new((10.0, 20.0), 2.0, 0.0);
+ aprox(cam.world_to_screen((10.0, 20.0), PANEL), (400.0, 300.0));
+ }
+
+ #[test]
+ fn round_trip_world_screen_sin_giro() {
+ let cam = Camara::new((5.0, -3.0), 1.7, 0.0);
+ let p = (123.0, 456.0);
+ aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
+ }
+
+ #[test]
+ fn round_trip_world_screen_con_giro() {
+ let cam = Camara::new((5.0, -3.0), 1.7, 0.6);
+ let p = (123.0, 456.0);
+ aprox(cam.world_to_screen(cam.screen_to_world(p, PANEL), PANEL), p);
+ }
+
+ #[test]
+ fn zoom_a_cursor_deja_fijo_el_punto_bajo_el_cursor() {
+ let mut cam = Camara::new((0.0, 0.0), 1.0, 0.0);
+ let cursor = (650.0, 120.0);
+ let mundo_antes = cam.screen_to_world(cursor, PANEL);
+ cam.zoom_a_cursor(1.1, cursor, PANEL);
+ // El mismo punto de mundo debe seguir bajo el cursor.
+ aprox(cam.world_to_screen(mundo_antes, PANEL), cursor);
+ assert!((cam.zoom - 1.1).abs() < 1e-9);
+ }
+
+ #[test]
+ fn zoom_a_cursor_respeta_clamps() {
+ let mut cam = Camara::new((0.0, 0.0), ZOOM_MAX, 0.0);
+ cam.zoom_a_cursor(10.0, (400.0, 300.0), PANEL);
+ assert_eq!(cam.zoom, ZOOM_MAX);
+ cam.zoom = ZOOM_MIN;
+ cam.zoom_a_cursor(0.001, (400.0, 300.0), PANEL);
+ assert_eq!(cam.zoom, ZOOM_MIN);
+ }
+
+ #[test]
+ fn pan_mueve_el_punto_bajo_el_cursor() {
+ let mut cam = Camara::new((0.0, 0.0), 2.0, 0.0);
+ let antes = cam.world_to_screen((0.0, 0.0), PANEL);
+ cam.pan(30.0, -10.0);
+ let despues = cam.world_to_screen((0.0, 0.0), PANEL);
+ aprox((despues.0 - antes.0, despues.1 - antes.1), (30.0, -10.0));
+ }
+
+ #[test]
+ fn fit_centra_y_encuadra() {
+ let marco = Rect::new(100.0, 100.0, 400.0, 200.0);
+ let cam = Camara::fit(marco, 0.0, PANEL);
+ // Centra el marco.
+ assert_eq!(cam.centro, (300.0, 200.0));
+ // Encaja por el eje más ajustado (800/400=2 vs 600/200=3 → 2) con margen.
+ assert!((cam.zoom - 2.0 * FIT_MARGEN).abs() < 1e-9);
+ // El centro del marco cae en el centro del panel.
+ aprox(cam.world_to_screen((300.0, 200.0), PANEL), (400.0, 300.0));
+ }
+
+ #[test]
+ fn interpolar_extremos_y_zoom_logaritmico() {
+ let a = Camara::new((0.0, 0.0), 1.0, 0.0);
+ let b = Camara::new((10.0, 20.0), 4.0, 1.0);
+ assert_eq!(Camara::interpolar(&a, &b, 0.0, Ease::Lineal), a);
+ assert_eq!(Camara::interpolar(&a, &b, 1.0, Ease::Lineal), b);
+ // En t=0.5 lineal el zoom es la media geométrica (2.0), no la aritmética (2.5).
+ let m = Camara::interpolar(&a, &b, 0.5, Ease::Lineal);
+ assert!((m.zoom - 2.0).abs() < 1e-9);
+ assert_eq!(m.centro, (5.0, 10.0));
+ }
+
+ #[test]
+ fn ease_suave_extremos_y_simetria() {
+ assert_eq!(Ease::SuaveInOut.aplicar(0.0), 0.0);
+ assert_eq!(Ease::SuaveInOut.aplicar(1.0), 1.0);
+ assert!((Ease::SuaveInOut.aplicar(0.5) - 0.5).abs() < 1e-9);
+ // Clamp fuera de rango.
+ assert_eq!(Ease::SuaveInOut.aplicar(-1.0), 0.0);
+ assert_eq!(Ease::SuaveInOut.aplicar(2.0), 1.0);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/lib.rs b/00_unanchay/pluma/pluma-deck-core/src/lib.rs
new file mode 100644
index 0000000..acb8c0c
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/lib.rs
@@ -0,0 +1,195 @@
+//! Deck core — máquinas de estado agnósticas para presentar, en dos modos:
+//!
+//! - **Lineal** ([`DeckState`], este archivo): strip horizontal de páginas con
+//! drag/snap. Dados los eventos crudos de pointer (coords + viewport width
+//! + n_pages) decide cuándo arrancar drag horizontal, cuánto trasladar el
+//! strip y a qué página snapear al soltar.
+//! - **Espacial** ([`recorrido`], tipo Prezi): lienzo 2D infinito con marcos en
+//! coordenadas de mundo y una ruta de cámara ([`camara::Camara`]) que vuela
+//! entre ellos con zoom/pan/giro. El modo lineal es su caso degenerado.
+//!
+//! Todo agnóstico: sin DOM, sin wasm-bindgen, sin render.
+
+pub mod camara;
+pub mod recorrido;
+/// Adaptador opcional pluma → Recorrido. Sólo con la feature `pluma`.
+#[cfg(feature = "pluma")]
+pub mod adaptador;
+
+pub use camara::{Camara, Ease, Rect, FIT_MARGEN, ZOOM_MAX, ZOOM_MIN};
+pub use recorrido::{
+ Autoplay, ContenidoMarco, Marco, MarcoId, Recorrido, RecorridoState, RejillaOpts,
+ DURACION_PASO_S, DWELL_S,
+};
+
+/// Umbral en pixels para confirmar gesto horizontal vs vertical.
+pub const DRAG_DECISION_PX: f64 = 8.0;
+/// Cuán más horizontal que vertical debe ser el delta para considerarse "swipe".
+pub const HORIZONTAL_BIAS: f64 = 1.3;
+
+#[derive(Clone, Debug, Default)]
+pub struct DeckState {
+ pub current_index: usize,
+ pointer_start: Option<(f64, f64, i32)>,
+ drag_active: bool,
+ drag_start_offset: f64,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum DragOutcome {
+ /// Aún no hay decisión — esperar más movimiento.
+ Idle,
+ /// Empezar drag horizontal: el host debe capturar el pointer.
+ StartHorizontal { pointer_id: i32 },
+ /// Movimiento vertical predominante — host debe ceder al scroll nativo.
+ CancelVertical,
+ /// Drag en curso — host debe trasladar el strip a este offset.
+ DragOffset(f64),
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct SnapResult {
+ pub target_index: usize,
+ pub offset_px: f64,
+ pub changed: bool,
+}
+
+impl DeckState {
+ pub fn new() -> Self { Self::default() }
+
+ /// Marca el inicio de un gesto. `viewport_width` se usa para anclar el
+ /// drag_start_offset a la página visible actual.
+ pub fn pointer_down(&mut self, x: f64, y: f64, pointer_id: i32, viewport_width: f64) {
+ self.pointer_start = Some((x, y, pointer_id));
+ self.drag_active = false;
+ self.drag_start_offset = -(self.current_index as f64) * viewport_width;
+ }
+
+ /// Procesa un movimiento. Devuelve la acción que el host debe ejecutar.
+ pub fn pointer_move(&mut self, x: f64, y: f64) -> DragOutcome {
+ let Some((sx, sy, pid)) = self.pointer_start else {
+ return DragOutcome::Idle;
+ };
+ let dx = x - sx;
+ let dy = y - sy;
+ if !self.drag_active {
+ let abs_dx = dx.abs();
+ let abs_dy = dy.abs();
+ if abs_dx > DRAG_DECISION_PX && abs_dx > abs_dy * HORIZONTAL_BIAS {
+ self.drag_active = true;
+ return DragOutcome::StartHorizontal { pointer_id: pid };
+ } else if abs_dy > DRAG_DECISION_PX {
+ self.pointer_start = None;
+ return DragOutcome::CancelVertical;
+ } else {
+ return DragOutcome::Idle;
+ }
+ }
+ DragOutcome::DragOffset(self.drag_start_offset + dx)
+ }
+
+ /// Finaliza el gesto. Si había drag activo, calcula la página snap y
+ /// actualiza `current_index`. `current_offset` viene del estado real
+ /// del strip (el host lee CSS transform / variable).
+ pub fn pointer_end(
+ &mut self,
+ current_offset: f64,
+ viewport_width: f64,
+ n_pages: usize,
+ ) -> Option<SnapResult> {
+ let was_active = self.drag_active;
+ self.drag_active = false;
+ self.pointer_start = None;
+ if !was_active || viewport_width <= 0.0 || n_pages == 0 {
+ return None;
+ }
+ let raw = -current_offset / viewport_width;
+ let target = (raw.round().max(0.0) as usize).min(n_pages - 1);
+ let offset_px = -(target as f64) * viewport_width;
+ let changed = self.current_index != target;
+ self.current_index = target;
+ Some(SnapResult { target_index: target, offset_px, changed })
+ }
+
+ /// Salto programático (click en tabs externos). Devuelve el offset
+ /// resultante para que el host lo aplique al strip.
+ pub fn goto(&mut self, index: usize, viewport_width: f64) -> SnapResult {
+ let offset_px = -(index as f64) * viewport_width;
+ let changed = self.current_index != index;
+ self.current_index = index;
+ SnapResult { target_index: index, offset_px, changed }
+ }
+
+ /// Reposiciona tras un resize. Devuelve el offset que el host debe
+ /// aplicar sin animación.
+ pub fn reposition(&self, viewport_width: f64) -> f64 {
+ -(self.current_index as f64) * viewport_width
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn vertical_drag_is_cancelled() {
+ let mut s = DeckState::new();
+ s.pointer_down(100.0, 100.0, 1, 800.0);
+ // Movimiento vertical mayor que el umbral.
+ let r = s.pointer_move(100.0, 120.0);
+ assert_eq!(r, DragOutcome::CancelVertical);
+ }
+
+ #[test]
+ fn horizontal_drag_starts_after_threshold() {
+ let mut s = DeckState::new();
+ s.pointer_down(100.0, 100.0, 7, 800.0);
+ // Justo por debajo del umbral → Idle.
+ assert_eq!(s.pointer_move(105.0, 100.0), DragOutcome::Idle);
+ // Sobre el umbral con bias horizontal → Start.
+ let r = s.pointer_move(120.0, 100.0);
+ assert_eq!(r, DragOutcome::StartHorizontal { pointer_id: 7 });
+ }
+
+ #[test]
+ fn snap_rounds_to_nearest_page() {
+ let mut s = DeckState::new();
+ s.current_index = 1;
+ s.pointer_down(0.0, 0.0, 1, 1000.0); // drag_start_offset = -1000
+ // Forzar drag activo
+ s.pointer_move(20.0, 0.0);
+ // Offset actual = -1000 + 20 = -980 → target round(980/1000) = 1, sin cambio
+ let r = s.pointer_end(-980.0, 1000.0, 3).unwrap();
+ assert_eq!(r.target_index, 1);
+ assert!(!r.changed);
+ // Mover lo suficiente para snapear a página 0
+ s.pointer_down(0.0, 0.0, 1, 1000.0);
+ s.pointer_move(600.0, 0.0);
+ let r = s.pointer_end(-400.0, 1000.0, 3).unwrap();
+ assert_eq!(r.target_index, 0);
+ assert!(r.changed);
+ }
+
+ #[test]
+ fn snap_clamps_to_bounds() {
+ let mut s = DeckState::new();
+ s.current_index = 2;
+ s.pointer_down(0.0, 0.0, 1, 500.0);
+ s.pointer_move(50.0, 0.0);
+ // Offset muy a la izquierda → debería clamp a n_pages-1
+ let r = s.pointer_end(-9999.0, 500.0, 3).unwrap();
+ assert_eq!(r.target_index, 2);
+ }
+
+ #[test]
+ fn goto_updates_index_and_offset() {
+ let mut s = DeckState::new();
+ let r = s.goto(2, 800.0);
+ assert_eq!(r.target_index, 2);
+ assert_eq!(r.offset_px, -1600.0);
+ assert!(r.changed);
+ // segundo goto al mismo índice → changed=false
+ let r = s.goto(2, 800.0);
+ assert!(!r.changed);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs
new file mode 100644
index 0000000..b21d6fa
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-core/src/recorrido.rs
@@ -0,0 +1,850 @@
+//! Modo `Recorrido` — presentación espacial sobre lienzo infinito (tipo Prezi).
+//!
+//! Un `Recorrido` coloca `Marco`s en coordenadas de mundo y define una **ruta**
+//! ordenada (`pasos`) que la cámara recorre: avanzar un paso encuadra el marco
+//! destino animando zoom/pan/giro desde la cámara actual. Entre pasos el usuario
+//! puede volar libre (drag = pan, wheel = zoom-a-cursor).
+//!
+//! El strip lineal de [`crate::DeckState`] es el caso degenerado: marcos del
+//! mismo tamaño en fila, zoom fijo, sin giro. Aquí el lienzo es 2D.
+//!
+//! Como toda pieza `*-core` del repo, esto es una máquina de estados pura: el
+//! host traduce pointer/wheel/teclado → llamadas, y tick'ea la animación con
+//! [`RecorridoState::avanzar`]; no hay render ni reloj propio.
+
+use crate::camara::{Camara, Ease, Rect};
+
+/// Duración por defecto del vuelo entre dos pasos, en segundos.
+pub const DURACION_PASO_S: f64 = 0.8;
+
+/// Tamaño mínimo (mundo) al redimensionar un marco — evita marcos degenerados.
+pub const MIN_MARCO: f64 = 20.0;
+
+pub type MarcoId = u64;
+
+/// Qué pinta el host dentro de un marco. El core es agnóstico: guarda una
+/// referencia o etiqueta y deja la resolución (cuerpo, subgrafo de átomos,
+/// imagen, página de deck) al frontend vía `pluma-render-plan` u otro.
+#[derive(Clone, Debug, PartialEq, Eq, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub enum ContenidoMarco {
+ #[default]
+ Vacio,
+ /// Texto plano de una línea — títulos de sección, hitos del recorrido.
+ Etiqueta(String),
+ /// Contenido de "slide": título opcional + párrafos. Agnóstico (sólo
+ /// strings); un adaptador convierte un cuerpo/subgrafo de pluma a esto.
+ Texto { titulo: Option<String>, parrafos: Vec<String> },
+ /// Imagen rasterizada: bytes **codificados** (PNG/JPEG/WebP) + dimensiones
+ /// en px. El core es agnóstico — guarda los bytes sin decodificar y deja la
+ /// rasterización al frontend; `ancho`/`alto` permiten encuadrar/aspectar el
+ /// marco sin tener que decodificar.
+ Imagen { bytes: Vec<u8>, ancho: u32, alto: u32 },
+ /// Referencia opaca que el host resuelve (hash BLAKE3, id de cuerpo, ruta…).
+ Ref(String),
+}
+
+/// Un marco colocado en el lienzo: su rectángulo en coordenadas de mundo, su
+/// giro propio y su contenido.
+#[derive(Clone, Debug, PartialEq)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Marco {
+ pub id: MarcoId,
+ pub rect: Rect,
+ pub rot_rad: f64,
+ pub contenido: ContenidoMarco,
+}
+
+impl Marco {
+ pub fn new(id: MarcoId, rect: Rect, contenido: ContenidoMarco) -> Self {
+ Self { id, rect, rot_rad: 0.0, contenido }
+ }
+
+ pub fn con_giro(mut self, rot_rad: f64) -> Self {
+ self.rot_rad = rot_rad;
+ self
+ }
+
+ /// Cámara que encuadra este marco en `panel`.
+ pub fn fit(&self, panel: Rect) -> Camara {
+ Camara::fit(self.rect, self.rot_rad, panel)
+ }
+
+ /// Bounding box axis-aligned (en coordenadas de mundo) que contiene al
+ /// marco **ya girado** alrededor de su centro. Para un marco recto coincide
+ /// con su `rect`; para uno girado lo envuelve sin recortar esquinas. Base de
+ /// [`Recorrido::bbox`] → vista general.
+ pub fn aabb(&self) -> Rect {
+ let (cx, cy) = self.rect.centro();
+ let (hw, hh) = (self.rect.w * 0.5, self.rect.h * 0.5);
+ let (s, c) = self.rot_rad.sin_cos();
+ // Las cuatro esquinas relativas al centro, giradas; el AABB lo fija la
+ // mayor extensión en cada eje (simétrico, así que basta el máximo).
+ let ex = (hw * c).abs() + (hh * s).abs();
+ let ey = (hw * s).abs() + (hh * c).abs();
+ Rect::new(cx - ex, cy - ey, ex * 2.0, ey * 2.0)
+ }
+
+ /// `true` si el punto de mundo `p` cae dentro del marco, considerando su
+ /// giro propio (deshace la rotación con que se dibuja antes del aabb test).
+ pub fn contiene(&self, p: (f64, f64)) -> bool {
+ let (cx, cy) = self.rect.centro();
+ // Inversa de la rotación de dibujo: local = centro + R(-rot)·(p-centro).
+ let (s, c) = (-self.rot_rad).sin_cos();
+ let dx = p.0 - cx;
+ let dy = p.1 - cy;
+ let lx = dx * c - dy * s + cx;
+ let ly = dx * s + dy * c + cy;
+ lx >= self.rect.x
+ && lx <= self.rect.x + self.rect.w
+ && ly >= self.rect.y
+ && ly <= self.rect.y + self.rect.h
+ }
+}
+
+/// Lienzo + ruta narrativa. `pasos` es una secuencia de `MarcoId` (puede
+/// repetir un marco, saltarse otros, o recorrerlos en cualquier orden).
+#[derive(Clone, Debug, Default)]
+#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
+pub struct Recorrido {
+ pub marcos: Vec<Marco>,
+ pub pasos: Vec<MarcoId>,
+}
+
+impl Recorrido {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn agregar_marco(&mut self, marco: Marco) -> MarcoId {
+ let id = marco.id;
+ self.marcos.push(marco);
+ id
+ }
+
+ pub fn marco(&self, id: MarcoId) -> Option<&Marco> {
+ self.marcos.iter().find(|m| m.id == id)
+ }
+
+ /// Marco bajo un punto de mundo, si hay — el de más arriba (último
+ /// dibujado) gana cuando se solapan. Para hit-test de autoría.
+ pub fn marco_en_punto(&self, p: (f64, f64)) -> Option<MarcoId> {
+ self.marcos.iter().rev().find(|m| m.contiene(p)).map(|m| m.id)
+ }
+
+ /// Traslada el marco `id` por un delta de mundo `(dx, dy)`. No-op si el id
+ /// no existe. Para arrastrar marcos en modo edición.
+ pub fn mover_marco(&mut self, id: MarcoId, dx: f64, dy: f64) {
+ if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+ m.rect.x += dx;
+ m.rect.y += dy;
+ }
+ }
+
+ /// Elimina el marco `id` y purga **todos** los pasos que lo referencian
+ /// (manteniendo el resto del guion). Devuelve `true` si el marco existía.
+ /// Para autoría — deja la ruta consistente sin ids colgantes.
+ pub fn eliminar_marco(&mut self, id: MarcoId) -> bool {
+ let antes = self.marcos.len();
+ self.marcos.retain(|m| m.id != id);
+ let elimino = self.marcos.len() != antes;
+ if elimino {
+ self.pasos.retain(|p| *p != id);
+ }
+ elimino
+ }
+
+ /// Redimensiona el marco `id` a `w`×`h` (clamp a [`MIN_MARCO`]), conservando
+ /// su esquina superior-izquierda. No-op si el id no existe.
+ pub fn redimensionar_marco(&mut self, id: MarcoId, w: f64, h: f64) {
+ if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+ m.rect.w = w.max(MIN_MARCO);
+ m.rect.h = h.max(MIN_MARCO);
+ }
+ }
+
+ /// Suma `delta_rad` al giro propio del marco `id`. No-op si no existe.
+ pub fn rotar_marco(&mut self, id: MarcoId, delta_rad: f64) {
+ if let Some(m) = self.marcos.iter_mut().find(|m| m.id == id) {
+ m.rot_rad += delta_rad;
+ }
+ }
+
+ /// Reordena el guion: mueve el paso en el índice `desde` a la posición
+ /// `hasta` (clamp al final). No-op si `desde` está fuera de rango.
+ pub fn mover_paso(&mut self, desde: usize, hasta: usize) {
+ if desde >= self.pasos.len() {
+ return;
+ }
+ let id = self.pasos.remove(desde);
+ self.pasos.insert(hasta.min(self.pasos.len()), id);
+ }
+
+ /// Marco al que apunta el paso `idx` (resolviendo el id contra `marcos`).
+ pub fn marco_en_paso(&self, idx: usize) -> Option<&Marco> {
+ self.pasos.get(idx).and_then(|id| self.marco(*id))
+ }
+
+ pub fn n_pasos(&self) -> usize {
+ self.pasos.len()
+ }
+
+ /// Bounding box de **todos** los marcos (cada uno por su [`Marco::aabb`], así
+ /// los girados entran enteros). `None` si no hay marcos. Es el encuadre de la
+ /// *vista general* — el zoom-out narrativo que muestra el mapa completo.
+ pub fn bbox(&self) -> Option<Rect> {
+ let mut it = self.marcos.iter().map(Marco::aabb);
+ let primero = it.next()?;
+ let (mut min_x, mut min_y) = (primero.x, primero.y);
+ let (mut max_x, mut max_y) = (primero.x + primero.w, primero.y + primero.h);
+ for r in it {
+ min_x = min_x.min(r.x);
+ min_y = min_y.min(r.y);
+ max_x = max_x.max(r.x + r.w);
+ max_y = max_y.max(r.y + r.h);
+ }
+ Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
+ }
+
+ /// Auto-layout: coloca una secuencia de contenidos en una rejilla y arma
+ /// la ruta en orden de lectura (fila por fila). Es el "dame N piezas →
+ /// dame un recorrido listo" — el frontend sólo pinta y vuela. Los ids se
+ /// asignan `1..=n` en orden.
+ pub fn en_rejilla(contenidos: Vec<ContenidoMarco>, opts: RejillaOpts) -> Recorrido {
+ let cols = opts.cols.max(1);
+ let mut rec = Recorrido::new();
+ for (i, c) in contenidos.into_iter().enumerate() {
+ let col = (i % cols) as f64;
+ let row = (i / cols) as f64;
+ let x = col * (opts.marco_w + opts.gap_x);
+ let y = row * (opts.marco_h + opts.gap_y);
+ let id = (i + 1) as MarcoId;
+ rec.agregar_marco(Marco::new(id, Rect::new(x, y, opts.marco_w, opts.marco_h), c));
+ rec.pasos.push(id);
+ }
+ rec
+ }
+}
+
+#[cfg(feature = "serde")]
+impl Recorrido {
+ /// Serializa el recorrido (marcos + ruta) a su forma binaria `postcard` —
+ /// el codec nativo del workspace (mismo que `format`/`akasha`/`pluma-cuerpo`).
+ /// Persiste sólo el modelo de datos; el estado de interacción
+ /// ([`RecorridoState`]) es efímero y no se guarda.
+ pub fn serializar(&self) -> Result<Vec<u8>, &'static str> {
+ postcard::to_allocvec(self).map_err(|_| "recorrido :: serializacion fallida")
+ }
+
+ /// Reconstruye un recorrido desde su forma binaria `postcard`.
+ pub fn deserializar(bytes: &[u8]) -> Result<Recorrido, &'static str> {
+ postcard::from_bytes::<Recorrido>(bytes).map_err(|_| "recorrido :: deserializacion fallida")
+ }
+}
+
+/// Parámetros del auto-layout en rejilla de [`Recorrido::en_rejilla`].
+#[derive(Clone, Copy, Debug)]
+pub struct RejillaOpts {
+ pub cols: usize,
+ pub marco_w: f64,
+ pub marco_h: f64,
+ pub gap_x: f64,
+ pub gap_y: f64,
+}
+
+impl Default for RejillaOpts {
+ fn default() -> Self {
+ Self { cols: 3, marco_w: 640.0, marco_h: 400.0, gap_x: 220.0, gap_y: 180.0 }
+ }
+}
+
+/// Animación de cámara en curso entre dos encuadres.
+#[derive(Clone, Copy, Debug)]
+struct Vuelo {
+ desde: Camara,
+ hasta: Camara,
+ /// Tiempo transcurrido / duración total, en segundos.
+ t: f64,
+ dur: f64,
+ ease: Ease,
+}
+
+/// Máquina de interacción del recorrido: cámara viva + paso actual + vuelo en
+/// curso + estado de arrastre para el paneo libre.
+#[derive(Clone, Debug)]
+pub struct RecorridoState {
+ pub camara: Camara,
+ /// Índice del paso actual dentro de `Recorrido::pasos`.
+ pub paso: usize,
+ vuelo: Option<Vuelo>,
+ arrastre: Option<(f64, f64)>,
+}
+
+impl Default for RecorridoState {
+ fn default() -> Self {
+ Self { camara: Camara::default(), paso: 0, vuelo: None, arrastre: None }
+ }
+}
+
+impl RecorridoState {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// `true` si hay un vuelo de cámara animándose.
+ pub fn animando(&self) -> bool {
+ self.vuelo.is_some()
+ }
+
+ // ---- Roam libre -------------------------------------------------------
+
+ /// Inicio de arrastre para panear. Cancela cualquier vuelo en curso (el
+ /// usuario toma el control manual).
+ pub fn pointer_down(&mut self, x: f64, y: f64) {
+ self.vuelo = None;
+ self.arrastre = Some((x, y));
+ }
+
+ /// Movimiento de puntero: si hay arrastre activo, panea la cámara por el
+ /// delta y devuelve `true` (el host debe repintar).
+ pub fn pointer_move(&mut self, x: f64, y: f64) -> bool {
+ let Some((px, py)) = self.arrastre else { return false };
+ self.camara.pan(x - px, y - py);
+ self.arrastre = Some((x, y));
+ true
+ }
+
+ pub fn pointer_up(&mut self) {
+ self.arrastre = None;
+ }
+
+ /// Paneo por delta de pantalla — para hosts que ya entregan el delta del
+ /// arrastre (p. ej. `llimphi-ui::draggable`, que da `(dx, dy)` por evento).
+ /// Cancela el vuelo (control manual). Alternativa a `pointer_down/move/up`.
+ pub fn arrastrar_delta(&mut self, dx: f64, dy: f64) {
+ self.vuelo = None;
+ self.camara.pan(dx, dy);
+ }
+
+ /// Wheel: zoom-a-cursor inmediato. Cancela el vuelo (control manual).
+ pub fn wheel(&mut self, mult: f64, cursor: (f64, f64), panel: Rect) {
+ self.vuelo = None;
+ self.camara.zoom_a_cursor(mult, cursor, panel);
+ }
+
+ // ---- Reproducción guiada ---------------------------------------------
+
+ /// Arranca un vuelo desde la cámara actual hasta encuadrar el paso `idx`.
+ /// No hace nada si el índice o su marco no existen. Fija `paso = idx`.
+ pub fn ir_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
+ let Some(marco) = rec.marco_en_paso(idx) else { return };
+ self.paso = idx;
+ self.iniciar_vuelo(marco.fit(panel), DURACION_PASO_S);
+ }
+
+ /// Avanza al paso siguiente (clamp al final). Devuelve `true` si arrancó
+ /// un vuelo nuevo.
+ pub fn siguiente(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+ if rec.n_pasos() == 0 || self.paso + 1 >= rec.n_pasos() {
+ return false;
+ }
+ self.ir_a_paso(rec, self.paso + 1, panel);
+ true
+ }
+
+ /// Retrocede al paso anterior (clamp en 0). Devuelve `true` si arrancó un
+ /// vuelo nuevo.
+ pub fn anterior(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+ if self.paso == 0 {
+ return false;
+ }
+ self.ir_a_paso(rec, self.paso - 1, panel);
+ true
+ }
+
+ /// Vuela a la **vista general**: aleja la cámara hasta encuadrar todos los
+ /// marcos (recta, sin giro), el gesto-firma de Prezi "alejarse para ver el
+ /// mapa". No toca `paso` — `siguiente`/`anterior` siguen desde donde iban.
+ /// Devuelve `true` si arrancó un vuelo (`false` si el lienzo está vacío).
+ pub fn vista_general(&mut self, rec: &Recorrido, panel: Rect) -> bool {
+ let Some(bbox) = rec.bbox() else { return false };
+ self.iniciar_vuelo(Camara::fit(bbox, 0.0, panel), DURACION_PASO_S);
+ true
+ }
+
+ /// Salto instantáneo (sin vuelo) al encuadre del paso `idx` — útil para
+ /// reposicionar tras un resize, o para "jump to" sin animación.
+ pub fn saltar_a_paso(&mut self, rec: &Recorrido, idx: usize, panel: Rect) {
+ let Some(marco) = rec.marco_en_paso(idx) else { return };
+ self.paso = idx;
+ self.vuelo = None;
+ self.camara = marco.fit(panel);
+ }
+
+ fn iniciar_vuelo(&mut self, hasta: Camara, dur: f64) {
+ if dur <= 0.0 {
+ self.camara = hasta;
+ self.vuelo = None;
+ return;
+ }
+ self.vuelo = Some(Vuelo { desde: self.camara, hasta, t: 0.0, dur, ease: Ease::default() });
+ }
+
+ /// Avanza la animación `dt` segundos. Devuelve `true` mientras siga
+ /// animando (el host repite el tick); `false` cuando ya no hay vuelo.
+ /// El host la llama desde un timer (p. ej. `Handle::spawn_periodic`).
+ pub fn avanzar(&mut self, dt: f64) -> bool {
+ let Some(mut v) = self.vuelo else { return false };
+ v.t += dt;
+ if v.t >= v.dur {
+ self.camara = v.hasta;
+ self.vuelo = None;
+ return false;
+ }
+ self.camara = Camara::interpolar(&v.desde, &v.hasta, v.t / v.dur, v.ease);
+ self.vuelo = Some(v);
+ true
+ }
+}
+
+/// Reproducción automática ("modo presentador"): tras aterrizar en un paso
+/// espera `dwell_s` segundos y avanza solo al siguiente. Al llegar al final,
+/// vuelve al inicio si `bucle`, o se detiene. Máquina de tiempo **pura** — el
+/// host la tickea junto a [`RecorridoState::avanzar`]; no tiene reloj propio.
+#[derive(Clone, Copy, Debug)]
+pub struct Autoplay {
+ /// Segundos de permanencia en cada paso una vez que el vuelo aterrizó.
+ pub dwell_s: f64,
+ /// Si al final vuelve al primer paso (`true`) o se detiene (`false`).
+ pub bucle: bool,
+ activo: bool,
+ espera: f64,
+}
+
+/// Dwell por defecto del modo presentador, en segundos.
+pub const DWELL_S: f64 = 2.5;
+
+impl Default for Autoplay {
+ fn default() -> Self {
+ Self { dwell_s: DWELL_S, bucle: true, activo: false, espera: 0.0 }
+ }
+}
+
+impl Autoplay {
+ pub fn new(dwell_s: f64, bucle: bool) -> Self {
+ Self { dwell_s, bucle, activo: false, espera: 0.0 }
+ }
+
+ pub fn activo(&self) -> bool {
+ self.activo
+ }
+
+ /// Arranca la reproducción (resetea el contador de permanencia).
+ pub fn play(&mut self) {
+ self.activo = true;
+ self.espera = 0.0;
+ }
+
+ pub fn pausa(&mut self) {
+ self.activo = false;
+ }
+
+ /// Alterna play/pausa. Devuelve el nuevo estado.
+ pub fn toggle(&mut self) -> bool {
+ if self.activo {
+ self.pausa();
+ } else {
+ self.play();
+ }
+ self.activo
+ }
+
+ /// Tick del modo presentador. No hace nada si está pausado. Mientras el
+ /// vuelo se anima, espera (no acumula dwell). Una vez quieto, acumula `dt`;
+ /// al superar `dwell_s` avanza un paso (o vuelve al inicio si `bucle`; o se
+ /// detiene). Devuelve `true` si disparó un avance este tick.
+ pub fn tick(&mut self, dt: f64, state: &mut RecorridoState, rec: &Recorrido, panel: Rect) -> bool {
+ if !self.activo || rec.n_pasos() == 0 {
+ return false;
+ }
+ if state.animando() {
+ self.espera = 0.0;
+ return false;
+ }
+ self.espera += dt;
+ if self.espera < self.dwell_s {
+ return false;
+ }
+ self.espera = 0.0;
+ if state.paso + 1 < rec.n_pasos() {
+ state.siguiente(rec, panel);
+ } else if self.bucle {
+ state.ir_a_paso(rec, 0, panel);
+ } else {
+ self.activo = false;
+ return false;
+ }
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+ fn recorrido_demo() -> Recorrido {
+ let mut r = Recorrido::new();
+ r.agregar_marco(Marco::new(1, Rect::new(0.0, 0.0, 400.0, 300.0), ContenidoMarco::Etiqueta("a".into())));
+ r.agregar_marco(Marco::new(2, Rect::new(2000.0, 0.0, 200.0, 150.0), ContenidoMarco::Etiqueta("b".into())));
+ r.agregar_marco(Marco::new(3, Rect::new(1000.0, 1000.0, 800.0, 600.0), ContenidoMarco::Etiqueta("c".into())));
+ r.pasos = vec![1, 2, 3];
+ r
+ }
+
+ #[test]
+ fn en_rejilla_coloca_y_rutea_en_orden_de_lectura() {
+ let contenidos = vec![
+ ContenidoMarco::Etiqueta("a".into()),
+ ContenidoMarco::Etiqueta("b".into()),
+ ContenidoMarco::Etiqueta("c".into()),
+ ContenidoMarco::Etiqueta("d".into()),
+ ];
+ let opts = RejillaOpts { cols: 2, marco_w: 100.0, marco_h: 50.0, gap_x: 20.0, gap_y: 10.0 };
+ let rec = Recorrido::en_rejilla(contenidos, opts);
+ assert_eq!(rec.marcos.len(), 4);
+ // Ruta secuencial 1..=4.
+ assert_eq!(rec.pasos, vec![1, 2, 3, 4]);
+ // Índice 0 en (0,0); índice 1 en la columna siguiente; índice 2 baja de fila.
+ assert_eq!(rec.marco(1).unwrap().rect, Rect::new(0.0, 0.0, 100.0, 50.0));
+ assert_eq!(rec.marco(2).unwrap().rect, Rect::new(120.0, 0.0, 100.0, 50.0));
+ assert_eq!(rec.marco(3).unwrap().rect, Rect::new(0.0, 60.0, 100.0, 50.0));
+ }
+
+ #[test]
+ fn marco_en_punto_devuelve_el_de_arriba() {
+ let r = recorrido_demo();
+ // Punto dentro del marco 1 (0,0,400,300).
+ assert_eq!(r.marco_en_punto((10.0, 10.0)), Some(1));
+ // Punto dentro del marco 2 (2000,0,200,150).
+ assert_eq!(r.marco_en_punto((2050.0, 50.0)), Some(2));
+ // Punto en el vacío.
+ assert_eq!(r.marco_en_punto((-500.0, -500.0)), None);
+ }
+
+ #[test]
+ fn marco_en_punto_respeta_giro() {
+ let mut r = Recorrido::new();
+ // Marco cuadrado centrado en (0,0), girado 45°.
+ r.agregar_marco(
+ Marco::new(7, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
+ .con_giro(std::f64::consts::FRAC_PI_4),
+ );
+ // El centro siempre está dentro.
+ assert_eq!(r.marco_en_punto((0.0, 0.0)), Some(7));
+ // Una esquina del aabb sin girar (49,49) queda FUERA del cuadrado girado
+ // (su semidiagonal es ~70.7, pero el lado rotado pasa antes por los ejes).
+ assert_eq!(r.marco_en_punto((49.0, 49.0)), None);
+ // Sobre el eje X a distancia 60 < semidiagonal: dentro del rombo.
+ assert_eq!(r.marco_en_punto((60.0, 0.0)), Some(7));
+ }
+
+ #[test]
+ fn marco_con_imagen_es_agnostico_al_hit_test() {
+ // El core no decodifica la imagen: guarda bytes + dims y el hit-test
+ // sigue dependiendo sólo de la geometría del marco.
+ let mut r = Recorrido::new();
+ let img = ContenidoMarco::Imagen { bytes: vec![0xDE, 0xAD, 0xBE, 0xEF], ancho: 320, alto: 240 };
+ r.agregar_marco(Marco::new(5, Rect::new(0.0, 0.0, 400.0, 300.0), img.clone()));
+ assert_eq!(r.marco_en_punto((100.0, 100.0)), Some(5));
+ assert_eq!(r.marco_en_punto((9999.0, 0.0)), None);
+ // La variante conserva bytes y dimensiones tal cual.
+ assert_eq!(r.marco(5).unwrap().contenido, img);
+ }
+
+ #[test]
+ fn mover_marco_traslada_el_rect() {
+ let mut r = recorrido_demo();
+ r.mover_marco(1, 100.0, -40.0);
+ assert_eq!(r.marco(1).unwrap().rect, Rect::new(100.0, -40.0, 400.0, 300.0));
+ // Id inexistente: no-op.
+ r.mover_marco(999, 1.0, 1.0);
+ }
+
+ #[test]
+ fn eliminar_marco_purga_la_ruta() {
+ let mut r = recorrido_demo(); // marcos 1,2,3; pasos [1,2,3]
+ r.pasos = vec![1, 2, 3, 2]; // el 2 aparece dos veces en el guion
+ assert!(r.eliminar_marco(2));
+ assert!(r.marco(2).is_none());
+ assert_eq!(r.pasos, vec![1, 3], "se purgan TODAS las apariciones del 2");
+ // Id inexistente: no-op, devuelve false.
+ assert!(!r.eliminar_marco(999));
+ assert_eq!(r.marcos.len(), 2);
+ }
+
+ #[test]
+ fn redimensionar_marco_clampa_al_minimo() {
+ let mut r = recorrido_demo();
+ r.redimensionar_marco(1, 500.0, 5.0); // alto por debajo del mínimo
+ let m = r.marco(1).unwrap();
+ assert_eq!(m.rect.w, 500.0);
+ assert_eq!(m.rect.h, MIN_MARCO);
+ // Conserva la esquina sup-izq (marco 1 estaba en 0,0).
+ assert_eq!((m.rect.x, m.rect.y), (0.0, 0.0));
+ }
+
+ #[test]
+ fn rotar_marco_acumula() {
+ let mut r = recorrido_demo();
+ r.rotar_marco(1, 0.3);
+ r.rotar_marco(1, 0.2);
+ assert!((r.marco(1).unwrap().rot_rad - 0.5).abs() < 1e-12);
+ r.rotar_marco(404, 1.0); // inexistente: no-op
+ }
+
+ #[test]
+ fn mover_paso_reordena_el_guion() {
+ let mut r = recorrido_demo(); // pasos [1,2,3]
+ r.mover_paso(0, 2); // lleva el primero al final
+ assert_eq!(r.pasos, vec![2, 3, 1]);
+ r.mover_paso(2, 0); // y de vuelta al inicio
+ assert_eq!(r.pasos, vec![1, 2, 3]);
+ // hasta fuera de rango → clamp al final; desde fuera de rango → no-op.
+ r.mover_paso(0, 99);
+ assert_eq!(r.pasos, vec![2, 3, 1]);
+ r.mover_paso(99, 0);
+ assert_eq!(r.pasos, vec![2, 3, 1]);
+ }
+
+ #[test]
+ fn marco_en_paso_resuelve_id() {
+ let r = recorrido_demo();
+ assert_eq!(r.marco_en_paso(1).unwrap().id, 2);
+ assert!(r.marco_en_paso(9).is_none());
+ }
+
+ #[test]
+ fn pan_libre_mueve_y_cancela_vuelo() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.ir_a_paso(&r, 1, PANEL);
+ assert!(s.animando());
+ s.pointer_down(100.0, 100.0);
+ assert!(!s.animando(), "el drag cancela el vuelo");
+ assert!(s.pointer_move(130.0, 100.0));
+ s.pointer_up();
+ assert!(!s.pointer_move(200.0, 200.0), "sin arrastre no panea");
+ }
+
+ #[test]
+ fn arrastrar_delta_panea_y_cancela_vuelo() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.ir_a_paso(&r, 1, PANEL);
+ s.camara.zoom = 2.0;
+ let antes = s.camara.world_to_screen((0.0, 0.0), PANEL);
+ s.arrastrar_delta(40.0, -20.0);
+ assert!(!s.animando());
+ let despues = s.camara.world_to_screen((0.0, 0.0), PANEL);
+ assert!((despues.0 - antes.0 - 40.0).abs() < 1e-9);
+ assert!((despues.1 - antes.1 + 20.0).abs() < 1e-9);
+ }
+
+ #[test]
+ fn wheel_hace_zoom_y_cancela_vuelo() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.ir_a_paso(&r, 1, PANEL);
+ let z = s.camara.zoom;
+ s.wheel(1.1, (400.0, 300.0), PANEL);
+ assert!(!s.animando());
+ assert!((s.camara.zoom - z * 1.1).abs() < 1e-9);
+ }
+
+ #[test]
+ fn siguiente_y_anterior_respetan_bordes() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ assert!(!s.anterior(&r, PANEL), "ya en el primero");
+ assert!(s.siguiente(&r, PANEL));
+ assert_eq!(s.paso, 1);
+ assert!(s.siguiente(&r, PANEL));
+ assert_eq!(s.paso, 2);
+ assert!(!s.siguiente(&r, PANEL), "ya en el último");
+ assert!(s.anterior(&r, PANEL));
+ assert_eq!(s.paso, 1);
+ }
+
+ #[test]
+ fn avanzar_completa_el_vuelo_y_aterriza_exacto() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.ir_a_paso(&r, 2, PANEL);
+ let objetivo = r.marco_en_paso(2).unwrap().fit(PANEL);
+ // Tickea en pasos hasta que el vuelo termina.
+ let mut iter = 0;
+ while s.avanzar(0.1) {
+ iter += 1;
+ assert!(iter < 1000, "el vuelo no converge");
+ }
+ // Al terminar aterriza EXACTAMENTE en el encuadre objetivo.
+ assert_eq!(s.camara, objetivo);
+ assert!(!s.animando());
+ // Un avanzar extra no hace nada.
+ assert!(!s.avanzar(0.1));
+ }
+
+ #[test]
+ fn aabb_recto_coincide_con_rect_y_girado_lo_envuelve() {
+ // Marco recto: el aabb es su propio rect.
+ let m = Marco::new(1, Rect::new(10.0, 20.0, 100.0, 60.0), ContenidoMarco::Vacio);
+ assert_eq!(m.aabb(), Rect::new(10.0, 20.0, 100.0, 60.0));
+ // Cuadrado 100×100 centrado en (0,0) girado 45°: su aabb es el cuadrado
+ // que lo circunscribe, lado = diagonal = 100·√2 ≈ 141.42.
+ let g = Marco::new(2, Rect::new(-50.0, -50.0, 100.0, 100.0), ContenidoMarco::Vacio)
+ .con_giro(std::f64::consts::FRAC_PI_4);
+ let bb = g.aabb();
+ let lado = 100.0 * std::f64::consts::SQRT_2;
+ assert!((bb.w - lado).abs() < 1e-6 && (bb.h - lado).abs() < 1e-6, "{bb:?}");
+ assert!((bb.centro().0).abs() < 1e-9 && (bb.centro().1).abs() < 1e-9);
+ }
+
+ #[test]
+ fn bbox_une_todos_los_marcos() {
+ let r = recorrido_demo();
+ // Marcos: (0,0,400,300), (2000,0,200,150), (1000,1000,800,600).
+ let bb = r.bbox().unwrap();
+ assert_eq!(bb, Rect::new(0.0, 0.0, 2200.0, 1600.0));
+ assert!(Recorrido::new().bbox().is_none(), "lienzo vacío no tiene bbox");
+ }
+
+ #[test]
+ fn vista_general_vuela_a_encuadrar_todo_sin_tocar_el_paso() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.ir_a_paso(&r, 2, PANEL);
+ while s.avanzar(0.1) {}
+ assert_eq!(s.paso, 2);
+ assert!(s.vista_general(&r, PANEL));
+ assert!(s.animando());
+ // No cambió el paso narrativo.
+ assert_eq!(s.paso, 2);
+ while s.avanzar(0.1) {}
+ // Aterriza en el fit del bbox completo, recto.
+ let objetivo = Camara::fit(r.bbox().unwrap(), 0.0, PANEL);
+ assert_eq!(s.camara, objetivo);
+ // Lienzo vacío: no-op.
+ assert!(!RecorridoState::new().vista_general(&Recorrido::new(), PANEL));
+ }
+
+ /// Tickea estado + autoplay hasta que el autoplay dispare un avance (o se
+ /// agote el presupuesto de iteraciones). Simula el bucle del host.
+ fn correr_hasta_avance(ap: &mut Autoplay, s: &mut RecorridoState, r: &Recorrido) -> bool {
+ for _ in 0..100_000 {
+ s.avanzar(1.0 / 60.0);
+ if ap.tick(1.0 / 60.0, s, r, PANEL) {
+ return true;
+ }
+ }
+ false
+ }
+
+ #[test]
+ fn autoplay_pausado_no_hace_nada() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ let mut ap = Autoplay::new(0.5, false);
+ assert!(!ap.activo());
+ // Sin play, mil ticks no mueven el paso.
+ for _ in 0..1000 {
+ assert!(!ap.tick(1.0 / 60.0, &mut s, &r, PANEL));
+ }
+ assert_eq!(s.paso, 0);
+ }
+
+ #[test]
+ fn autoplay_avanza_tras_el_dwell_esperando_a_que_aterrice_el_vuelo() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ let mut ap = Autoplay::new(0.5, false);
+ ap.play();
+ assert!(ap.toggle() == false, "toggle desde activo pausa");
+ ap.play();
+ // Mientras el contador de dwell no llega, no avanza; el avance ocurre
+ // recién tras ~0.5s quietos.
+ assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+ assert_eq!(s.paso, 1);
+ // Y respeta que el vuelo aterrice antes de contar el siguiente dwell.
+ assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+ assert_eq!(s.paso, 2);
+ }
+
+ #[test]
+ fn autoplay_al_final_sin_bucle_se_detiene_y_con_bucle_reinicia() {
+ let r = recorrido_demo(); // 3 pasos
+ // Sin bucle: tras el último, se desactiva solo.
+ let mut s = RecorridoState::new();
+ let mut ap = Autoplay::new(0.2, false);
+ ap.play();
+ correr_hasta_avance(&mut ap, &mut s, &r); // → paso 1
+ correr_hasta_avance(&mut ap, &mut s, &r); // → paso 2 (último)
+ assert_eq!(s.paso, 2);
+ // En el último, el dwell vence pero no avanza: se apaga.
+ let arranco = correr_hasta_avance(&mut ap, &mut s, &r);
+ assert!(!arranco && !ap.activo(), "sin bucle se detiene en el final");
+ // Con bucle: del último vuelve al inicio.
+ let mut s = RecorridoState::new();
+ s.saltar_a_paso(&r, 2, PANEL);
+ let mut ap = Autoplay::new(0.2, true);
+ ap.play();
+ assert!(correr_hasta_avance(&mut ap, &mut s, &r));
+ assert_eq!(s.paso, 0, "con bucle reinicia");
+ }
+
+ #[cfg(feature = "serde")]
+ #[test]
+ fn roundtrip_postcard_preserva_marcos_ruta_y_contenido() {
+ let mut r = recorrido_demo();
+ // Un marco con giro, imagen (bytes crudos) y texto, para cubrir variantes.
+ r.agregar_marco(
+ Marco::new(4, Rect::new(10.0, 20.0, 300.0, 200.0), ContenidoMarco::Imagen {
+ bytes: vec![1, 2, 3, 4, 5],
+ ancho: 64,
+ alto: 48,
+ })
+ .con_giro(0.37),
+ );
+ r.agregar_marco(Marco::new(
+ 5,
+ Rect::new(0.0, 0.0, 100.0, 100.0),
+ ContenidoMarco::Texto { titulo: Some("T".into()), parrafos: vec!["p1".into(), "p2".into()] },
+ ));
+ r.pasos = vec![1, 2, 3, 4, 5];
+ let bytes = r.serializar().unwrap();
+ let r2 = Recorrido::deserializar(&bytes).unwrap();
+ assert_eq!(r2.pasos, r.pasos);
+ assert_eq!(r2.marcos.len(), r.marcos.len());
+ // El marco girado con imagen conserva geometría, giro y bytes.
+ let m4 = r2.marco(4).unwrap();
+ assert_eq!(m4.rect, Rect::new(10.0, 20.0, 300.0, 200.0));
+ assert!((m4.rot_rad - 0.37).abs() < 1e-12);
+ assert_eq!(
+ m4.contenido,
+ ContenidoMarco::Imagen { bytes: vec![1, 2, 3, 4, 5], ancho: 64, alto: 48 }
+ );
+ // Bytes corruptos no panican: error controlado.
+ assert!(Recorrido::deserializar(&[0xFF]).is_err());
+ }
+
+ #[test]
+ fn saltar_a_paso_es_instantaneo() {
+ let r = recorrido_demo();
+ let mut s = RecorridoState::new();
+ s.saltar_a_paso(&r, 2, PANEL);
+ assert!(!s.animando());
+ assert_eq!(s.paso, 2);
+ assert_eq!(s.camara, r.marco_en_paso(2).unwrap().fit(PANEL));
+ }
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml
new file mode 100644
index 0000000..ad1959f
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "pluma-deck-recorrido-llimphi"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Frontend Llimphi del modo Recorrido de pluma-deck: lienzo infinito + cámara que vuela entre marcos (presentación tipo Prezi)."
+
+[dependencies]
+pluma-deck-core = { path = "../pluma-deck-core" }
+llimphi-ui = { workspace = true }
+# Decodifica los bytes de ContenidoMarco::Imagen (PNG/JPEG/WebP) → RGBA8 para
+# pintarlos como peniko::Image. El core guarda bytes crudos; aquí se rasterizan.
+image = { workspace = true }
+
+[dev-dependencies]
+# Sólo para el example recorrido_md_demo: muestra la cadena markdown →
+# cuerpo/átomos → Recorrido sin acoplar el lib al modelo de documento. La
+# feature `pluma` del core (sólo en dev) trae el adaptador reusable que el
+# demo dogfoodea en vez de su antiguo glue inline.
+pluma-md = { path = "../pluma-md" }
+pluma-core = { path = "../pluma-core" }
+pluma-deck-core = { path = "../pluma-deck-core", features = ["pluma", "serde"] }
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs
new file mode 100644
index 0000000..17e42e6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_demo.rs
@@ -0,0 +1,181 @@
+//! Demo del modo Recorrido (presentación espacial tipo Prezi).
+//!
+//! Un lienzo infinito con 5 marcos esparcidos a distintas escalas y giros;
+//! la cámara vuela entre ellos siguiendo la ruta. Controles:
+//! - **→ / ↓ / Espacio / Enter**: paso siguiente (la cámara vuela al marco).
+//! - **← / ↑**: paso anterior.
+//! - **Home / Esc**: vista general (aleja para ver todo el lienzo).
+//! - **p**: play/pausa del modo presentador (avance automático con bucle).
+//! - **rueda**: zoom-a-cursor.
+//! - **arrastrar**: paneo libre por el lienzo.
+//!
+//! Corre con:
+//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{Autoplay, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+/// Panel inicial supuesto antes del primer paint (= `initial_size`), para
+/// encuadrar el primer marco de entrada. Tras el primer frame se usa el real.
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+#[derive(Clone)]
+enum Msg {
+ Zoom { mult: f64, cursor: (f32, f32) },
+ Pan { dx: f32, dy: f32 },
+ Siguiente,
+ Anterior,
+ VistaGeneral,
+ ToggleAutoplay,
+ Tick,
+}
+
+struct Model {
+ rec: Recorrido,
+ state: RecorridoState,
+ autoplay: Autoplay,
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · recorrido (presentación espacial tipo Prezi)"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+ // Seis "slides" con contenido real (título + párrafos), auto-colocados
+ // en rejilla por en_rejilla; la ruta los recorre en orden de lectura.
+ let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+ titulo: Some(t.into()),
+ parrafos: ps.iter().map(|s| s.to_string()).collect(),
+ };
+ let contenidos = vec![
+ slide(
+ "Presentaciones espaciales",
+ &[
+ "Tipo Prezi: un lienzo infinito en vez de una pila de diapositivas.",
+ "La cámara vuela entre marcos haciendo zoom y paneo — el recorrido ES la narrativa.",
+ ],
+ ),
+ slide(
+ "Un solo material",
+ &[
+ "Cada marco vive en coordenadas de mundo; el orden de los pasos define el guion.",
+ "El strip lineal de pluma-deck es el caso degenerado de esto.",
+ ],
+ ),
+ slide(
+ "Zoom narrativo",
+ &["Alejarse muestra el mapa completo; acercarse, el detalle.", "El zoom se interpola en espacio logarítmico para un vuelo natural."],
+ ),
+ slide(
+ "Contenido nativo",
+ &["El contenido es agnóstico (título + párrafos).", "Un adaptador mapea un cuerpo o subgrafo de pluma a estos marcos."],
+ ),
+ slide(
+ "Controles",
+ &["Flechas / Espacio / Enter: volar al paso.", "Rueda: zoom-a-cursor. Arrastrar: paneo libre."],
+ ),
+ slide("Fin", &["Esto es la Fase 3a del §6.sexies en marcha."]),
+ ];
+ let mut rec = Recorrido::en_rejilla(
+ contenidos,
+ RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+ );
+ // Un par de marcos sueltos con giro para lucir la libertad espacial.
+ let id_a = (rec.marcos.len() + 1) as u64;
+ rec.agregar_marco(
+ Marco::new(id_a, Rect::new(-520.0, 760.0, 300.0, 200.0), ContenidoMarco::Etiqueta("← lienzo infinito →".into()))
+ .con_giro(-0.12),
+ );
+
+ let mut state = RecorridoState::new();
+ state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+
+ // Tick de animación a ~60 Hz; avanzar() es no-op cuando no hay vuelo.
+ handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+
+ Model { rec, state, autoplay: Autoplay::default() }
+ }
+
+ fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+ let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+ match msg {
+ Msg::Zoom { mult, cursor } => {
+ model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+ }
+ Msg::Pan { dx, dy } => {
+ model.state.arrastrar_delta(dx as f64, dy as f64);
+ }
+ Msg::Siguiente => {
+ model.state.siguiente(&model.rec, panel);
+ }
+ Msg::Anterior => {
+ model.state.anterior(&model.rec, panel);
+ }
+ Msg::VistaGeneral => {
+ model.state.vista_general(&model.rec, panel);
+ }
+ Msg::ToggleAutoplay => {
+ model.autoplay.toggle();
+ }
+ Msg::Tick => {
+ model.state.avanzar(1.0 / 60.0);
+ model.autoplay.tick(1.0 / 60.0, &mut model.state, &model.rec, panel);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Self::Model) -> View<Self::Msg> {
+ recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Pan { dx, dy }),
+ DragPhase::End => None,
+ })
+ }
+
+ fn on_wheel(
+ _model: &Self::Model,
+ delta: WheelDelta,
+ cursor: (f32, f32),
+ _modifiers: Modifiers,
+ ) -> Option<Self::Msg> {
+ let panel = panel_actual()?;
+ if !dentro(panel, cursor.0, cursor.1) {
+ return None;
+ }
+ // delta.y > 0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
+ let mult = ZOOM_BASE.powf(-delta.y as f64);
+ Some(Msg::Zoom { mult, cursor })
+ }
+
+ fn on_key(_model: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+ if ev.state != KeyState::Pressed {
+ return None;
+ }
+ match &ev.key {
+ Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+ Some(Msg::Siguiente)
+ }
+ Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+ Key::Named(NamedKey::Home | NamedKey::Escape) => Some(Msg::VistaGeneral),
+ Key::Character(c) if c.as_str().eq_ignore_ascii_case("p") => Some(Msg::ToggleAutoplay),
+ _ => None,
+ }
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs
new file mode 100644
index 0000000..06f5859
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_editor_demo.rs
@@ -0,0 +1,210 @@
+//! Demo de **autoría**: colocar/mover/editar marcos en el lienzo y guardar.
+//!
+//! A diferencia de `recorrido_demo` (presentar), aquí el arrastre edita:
+//! - **arrastrar sobre un marco**: lo mueve (y lo selecciona; borde ámbar).
+//! - **arrastrar sobre el vacío**: panea el lienzo.
+//! - **n**: crea un marco nuevo en el centro de la cámara (lo agrega a la ruta y lo selecciona).
+//! - **Supr / Retroceso**: elimina el marco seleccionado (y lo purga del guion).
+//! - **[ / ]**: rota el marco seleccionado.
+//! - **Ctrl+S / Ctrl+O**: guarda / carga el recorrido en `recorrido.deck` (postcard).
+//! - **rueda**: zoom-a-cursor. **flechas / Espacio**: vuela por la ruta.
+//!
+//! El hit-test, el movimiento y las ops de autoría (`eliminar_marco`,
+//! `rotar_marco`, …) y la persistencia (`serializar`/`deserializar`) viven en
+//! `pluma-deck-core`; aquí sólo se cablean a eventos.
+//!
+//! Corre con:
+//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_editor_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view_editor, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+/// Archivo donde guarda/carga el demo (codec nativo postcard).
+const ARCHIVO: &str = "recorrido.deck";
+
+#[derive(Clone)]
+enum Msg {
+ Zoom { mult: f64, cursor: (f32, f32) },
+ /// Move del arrastre: delta `(dx,dy)` + posición inicial del press `(lx,ly)`.
+ Arrastre { dx: f32, dy: f32, lx: f32, ly: f32 },
+ FinArrastre,
+ NuevoMarco,
+ Eliminar,
+ Rotar(f64),
+ Guardar,
+ Cargar,
+ Siguiente,
+ Anterior,
+ Tick,
+}
+
+struct Model {
+ rec: Recorrido,
+ state: RecorridoState,
+ /// `None` = sin arrastre. `Some(None)` = paneando. `Some(Some(id))` = moviendo ese marco.
+ arrastrando: Option<Option<u64>>,
+ /// Marco seleccionado (objetivo de eliminar/rotar). Se realza en ámbar.
+ seleccionado: Option<u64>,
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · recorrido (autoría: arrastrar mueve marcos, n crea)"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+ let etiqueta = |s: &str| ContenidoMarco::Etiqueta(s.into());
+ let rec = Recorrido::en_rejilla(
+ vec![etiqueta("arrastrá un marco"), etiqueta("o el vacío para panear"), etiqueta("tecla n: marco nuevo")],
+ RejillaOpts { cols: 3, marco_w: 460.0, marco_h: 300.0, gap_x: 200.0, gap_y: 160.0 },
+ );
+ let mut state = RecorridoState::new();
+ state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+ handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+ Model { rec, state, arrastrando: None, seleccionado: None }
+ }
+
+ fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+ let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+ match msg {
+ Msg::Zoom { mult, cursor } => {
+ model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+ }
+ Msg::Arrastre { dx, dy, lx, ly } => {
+ // En el primer Move decidimos qué se agarró (marco o vacío) y
+ // lo fijamos hasta soltar — así no se cambia de presa a mitad.
+ let modo = match model.arrastrando {
+ Some(m) => m,
+ None => {
+ let world = model.state.camara.screen_to_world((lx as f64, ly as f64), panel);
+ let m = model.rec.marco_en_punto(world);
+ model.arrastrando = Some(m);
+ // Agarrar un marco lo selecciona (objetivo de editar).
+ if m.is_some() {
+ model.seleccionado = m;
+ }
+ m
+ }
+ };
+ match modo {
+ Some(id) => {
+ let (wdx, wdy) = model.state.camara.delta_pantalla_a_mundo(dx as f64, dy as f64);
+ model.rec.mover_marco(id, wdx, wdy);
+ }
+ None => model.state.arrastrar_delta(dx as f64, dy as f64),
+ }
+ }
+ Msg::FinArrastre => model.arrastrando = None,
+ Msg::NuevoMarco => {
+ let id = model.rec.marcos.iter().map(|m| m.id).max().unwrap_or(0) + 1;
+ let (cx, cy) = model.state.camara.centro;
+ let (w, h) = (420.0, 260.0);
+ model.rec.agregar_marco(Marco::new(
+ id,
+ Rect::new(cx - w * 0.5, cy - h * 0.5, w, h),
+ ContenidoMarco::Etiqueta(format!("marco {id}")),
+ ));
+ model.rec.pasos.push(id);
+ model.seleccionado = Some(id);
+ }
+ Msg::Eliminar => {
+ if let Some(id) = model.seleccionado.take() {
+ model.rec.eliminar_marco(id);
+ // Reencuadra el paso actual (el guion pudo encogerse).
+ let idx = model.state.paso.min(model.rec.n_pasos().saturating_sub(1));
+ model.state.saltar_a_paso(&model.rec, idx, panel);
+ }
+ }
+ Msg::Rotar(d) => {
+ if let Some(id) = model.seleccionado {
+ model.rec.rotar_marco(id, d);
+ }
+ }
+ Msg::Guardar => match model.rec.serializar() {
+ Ok(bytes) => {
+ let _ = std::fs::write(ARCHIVO, &bytes);
+ eprintln!("guardado {ARCHIVO} ({} bytes)", bytes.len());
+ }
+ Err(e) => eprintln!("error al guardar: {e}"),
+ },
+ Msg::Cargar => match std::fs::read(ARCHIVO).map_err(|_| "no se pudo leer").and_then(|b| Recorrido::deserializar(&b)) {
+ Ok(rec) => {
+ model.rec = rec;
+ model.seleccionado = None;
+ model.state.saltar_a_paso(&model.rec, 0, panel);
+ eprintln!("cargado {ARCHIVO}");
+ }
+ Err(e) => eprintln!("error al cargar: {e}"),
+ },
+ Msg::Siguiente => {
+ model.state.siguiente(&model.rec, panel);
+ }
+ Msg::Anterior => {
+ model.state.anterior(&model.rec, panel);
+ }
+ Msg::Tick => {
+ model.state.avanzar(1.0 / 60.0);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Self::Model) -> View<Self::Msg> {
+ recorrido_view_editor(&model.rec, &model.state, model.seleccionado).draggable_at(
+ |phase, dx, dy, lx, ly| match phase {
+ DragPhase::Move => Some(Msg::Arrastre { dx, dy, lx, ly }),
+ DragPhase::End => Some(Msg::FinArrastre),
+ },
+ )
+ }
+
+ fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+ let panel = panel_actual()?;
+ if !dentro(panel, cursor.0, cursor.1) {
+ return None;
+ }
+ Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+ }
+
+ fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+ if ev.state != KeyState::Pressed {
+ return None;
+ }
+ // Atajos con Ctrl: guardar / cargar.
+ if ev.modifiers.ctrl {
+ return match &ev.key {
+ Key::Character(c) if c.eq_ignore_ascii_case("s") => Some(Msg::Guardar),
+ Key::Character(c) if c.eq_ignore_ascii_case("o") => Some(Msg::Cargar),
+ _ => None,
+ };
+ }
+ match &ev.key {
+ Key::Character(c) if c.as_str() == "n" => Some(Msg::NuevoMarco),
+ Key::Character(c) if c.as_str() == "[" => Some(Msg::Rotar(-0.08)),
+ Key::Character(c) if c.as_str() == "]" => Some(Msg::Rotar(0.08)),
+ Key::Named(NamedKey::Delete | NamedKey::Backspace) => Some(Msg::Eliminar),
+ Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+ Some(Msg::Siguiente)
+ }
+ Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+ _ => None,
+ }
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs
new file mode 100644
index 0000000..8a2a6ca
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_imagen_demo.rs
@@ -0,0 +1,163 @@
+//! Demo de **imagen dentro del marco** (cierra el pendiente menor de la Fase 3
+//! del §6.sexies): un marco cuyo contenido es `ContenidoMarco::Imagen`.
+//!
+//! El core guarda los bytes **codificados** (aquí un PNG generado en memoria,
+//! sin tocar disco) + sus dimensiones; el frontend Llimphi los decodifica una
+//! vez, los cachea y los pinta encajados en el marco preservando aspect ratio
+//! —respetando giro y zoom de la cámara igual que el texto—.
+//!
+//! Mezcla slides de texto con dos marcos-imagen (uno girado, para lucir que la
+//! imagen vuela con el marco). Controles iguales que `recorrido_demo`:
+//! - **→ / ↓ / Espacio / Enter**: paso siguiente. **← / ↑**: anterior.
+//! - **rueda**: zoom-a-cursor. **arrastrar**: paneo libre.
+//!
+//! Corre con:
+//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_imagen_demo --release`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::{ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+#[derive(Clone)]
+enum Msg {
+ Zoom { mult: f64, cursor: (f32, f32) },
+ Pan { dx: f32, dy: f32 },
+ Siguiente,
+ Anterior,
+ Tick,
+}
+
+struct Model {
+ rec: Recorrido,
+ state: RecorridoState,
+}
+
+/// Genera un PNG en memoria: degradado diagonal con una rejilla, para tener una
+/// imagen reconocible sin depender de un asset en disco. Devuelve los bytes PNG
+/// codificados (lo que viaja en `ContenidoMarco::Imagen`) + sus dimensiones.
+fn png_demo(w: u32, h: u32, base: (u8, u8, u8)) -> (Vec<u8>, u32, u32) {
+ let mut img = image::RgbaImage::new(w, h);
+ for (x, y, px) in img.enumerate_pixels_mut() {
+ let fx = x as f32 / w as f32;
+ let fy = y as f32 / h as f32;
+ // Degradado diagonal sobre el color base + rejilla cada 40 px.
+ let t = (fx + fy) * 0.5;
+ let mezcla = |c: u8| (c as f32 * (0.35 + 0.65 * t)) as u8;
+ let rejilla = (x % 40 == 0 || y % 40 == 0) as u8 * 40;
+ *px = image::Rgba([
+ mezcla(base.0).saturating_add(rejilla),
+ mezcla(base.1).saturating_add(rejilla),
+ mezcla(base.2).saturating_add(rejilla),
+ 255,
+ ]);
+ }
+ let mut bytes = Vec::new();
+ image::DynamicImage::ImageRgba8(img)
+ .write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
+ .expect("codificar PNG demo");
+ (bytes, w, h)
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · recorrido (imagen dentro del marco)"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+ let slide = |t: &str, ps: &[&str]| ContenidoMarco::Texto {
+ titulo: Some(t.into()),
+ parrafos: ps.iter().map(|s| s.to_string()).collect(),
+ };
+ let (png_a, wa, ha) = png_demo(480, 320, (90, 150, 230));
+ let (png_b, wb, hb) = png_demo(360, 360, (230, 140, 90));
+
+ let contenidos = vec![
+ slide(
+ "Imagen nativa en el marco",
+ &[
+ "El marco siguiente lleva una imagen: el core guarda bytes PNG; el frontend los rasteriza y encaja.",
+ "La imagen vuela con la cámara — zoom y giro la transforman como a cualquier marco.",
+ ],
+ ),
+ ContenidoMarco::Imagen { bytes: png_a, ancho: wa, alto: ha },
+ slide("Aspect ratio preservado", &["La imagen se centra y encaja sin deformarse, clipeada al marco."]),
+ ];
+ let mut rec = Recorrido::en_rejilla(
+ contenidos,
+ RejillaOpts { cols: 3, marco_w: 640.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+ );
+ // Marco-imagen suelto y girado, para lucir que la imagen sigue el giro.
+ let id = (rec.marcos.len() + 1) as u64;
+ rec.agregar_marco(
+ Marco::new(id, Rect::new(220.0, 760.0, 360.0, 360.0), ContenidoMarco::Imagen { bytes: png_b, ancho: wb, alto: hb })
+ .con_giro(0.18),
+ );
+ rec.pasos.push(id);
+
+ let mut state = RecorridoState::new();
+ state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+ handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+ Model { rec, state }
+ }
+
+ fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+ let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+ match msg {
+ Msg::Zoom { mult, cursor } => model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel),
+ Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+ Msg::Siguiente => {
+ model.state.siguiente(&model.rec, panel);
+ }
+ Msg::Anterior => {
+ model.state.anterior(&model.rec, panel);
+ }
+ Msg::Tick => {
+ model.state.avanzar(1.0 / 60.0);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Self::Model) -> View<Self::Msg> {
+ recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Pan { dx, dy }),
+ DragPhase::End => None,
+ })
+ }
+
+ fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+ let panel = panel_actual()?;
+ if !dentro(panel, cursor.0, cursor.1) {
+ return None;
+ }
+ Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+ }
+
+ fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+ if ev.state != KeyState::Pressed {
+ return None;
+ }
+ match &ev.key {
+ Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => Some(Msg::Siguiente),
+ Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+ _ => None,
+ }
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs
new file mode 100644
index 0000000..4e6b4a0
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/examples/recorrido_md_demo.rs
@@ -0,0 +1,146 @@
+//! Demo: un documento **markdown real** presentado como recorrido espacial.
+//!
+//! Cadena completa: `markdown → pluma_md::parse_md → átomos → Recorrido`.
+//! Cada encabezado (`#`, `##`, …) abre un "slide" cuyo título es el del
+//! encabezado y cuyos párrafos son los bloques siguientes hasta el próximo
+//! encabezado. `en_rejilla` los coloca y rutea en orden de lectura.
+//!
+//! El adaptador (`recorrido_desde_atomos`) ya **no vive aquí**: se promovió a
+//! `pluma_deck_core::adaptador` (feature `pluma`) y este demo lo dogfoodea, sin
+//! glue inline. El core sigue agnóstico por defecto; la feature sólo se activa
+//! en dev para los demos que tocan el modelo de documento de pluma.
+//!
+//! Corre con un .md propio o el de ejemplo embebido:
+//! `cargo run -p pluma-deck-recorrido-llimphi --example recorrido_md_demo --release [archivo.md]`
+
+use std::time::Duration;
+
+use llimphi_ui::{App, DragPhase, Handle, Key, KeyEvent, KeyState, Modifiers, NamedKey, View, WheelDelta};
+use pluma_deck_core::adaptador::recorrido_desde_atomos;
+use pluma_deck_core::{Recorrido, RecorridoState, Rect, RejillaOpts};
+use pluma_deck_recorrido_llimphi::{dentro, panel_actual, recorrido_view, ZOOM_BASE};
+
+const PANEL_INICIAL: Rect = Rect { x: 0.0, y: 0.0, w: 1100.0, h: 720.0 };
+
+const MD_EJEMPLO: &str = "\
+# Presentaciones desde markdown
+
+gioser convierte un documento real en un recorrido espacial.
+
+Cada encabezado abre un marco; los párrafos que le siguen son su cuerpo.
+
+## El pipeline
+
+El markdown se parsea con `pluma-md` en átomos (un bloque por átomo).
+
+El adaptador agrupa esos átomos en slides por encabezado.
+
+## El render
+
+`pluma-deck-core` coloca los slides en una rejilla y arma la ruta.
+
+El frontend Llimphi pinta cada marco y la cámara vuela entre ellos.
+
+## Cierre
+
+Mismo material que ya escribís — presentado sin diapositivas.
+";
+
+#[derive(Clone)]
+enum Msg {
+ Zoom { mult: f64, cursor: (f32, f32) },
+ Pan { dx: f32, dy: f32 },
+ Siguiente,
+ Anterior,
+ Tick,
+}
+
+struct Model {
+ rec: Recorrido,
+ state: RecorridoState,
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · recorrido desde markdown"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(handle: &Handle<Self::Msg>) -> Self::Model {
+ // Si pasan un .md por arg lo leemos; si no, el embebido.
+ let md = std::env::args()
+ .nth(1)
+ .and_then(|p| std::fs::read_to_string(p).ok())
+ .unwrap_or_else(|| MD_EJEMPLO.to_string());
+ let doc = pluma_md::parse_md(&md, "es", "recorrido", 0);
+ let rec = recorrido_desde_atomos(
+ &doc.atoms,
+ RejillaOpts { cols: 3, marco_w: 660.0, marco_h: 420.0, gap_x: 240.0, gap_y: 200.0 },
+ );
+
+ let mut state = RecorridoState::new();
+ state.saltar_a_paso(&rec, 0, PANEL_INICIAL);
+ handle.spawn_periodic(Duration::from_millis(16), || Msg::Tick);
+ Model { rec, state }
+ }
+
+ fn update(mut model: Self::Model, msg: Self::Msg, _: &Handle<Self::Msg>) -> Self::Model {
+ let panel = panel_actual().unwrap_or(PANEL_INICIAL);
+ match msg {
+ Msg::Zoom { mult, cursor } => {
+ model.state.wheel(mult, (cursor.0 as f64, cursor.1 as f64), panel);
+ }
+ Msg::Pan { dx, dy } => model.state.arrastrar_delta(dx as f64, dy as f64),
+ Msg::Siguiente => {
+ model.state.siguiente(&model.rec, panel);
+ }
+ Msg::Anterior => {
+ model.state.anterior(&model.rec, panel);
+ }
+ Msg::Tick => {
+ model.state.avanzar(1.0 / 60.0);
+ }
+ }
+ model
+ }
+
+ fn view(model: &Self::Model) -> View<Self::Msg> {
+ recorrido_view(&model.rec, &model.state).draggable(|phase, dx, dy| match phase {
+ DragPhase::Move => Some(Msg::Pan { dx, dy }),
+ DragPhase::End => None,
+ })
+ }
+
+ fn on_wheel(_m: &Self::Model, delta: WheelDelta, cursor: (f32, f32), _mods: Modifiers) -> Option<Self::Msg> {
+ let panel = panel_actual()?;
+ if !dentro(panel, cursor.0, cursor.1) {
+ return None;
+ }
+ Some(Msg::Zoom { mult: ZOOM_BASE.powf(-delta.y as f64), cursor })
+ }
+
+ fn on_key(_m: &Self::Model, ev: &KeyEvent) -> Option<Self::Msg> {
+ if ev.state != KeyState::Pressed {
+ return None;
+ }
+ match &ev.key {
+ Key::Named(NamedKey::ArrowRight | NamedKey::ArrowDown | NamedKey::Enter | NamedKey::Space) => {
+ Some(Msg::Siguiente)
+ }
+ Key::Named(NamedKey::ArrowLeft | NamedKey::ArrowUp) => Some(Msg::Anterior),
+ _ => None,
+ }
+ }
+}
+
+fn main() {
+ llimphi_ui::run::<Demo>();
+}
diff --git a/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs
new file mode 100644
index 0000000..26fdeb6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-recorrido-llimphi/src/lib.rs
@@ -0,0 +1,463 @@
+//! Frontend Llimphi del modo `Recorrido` de `pluma-deck-core` — presentación
+//! espacial tipo Prezi: un lienzo 2D infinito con marcos colocados en
+//! coordenadas de mundo y una cámara que vuela entre ellos.
+//!
+//! La lógica vive entera en `pluma-deck-core` (cámara, ruta, máquina de
+//! interacción); aquí sólo hay **pintura** (`View::paint_with` aplicando el
+//! transform de la cámara) y el cableado de eventos. Sigue la regla #2 del
+//! repo: la UI es un frontend intercambiable sobre un `*-core` agnóstico.
+//!
+//! El host arma su `App` así:
+//! - `view`: nodo a pantalla completa con [`recorrido_view`] (registra el rect
+//! del panel en un side-channel para que `on_wheel` sepa el tamaño).
+//! - `on_wheel`: lee [`panel_actual`] y despacha un zoom-a-cursor.
+//! - drag sobre el nodo: `RecorridoState::arrastrar_delta` (pan libre).
+//! - flechas: `siguiente`/`anterior` + un tick periódico que llama
+//! `RecorridoState::avanzar(dt)` para animar el vuelo.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, OnceLock};
+
+use llimphi_ui::llimphi_raster::kurbo::{Affine, BezPath, Circle, Rect as KurboRect, RoundedRect, Stroke};
+use llimphi_ui::llimphi_raster::peniko::{Blob, Color, Fill, Image as PenikoImage, ImageFormat, Mix};
+use llimphi_ui::llimphi_text::{draw_layout_xf, layout_block, measurement, Alignment, TextBlock};
+use llimphi_ui::llimphi_layout::taffy::prelude::{percent, Size, Style};
+use llimphi_ui::{PaintRect, View};
+
+use pluma_deck_core::{Camara, ContenidoMarco, MarcoId, Recorrido, RecorridoState, Rect};
+
+/// Base del zoom por "clic" de rueda (igual criterio que tullpu: `1.1`).
+pub const ZOOM_BASE: f64 = 1.1;
+
+type Scene = llimphi_ui::llimphi_raster::vello::Scene;
+type Ts = llimphi_ui::llimphi_text::Typesetter;
+
+// ---- Side-channel del rect del panel -------------------------------------
+//
+// `App::on_wheel` recibe el cursor absoluto pero no el tamaño del viewport.
+// El `paint_with` de [`recorrido_view`] escribe el rect del panel cada frame
+// y `on_wheel`/handlers lo leen. Mismo patrón que `tullpu` (`LIENZO_RECT`).
+
+static PANEL_RECT: OnceLock<Mutex<Option<Rect>>> = OnceLock::new();
+
+fn panel_set(r: Rect) {
+ let cell = PANEL_RECT.get_or_init(|| Mutex::new(None));
+ if let Ok(mut g) = cell.lock() {
+ *g = Some(r);
+ }
+}
+
+/// Último rect del panel (px de pantalla) registrado por [`recorrido_view`].
+/// `None` hasta el primer frame pintado. Lo usan `on_wheel` (zoom-a-cursor),
+/// `siguiente`/`anterior` (encuadre) en el `update` del host.
+pub fn panel_actual() -> Option<Rect> {
+ PANEL_RECT.get()?.lock().ok().and_then(|g| *g)
+}
+
+/// `true` si `(cx, cy)` (px de pantalla) cae dentro de `panel`.
+pub fn dentro(panel: Rect, cx: f32, cy: f32) -> bool {
+ let (cx, cy) = (cx as f64, cy as f64);
+ cx >= panel.x && cx <= panel.x + panel.w && cy >= panel.y && cy <= panel.y + panel.h
+}
+
+// ---- Caché de imágenes decodificadas -------------------------------------
+//
+// `ContenidoMarco::Imagen` guarda bytes **codificados** (PNG/JPEG/WebP): el
+// core es agnóstico al render. Decodificarlos a RGBA8 en cada frame sería
+// carísimo, así que se decodifica una vez y se cachea la `peniko::Image`
+// (que es barata de clonar — su `Blob` es `Arc`). La clave `(id, len)` detecta
+// el caso de reemplazar la imagen de un marco por otra de distinto tamaño.
+
+static IMG_CACHE: OnceLock<Mutex<HashMap<(MarcoId, usize), PenikoImage>>> = OnceLock::new();
+
+/// Devuelve la `peniko::Image` del marco `id`, decodificando+cacheando la
+/// primera vez. `None` si los bytes no son una imagen válida.
+fn imagen_cacheada(id: MarcoId, bytes: &[u8]) -> Option<PenikoImage> {
+ let cell = IMG_CACHE.get_or_init(|| Mutex::new(HashMap::new()));
+ let mut g = cell.lock().ok()?;
+ if let Some(img) = g.get(&(id, bytes.len())) {
+ return Some(img.clone());
+ }
+ let img = decodificar(bytes)?;
+ g.insert((id, bytes.len()), img.clone());
+ Some(img)
+}
+
+fn decodificar(bytes: &[u8]) -> Option<PenikoImage> {
+ let img = image::load_from_memory(bytes).ok()?.to_rgba8();
+ let (w, h) = (img.width(), img.height());
+ let blob = Blob::from(img.into_raw());
+ Some(PenikoImage::new(blob, ImageFormat::Rgba8, w, h))
+}
+
+// ---- Pintura por marco ---------------------------------------------------
+//
+// El `paint_with` corre cada frame con un closure `Send + Sync`. Para no clonar
+// los bytes de imagen (ni re-decodificar) por frame, `recorrido_view` precocina
+// cada marco a una `Pintura` ligera: el texto se clona (barato) y la imagen se
+// resuelve a una `peniko::Image` cacheada (clon barato).
+
+enum Pintura {
+ Etiqueta(String),
+ Texto { titulo: Option<String>, parrafos: Vec<String> },
+ Imagen(PenikoImage),
+ Nada,
+}
+
+struct MarcoPintura {
+ id: MarcoId,
+ rect: Rect,
+ rot_rad: f64,
+ pintura: Pintura,
+}
+
+// ---- Colores del lienzo (no temáticos todavía; placeholder sobrio) -------
+
+const FONDO: Color = Color::from_rgba8(18, 20, 28, 255);
+const MARCO_FONDO: Color = Color::from_rgba8(38, 42, 56, 255);
+const MARCO_BORDE: Color = Color::from_rgba8(80, 86, 104, 255);
+const MARCO_ACENTO: Color = Color::from_rgba8(120, 180, 255, 255);
+const TEXTO: Color = Color::from_rgba8(225, 230, 240, 235);
+const TEXTO_TENUE: Color = Color::from_rgba8(186, 194, 210, 225);
+// Camino narrativo: línea punteada que une los marcos en orden de la ruta,
+// pintada por detrás de los marcos. Acento tenue para no robar protagonismo.
+const RUTA: Color = Color::from_rgba8(120, 180, 255, 120);
+// Realce del marco seleccionado en autoría — ámbar, distinto del acento azul
+// del paso actual, para que selección y "paso actual" se distingan de un vistazo.
+const SELECCION: Color = Color::from_rgba8(255, 196, 92, 255);
+// HUD "paso X / N": píldora sobria abajo-centro, en espacio de pantalla.
+const HUD_FONDO: Color = Color::from_rgba8(12, 14, 20, 190);
+const HUD_TEXTO: Color = Color::from_rgba8(205, 212, 226, 235);
+
+/// Nodo a pantalla completa que pinta el recorrido y registra el rect del
+/// panel. `Msg` es libre: el caller suele colgarle un `.draggable(...)` para
+/// el pan — esta función no lo impone para no fijar el tipo de mensaje.
+pub fn recorrido_view<Msg: 'static>(rec: &Recorrido, state: &RecorridoState) -> View<Msg> {
+ vista(rec, state, None)
+}
+
+/// Igual que [`recorrido_view`] pero realzando el marco `seleccionado` (autoría):
+/// se pinta con un borde de selección distinto del acento del paso actual.
+pub fn recorrido_view_editor<Msg: 'static>(
+ rec: &Recorrido,
+ state: &RecorridoState,
+ seleccionado: Option<MarcoId>,
+) -> View<Msg> {
+ vista(rec, state, seleccionado)
+}
+
+fn vista<Msg: 'static>(rec: &Recorrido, state: &RecorridoState, seleccionado: Option<MarcoId>) -> View<Msg> {
+ // Precocinamos cada marco a una `Pintura` ligera (texto clonado, imagen
+ // resuelta a peniko::Image cacheada) para no clonar bytes ni re-decodificar
+ // por frame, y para que el closure `Send + Sync` sobreviva sin los bytes.
+ let pinturas: Vec<MarcoPintura> = rec
+ .marcos
+ .iter()
+ .map(|m| {
+ let pintura = match &m.contenido {
+ ContenidoMarco::Etiqueta(t) if !t.is_empty() => Pintura::Etiqueta(t.clone()),
+ ContenidoMarco::Texto { titulo, parrafos } => {
+ Pintura::Texto { titulo: titulo.clone(), parrafos: parrafos.clone() }
+ }
+ ContenidoMarco::Imagen { bytes, .. } => {
+ imagen_cacheada(m.id, bytes).map(Pintura::Imagen).unwrap_or(Pintura::Nada)
+ }
+ _ => Pintura::Nada,
+ };
+ MarcoPintura { id: m.id, rect: m.rect, rot_rad: m.rot_rad, pintura }
+ })
+ .collect();
+ let paso_id = rec.pasos.get(state.paso).copied();
+ // Centros (coords de mundo) de los marcos en orden de la ruta, para pintar
+ // el camino narrativo. Los pasos cuyo id no resuelve se omiten.
+ let ruta: Vec<(f64, f64)> =
+ rec.pasos.iter().filter_map(|id| rec.marco(*id)).map(|m| m.rect.centro()).collect();
+ let camara = state.camara;
+ let paso = state.paso;
+ let n_pasos = rec.n_pasos();
+ View::new(Style {
+ size: Size { width: percent(1.0_f32), height: percent(1.0_f32) },
+ ..Default::default()
+ })
+ .fill(FONDO)
+ .paint_with(move |scene, ts, rect: PaintRect| {
+ panel_set(to_rect(rect));
+ pintar(scene, ts, rect, &pinturas, &ruta, paso_id, seleccionado, &camara);
+ pintar_hud(scene, ts, rect, paso, n_pasos);
+ })
+}
+
+fn to_rect(r: PaintRect) -> Rect {
+ Rect::new(r.x as f64, r.y as f64, r.w as f64, r.h as f64)
+}
+
+/// Affine mundo→pantalla de una cámara, dado el rect del panel.
+/// `pantalla = centro_panel + escala(zoom) · rot(-rot) · (mundo - centro)`.
+fn world_to_screen_affine(cam: &Camara, panel: Rect) -> Affine {
+ let pcx = panel.x + panel.w * 0.5;
+ let pcy = panel.y + panel.h * 0.5;
+ Affine::translate((pcx, pcy))
+ * Affine::scale(cam.zoom)
+ * Affine::rotate(-cam.rot_rad)
+ * Affine::translate((-cam.centro.0, -cam.centro.1))
+}
+
+fn pintar(
+ scene: &mut llimphi_ui::llimphi_raster::vello::Scene,
+ ts: &mut llimphi_ui::llimphi_text::Typesetter,
+ rect: PaintRect,
+ marcos: &[MarcoPintura],
+ ruta: &[(f64, f64)],
+ paso_id: Option<MarcoId>,
+ seleccionado: Option<MarcoId>,
+ cam: &Camara,
+) {
+ if rect.w <= 0.0 || rect.h <= 0.0 {
+ return;
+ }
+ let panel = to_rect(rect);
+ let w2s = world_to_screen_affine(cam, panel);
+
+ // Clip al panel: un marco con zoom-in no debe derramar fuera del nodo.
+ let node = KurboRect::new(
+ rect.x as f64,
+ rect.y as f64,
+ (rect.x + rect.w) as f64,
+ (rect.y + rect.h) as f64,
+ );
+ scene.push_layer(Mix::Clip, 1.0, Affine::IDENTITY, &node);
+
+ // Camino narrativo por detrás de los marcos.
+ pintar_ruta(scene, cam, panel, ruta);
+
+ for m in marcos {
+ let (mcx, mcy) = m.rect.centro();
+ // Giro propio del marco alrededor de su centro, encadenado al mundo→pantalla.
+ let xf = w2s
+ * Affine::translate((mcx, mcy))
+ * Affine::rotate(m.rot_rad)
+ * Affine::translate((-mcx, -mcy));
+ let kr = KurboRect::new(
+ m.rect.x,
+ m.rect.y,
+ m.rect.x + m.rect.w,
+ m.rect.y + m.rect.h,
+ );
+ scene.fill(Fill::NonZero, xf, MARCO_FONDO, None, &kr);
+
+ // La imagen se pinta encajada en el marco (respeta giro/zoom vía `xf`).
+ if let Pintura::Imagen(img) = &m.pintura {
+ pintar_imagen(scene, xf, &m.rect, img);
+ }
+
+ let actual = paso_id == Some(m.id);
+ let (grosor, color) = if actual { (3.0, MARCO_ACENTO) } else { (1.0, MARCO_BORDE) };
+ scene.stroke(&Stroke::new(grosor), xf, color, None, &kr);
+
+ // Realce de selección (autoría): borde ámbar punteado por encima, así se
+ // distingue del acento azul del paso actual aunque coincidan.
+ if seleccionado == Some(m.id) {
+ let sel = Stroke::new(2.5).with_dashes(0.0, [7.0, 5.0]);
+ scene.stroke(&sel, xf, SELECCION, None, &kr);
+ }
+
+ // El texto se pinta en el **espacio local del marco** (origen en su
+ // esquina sup-izq, ejes alineados al marco, 1 unidad = 1 px de pantalla),
+ // así sigue el giro del marco como lo hace su borde. Los tamaños de
+ // fuente se clampan por zoom para seguir legibles lejos/cerca.
+ let local = marco_local_xf(cam, panel, &m.rect, m.rot_rad);
+ let w_px = (m.rect.w * cam.zoom) as f32;
+ let h_px = (m.rect.h * cam.zoom) as f32;
+ match &m.pintura {
+ Pintura::Etiqueta(t) => pintar_etiqueta(scene, ts, local, w_px, h_px, cam.zoom, t),
+ Pintura::Texto { titulo, parrafos } => {
+ pintar_texto(scene, ts, local, w_px, h_px, cam.zoom, titulo.as_deref(), parrafos);
+ }
+ Pintura::Imagen(_) | Pintura::Nada => {}
+ }
+ }
+
+ scene.pop_layer();
+}
+
+/// Pinta el **camino narrativo**: una polilínea punteada que une los centros
+/// de los marcos en orden de la ruta, con un nodo en cada paso. Se dibuja en
+/// espacio de pantalla (grosor constante en px a cualquier zoom) y por detrás
+/// de los marcos. No-op con menos de dos pasos.
+fn pintar_ruta(scene: &mut Scene, cam: &Camara, panel: Rect, ruta: &[(f64, f64)]) {
+ if ruta.len() < 2 {
+ return;
+ }
+ let mut path = BezPath::new();
+ for (i, p) in ruta.iter().enumerate() {
+ let s = cam.world_to_screen(*p, panel);
+ if i == 0 {
+ path.move_to(s);
+ } else {
+ path.line_to(s);
+ }
+ }
+ let trazo = Stroke::new(2.0).with_dashes(0.0, [9.0, 7.0]);
+ scene.stroke(&trazo, Affine::IDENTITY, RUTA, None, &path);
+ for p in ruta {
+ let s = cam.world_to_screen(*p, panel);
+ scene.fill(Fill::NonZero, Affine::IDENTITY, RUTA, None, &Circle::new(s, 3.5));
+ }
+}
+
+/// Pinta `img` encajada en el rect del marco preservando aspect ratio,
+/// centrada y clipeada al marco (en su espacio transformado, así respeta el
+/// giro propio). `xf` es el mundo→pantalla del marco ya con su rotación.
+fn pintar_imagen(scene: &mut Scene, xf: Affine, rect: &Rect, img: &PenikoImage) {
+ let (iw, ih) = (img.width as f64, img.height as f64);
+ if iw <= 0.0 || ih <= 0.0 || rect.w <= 0.0 || rect.h <= 0.0 {
+ return;
+ }
+ let s = (rect.w / iw).min(rect.h / ih);
+ let (dw, dh) = (iw * s, ih * s);
+ let ox = rect.x + (rect.w - dw) * 0.5;
+ let oy = rect.y + (rect.h - dh) * 0.5;
+ let kr = KurboRect::new(rect.x, rect.y, rect.x + rect.w, rect.y + rect.h);
+ // Clip al rect del marco en su propio espacio (xf incluye giro+zoom).
+ scene.push_layer(Mix::Clip, 1.0, xf, &kr);
+ let img_xf = xf * Affine::translate((ox, oy)) * Affine::scale(s);
+ scene.draw_image(img, img_xf);
+ scene.pop_layer();
+}
+
+/// Afín que lleva coordenadas **locales del marco** a pantalla: el origen (0,0)
+/// es su esquina superior-izquierda, los ejes están alineados al marco (rotados
+/// según el giro del marco *relativo a la cámara*) y 1 unidad local = 1 px de
+/// pantalla. Pintar texto en este espacio hace que siga el giro del marco igual
+/// que su borde, sin que el zoom deforme el tamaño de fuente (que se clampa).
+fn marco_local_xf(cam: &Camara, panel: Rect, rect: &Rect, rot_rad: f64) -> Affine {
+ let (scx, scy) = cam.world_to_screen(rect.centro(), panel);
+ // Giro del marco visto en pantalla: la cámara rota -rot, el marco +rot_rad.
+ let ang = rot_rad - cam.rot_rad;
+ let (w_px, h_px) = (rect.w * cam.zoom, rect.h * cam.zoom);
+ Affine::translate((scx, scy))
+ * Affine::rotate(ang)
+ * Affine::translate((-w_px * 0.5, -h_px * 0.5))
+}
+
+/// Etiqueta de una línea centrada en el marco, en su espacio local (sigue el
+/// giro). El tamaño escala con el zoom (clamp para seguir legible lejos/cerca).
+fn pintar_etiqueta(scene: &mut Scene, ts: &mut Ts, local: Affine, w_px: f32, h_px: f32, zoom: f64, t: &str) {
+ if w_px < 12.0 {
+ return; // demasiado chico para texto
+ }
+ let size_px = ((16.0 * zoom) as f32).clamp(9.0, 40.0);
+ let block = TextBlock {
+ text: t,
+ size_px,
+ color: TEXTO,
+ // Centrado vertical aproximado dentro del marco; el `max_width` + center
+ // resuelven el horizontal.
+ origin: (0.0, (h_px as f64 - size_px as f64) * 0.5),
+ max_width: Some(w_px),
+ alignment: Alignment::Center,
+ line_height: 1.2,
+ italic: false,
+ font_family: None,
+ };
+ let layout = layout_block(ts, &block);
+ draw_layout_xf(scene, &layout, block.color, local * Affine::translate(block.origin));
+}
+
+/// Contenido de "slide": título (si hay) + párrafos, fluidos desde la esquina
+/// superior-izquierda del marco, clipeados a su rect — todo en el espacio local
+/// del marco, así el texto gira con él. El apilado usa la altura medida del
+/// título.
+fn pintar_texto(
+ scene: &mut Scene,
+ ts: &mut Ts,
+ local: Affine,
+ w_px: f32,
+ h_px: f32,
+ zoom: f64,
+ titulo: Option<&str>,
+ parrafos: &[String],
+) {
+ if w_px < 40.0 || h_px < 24.0 {
+ return; // demasiado chico para texto fluido
+ }
+ let pad = ((12.0 * zoom) as f32).clamp(5.0, 22.0);
+ let inner_w = (w_px - 2.0 * pad).max(8.0);
+ let left = pad as f64;
+ let mut y = pad as f64;
+
+ // Clip al rect del marco en su espacio local (gira con el marco).
+ let clip = KurboRect::new(0.0, 0.0, w_px as f64, h_px as f64);
+ scene.push_layer(Mix::Clip, 1.0, local, &clip);
+
+ if let Some(tt) = titulo.filter(|s| !s.is_empty()) {
+ let size = ((22.0 * zoom) as f32).clamp(12.0, 46.0);
+ let block = TextBlock {
+ text: tt,
+ size_px: size,
+ color: TEXTO,
+ origin: (left, y),
+ max_width: Some(inner_w),
+ alignment: Alignment::Start,
+ line_height: 1.15,
+ italic: false,
+ font_family: None,
+ };
+ let layout = layout_block(ts, &block);
+ let medida = measurement(&layout);
+ draw_layout_xf(scene, &layout, TEXTO, local * Affine::translate((left, y)));
+ y += medida.height as f64 + ((10.0 * zoom) as f32).clamp(4.0, 18.0) as f64;
+ }
+
+ if !parrafos.is_empty() {
+ let cuerpo = parrafos.join("\n\n");
+ let size = ((15.0 * zoom) as f32).clamp(9.0, 32.0);
+ let block = TextBlock {
+ text: &cuerpo,
+ size_px: size,
+ color: TEXTO_TENUE,
+ origin: (left, y),
+ max_width: Some(inner_w),
+ alignment: Alignment::Start,
+ line_height: 1.35,
+ italic: false,
+ font_family: None,
+ };
+ let layout = layout_block(ts, &block);
+ draw_layout_xf(scene, &layout, TEXTO_TENUE, local * Affine::translate((left, y)));
+ }
+
+ scene.pop_layer();
+}
+
+/// HUD de progreso "paso actual / total" — píldora sobria abajo-centro, pintada
+/// en **espacio de pantalla** (no la afecta la cámara). Orienta al presentador
+/// sin robar protagonismo al lienzo. No-op si la ruta está vacía.
+fn pintar_hud(scene: &mut Scene, ts: &mut Ts, rect: PaintRect, paso: usize, n_pasos: usize) {
+ if n_pasos == 0 || rect.w <= 0.0 || rect.h <= 0.0 {
+ return;
+ }
+ let texto = format!("{} / {}", paso + 1, n_pasos);
+ let block = TextBlock {
+ text: &texto,
+ size_px: 14.0,
+ color: HUD_TEXTO,
+ origin: (0.0, 0.0),
+ max_width: None,
+ alignment: Alignment::Start,
+ line_height: 1.2,
+ italic: false,
+ font_family: None,
+ };
+ let layout = layout_block(ts, &block);
+ let m = measurement(&layout);
+ let (pad_x, pad_y) = (12.0_f64, 6.0_f64);
+ let w = m.width as f64 + pad_x * 2.0;
+ let h = m.height as f64 + pad_y * 2.0;
+ let cx = rect.x as f64 + rect.w as f64 * 0.5;
+ let x0 = cx - w * 0.5;
+ let y0 = rect.y as f64 + rect.h as f64 - h - 16.0;
+ let pill = RoundedRect::new(x0, y0, x0 + w, y0 + h, h * 0.5);
+ scene.fill(Fill::NonZero, Affine::IDENTITY, HUD_FONDO, None, &pill);
+ draw_layout_xf(scene, &layout, HUD_TEXTO, Affine::translate((x0 + pad_x, y0 + pad_y)));
+}
diff --git a/00_unanchay/pluma/pluma-deck-web/Cargo.toml b/00_unanchay/pluma/pluma-deck-web/Cargo.toml
new file mode 100644
index 0000000..8e9de25
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/Cargo.toml
@@ -0,0 +1,32 @@
+[package]
+name = "pluma-deck-web"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+
+[dependencies]
+pluma-deck-core = { path = "../pluma-deck-core" }
+wasm-bindgen.workspace = true
+js-sys.workspace = true
+
+[dependencies.web-sys]
+workspace = true
+features = [
+ "Window",
+ "Document",
+ "Element",
+ "HtmlElement",
+ "CssStyleDeclaration",
+ "DomTokenList",
+ "Event",
+ "EventTarget",
+ "PointerEvent",
+ "MouseEvent",
+ "WheelEvent",
+ "KeyboardEvent",
+ "HtmlCollection",
+ "DomRect",
+ "AddEventListenerOptions",
+]
diff --git a/00_unanchay/pluma/pluma-deck-web/LEEME.md b/00_unanchay/pluma/pluma-deck-web/LEEME.md
new file mode 100644
index 0000000..fe0b400
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/LEEME.md
@@ -0,0 +1,54 @@
+# pluma-deck-web
+
+> Deck en navegador para [pluma](../README.md).
+
+Toma un `Deck` de [`pluma-deck-core`](../pluma-deck-core/README.md) y lo renderiza como SPA: slide actual + nav (←/→ teclas), aspect ratio configurable, full-screen, soporta el mismo theme system que el reader.
+
+## API
+
+```rust
+use pluma_deck_web::Presenter;
+
+let p = Presenter::new(container);
+p.cargar(&deck);
+```
+
+## Modo espacial — Recorrido (tipo Prezi)
+
+Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): en vez del
+strip 1D, un **lienzo infinito** con marcos en coordenadas de mundo y una
+cámara que vuela entre ellos. La lógica (cámara/ruta/gesto) vive en
+[`pluma-deck-core`](../pluma-deck-core/README.md); el binding (`recorrido`)
+sólo aplica la cámara como **un único `transform` CSS** sobre `mundo`
+(estilo impress.js) y delega el vuelo entre pasos a una transición CSS.
+
+### Contrato DOM
+
+```html
+<div class="recorrido-viewport">
+ <div class="recorrido-mundo">
+ <div class="recorrido-marco" data-x="0" data-y="0" data-w="640" data-h="400">…</div>
+ <div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
+ </div>
+</div>
+```
+
+Cada marco lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y un giro opcional
+`data-rot` (radianes). El orden DOM define la ruta. El contenido HTML interno es
+libre (texto, `<img>`, etc.).
+
+### API
+
+```rust
+use pluma_deck_web::recorrido::RecorridoWeb;
+
+let r = RecorridoWeb::mount(viewport, mundo)?;
+r.on_change(|paso| { /* … */ });
+// flechas/espacio/enter avanzan; rueda = zoom-a-cursor; arrastrar = paneo.
+r.siguiente(); r.anterior(); r.goto(2, true);
+```
+
+## Deps
+
+- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
+- `wasm-bindgen`, `web-sys`
diff --git a/00_unanchay/pluma/pluma-deck-web/README.md b/00_unanchay/pluma/pluma-deck-web/README.md
new file mode 100644
index 0000000..3386600
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/README.md
@@ -0,0 +1,19 @@
+# pluma-deck-web
+
+> Browser-side deck for [pluma](../README.md).
+
+Takes a `Deck` from [`pluma-deck-core`](../pluma-deck-core/README.md) and renders it as an SPA: current slide + nav (←/→ keys), configurable aspect ratio, fullscreen, same theme system as the reader.
+
+## API
+
+```rust
+use pluma_deck_web::Presenter;
+
+let p = Presenter::new(container);
+p.cargar(&deck);
+```
+
+## Deps
+
+- [`pluma-deck-core`](../pluma-deck-core/README.md), [`pluma-md`](../pluma-md/README.md)
+- `wasm-bindgen`, `web-sys`
diff --git a/00_unanchay/pluma/pluma-deck-web/src/lib.rs b/00_unanchay/pluma/pluma-deck-web/src/lib.rs
new file mode 100644
index 0000000..071c83b
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/src/lib.rs
@@ -0,0 +1,212 @@
+//! Vista-web — binding DOM del deck horizontal. Toda la lógica de
+//! decisión de gesto/snap vive en `vista-core`; este crate sólo traduce
+//! `PointerEvent`s en eventos de `DeckState` y aplica el offset al DOM.
+//!
+//! Contrato CSS (el caller provee):
+//! ```css
+//! .vista-deck { overflow: hidden; touch-action: pan-y; }
+//! .vista-strip {
+//! display: flex;
+//! width: 100%;
+//! height: 100%;
+//! transform: translate3d(var(--vista-offset, 0px), 0, 0);
+//! transition: transform 360ms cubic-bezier(0.22, 0.61, 0.36, 1);
+//! }
+//! .vista-strip.vista-dragging,
+//! .vista-strip.vista-instant { transition: none; }
+//! .vista-page { flex: 0 0 100%; height: 100%; overflow-y: auto; }
+//! ```
+
+pub mod recorrido;
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use pluma_deck_core::{DeckState, DragOutcome};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{Event, HtmlElement, PointerEvent};
+
+#[derive(Clone)]
+pub struct Deck {
+ strip: HtmlElement,
+ inner: Rc<RefCell<Inner>>,
+}
+
+struct Inner {
+ state: DeckState,
+ on_change: Option<Box<dyn FnMut(usize)>>,
+}
+
+impl Deck {
+ pub fn mount(strip: HtmlElement) -> Result<Self, JsValue> {
+ let inner = Rc::new(RefCell::new(Inner {
+ state: DeckState::new(),
+ on_change: None,
+ }));
+ install_pointerdown(&strip, &inner)?;
+ install_pointermove(&strip, &inner)?;
+ install_pointerend(&strip, &inner, "pointerup")?;
+ install_pointerend(&strip, &inner, "pointercancel")?;
+ install_pointerend(&strip, &inner, "pointerleave")?;
+ install_resize(&strip, &inner)?;
+ Ok(Self { strip, inner })
+ }
+
+ pub fn goto(&self, index: usize, smooth: bool) {
+ let width = self.strip.client_width() as f64;
+ let mut i = self.inner.borrow_mut();
+ let r = i.state.goto(index, width);
+ drop(i);
+ if !smooth {
+ let _ = self.strip.class_list().add_1("vista-instant");
+ }
+ set_offset(&self.strip, r.offset_px);
+ if !smooth {
+ clear_instant_next_frame(&self.strip);
+ }
+ if r.changed {
+ let mut i = self.inner.borrow_mut();
+ if let Some(cb) = i.on_change.as_mut() {
+ cb(r.target_index);
+ }
+ }
+ }
+
+ pub fn current_index(&self) -> usize {
+ self.inner.borrow().state.current_index
+ }
+
+ pub fn page_count(&self) -> u32 {
+ self.strip.child_element_count()
+ }
+
+ pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
+ self.inner.borrow_mut().on_change = Some(Box::new(cb));
+ }
+
+ pub fn strip(&self) -> &HtmlElement {
+ &self.strip
+ }
+}
+
+fn install_pointerdown(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+ let strip2 = strip.clone();
+ let inner2 = inner.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ let width = strip2.client_width() as f64;
+ inner2.borrow_mut().state.pointer_down(
+ e.client_x() as f64,
+ e.client_y() as f64,
+ e.pointer_id(),
+ width,
+ );
+ });
+ strip.add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+}
+
+fn install_pointermove(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+ let strip2 = strip.clone();
+ let inner2 = inner.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ let outcome = inner2
+ .borrow_mut()
+ .state
+ .pointer_move(e.client_x() as f64, e.client_y() as f64);
+ match outcome {
+ DragOutcome::StartHorizontal { pointer_id } => {
+ let _ = strip2.class_list().add_1("vista-dragging");
+ let _ = strip2.set_pointer_capture(pointer_id);
+ }
+ DragOutcome::DragOffset(offset) => {
+ set_offset(&strip2, offset);
+ e.prevent_default();
+ }
+ DragOutcome::Idle | DragOutcome::CancelVertical => {}
+ }
+ });
+ let opts = web_sys::AddEventListenerOptions::new();
+ opts.set_passive(false);
+ strip.add_event_listener_with_callback_and_add_event_listener_options(
+ "pointermove",
+ cb.as_ref().unchecked_ref(),
+ &opts,
+ )?;
+ cb.forget();
+ Ok(())
+}
+
+fn install_pointerend(
+ strip: &HtmlElement,
+ inner: &Rc<RefCell<Inner>>,
+ event_name: &str,
+) -> Result<(), JsValue> {
+ let strip2 = strip.clone();
+ let inner2 = inner.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ let width = strip2.client_width() as f64;
+ let offset = current_offset_px(&strip2);
+ let n_pages = strip2.child_element_count() as usize;
+ let res = inner2.borrow_mut().state.pointer_end(offset, width, n_pages);
+ let _ = strip2.class_list().remove_1("vista-dragging");
+ let _ = strip2.release_pointer_capture(e.pointer_id());
+ if let Some(r) = res {
+ set_offset(&strip2, r.offset_px);
+ if r.changed {
+ let mut i = inner2.borrow_mut();
+ if let Some(cb) = i.on_change.as_mut() {
+ cb(r.target_index);
+ }
+ }
+ }
+ });
+ strip.add_event_listener_with_callback(event_name, cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+}
+
+fn install_resize(strip: &HtmlElement, inner: &Rc<RefCell<Inner>>) -> Result<(), JsValue> {
+ let Some(window) = web_sys::window() else { return Ok(()) };
+ let strip2 = strip.clone();
+ let inner2 = inner.clone();
+ let cb = Closure::<dyn FnMut()>::new(move || {
+ let width = strip2.client_width() as f64;
+ let offset = inner2.borrow().state.reposition(width);
+ let _ = strip2.class_list().add_1("vista-instant");
+ set_offset(&strip2, offset);
+ clear_instant_next_frame(&strip2);
+ });
+ window.add_event_listener_with_callback("resize", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+}
+
+fn set_offset(strip: &HtmlElement, offset_px: f64) {
+ let _ = strip
+ .style()
+ .set_property("--vista-offset", &format!("{}px", offset_px));
+}
+
+fn current_offset_px(strip: &HtmlElement) -> f64 {
+ let s = strip
+ .style()
+ .get_property_value("--vista-offset")
+ .unwrap_or_default();
+ s.trim().trim_end_matches("px").parse::<f64>().unwrap_or(0.0)
+}
+
+fn clear_instant_next_frame(strip: &HtmlElement) {
+ let strip2 = strip.clone();
+ let cb = Closure::once(Box::new(move || {
+ let _ = strip2.class_list().remove_1("vista-instant");
+ }) as Box<dyn FnOnce()>);
+ if let Some(w) = web_sys::window() {
+ let _ = w.request_animation_frame(cb.as_ref().unchecked_ref());
+ }
+ cb.forget();
+}
+
+#[doc(hidden)]
+pub fn __unused_event_marker(_e: &Event) {}
diff --git a/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs
new file mode 100644
index 0000000..a4958fb
--- /dev/null
+++ b/00_unanchay/pluma/pluma-deck-web/src/recorrido.rs
@@ -0,0 +1,603 @@
+//! Vista-web **espacial** — binding DOM del modo Recorrido (tipo Prezi).
+//!
+//! Espejo web del frontend Llimphi (`pluma-deck-recorrido-llimphi`): la lógica
+//! de cámara/ruta/gesto vive entera en `pluma-deck-core`; aquí sólo se traduce
+//! pointer/wheel/teclado → llamadas al core y se aplica la cámara como **un
+//! único `transform` CSS** sobre un contenedor `mundo` (estilo impress.js).
+//!
+//! A diferencia del strip lineal ([`crate::Deck`], `translate3d` 1D), el modo
+//! espacial coloca cada marco en coordenadas de mundo dentro de `mundo` y
+//! mueve la cámara sobre todos ellos. El vuelo guiado entre pasos se delega a
+//! una **transición CSS** del transform (el core sólo provee la cámara objetivo
+//! vía `fit`), igual que el strip delega su deslizamiento a `transition`.
+//!
+//! Contrato DOM (el caller provee):
+//! ```html
+//! <div class="recorrido-viewport">
+//! <div class="recorrido-mundo">
+//! <div class="recorrido-marco" data-x="0" data-y="0" data-w="640" data-h="400">…</div>
+//! <div class="recorrido-marco" data-x="900" data-y="0" data-w="640" data-h="400" data-rot="0.1">…</div>
+//! </div>
+//! </div>
+//! ```
+//! Cada `.recorrido-marco` lleva su rect de **mundo** en `data-{x,y,w,h}` (px) y
+//! un giro opcional `data-rot` (radianes). El orden DOM define la ruta. El
+//! binding posiciona los marcos y mueve la cámara; el contenido HTML interno es
+//! libre (texto, `<img>`, lo que sea).
+
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use pluma_deck_core::{Camara, ContenidoMarco, Marco, Recorrido, RecorridoState, Rect, DURACION_PASO_S};
+use wasm_bindgen::prelude::*;
+use wasm_bindgen::JsCast;
+use web_sys::{HtmlElement, KeyboardEvent, PointerEvent, WheelEvent};
+
+/// Base de zoom por "clic" de rueda (igual criterio que tullpu y el frontend Llimphi).
+pub const ZOOM_BASE: f64 = 1.1;
+/// Normalizador del `deltaY` de la rueda a "notches" de zoom: deltaY en modo
+/// pixel ronda ±100 por muesca, así que dividir por esto da ~1 notch por muesca
+/// y a la vez deja que el pinch de trackpad (deltas chicos) zoomee proporcional.
+const WHEEL_NORM: f64 = 100.0;
+/// Permanencia por defecto del modo presentador, en ms (espejo de `DWELL_S`).
+pub const DWELL_MS: i32 = 2500;
+
+/// Curva del vuelo entre pasos (misma que el strip lineal: salida suave).
+const EASE_VUELO: &str = "cubic-bezier(0.22, 0.61, 0.36, 1)";
+
+#[derive(Clone)]
+pub struct RecorridoWeb {
+ viewport: HtmlElement,
+ mundo: HtmlElement,
+ inner: Rc<RefCell<Inner>>,
+}
+
+struct Inner {
+ rec: Recorrido,
+ state: RecorridoState,
+ /// `Some((x,y))` = paneando desde esa última posición de pointer.
+ arrastrando: Option<(f64, f64)>,
+ on_change: Option<Box<dyn FnMut(usize)>>,
+ /// Handle del `setInterval` del modo presentador (autoplay), si está activo.
+ autoplay_id: Option<i32>,
+ /// Closure del intervalo — se guarda para mantenerla viva mientras corre.
+ autoplay_cb: Option<Closure<dyn FnMut()>>,
+}
+
+impl RecorridoWeb {
+ /// Monta el recorrido sobre `viewport` (clip) y `mundo` (contenedor
+ /// transformado). Lee los `.recorrido-marco` hijos de `mundo`, los posiciona
+ /// en coordenadas de mundo y encuadra el primero.
+ pub fn mount(viewport: HtmlElement, mundo: HtmlElement) -> Result<Self, JsValue> {
+ estilo(&viewport, "overflow", "hidden");
+ estilo(&viewport, "position", "relative");
+ estilo(&viewport, "touch-action", "none");
+ estilo(&viewport, "user-select", "none");
+ estilo(&mundo, "position", "absolute");
+ estilo(&mundo, "left", "0");
+ estilo(&mundo, "top", "0");
+ estilo(&mundo, "transform-origin", "0 0");
+ estilo(&mundo, "will-change", "transform");
+
+ let rec = leer_marcos(&mundo);
+ let mut state = RecorridoState::new();
+ let panel = panel_de(&viewport);
+ state.saltar_a_paso(&rec, 0, panel);
+
+ let inner = Rc::new(RefCell::new(Inner {
+ rec,
+ state,
+ arrastrando: None,
+ on_change: None,
+ autoplay_id: None,
+ autoplay_cb: None,
+ }));
+ aplicar_camara(&mundo, &inner.borrow().state.camara, panel, false);
+
+ let this = Self { viewport, mundo, inner };
+ this.install_pointer()?;
+ this.install_wheel()?;
+ this.install_keys()?;
+ Ok(this)
+ }
+
+ fn panel(&self) -> Rect {
+ panel_de(&self.viewport)
+ }
+
+ fn aplicar(&self, transicion: bool) {
+ let panel = self.panel();
+ aplicar_camara(&self.mundo, &self.inner.borrow().state.camara, panel, transicion);
+ }
+
+ /// Vuela a encuadrar el paso `idx` (con transición si `smooth`). Notifica
+ /// `on_change` si cambió el paso.
+ pub fn goto(&self, idx: usize, smooth: bool) {
+ let panel = self.panel();
+ let mut i = self.inner.borrow_mut();
+ let antes = i.state.paso;
+ let Inner { rec, state, .. } = &mut *i;
+ state.saltar_a_paso(rec, idx, panel);
+ let ahora = state.paso;
+ drop(i);
+ self.aplicar(smooth);
+ if ahora != antes {
+ if let Some(cb) = self.inner.borrow_mut().on_change.as_mut() {
+ cb(ahora);
+ }
+ }
+ }
+
+ /// Paso siguiente (clamp al final). `true` si se movió.
+ pub fn siguiente(&self) -> bool {
+ let i = self.inner.borrow();
+ let n = i.rec.n_pasos();
+ let p = i.state.paso;
+ drop(i);
+ if n == 0 || p + 1 >= n {
+ return false;
+ }
+ self.goto(p + 1, true);
+ true
+ }
+
+ /// Paso anterior (clamp en 0). `true` si se movió.
+ pub fn anterior(&self) -> bool {
+ let p = self.inner.borrow().state.paso;
+ if p == 0 {
+ return false;
+ }
+ self.goto(p - 1, true);
+ true
+ }
+
+ /// Vuela a la **vista general** (aleja para encuadrar todo el lienzo). Gesto
+ /// Prezi de "ver el mapa". No cambia el paso narrativo. Igual que `goto`, el
+ /// vuelo es una transición CSS: se fija la cámara objetivo del core (instante)
+ /// y el navegador la anima — el core nunca tickea `avanzar` en web.
+ pub fn vista_general(&self) {
+ let panel = self.panel();
+ {
+ let mut i = self.inner.borrow_mut();
+ let Some(bbox) = i.rec.bbox() else { return };
+ i.state.camara = Camara::fit(bbox, 0.0, panel);
+ }
+ self.aplicar(true);
+ }
+
+ pub fn paso_actual(&self) -> usize {
+ self.inner.borrow().state.paso
+ }
+
+ /// Exporta el recorrido a un documento **HTML autocontenido** (un solo
+ /// `.html` sin servidor ni `.wasm`): lee los `.recorrido-marco` vivos del
+ /// DOM (geometría de mundo + su HTML interno) y emite un documento con CSS y
+ /// un JS vanilla que replica la cámara del core (fit, zoom-a-cursor, pan,
+ /// pasos, vista general). Para publicar la presentación offline.
+ pub fn exportar_html(&self, titulo: &str) -> String {
+ let hijos = self.mundo.children();
+ let mut marcos = Vec::new();
+ for idx in 0..hijos.length() {
+ let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else {
+ continue;
+ };
+ marcos.push(MarcoExport {
+ x: attr_f64(&el, "data-x", 0.0),
+ y: attr_f64(&el, "data-y", 0.0),
+ w: attr_f64(&el, "data-w", 640.0),
+ h: attr_f64(&el, "data-h", 400.0),
+ rot: attr_f64(&el, "data-rot", 0.0),
+ html: el.inner_html(),
+ });
+ }
+ recorrido_a_html(titulo, &marcos)
+ }
+
+ /// `true` si el modo presentador (autoplay) está corriendo.
+ pub fn autoplay_activo(&self) -> bool {
+ self.inner.borrow().autoplay_id.is_some()
+ }
+
+ /// Arranca el modo presentador: cada `dur_paso + dwell_ms` avanza un paso
+ /// solo (vuelve al inicio al llegar al final). Cancela cualquier autoplay
+ /// previo. Espejo vivo del `setInterval` del HTML exportado.
+ pub fn iniciar_autoplay(&self, dwell_ms: i32) {
+ self.detener_autoplay();
+ let Some(window) = web_sys::window() else { return };
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut()>::new(move || {
+ let (paso, n) = {
+ let i = this.inner.borrow();
+ (i.state.paso, i.rec.n_pasos())
+ };
+ if n == 0 {
+ return;
+ }
+ this.goto(if paso + 1 < n { paso + 1 } else { 0 }, true);
+ });
+ let periodo = (DURACION_PASO_S * 1000.0) as i32 + dwell_ms.max(0);
+ if let Ok(id) = window.set_interval_with_callback_and_timeout_and_arguments_0(
+ cb.as_ref().unchecked_ref(),
+ periodo,
+ ) {
+ let mut i = self.inner.borrow_mut();
+ i.autoplay_id = Some(id);
+ i.autoplay_cb = Some(cb);
+ }
+ }
+
+ /// Detiene el modo presentador (no-op si no estaba activo).
+ pub fn detener_autoplay(&self) {
+ let mut i = self.inner.borrow_mut();
+ if let Some(id) = i.autoplay_id.take() {
+ if let Some(w) = web_sys::window() {
+ w.clear_interval_with_handle(id);
+ }
+ }
+ i.autoplay_cb = None;
+ }
+
+ /// Alterna el modo presentador con el dwell por defecto. Devuelve el nuevo
+ /// estado (`true` = corriendo).
+ pub fn toggle_autoplay(&self) -> bool {
+ if self.autoplay_activo() {
+ self.detener_autoplay();
+ false
+ } else {
+ self.iniciar_autoplay(DWELL_MS);
+ true
+ }
+ }
+
+ pub fn on_change<F: FnMut(usize) + 'static>(&self, cb: F) {
+ self.inner.borrow_mut().on_change = Some(Box::new(cb));
+ }
+
+ // ---- Cableado de eventos --------------------------------------------
+
+ fn install_pointer(&self) -> Result<(), JsValue> {
+ // down: arranca paneo (cancela cualquier vuelo, control manual).
+ {
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ this.inner.borrow_mut().arrastrando = Some((e.client_x() as f64, e.client_y() as f64));
+ let _ = this.viewport.set_pointer_capture(e.pointer_id());
+ });
+ self.viewport
+ .add_event_listener_with_callback("pointerdown", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ }
+ // move: si hay arrastre, panea por el delta de pantalla (sin transición).
+ {
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ let (x, y) = (e.client_x() as f64, e.client_y() as f64);
+ let mut i = this.inner.borrow_mut();
+ let Some((px, py)) = i.arrastrando else { return };
+ i.state.arrastrar_delta(x - px, y - py);
+ i.arrastrando = Some((x, y));
+ drop(i);
+ this.aplicar(false);
+ });
+ self.viewport
+ .add_event_listener_with_callback("pointermove", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ }
+ // up/cancel/leave: fin del paneo.
+ for ev in ["pointerup", "pointercancel", "pointerleave"] {
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut(PointerEvent)>::new(move |e: PointerEvent| {
+ this.inner.borrow_mut().arrastrando = None;
+ let _ = this.viewport.release_pointer_capture(e.pointer_id());
+ });
+ self.viewport
+ .add_event_listener_with_callback(ev, cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ }
+ Ok(())
+ }
+
+ fn install_wheel(&self) -> Result<(), JsValue> {
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut(WheelEvent)>::new(move |e: WheelEvent| {
+ e.prevent_default();
+ let rect = this.viewport.get_bounding_client_rect();
+ let cursor = (e.client_x() as f64 - rect.left(), e.client_y() as f64 - rect.top());
+ // deltaY>0 ⇒ scroll abajo ⇒ alejar (convención CSS, igual que tullpu).
+ // Proporcional al delta (no sólo al signo) para que el pinch de
+ // trackpad sea suave; normalizado por `WHEEL_NORM` (un "notch" de
+ // rueda ≈ ±100px en modo pixel) y acotado para que un golpe fuerte no
+ // teletransporte el zoom.
+ let pasos = (e.delta_y() / WHEEL_NORM).clamp(-3.0, 3.0);
+ let mult = ZOOM_BASE.powf(-pasos);
+ let panel = this.panel();
+ this.inner.borrow_mut().state.wheel(mult, cursor, panel);
+ this.aplicar(false);
+ });
+ let opts = web_sys::AddEventListenerOptions::new();
+ opts.set_passive(false);
+ self.viewport.add_event_listener_with_callback_and_add_event_listener_options(
+ "wheel",
+ cb.as_ref().unchecked_ref(),
+ &opts,
+ )?;
+ cb.forget();
+ Ok(())
+ }
+
+ fn install_keys(&self) -> Result<(), JsValue> {
+ let Some(window) = web_sys::window() else { return Ok(()) };
+ let this = self.clone();
+ let cb = Closure::<dyn FnMut(KeyboardEvent)>::new(move |e: KeyboardEvent| {
+ let movido = match e.key().as_str() {
+ "ArrowRight" | "ArrowDown" | " " | "Spacebar" | "Enter" => this.siguiente(),
+ "ArrowLeft" | "ArrowUp" => this.anterior(),
+ "Home" | "Escape" => {
+ this.vista_general();
+ true
+ }
+ "p" | "P" => {
+ this.toggle_autoplay();
+ true
+ }
+ _ => return,
+ };
+ if movido {
+ e.prevent_default();
+ }
+ });
+ window.add_event_listener_with_callback("keydown", cb.as_ref().unchecked_ref())?;
+ cb.forget();
+ Ok(())
+ }
+}
+
+// ---- Lectura del DOM + geometría -----------------------------------------
+
+/// Construye el `Recorrido` del core leyendo los `.recorrido-marco` hijos de
+/// `mundo`, y posiciona cada uno en coordenadas de mundo. El orden DOM define
+/// la ruta; los ids se asignan `1..=n`.
+fn leer_marcos(mundo: &HtmlElement) -> Recorrido {
+ let mut rec = Recorrido::new();
+ let hijos = mundo.children();
+ for idx in 0..hijos.length() {
+ let Some(el) = hijos.item(idx).and_then(|e| e.dyn_into::<HtmlElement>().ok()) else { continue };
+ let x = attr_f64(&el, "data-x", 0.0);
+ let y = attr_f64(&el, "data-y", 0.0);
+ let w = attr_f64(&el, "data-w", 640.0);
+ let h = attr_f64(&el, "data-h", 400.0);
+ let rot = attr_f64(&el, "data-rot", 0.0);
+ // El marco vive en coordenadas de mundo dentro de `mundo`: left/top/size
+ // en px-mundo y su giro propio alrededor del centro (transform-origin
+ // por defecto = center), espejando el render Llimphi.
+ estilo(&el, "position", "absolute");
+ estilo(&el, "left", &format!("{x}px"));
+ estilo(&el, "top", &format!("{y}px"));
+ estilo(&el, "width", &format!("{w}px"));
+ estilo(&el, "height", &format!("{h}px"));
+ estilo(&el, "box-sizing", "border-box");
+ if rot != 0.0 {
+ estilo(&el, "transform", &format!("rotate({rot}rad)"));
+ }
+ let id = (idx + 1) as u64;
+ rec.agregar_marco(Marco::new(id, Rect::new(x, y, w, h), ContenidoMarco::Vacio).con_giro(rot));
+ rec.pasos.push(id);
+ }
+ rec
+}
+
+fn panel_de(viewport: &HtmlElement) -> Rect {
+ Rect::new(0.0, 0.0, viewport.client_width() as f64, viewport.client_height() as f64)
+}
+
+/// Construye el `transform` CSS de la cámara: réplica exacta de
+/// `Camara::world_to_screen` (`centro_panel + zoom·R(-rot)·(mundo - centro)`)
+/// como cadena aplicable a `mundo` con `transform-origin: 0 0`.
+fn camara_css(cam: &Camara, panel: Rect) -> String {
+ let (pcx, pcy) = panel.centro();
+ format!(
+ "translate({pcx}px,{pcy}px) scale({z}) rotate({r}rad) translate({cx}px,{cy}px)",
+ z = cam.zoom,
+ r = -cam.rot_rad,
+ cx = -cam.centro.0,
+ cy = -cam.centro.1,
+ )
+}
+
+fn aplicar_camara(mundo: &HtmlElement, cam: &Camara, panel: Rect, transicion: bool) {
+ let trans = if transicion {
+ format!("transform {}ms {EASE_VUELO}", (DURACION_PASO_S * 1000.0) as i64)
+ } else {
+ "none".to_string()
+ };
+ estilo(mundo, "transition", &trans);
+ estilo(mundo, "transform", &camara_css(cam, panel));
+}
+
+fn estilo(el: &HtmlElement, prop: &str, val: &str) {
+ let _ = el.style().set_property(prop, val);
+}
+
+fn attr_f64(el: &HtmlElement, name: &str, def: f64) -> f64 {
+ el.get_attribute(name).and_then(|s| s.trim().parse::<f64>().ok()).unwrap_or(def)
+}
+
+// ---- Export a HTML autocontenido -----------------------------------------
+
+/// Un marco listo para exportar: su rect de mundo + giro + su HTML interno.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MarcoExport {
+ pub x: f64,
+ pub y: f64,
+ pub w: f64,
+ pub h: f64,
+ pub rot: f64,
+ /// HTML interno del marco (texto, `<img>`, lo que sea).
+ pub html: String,
+}
+
+/// Genera un documento HTML **autocontenido** desde los marcos (función pura,
+/// sin DOM — testeable en host). Embebe el CSS y un JS vanilla que replica la
+/// cámara de `pluma-deck-core` (`fit`/`zoom_a_cursor`/`pan`/pasos/`vista_general`)
+/// para que el `.html` resultante presente offline sin servidor ni `.wasm`.
+pub fn recorrido_a_html(titulo: &str, marcos: &[MarcoExport]) -> String {
+ let t = escapar_html(titulo);
+ let mut cuerpo = String::new();
+ for m in marcos {
+ let rot = if m.rot != 0.0 {
+ format!(";transform:rotate({}rad)", m.rot)
+ } else {
+ String::new()
+ };
+ cuerpo.push_str(&format!(
+ "<div class=\"recorrido-marco\" data-x=\"{x}\" data-y=\"{y}\" data-w=\"{w}\" data-h=\"{h}\" data-rot=\"{r}\" \
+ style=\"left:{x}px;top:{y}px;width:{w}px;height:{h}px{rot}\">{html}</div>",
+ x = m.x, y = m.y, w = m.w, h = m.h, r = m.rot, rot = rot, html = m.html,
+ ));
+ }
+ format!(
+ "<!DOCTYPE html>\n<html lang=\"es\"><head><meta charset=\"utf-8\">\
+ <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
+ <title>{t}</title><style>{css}</style></head><body>\
+ <div class=\"recorrido-viewport\"><div class=\"recorrido-mundo\">{cuerpo}</div>\
+ <div class=\"recorrido-hud\"></div></div><script>{js}</script></body></html>",
+ t = t, css = EXPORT_CSS, cuerpo = cuerpo, js = EXPORT_JS,
+ )
+}
+
+/// Escape mínimo para insertar texto en HTML (sólo para el `<title>`).
+fn escapar_html(s: &str) -> String {
+ s.replace('&', "&").replace('<', "<").replace('>', ">")
+}
+
+const EXPORT_CSS: &str = "\
+html,body{margin:0;height:100%;background:#12141c;font-family:system-ui,sans-serif}\
+.recorrido-viewport{position:relative;width:100vw;height:100vh;overflow:hidden;touch-action:none;user-select:none}\
+.recorrido-mundo{position:absolute;left:0;top:0;transform-origin:0 0;will-change:transform}\
+.recorrido-marco{position:absolute;box-sizing:border-box;background:#262a38;border:1px solid #505668;\
+border-radius:6px;color:#e1e6f0;padding:20px;overflow:hidden}\
+.recorrido-marco h1,.recorrido-marco h2{margin:0 0 .4em;color:#fff}\
+.recorrido-marco img{max-width:100%;max-height:100%;object-fit:contain}\
+.recorrido-hud{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);\
+background:rgba(12,14,20,.78);color:#cdd4e2;padding:6px 12px;border-radius:999px;font-size:14px;pointer-events:none}";
+
+// JS vanilla — espejo de RecorridoWeb/Camara. Se inserta como argumento de
+// format!, así sus llaves no necesitan escaparse.
+const EXPORT_JS: &str = r#"(function(){
+var vp=document.querySelector('.recorrido-viewport');
+var mundo=document.querySelector('.recorrido-mundo');
+var hud=document.querySelector('.recorrido-hud');
+var ZB=1.1,FIT=0.9,ZMIN=0.02,ZMAX=64,DUR=800,WN=100,DWELL=2500;
+var autoTimer=null;
+var EASE='transform '+DUR+'ms cubic-bezier(0.22,0.61,0.36,1)';
+var marcos=[].slice.call(mundo.children).map(function(el){return{
+ x:+el.dataset.x||0,y:+el.dataset.y||0,w:+el.dataset.w||640,h:+el.dataset.h||400,rot:+el.dataset.rot||0};});
+var cam={cx:0,cy:0,zoom:1,rot:0},paso=0;
+function P(){return{w:vp.clientWidth,h:vp.clientHeight};}
+function clamp(z){return Math.max(ZMIN,Math.min(ZMAX,z));}
+function fit(m){var p=P();var zw=m.w>0?p.w/m.w:1,zh=m.h>0?p.h/m.h:1;
+ return{cx:m.x+m.w/2,cy:m.y+m.h/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:m.rot};}
+function fitAll(){if(!marcos.length)return cam;var p=P();
+ var minx=1/0,miny=1/0,maxx=-1/0,maxy=-1/0;
+ marcos.forEach(function(m){var hw=m.w/2,hh=m.h/2,c=Math.abs(Math.cos(m.rot)),s=Math.abs(Math.sin(m.rot));
+ var ex=hw*c+hh*s,ey=hw*s+hh*c,cx=m.x+hw,cy=m.y+hh;
+ minx=Math.min(minx,cx-ex);miny=Math.min(miny,cy-ey);maxx=Math.max(maxx,cx+ex);maxy=Math.max(maxy,cy+ey);});
+ var bw=maxx-minx,bh=maxy-miny;var zw=bw>0?p.w/bw:1,zh=bh>0?p.h/bh:1;
+ return{cx:(minx+maxx)/2,cy:(miny+maxy)/2,zoom:clamp(Math.min(zw,zh)*FIT),rot:0};}
+function apply(smooth){var p=P();mundo.style.transition=smooth?EASE:'none';
+ mundo.style.transform='translate('+(p.w/2)+'px,'+(p.h/2)+'px) scale('+cam.zoom+') rotate('+(-cam.rot)+'rad) translate('+(-cam.cx)+'px,'+(-cam.cy)+'px)';
+ if(hud)hud.textContent=(paso+1)+' / '+marcos.length;}
+function s2w(px,py){var p=P();var sx=(px-p.w/2)/cam.zoom,sy=(py-p.h/2)/cam.zoom;
+ var c=Math.cos(cam.rot),s=Math.sin(cam.rot);return[cam.cx+sx*c-sy*s,cam.cy+sx*s+sy*c];}
+function goto(i,smooth){if(i<0||i>=marcos.length)return;paso=i;cam=fit(marcos[i]);apply(smooth);}
+function setAuto(on){if(autoTimer){clearInterval(autoTimer);autoTimer=null;}
+ if(on)autoTimer=setInterval(function(){goto(paso+1<marcos.length?paso+1:0,true);},DUR+DWELL);}
+marcos.forEach(function(m,i){var el=mundo.children[i];el.style.position='absolute';
+ el.style.left=m.x+'px';el.style.top=m.y+'px';el.style.width=m.w+'px';el.style.height=m.h+'px';
+ el.style.boxSizing='border-box';if(m.rot)el.style.transform='rotate('+m.rot+'rad)';});
+vp.addEventListener('wheel',function(e){e.preventDefault();var r=vp.getBoundingClientRect();
+ var cx=e.clientX-r.left,cy=e.clientY-r.top;var pasos=Math.max(-3,Math.min(3,e.deltaY/WN));
+ var mult=Math.pow(ZB,-pasos);var anc=s2w(cx,cy);cam.zoom=clamp(cam.zoom*mult);
+ var p=P();var sx=(cx-p.w/2)/cam.zoom,sy=(cy-p.h/2)/cam.zoom;var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
+ cam.cx=anc[0]-(sx*c-sy*s);cam.cy=anc[1]-(sx*s+sy*c);apply(false);},{passive:false});
+var drag=null;
+vp.addEventListener('pointerdown',function(e){drag=[e.clientX,e.clientY];vp.setPointerCapture(e.pointerId);});
+vp.addEventListener('pointermove',function(e){if(!drag)return;var dx=e.clientX-drag[0],dy=e.clientY-drag[1];
+ drag=[e.clientX,e.clientY];var c=Math.cos(cam.rot),s=Math.sin(cam.rot);
+ cam.cx-=(dx*c-dy*s)/cam.zoom;cam.cy-=(dx*s+dy*c)/cam.zoom;apply(false);});
+['pointerup','pointercancel','pointerleave'].forEach(function(ev){vp.addEventListener(ev,function(){drag=null;});});
+window.addEventListener('keydown',function(e){var k=e.key;
+ if(k==='ArrowRight'||k==='ArrowDown'||k===' '||k==='Spacebar'||k==='Enter'){if(paso+1<marcos.length){goto(paso+1,true);e.preventDefault();}}
+ else if(k==='ArrowLeft'||k==='ArrowUp'){if(paso>0){goto(paso-1,true);e.preventDefault();}}
+ else if(k==='Home'||k==='Escape'){cam=fitAll();apply(true);e.preventDefault();}
+ else if(k==='p'||k==='P'){setAuto(!autoTimer);e.preventDefault();}});
+window.addEventListener('resize',function(){apply(false);});
+goto(0,false);
+})();"#;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const PANEL: Rect = Rect { x: 0.0, y: 0.0, w: 800.0, h: 600.0 };
+
+ #[test]
+ fn camara_identidad_centra_el_origen() {
+ // Cámara por defecto (centro 0,0 zoom 1 rot 0): el transform lleva el
+ // origen de mundo al centro del panel.
+ let css = camara_css(&Camara::default(), PANEL);
+ assert_eq!(css, "translate(400px,300px) scale(1) rotate(-0rad) translate(-0px,-0px)");
+ }
+
+ #[test]
+ fn camara_css_refleja_world_to_screen() {
+ // Para varios puntos, evaluar el transform CSS a mano debe coincidir con
+ // Camara::world_to_screen — el CSS es ese mismo afín.
+ let cam = Camara::new((120.0, -40.0), 1.7, 0.3);
+ for &(wx, wy) in &[(0.0, 0.0), (200.0, 50.0), (-80.0, 300.0)] {
+ let esperado = cam.world_to_screen((wx, wy), PANEL);
+ // Aplicar el transform manualmente: translate(pc)·scale·rotate(-rot)·translate(-c).
+ let (pcx, pcy) = PANEL.centro();
+ let (dx, dy) = (wx - cam.centro.0, wy - cam.centro.1);
+ let (s, c) = (-cam.rot_rad).sin_cos();
+ let (rx, ry) = (dx * c - dy * s, dx * s + dy * c);
+ let got = (pcx + rx * cam.zoom, pcy + ry * cam.zoom);
+ assert!((got.0 - esperado.0).abs() < 1e-9, "x: {got:?} vs {esperado:?}");
+ assert!((got.1 - esperado.1).abs() < 1e-9, "y: {got:?} vs {esperado:?}");
+ }
+ // Y la cadena contiene los componentes esperados.
+ let css = camara_css(&cam, PANEL);
+ assert!(css.contains("scale(1.7)"), "{css}");
+ assert!(css.contains("rotate(-0.3rad)"), "{css}");
+ }
+
+ #[test]
+ fn export_html_es_documento_autocontenido() {
+ let marcos = vec![
+ MarcoExport { x: 0.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.0, html: "<h1>Uno</h1>".into() },
+ MarcoExport { x: 900.0, y: 0.0, w: 640.0, h: 400.0, rot: 0.12, html: "<p>Dos</p>".into() },
+ ];
+ let html = recorrido_a_html("Mi <demo>", &marcos);
+ // Documento completo con CSS + JS embebidos (sin recursos externos).
+ assert!(html.starts_with("<!DOCTYPE html>"));
+ assert!(html.contains("<style>") && html.contains("<script>"));
+ assert!(!html.contains("http://") && !html.contains("https://") && !html.contains(".wasm"));
+ // El título se escapa.
+ assert!(html.contains("<title>Mi <demo></title>"), "{}", &html[..200]);
+ // Cada marco con su geometría de mundo y su HTML interno.
+ assert!(html.contains("data-x=\"900\"") && html.contains("data-rot=\"0.12\""));
+ assert!(html.contains("<h1>Uno</h1>") && html.contains("<p>Dos</p>"));
+ // El JS replica la cámara del core (funciones clave presentes).
+ assert!(html.contains("function fit(") && html.contains("function fitAll("));
+ assert!(html.contains("function s2w(") && html.contains("goto(0,false)"));
+ // Y el modo presentador (autoplay con setInterval, tecla 'p').
+ assert!(html.contains("function setAuto(") && html.contains("setInterval("));
+ }
+
+ #[test]
+ fn export_html_sin_marcos_sigue_siendo_valido() {
+ let html = recorrido_a_html("vacío", &[]);
+ assert!(html.starts_with("<!DOCTYPE html>") && html.ends_with("</html>"));
+ assert!(html.contains("recorrido-mundo"));
+ }
+}
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml
new file mode 100644
index 0000000..53b771a
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "pluma-editor-cuerpo"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — sincronía bidireccional cuerpo ↔ texto plano. Un solo control (el texto), átomos por debajo: edit donde se sienta natural, los cambios bajan a `NarrativeAtom`s con el mínimo diff posible."
+
+[dependencies]
+uuid = { workspace = true, features = ["serde"] }
+serde = { workspace = true }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md
new file mode 100644
index 0000000..29ccec6
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/LEEME.md
@@ -0,0 +1,20 @@
+# pluma-editor-cuerpo
+
+> Editor texto ↔ átomos con diff (greedy) para [pluma](../README.md).
+
+`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatena con `SEPARADOR = "\n\n"`. `parrafos()` recupera el split actual. `diff(atoms_originales) -> Vec<CambioAtom>` con greedy por contenido: párrafos que coinciden se saltan, los que difieren emiten `Mutar` reusando el `Uuid` (hebras vivas), sobrantes emiten `Crear` o `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extiende/remueve el `atom_ids` tras persistir.
+
+## API
+
+```rust
+use pluma_editor_cuerpo::EditorCuerpo;
+
+let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
+e.set_texto(nuevo);
+let cambios = e.diff(&atoms_originales);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/README.md b/00_unanchay/pluma/pluma-editor-cuerpo/README.md
new file mode 100644
index 0000000..dd9544e
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/README.md
@@ -0,0 +1,20 @@
+# pluma-editor-cuerpo
+
+> Text ↔ atoms editor with diff (greedy) for [pluma](../README.md).
+
+`EditorCuerpo { texto, atom_ids }`. `from_cuerpo(c, atoms)` concatenates with `SEPARADOR = "\n\n"`. `parrafos()` retrieves the current split. `diff(atoms_originales) -> Vec<CambioAtom>` with greedy content-matching: matching paragraphs are skipped, differing ones emit `Mutar` reusing the `Uuid` (live threads), excess emits `Crear` or `Eliminar`. `aplicar_cambios(cambios, nuevos_ids)` extends/removes `atom_ids` after persisting.
+
+## API
+
+```rust
+use pluma_editor_cuerpo::EditorCuerpo;
+
+let mut e = EditorCuerpo::from_cuerpo(&c, &atoms);
+e.set_texto(nuevo);
+let cambios = e.diff(&atoms_originales);
+```
+
+## Deps
+
+- [`pluma-core`](../pluma-core/README.md), [`pluma-cuerpo`](../pluma-cuerpo/README.md)
+- `serde`, `uuid`
diff --git a/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs
new file mode 100644
index 0000000..24a94cc
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-cuerpo/src/lib.rs
@@ -0,0 +1,352 @@
+//! `pluma-editor-cuerpo` — sincronía entre un cuerpo (lista ordenada de
+//! `NarrativeAtom`s) y un único buffer de texto plano editable.
+//!
+//! La idea del editor multilienzo es **un solo control para la página
+//! entera**: el usuario ve todos los párrafos concatenados en un
+//! `text-editor` IDE estándar (cursor libre, selección entre párrafos,
+//! undo global) — pero por debajo cada párrafo sigue siendo un
+//! `NarrativeAtom` con `Uuid` propio. Hebras, alineamientos,
+//! transformaciones LLM, persistencia: todo lo que pluma ya hace, sigue
+//! funcionando sin saber que la UI muestra un buffer único.
+//!
+//! Este crate cubre la traducción en los dos sentidos:
+//!
+//! - **Cuerpo → texto**: concatena los atoms con `\n\n` entre cada uno.
+//! Doble salto es el separador natural de párrafo en markdown y un
+//! usuario no escribiría doble enter dentro de un mismo párrafo.
+//!
+//! - **Texto → cuerpo**: dado el texto editado + el orden previo de
+//! atoms + un índice de los contenidos viejos, computa el mínimo
+//! diff (mutar / crear / eliminar atoms) para que el cuerpo refleje
+//! el texto. Greedy con anclas por contenido: si un párrafo del texto
+//! coincide byte-a-byte con un atom viejo, lo reusamos (UUID
+//! preservado — las hebras siguen vigentes).
+//!
+//! El crate NO renderiza nada. La UI que reuse el `text-editor` IDE
+//! (con sus EditorState/cursor/undo/highlight) vive arriba y consume
+//! estas estructuras para sincronizar el `Buffer` de ropey con el grafo.
+
+#![forbid(unsafe_code)]
+
+use std::collections::HashMap;
+
+use uuid::Uuid;
+
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::Cuerpo;
+
+/// Separador canónico entre párrafos en el buffer plano.
+/// Doble salto: lo entiende cualquier markdown reader y nadie lo usa
+/// dentro de un párrafo normal.
+pub const SEPARADOR: &str = "\n\n";
+
+/// Diferencia entre dos estados del cuerpo. `EditorCuerpo::diff`
+/// produce esta lista en orden de aplicación; el caller la consume
+/// secuencialmente.
+#[derive(Debug, Clone, PartialEq)]
+pub enum CambioAtom {
+ /// El atom `id` existe en el cuerpo viejo Y en el nuevo, pero su
+ /// contenido cambió. Aplicar: `graph.get_mut(id).set_content(...)`
+ /// + `propagate_mutation(id)`.
+ Mutar { id: Uuid, texto_nuevo: String },
+ /// Un párrafo nuevo apareció. Crear un `NarrativeAtom` con el texto
+ /// y agregarlo al cuerpo en la posición indicada (0-based).
+ Crear { texto: String, posicion: usize },
+ /// El atom `id` ya no aparece en el texto. Removerlo del cuerpo
+ /// (el atom mismo puede quedar en el grafo — el usuario decide).
+ Eliminar { id: Uuid },
+}
+
+/// Vista plana de un cuerpo como texto editable + mapeo a los Uuids
+/// originales.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct EditorCuerpo {
+ /// Texto plano del cuerpo entero: párrafos concatenados con
+ /// `SEPARADOR` entre cada uno. El text-editor IDE edita esto.
+ pub texto: String,
+ /// Uuids de los atoms del cuerpo en el orden en que aparecen en
+ /// `texto`. `atom_ids.len()` siempre coincide con la cantidad de
+ /// párrafos no-vacíos en `texto` AL CONSTRUIRSE (después de un
+ /// edit del usuario puede divergir — la función `diff` resuelve).
+ pub atom_ids: Vec<Uuid>,
+}
+
+impl EditorCuerpo {
+ /// Construye un editor a partir de un cuerpo + el índice de atoms
+ /// del grafo. Los atoms cuyo Uuid no esté en el índice se omiten
+ /// (cuerpo huérfano — un caso de datos corruptos).
+ pub fn from_cuerpo(cuerpo: &Cuerpo, atoms: &HashMap<Uuid, &NarrativeAtom>) -> Self {
+ let mut chunks: Vec<&str> = Vec::with_capacity(cuerpo.orden.len());
+ let mut ids: Vec<Uuid> = Vec::with_capacity(cuerpo.orden.len());
+ for id in &cuerpo.orden {
+ if let Some(atom) = atoms.get(id) {
+ chunks.push(atom.content.as_str());
+ ids.push(*id);
+ }
+ }
+ let texto = chunks.join(SEPARADOR);
+ Self {
+ texto,
+ atom_ids: ids,
+ }
+ }
+
+ /// Lista los párrafos actuales del texto (split por `SEPARADOR`,
+ /// trim de espacios alrededor, vacíos descartados). El resultado
+ /// puede tener tamaño distinto a `atom_ids` después de una edición
+ /// que insertó/eliminó separadores.
+ pub fn parrafos(&self) -> Vec<&str> {
+ self.texto
+ .split(SEPARADOR)
+ .map(str::trim)
+ .filter(|s| !s.is_empty())
+ .collect()
+ }
+
+ /// Reemplaza el texto entero — equivalente a aplicar la salida del
+ /// `text-editor` IDE de un golpe. No toca `atom_ids`; el caller
+ /// debe llamar `diff` después para sincronizar el cuerpo.
+ pub fn set_texto(&mut self, nuevo: impl Into<String>) {
+ self.texto = nuevo.into();
+ }
+
+ /// Calcula el diff mínimo para que el cuerpo (referenciado por
+ /// `atom_ids`) refleje el `texto` actual. Estrategia greedy con
+ /// anclas por contenido:
+ ///
+ /// 1. Recorre los párrafos del texto y los atoms originales en
+ /// paralelo.
+ /// 2. Si el párrafo i coincide byte-a-byte con el atom i del
+ /// `atoms_originales`, no hay cambio para esa posición.
+ /// 3. Si difiere, emite `Mutar` reusando el Uuid del atom i (las
+ /// hebras siguen apuntando al mismo Uuid).
+ /// 4. Si el texto tiene MÁS párrafos que los atoms originales, los
+ /// sobrantes son `Crear` al final.
+ /// 5. Si el texto tiene MENOS, los atoms sobrantes son `Eliminar`.
+ ///
+ /// `atoms_originales` debe contener los Uuids de `self.atom_ids`
+ /// (los del cuerpo cuando se construyó el editor). El caller suele
+ /// recolectarlos del grafo justo antes de llamar a `diff`.
+ pub fn diff(
+ &self,
+ atoms_originales: &HashMap<Uuid, &NarrativeAtom>,
+ ) -> Vec<CambioAtom> {
+ let parrafos_nuevos = self.parrafos();
+ let mut cambios = Vec::new();
+ let n = parrafos_nuevos.len();
+ let m = self.atom_ids.len();
+ let comun = n.min(m);
+
+ for i in 0..comun {
+ let id_viejo = self.atom_ids[i];
+ let texto_nuevo = parrafos_nuevos[i].to_string();
+ let texto_viejo = atoms_originales
+ .get(&id_viejo)
+ .map(|a| a.content.as_str())
+ .unwrap_or("");
+ if texto_viejo != texto_nuevo {
+ cambios.push(CambioAtom::Mutar {
+ id: id_viejo,
+ texto_nuevo,
+ });
+ }
+ }
+ // Sobrantes del texto: párrafos nuevos.
+ for (offset, p) in parrafos_nuevos.iter().enumerate().skip(comun) {
+ cambios.push(CambioAtom::Crear {
+ texto: p.to_string(),
+ posicion: offset,
+ });
+ }
+ // Sobrantes del cuerpo: atoms a eliminar.
+ for &id in self.atom_ids.iter().skip(comun) {
+ cambios.push(CambioAtom::Eliminar { id });
+ }
+ cambios
+ }
+
+ /// Aplica una lista de cambios al `atom_ids` del editor — útil tras
+ /// que el caller persistió los cambios en el grafo. Devuelve los
+ /// Uuids de los atoms recién creados, en orden, para que el caller
+ /// los asigne a los `Crear` reales (el editor no sabe generar
+ /// `Uuid`s — eso lo hace `NarrativeAtom::new` arriba).
+ ///
+ /// `nuevos_ids` debe tener al menos tantos elementos como cambios
+ /// `Crear` haya. Si tiene menos, los `Crear` sobrantes se descartan;
+ /// si más, se ignoran los extras.
+ pub fn aplicar_cambios(&mut self, cambios: &[CambioAtom], nuevos_ids: &[Uuid]) {
+ let mut idx_nuevo = 0;
+ let mut a_eliminar: Vec<Uuid> = Vec::new();
+ for c in cambios {
+ match c {
+ CambioAtom::Mutar { .. } => {} // el atom_id viejo se reusa
+ CambioAtom::Crear { .. } => {
+ if let Some(&id) = nuevos_ids.get(idx_nuevo) {
+ self.atom_ids.push(id);
+ idx_nuevo += 1;
+ }
+ }
+ CambioAtom::Eliminar { id } => {
+ a_eliminar.push(*id);
+ }
+ }
+ }
+ self.atom_ids.retain(|id| !a_eliminar.contains(id));
+ }
+}
+
+#[cfg(test)]
+mod pruebas {
+ use super::*;
+ use pluma_cuerpo::Intencion;
+
+ fn cuerpo_con_atoms(textos: &[&str]) -> (Cuerpo, Vec<NarrativeAtom>) {
+ let mut c = Cuerpo::nuevo("es", "es", Intencion::Original, 0);
+ let atoms: Vec<NarrativeAtom> =
+ textos.iter().map(|t| NarrativeAtom::new(*t, "es")).collect();
+ for a in &atoms {
+ c.agregar(a.id, 0);
+ }
+ (c, atoms)
+ }
+
+ fn indice(atoms: &[NarrativeAtom]) -> HashMap<Uuid, &NarrativeAtom> {
+ atoms.iter().map(|a| (a.id, a)).collect()
+ }
+
+ #[test]
+ fn from_cuerpo_concatena_con_separador_y_mantiene_orden() {
+ let (c, atoms) = cuerpo_con_atoms(&["Uno.", "Dos.", "Tres."]);
+ let idx = indice(&atoms);
+ let ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ assert_eq!(ed.texto, "Uno.\n\nDos.\n\nTres.");
+ assert_eq!(ed.atom_ids, vec![atoms[0].id, atoms[1].id, atoms[2].id]);
+ }
+
+ #[test]
+ fn parrafos_split_y_trim_de_vacios() {
+ let (c, atoms) = cuerpo_con_atoms(&["Uno", "Dos"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ // El usuario agrega espacios y un párrafo vacío entre medio:
+ ed.set_texto(" Uno \n\n\n\n Dos ");
+ let p = ed.parrafos();
+ assert_eq!(p, vec!["Uno", "Dos"]);
+ }
+
+ #[test]
+ fn sin_cambios_diff_vacio() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+ let idx = indice(&atoms);
+ let ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ assert!(ed.diff(&idx).is_empty());
+ }
+
+ #[test]
+ fn mutar_un_parrafo_emite_mutar_con_uuid_preservado() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ // El usuario edita el segundo párrafo.
+ ed.set_texto("uno\n\nDOS!\n\ntres");
+ let d = ed.diff(&idx);
+ assert_eq!(d.len(), 1);
+ assert_eq!(d[0], CambioAtom::Mutar {
+ id: atoms[1].id,
+ texto_nuevo: "DOS!".to_string(),
+ });
+ }
+
+ #[test]
+ fn agregar_parrafos_emite_crear() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ ed.set_texto("uno\n\ndos\n\ntres\n\ncuatro");
+ let d = ed.diff(&idx);
+ assert_eq!(d.len(), 2);
+ assert_eq!(d[0], CambioAtom::Crear {
+ texto: "tres".to_string(),
+ posicion: 2,
+ });
+ assert_eq!(d[1], CambioAtom::Crear {
+ texto: "cuatro".to_string(),
+ posicion: 3,
+ });
+ }
+
+ #[test]
+ fn eliminar_parrafos_emite_eliminar_para_los_ids_sobrantes() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres", "cuatro"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ // El usuario borra los dos últimos.
+ ed.set_texto("uno\n\ndos");
+ let d = ed.diff(&idx);
+ assert_eq!(d.len(), 2);
+ assert_eq!(d[0], CambioAtom::Eliminar { id: atoms[2].id });
+ assert_eq!(d[1], CambioAtom::Eliminar { id: atoms[3].id });
+ }
+
+ #[test]
+ fn cambios_mixtos_solo_emiten_lo_que_cambia() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ // Cambia el primero ("uno" → "UNO!"), conserva el segundo,
+ // cambia el tercero ("tres" → "nuevo"). 3 párrafos, 3 atoms.
+ ed.set_texto("UNO!\n\ndos\n\nnuevo");
+ let d = ed.diff(&idx);
+ // Solo dos Mutar — el segundo párrafo coincide byte-a-byte y
+ // se omite sin emitir cambio.
+ assert_eq!(d.len(), 2);
+ match &d[0] {
+ CambioAtom::Mutar { id, texto_nuevo } => {
+ assert_eq!(*id, atoms[0].id);
+ assert_eq!(texto_nuevo, "UNO!");
+ }
+ otro => panic!("esperaba Mutar(0), fue {otro:?}"),
+ }
+ match &d[1] {
+ CambioAtom::Mutar { id, texto_nuevo } => {
+ assert_eq!(*id, atoms[2].id);
+ assert_eq!(texto_nuevo, "nuevo");
+ }
+ otro => panic!("esperaba Mutar(2), fue {otro:?}"),
+ }
+ }
+
+ #[test]
+ fn aplicar_cambios_extiende_atom_ids_para_crear_y_remueve_eliminar() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ // Texto nuevo: dos crear + un eliminar.
+ ed.set_texto("uno\n\ntres\n\ncuatro\n\ncinco");
+ let cambios = ed.diff(&idx);
+ // 1 Mutar(dos → tres) + 2 Crear(cuatro, cinco) + 0 Eliminar.
+ // ATOM ORIGINAL: ["uno", "dos"] (2 items)
+ // PARRAFOS NUEVOS: ["uno", "tres", "cuatro", "cinco"] (4 items)
+ // comun = 2 → Mutar(atoms[1].id, "tres") (uno está igual).
+ // Crear "cuatro" pos 2, Crear "cinco" pos 3.
+ let nuevos_ids: Vec<Uuid> = vec![Uuid::new_v4(), Uuid::new_v4()];
+ ed.aplicar_cambios(&cambios, &nuevos_ids);
+ assert_eq!(ed.atom_ids.len(), 4);
+ assert_eq!(ed.atom_ids[0], atoms[0].id);
+ assert_eq!(ed.atom_ids[1], atoms[1].id); // reusado en Mutar
+ assert_eq!(ed.atom_ids[2], nuevos_ids[0]);
+ assert_eq!(ed.atom_ids[3], nuevos_ids[1]);
+ }
+
+ #[test]
+ fn aplicar_cambios_remueve_atom_ids_eliminados() {
+ let (c, atoms) = cuerpo_con_atoms(&["uno", "dos", "tres"]);
+ let idx = indice(&atoms);
+ let mut ed = EditorCuerpo::from_cuerpo(&c, &idx);
+ ed.set_texto("uno");
+ let cambios = ed.diff(&idx);
+ // 0 Mutar + 0 Crear + 2 Eliminar (dos, tres).
+ ed.aplicar_cambios(&cambios, &[]);
+ assert_eq!(ed.atom_ids, vec![atoms[0].id]);
+ }
+}
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml
new file mode 100644
index 0000000..28ed5dc
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/Cargo.toml
@@ -0,0 +1,37 @@
+[package]
+name = "pluma-editor-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "pluma — backend Llimphi del editor DAG: pinta un RenderPlan como bloques de átomo, conectores de dependencia y osciloscopio de coherencia."
+
+[dependencies]
+pluma-render-plan = { path = "../pluma-render-plan" }
+pluma-core = { path = "../pluma-core" }
+pluma-cuerpo = { path = "../pluma-cuerpo" }
+pluma-align = { path = "../pluma-align" }
+pluma-editor-cuerpo = { path = "../pluma-editor-cuerpo" }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-text-editor = { workspace = true }
+rimay-localize = { workspace = true }
+uuid = { workspace = true, features = ["serde"] }
+
+[dev-dependencies]
+pluma-graph = { path = "../pluma-graph" }
+pluma-graph-transform = { path = "../pluma-graph-transform" }
+pluma-transform = { path = "../pluma-transform" }
+pluma-transform-tabla = { path = "../pluma-transform-tabla" }
+pluma-transform-llm = { path = "../pluma-transform-llm" }
+pluma-align-embeddings = { path = "../pluma-align-embeddings" }
+pluma-llm = { path = "../pluma-llm" }
+pluma-llm-core = { path = "../pluma-llm-core" }
+pluma-llm-mock = { path = "../pluma-llm-mock" }
+pluma-store = { path = "../pluma-store" }
+llimphi-widget-button = { workspace = true }
+rimay-verbo-core = { workspace = true }
+rimay-verbo-mock = { workspace = true }
+rimay-verbo-daemon = { workspace = true }
+tokio = { workspace = true }
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md
new file mode 100644
index 0000000..e1a492e
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/LEEME.md
@@ -0,0 +1,13 @@
+# pluma-editor-llimphi
+
+> Editor visual Llimphi de [pluma](../README.md).
+
+UI: panel izquierdo con lista de documentos (de [`pluma-store`](../pluma-store/README.md)), centro con [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) sobre el cuerpo concatenado, panel derecho con LLM (cycler de backends via [`pluma-llm`](../pluma-llm/README.md)), historial, diff. Cada save dispara [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
+
+Selector de modelo en runtime: botón cíclico Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` reconstruye `Arc<dyn ChatClient>` en vivo; si el backend no está configurado, conserva el anterior con mensaje de error.
+
+## Deps
+
+- Todos los crates `pluma-*`
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/README.md b/00_unanchay/pluma/pluma-editor-llimphi/README.md
new file mode 100644
index 0000000..086fa96
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/README.md
@@ -0,0 +1,13 @@
+# pluma-editor-llimphi
+
+> Visual Llimphi editor for [pluma](../README.md).
+
+UI: left panel with document list (from [`pluma-store`](../pluma-store/README.md)), center with [`text-editor`](../../../02_ruway/llimphi/widgets/text-editor/README.md) over the concatenated body, right panel with LLM (backend cycler via [`pluma-llm`](../pluma-llm/README.md)), history, diff. Each save triggers [`pluma-editor-cuerpo::diff`](../pluma-editor-cuerpo/README.md).
+
+Runtime model selector: cyclic button Mock → Gemini → Anthropic → DeepSeek → Cohere → Ollama → Mock. Click → `build_client(...)` rebuilds the `Arc<dyn ChatClient>` live; if a backend isn't configured, keeps the previous one with an error message.
+
+## Deps
+
+- All `pluma-*` crates
+- [`llimphi-ui`](../../../02_ruway/llimphi/) + widgets `text-editor`, `tree`, `tabs`, `splitter`
+- [`wawa-config-llimphi`](../../../shared/wawa-config-llimphi/)
diff --git a/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs
new file mode 100644
index 0000000..a01449f
--- /dev/null
+++ b/00_unanchay/pluma/pluma-editor-llimphi/examples/cuerpo_ide_demo.rs
@@ -0,0 +1,439 @@
+//! Demo del **text-editor IDE de Llimphi sobre `EditorCuerpo`**.
+//!
+//! Pintamos un cuerpo `es` con 5 párrafos sintéticos. El widget
+//! `text-editor` lo ve como UN buffer plano — cada `\n\n` separa un
+//! átomo. Editás libremente como en cualquier IDE:
+//!
+//! - Tipeo / borrado normal, multi-cursor con `Ctrl+Alt+↑/↓`.
+//! - `Ctrl+Z` undo / `Ctrl+Shift+Z` redo (del widget).
+//! - Click + drag = selección; `Ctrl+C/X/V` con un clipboard en memoria.
+//! - **`Ctrl+S`** = "guardar": calcula el diff contra los atoms
+//! originales, lo aplica al `HashMap<Uuid, NarrativeAtom>` del
+//! modelo (mutando contenido, creando atoms nuevos para los
+//! párrafos extra, eliminando los faltantes), sincroniza el
+//! `Cuerpo.orden` y resetea el editor sobre el cuerpo nuevo
+//! preservando la posición del caret y el scroll.
+//! - **`Ctrl+]`** salta al siguiente átomo (`posicion_de_atom` +
+//! `set_caret`). Demuestra el lookup átomo → línea.
+//!
+//! No hay `pluma-graph` ni `pluma-store` acá — el modelo guarda los
+//! atoms y el `Cuerpo` directamente. Es el caso pelado del IDE: el
+//! caller real reemplaza el `HashMap` por su `NarrativeGraph` y
+//! persiste con `pluma-store`.
+//!
+//! ```bash
+//! cargo run -p pluma-editor-llimphi --example cuerpo_ide_demo --release
+//! ```
+
+use std::collections::HashMap;
+
+use llimphi_ui::llimphi_layout::taffy::prelude::{
+ auto, length, percent, FlexDirection, Rect, Size, Style,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, View};
+use llimphi_widget_text_editor::{
+ EditorMetrics, EditorPalette, Language, MemClipboard, PointerEvent,
+};
+use pluma_core::NarrativeAtom;
+use pluma_cuerpo::{Cuerpo, Intencion};
+use pluma_editor_cuerpo::CambioAtom;
+use pluma_editor_llimphi::cuerpo_ide::{cuerpo_ide_view, CuerpoIde};
+use uuid::Uuid;
+
+/// Métricas del editor — fijas durante toda la sesión. Definidas como
+/// const para no recalcularlas en cada `update` / `view`.
+const METRICS: EditorMetrics = EditorMetrics::for_font_size(13.0);
+
+/// Cap muy amplio para `visible_lines`. El widget lo trunca a 200
+/// internamente — pasamos 200 y dejamos que decida.
+const VISIBLE_LINES: usize = 200;
+
+#[derive(Clone, Debug)]
+enum Msg {
+ EditorKey(KeyEvent),
+ EditorPointer(PointerEvent),
+ /// `Ctrl+S` — el caller pidió persistir los cambios.
+ Guardar,
+ /// `Ctrl+]` — saltar al átomo siguiente del cuerpo activo.
+ SaltarAtomoSiguiente,
+ /// `Ctrl+J` — togglea la junction inmediatamente anterior al
+ /// átomo bajo el caret: si era separador (línea-guarda), pasa a
+ /// fundida (línea editable, parte de la misma zona); si era
+ /// fundida, vuelve a separador.
+ ToglearFusion,
+ /// `Ctrl+Shift+]` — salta el caret a la siguiente zona (grupo de
+ /// atoms unidos por junctions fundidas). Wrap circular al final.
+ ZonaSiguiente,
+ /// `Ctrl+Shift+[` — salta a la zona anterior. Wrap circular al inicio.
+ ZonaAnterior,
+ /// `Ctrl+Shift+A` — selecciona la zona donde está el caret.
+ SeleccionarZona,
+}
+
+struct Model {
+ /// Cuerpo activo — su `orden` es lo que el editor reconstruye.
+ cuerpo: Cuerpo,
+ /// Atoms del "grafo" plano. Clave = `id`, valor = atom completo.
+ atoms: HashMap<Uuid, NarrativeAtom>,
+ /// El IDE: buffer + cursor + diff vs `editor_cuerpo`.
+ ide: CuerpoIde,
+ /// Clipboard local (no toca el del sistema — los demos viven en
+ /// sandbox). `MemClipboard` cubre Ctrl+C/X/V durante la sesión.
+ clipboard: MemClipboard,
+ /// Mensaje del último `Guardar` para mostrar en el footer.
+ ultimo_save: String,
+ /// Acumulador de drag para selección por mouse.
+ drag_accum: (f32, f32),
+}
+
+struct Demo;
+
+impl App for Demo {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "pluma · text-editor IDE sobre EditorCuerpo"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (1100, 720)
+ }
+
+ fn init(_: &Handle<Msg>) -> Model {
+ let textos = [
+ "El cóndor cruzó el cielo del valle al amanecer.",
+ "Las llamas pastaban entre los pastizales del altiplano.",
+ "Una mujer joven tejía un telar bajo el alero.",
+ "El río Apurímac descendía rugiente por las rocas.",
+ "Al caer la tarde, las nubes cubrieron el sol.",
+ ];
+ let atoms_vec: Vec<NarrativeAtom> = textos
+ .iter()
+ .map(|t| NarrativeAtom::new(*t, "es"))
+ .collect();
+ let mut cuerpo = Cuerpo::nuevo("es", "español (original)", Intencion::Original, 0);
+ for a in &atoms_vec {
+ cuerpo.agregar(a.id, 0);
+ }
+ let atoms: HashMap<Uuid, NarrativeAtom> =
+ atoms_vec.into_iter().map(|a| (a.id, a)).collect();
+
+ let idx: HashMap<Uuid, &NarrativeAtom> = atoms.iter().map(|(k, v)| (*k, v)).collect();
+ let ide = CuerpoIde::from_cuerpo(&cuerpo, &idx);
+
+ Model {
+ cuerpo,
+ atoms,
+ ide,
+ clipboard: MemClipboard::default(),
+ ultimo_save: String::new(),
+ drag_accum: (0.0, 0.0),
+ }
+ }
+
+ fn update(model: Model, msg: Msg, _: &Handle<Msg>) -> Model {
+ match msg {
+ Msg::EditorKey(ev) => {
+ let mut model = model;
+ let _ = model.ide.apply_key_with_clipboard(&ev, &mut model.clipboard);
+ model
+ }
+ Msg::EditorPointer(ev) => {
+ let mut model = model;
+ let scroll = model.ide.state.scroll_offset;
+ match ev {
+ PointerEvent::Click { x, y } => {
+ model.drag_accum = (0.0, 0.0);
+ let (line, col) = METRICS.screen_to_pos(x, y, scroll);
+ model.ide.set_caret(line, col);
+ }
+ PointerEvent::Drag {
+ initial_x,
+ initial_y,
+ dx,
+ dy,
+ } => {
+ model.drag_accum.0 += dx;
+ model.drag_accum.1 += dy;
+ let cx = initial_x + model.drag_accum.0;
+ let cy = initial_y + model.drag_accum.1;
+ let (line, col) = METRICS.screen_to_pos(cx, cy, scroll);
+ model.ide.state.extend_selection_to(line, col);
+ }
+ }
+ model
+ }
+ Msg::Guardar => guardar(model),
+ Msg::SaltarAtomoSiguiente => {
+
El parche sigue, pero se cortó acá: pedí el commit por git para verlo entero.