chore: monorepo inicial con arje + minga + yahweh absorbidos

Workspace en 4 ejes (core/modules/apps/shared):

- core/: 24 crates de arje (Init systemd-compatible: ente-card, ente-zero,
  ente-kernel, ente-bus, ente-cas, ente-soma, ente-wasm, ente-snapshot,
  ente-brain, ente-echo, ente-policy-provider, + 12 crates *-compat)
- modules/semantic_dht/: 5 crates de minga (minga-core con AST/CAS/MST,
  minga-p2p con libp2p Kad, minga-store, minga-vfs, minga-cli)
- modules/ui_engine/: 11 crates de yahweh (libs/{core,theme,bus,providers},
  widgets/{tree,splitter,tabs,tiled,container_core,text_input})
- apps/: 5 crates de yahweh (file_explorer, database_explorer, text_viewer,
  image_viewer, yahweh-shell)
- shared_wit/protocol.wit: handshake/lifecycle inicial

Cargo.toml unificado: thiserror bumped a 2 (transparente para arje), tokio
"full", paths intra-workspace de yahweh redirigidos a su nueva ubicación.

cargo check --workspace: 0 errores, 17 warnings (dead code preexistente).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergio
2026-05-08 04:45:44 +00:00
commit 53dbdf0f1d
176 changed files with 34845 additions and 0 deletions
@@ -0,0 +1,10 @@
[package]
name = "yahweh-widget-container-core"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "Tipos compartidos para contenedores (ChildSlot, etc.). Imported por Splitter, Tabs, Tiled y la Shell."
[dependencies]
gpui = { workspace = true }
yahweh-core = { workspace = true }
@@ -0,0 +1,38 @@
//! `yahweh_widget_container_core` — tipos compartidos por todos los
//! contenedores (Splitter, Tabs, Tiled, futuros).
//!
//! La pieza más relevante es [`ChildSlot`]: el "paquete" con que la Shell
//! le entrega a un contenedor un hijo ya instanciado. La identidad
//! estable (`id: NodeId`) es lo que permite **swappear el kind del
//! contenedor sin perder los hijos**: cuando el JSON cambia
//! `kind: "Split"` por `kind: "Tabs"`, el LayoutHost descarta el viejo
//! contenedor pero pasa los mismos `ChildSlot` (con los mismos AnyView ya
//! con estado) al contenedor nuevo. Esa preservación es la promesa
//! arquitectónica de la app.
//!
//! `flex` y `label` son metadatos opcionales que cada contenedor
//! interpreta a su gusto:
//! - Splitter: usa `flex` para repartir; ignora `label`.
//! - Tabs: usa `label` para el título de la pestaña; ignora `flex`.
//! - Tiled: usa ambos opcionalmente (peso de tile, label hover).
use gpui::AnyView;
use yahweh_core::NodeId;
/// Slot de un hijo entregado a un contenedor. La Shell construye el
/// `Vec<ChildSlot>` haciendo DFS sobre el `LayerConfig` del JSON.
#[derive(Clone)]
pub struct ChildSlot {
/// Identidad estable (proviene del campo `id` del JSON, o se
/// sintetiza desde el path estructural).
pub id: NodeId,
/// Peso flex relativo entre hermanos. Útil para Splitter / Tiled;
/// los contenedores que no lo usan lo ignoran.
pub flex: f32,
/// Texto opcional para decoración (título de tab, label de tile, etc).
/// Si `None`, los contenedores que lo necesiten caen al `id` como
/// fallback razonable.
pub label: Option<String>,
/// El widget instanciado, listo para colgar del árbol de render.
pub view: AnyView,
}
@@ -0,0 +1,12 @@
[package]
name = "yahweh-widget-splitter"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "SplitContainer — n hijos con flex weights y divisores arrastrables."
[dependencies]
gpui = { workspace = true }
yahweh-core = { workspace = true }
yahweh-theme = { workspace = true }
yahweh-widget-container-core = { workspace = true }
@@ -0,0 +1,362 @@
//! `yahweh_widget_splitter` — `SplitContainer` genérico.
//!
//! Aloja `n` hijos `AnyView` con flex weights individuales y un divisor
//! arrastrable entre cada par adyacente. Dirección horizontal o vertical
//! intercambiable. Emite [`SplitEvent::FlexChanged`] cuando un drag termina,
//! para que el host (LayoutHost / DemoApp) persista los flex.
//!
//! El SplitContainer NO conoce a sus hijos: los recibe vía
//! `set_children(Vec<ChildSlot>)`. Eso permite que el LayoutHost reuse las
//! mismas instancias cuando el JSON cambia el `kind` del contenedor (Split
//! → Tabs → Tiled) — los AnyView siguen vivos, solo cambia su contenedor.
//!
//! Drag: usamos el patrón canónico de gpui (ver `data_table.rs` ejemplo) —
//! cada divider tiene un `canvas(prepaint, paint)` que en su paint registra
//! handlers de `MouseDown / MouseMove / MouseUp` a nivel de window vía
//! `window.on_mouse_event`. Esto garantiza que el drag continúa aunque el
//! cursor salga del divider.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
};
use yahweh_core::{LayoutDirection, NodeId};
use yahweh_theme::Theme;
pub use yahweh_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
pub enum SplitEvent {
/// Un drag actualizó los flex weights. Se emite UNA vez por movimiento
/// (cada frame durante un drag), con los IDs y flex finales de los dos
/// hijos adyacentes al divisor.
FlexChanged {
left_id: NodeId,
right_id: NodeId,
left_flex: f32,
right_flex: f32,
},
/// El drag terminó (mouseup). Útil para persistir batched.
DragEnd,
}
// =====================================================================
// Widget
// =====================================================================
/// Estado interno del drag activo. `divider_index` apunta al espacio entre
/// `children[i]` y `children[i+1]`. Los snapshots `flex_*_initial` y
/// `start_pos_main` se capturan en MouseDown — durante MouseMove se
/// recalcula el flex desde el delta.
struct DragState {
divider_index: usize,
start_pos_main: Pixels,
flex_left_initial: f32,
flex_right_initial: f32,
/// Longitud total del SplitContainer en el eje principal al iniciar el
/// drag (capturada de `bounds`). Usada para convertir delta_px ↔
/// delta_flex preservando el sum total.
total_main_size: Pixels,
total_flex_initial: f32,
}
pub struct SplitContainer {
children: Vec<ChildSlot>,
direction: LayoutDirection,
drag: Option<DragState>,
/// Bounds del frame anterior. Capturados vía canvas absolute en cada
/// paint. Lo usamos al iniciar drag para resolver `total_main_size`.
bounds: Rc<RefCell<Option<Bounds<Pixels>>>>,
}
impl EventEmitter<SplitEvent> for SplitContainer {}
impl SplitContainer {
pub fn new(direction: LayoutDirection, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
direction,
drag: None,
bounds: Rc::new(RefCell::new(None)),
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
self.children = children;
cx.notify();
}
pub fn set_direction(&mut self, direction: LayoutDirection, cx: &mut Context<Self>) {
if self.direction != direction {
self.direction = direction;
cx.notify();
}
}
pub fn direction(&self) -> LayoutDirection {
self.direction
}
pub fn children(&self) -> &[ChildSlot] {
&self.children
}
// -------- Drag handlers --------
fn start_drag(&mut self, divider_index: usize, position: Point<Pixels>) {
if divider_index >= self.children.len().saturating_sub(1) {
return;
}
let bounds = match *self.bounds.borrow() {
Some(b) => b,
None => return,
};
let raw_main = main_axis(self.direction, bounds.size.width, bounds.size.height);
// Restamos el espacio que ocupan los divisores — son fixed-size en el
// eje principal, no participan del flex. El "espacio disponible
// para flex" es lo que importa para convertir delta_px → delta_flex.
let dividers_total = px(DIVIDER_THICKNESS) * (self.children.len().saturating_sub(1) as f32);
let total_main = raw_main - dividers_total;
if total_main <= px(0.0) {
return;
}
let total_flex: f32 = self.children.iter().map(|c| c.flex.max(0.0)).sum();
let total_flex = total_flex.max(0.001);
let start_main = main_axis_pt(self.direction, position);
self.drag = Some(DragState {
divider_index,
start_pos_main: start_main,
flex_left_initial: self.children[divider_index].flex,
flex_right_initial: self.children[divider_index + 1].flex,
total_main_size: total_main,
total_flex_initial: total_flex,
});
}
fn continue_drag(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(drag) = &self.drag else { return };
let drag_idx = drag.divider_index;
if drag_idx + 1 >= self.children.len() {
return;
}
let cur_main = main_axis_pt(self.direction, position);
let delta_px = cur_main - drag.start_pos_main;
// delta_flex = delta_px / total_main_size * total_flex_initial.
let total_main_f = f32::from(drag.total_main_size).max(1.0);
let delta_flex = (f32::from(delta_px) / total_main_f) * drag.total_flex_initial;
const MIN_FLEX: f32 = 0.05;
let new_left = (drag.flex_left_initial + delta_flex).max(MIN_FLEX);
let new_right = (drag.flex_right_initial - delta_flex).max(MIN_FLEX);
// Solo aplicamos si NINGUNO se aplastó al mínimo y se "comió" el
// delta — eso significa que el drag llegó al borde de un hijo.
let fits = (drag.flex_left_initial + delta_flex) >= MIN_FLEX
&& (drag.flex_right_initial - delta_flex) >= MIN_FLEX;
if !fits {
// Recortamos: aplicamos los mínimos pero no propagamos delta más
// allá del límite. Resultado: el divisor "frena" en el borde.
}
self.children[drag_idx].flex = new_left;
self.children[drag_idx + 1].flex = new_right;
let left_id = self.children[drag_idx].id.clone();
let right_id = self.children[drag_idx + 1].id.clone();
cx.emit(SplitEvent::FlexChanged {
left_id,
right_id,
left_flex: new_left,
right_flex: new_right,
});
cx.notify();
}
fn end_drag(&mut self, cx: &mut Context<Self>) {
if self.drag.take().is_some() {
cx.emit(SplitEvent::DragEnd);
cx.notify();
}
}
}
// =====================================================================
// Helpers de eje
// =====================================================================
fn main_axis(dir: LayoutDirection, w: Pixels, h: Pixels) -> Pixels {
match dir {
LayoutDirection::Horizontal => w,
_ => h,
}
}
fn main_axis_pt(dir: LayoutDirection, p: Point<Pixels>) -> Pixels {
match dir {
LayoutDirection::Horizontal => p.x,
_ => p.y,
}
}
// =====================================================================
// Render
// =====================================================================
const DIVIDER_THICKNESS: f32 = 4.0;
impl Render for SplitContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let direction = self.direction;
let entity = cx.entity();
let bounds_holder = self.bounds.clone();
let total_flex: f32 = self
.children
.iter()
.map(|c| c.flex.max(0.0))
.sum::<f32>()
.max(0.001);
// Root flex container.
let mut root = div().size_full().relative();
root = match direction {
LayoutDirection::Horizontal => root.flex().flex_row(),
_ => root.flex().flex_col(),
};
// Canvas absolute para capturar bounds del SplitContainer en cada
// frame. No participa del flex (absolute), no captura clicks
// (canvas sin id es no-interactivo).
root = root.child({
let bounds_holder = bounds_holder.clone();
canvas(
move |bounds, _w, _cx| {
*bounds_holder.borrow_mut() = Some(bounds);
},
|_, _, _, _| {},
)
.absolute()
.size_full()
});
// Children + dividers entre cada par.
let n = self.children.len();
for (i, child) in self.children.iter().enumerate() {
let weight = (child.flex.max(0.0) / total_flex).max(0.001);
let mut item = div().relative();
// flex_grow fraccional — el helper `flex_grow()` solo setea 1.0,
// así que vamos directo al campo subyacente para repartir
// proporcionalmente según el `flex` de cada slot.
item.style().flex_grow = Some(weight);
item.style().flex_shrink = Some(1.0);
// CRUCIAL: el default de flexbox es `min-width: auto` (= min
// content size). Si no lo aplastamos a 0, taffy clamp-ea al
// tamaño mínimo del contenido (un TreeView con label largo, un
// uniform_list, etc.) y el divisor no puede pasar de ese punto
// — el cursor avanza pero el divisor se queda. Forzando min=0
// y overflow:hidden en el wrapper, el child puede shrink-arse a
// donde sea y el contenido se recorta.
item.style().min_size.width = Some(Length::Definite(px(0.0).into()));
item.style().min_size.height = Some(Length::Definite(px(0.0).into()));
// Eje cruzado: full. Eje principal: lo decide flex.
let item = match direction {
LayoutDirection::Horizontal => item.h_full(),
_ => item.w_full(),
}
.overflow_hidden()
.child(child.view.clone());
root = root.child(item);
// Divisor entre i e i+1 (no después del último).
if i + 1 < n {
let divider_idx = i;
let entity_for_canvas = entity.clone();
let mut divider = div();
let divider_bg = if self.drag.as_ref().map(|d| d.divider_index) == Some(divider_idx)
{
theme.accent_strong
} else {
theme.border_strong
};
divider = divider.bg(divider_bg).hover(|s| s.bg(theme.accent));
divider = match direction {
LayoutDirection::Horizontal => divider
.w(px(DIVIDER_THICKNESS))
.h_full()
.cursor_ew_resize(),
_ => divider
.w_full()
.h(px(DIVIDER_THICKNESS))
.cursor_ns_resize(),
};
// Canvas con handlers de drag a nivel de window.
let divider = divider.child(
canvas(
|_, _, _| (),
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
// MouseDown sobre el divisor → start_drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
if ev.button != MouseButton::Left {
return;
}
if !canvas_bounds.contains(&ev.position) {
return;
}
entity.update(cx, |this, _| {
this.start_drag(divider_idx, ev.position);
});
}
});
// MouseMove anywhere → continue_drag si hay drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
if !ev.dragging() {
return;
}
entity.update(cx, |this, cx| {
if this.drag.is_some() {
this.continue_drag(ev.position, cx);
}
});
}
});
// MouseUp anywhere → end_drag.
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| this.end_drag(cx));
}
});
},
)
.size_full(),
);
root = root.child(divider);
}
}
root
}
}
@@ -0,0 +1,12 @@
[package]
name = "yahweh-widget-tabs"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TabContainer — n hijos, uno visible, header con tabs clickeables."
[dependencies]
gpui = { workspace = true }
yahweh-core = { workspace = true }
yahweh-theme = { workspace = true }
yahweh-widget-container-core = { workspace = true }
@@ -0,0 +1,192 @@
//! `yahweh_widget_tabs` — `TabContainer`.
//!
//! `n` hijos `AnyView`, **uno visible** por vez (la pestaña activa). Header
//! horizontal con un botón por hijo; click cambia la pestaña activa. La
//! identidad del hijo activo se preserva por `NodeId`, así que swappear de
//! Split → Tabs y volver no resetea cuál está abierto.
//!
//! API alineada con `SplitContainer` (mismo `set_children`) para que el
//! LayoutHost los use intercambiablemente.
use gpui::{
ClickEvent, Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
px,
};
use yahweh_core::NodeId;
use yahweh_theme::Theme;
use yahweh_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum TabsEvent {
/// Una pestaña distinta quedó activa (por click o `set_active`).
TabActivated { id: NodeId, index: usize },
}
pub struct TabContainer {
children: Vec<ChildSlot>,
/// Id del hijo activo. Lo guardamos por id (no por índice) para que
/// reorders/inserts no rompan la selección.
active_id: Option<NodeId>,
}
impl EventEmitter<TabsEvent> for TabContainer {}
impl TabContainer {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
active_id: None,
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
// Si el id activo previo sigue presente, preservarlo. Si no, caer
// al primero (o None si vacío).
let still_present = self
.active_id
.as_ref()
.map(|id| children.iter().any(|c| &c.id == id))
.unwrap_or(false);
if !still_present {
self.active_id = children.first().map(|c| c.id.clone());
}
self.children = children;
cx.notify();
}
pub fn set_active(&mut self, id: NodeId, cx: &mut Context<Self>) {
if self.children.iter().any(|c| c.id == id) && self.active_id.as_ref() != Some(&id) {
let index = self.children.iter().position(|c| c.id == id).unwrap_or(0);
self.active_id = Some(id.clone());
cx.emit(TabsEvent::TabActivated { id, index });
cx.notify();
}
}
pub fn active_id(&self) -> Option<&NodeId> {
self.active_id.as_ref()
}
fn active_index(&self) -> Option<usize> {
let id = self.active_id.as_ref()?;
self.children.iter().position(|c| &c.id == id)
}
fn on_tab_click(
&mut self,
id: NodeId,
_click: &ClickEvent,
_w: &mut Window,
cx: &mut Context<Self>,
) {
self.set_active(id, cx);
}
}
const TAB_HEADER_HEIGHT: f32 = 30.0;
impl Render for TabContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let active_idx = self.active_index();
// Header — una "pestaña" por hijo. Cada tab usa una stripe inferior
// (un div hijo de 2px de alto) como indicador de "activa", porque
// gpui no expone `border_b_color` por separado del border global.
let mut header = div()
.h(px(TAB_HEADER_HEIGHT))
.w_full()
.border_b_1()
.border_color(theme.border)
.bg(theme.bg_panel.clone())
.flex()
.flex_row();
for (i, child) in self.children.iter().enumerate() {
let is_active = active_idx == Some(i);
let label_text = child
.label
.clone()
.unwrap_or_else(|| child.id.as_str().to_string());
let id_for_click = child.id.clone();
let tab_id: SharedString =
SharedString::from(format!("tab-{}", child.id));
let bg = if is_active {
theme.bg_panel_alt.clone()
} else {
theme.bg_panel.clone()
};
let fg = if is_active {
theme.fg_text
} else {
theme.fg_muted
};
let stripe_color = if is_active {
theme.accent_strong
} else {
gpui::hsla(0.0, 0.0, 0.0, 0.0)
};
header = header.child(
div()
.id(tab_id)
.h_full()
.border_r_1()
.border_color(theme.border)
.bg(bg)
.text_color(fg)
.text_size(px(12.0))
.hover(|s| s.opacity(0.85))
.flex()
.flex_col()
.child(
// Etiqueta + padding centrado.
div()
.flex_grow()
.px(px(14.0))
.flex()
.items_center()
.child(SharedString::from(label_text)),
)
.child(
// Stripe inferior de 2px — indicador de activa.
div().h(px(2.0)).w_full().bg(stripe_color),
)
.on_click(cx.listener(move |this, click, w, cx| {
this.on_tab_click(id_for_click.clone(), click, w, cx);
})),
);
}
// Cuerpo — solo el child activo. Si no hay ninguno (children
// vacío), pintamos un mensaje neutro.
let body = match active_idx.and_then(|i| self.children.get(i)) {
Some(child) => div()
.flex_grow()
.min_h(px(0.0))
.bg(theme.bg_panel_alt.clone())
.child(child.view.clone())
.into_any_element(),
None => div()
.flex_grow()
.flex()
.items_center()
.justify_center()
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child("(sin hijos)")
.into_any_element(),
};
div()
.size_full()
.flex()
.flex_col()
.child(header)
.child(body)
}
}
@@ -0,0 +1,10 @@
[package]
name = "yahweh-widget-text-input"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TextInput minimalista para diálogos (rename, prompts). Single-line, sin selección/clipboard."
[dependencies]
gpui = { workspace = true }
yahweh-theme = { workspace = true }
@@ -0,0 +1,156 @@
//! `yahweh_widget_text_input` — input de texto minimal.
//!
//! Diseñado para diálogos cortos (rename, prompts). NO es un editor — no
//! soporta:
//! - cursor positioning con flechas / mouse,
//! - selección con shift / arrastre,
//! - copy / cut / paste,
//! - IME / multilínea.
//!
//! Soporta lo justo:
//! - escribir caracteres (cualquier `key_char` printable los appendea al final),
//! - `Backspace` quita el último char,
//! - `Enter` emite [`TextInputEvent::Confirmed`] con el texto actual,
//! - `Escape` emite [`TextInputEvent::Cancelled`].
//!
//! Cuando montes el widget, llamá `request_focus(window)` para que reciba
//! teclas de inmediato. El padre se subscribe vía `cx.subscribe(&input,
//! …)` para recibir Confirmed/Cancelled.
//!
//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
use gpui::{
Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
SharedString, Window, div, prelude::*, px,
};
use yahweh_theme::Theme;
#[derive(Clone, Debug)]
pub enum TextInputEvent {
/// El usuario apretó Enter. El payload es el texto actual.
Confirmed(String),
/// El usuario apretó Escape. El padre suele cerrar el modal.
Cancelled,
}
pub struct TextInput {
text: String,
focus_handle: FocusHandle,
/// Placeholder visible cuando `text` está vacío.
placeholder: SharedString,
}
impl EventEmitter<TextInputEvent> for TextInput {}
impl Focusable for TextInput {
fn focus_handle(&self, _: &gpui::App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl TextInput {
pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
text: initial.into(),
focus_handle: cx.focus_handle(),
placeholder: SharedString::from(""),
}
}
/// Setea el placeholder mostrado cuando el campo está vacío.
#[allow(dead_code)]
pub fn with_placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
self.placeholder = placeholder.into();
self
}
pub fn text(&self) -> &str {
&self.text
}
/// Reemplaza el contenido completo (e.g. al abrir un modal pre-cargado).
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
self.text = text.into();
cx.notify();
}
/// Pide focus para que las próximas teclas vayan al input. Llamar
/// cuando montás el widget en un modal para que esté "activo".
pub fn request_focus(&self, window: &mut Window) {
window.focus(&self.focus_handle);
}
fn handle_key_down(
&mut self,
event: &KeyDownEvent,
_w: &mut Window,
cx: &mut Context<Self>,
) {
let key = event.keystroke.key.as_str();
match key {
"enter" => {
cx.emit(TextInputEvent::Confirmed(self.text.clone()));
return;
}
"escape" => {
cx.emit(TextInputEvent::Cancelled);
return;
}
"backspace" => {
self.text.pop();
cx.notify();
return;
}
_ => {}
}
// Char "imprimible": tomamos `key_char` (que respeta el layout +
// modificadores) si está presente. `key_char` es el que el sistema
// dice "esto es lo que el usuario realmente escribió".
if let Some(ch) = event.keystroke.key_char.as_deref() {
// Solo apendeamos si NO contiene control chars (newline,
// backspace, etc — que llegarían como key_char en algunas
// plataformas).
if !ch.chars().any(|c| c.is_control()) {
self.text.push_str(ch);
cx.notify();
}
}
}
}
impl Render for TextInput {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let is_empty = self.text.is_empty();
let display: SharedString = if is_empty {
self.placeholder.clone()
} else {
// Cursor siempre al final — sin movimiento de cursor.
SharedString::from(format!("{}|", self.text))
};
let text_color = if is_empty {
theme.fg_disabled
} else {
theme.fg_text
};
div()
.id("yahweh-text-input")
.track_focus(&self.focus_handle)
.key_context("YahwehTextInput")
.on_key_down(cx.listener(Self::handle_key_down))
.px(px(10.0))
.py(px(6.0))
.min_w(px(200.0))
.bg(theme.bg_panel.clone())
.border_1()
.border_color(theme.accent_strong)
.rounded(px(4.0))
.text_size(px(13.0))
.text_color(text_color)
.child(display)
}
}
@@ -0,0 +1,12 @@
[package]
name = "yahweh-widget-tiled"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TiledContainer — n hijos en grid auto cols×rows."
[dependencies]
gpui = { workspace = true }
yahweh-core = { workspace = true }
yahweh-theme = { workspace = true }
yahweh-widget-container-core = { workspace = true }
@@ -0,0 +1,327 @@
//! `yahweh_widget_tiled` — `TiledContainer`.
//!
//! Distribuye `n` hijos en una grilla auto-calculada: `cols = ⌈√n⌉`,
//! `rows = ⌈n/cols⌉`. Las celdas tienen el mismo peso.
//!
//! ## Drag-to-swap
//!
//! Cada tile tiene una franja superior de 18px (la "title bar") con cursor
//! de `move`: arrastrarla dispara un swap. Anatomía:
//!
//! 1. Mouse down sobre la title bar de tile A → record `dragging_idx = A`.
//! 2. Mouse move (window-level) actualiza `hover_idx` chequeando bounds
//! de cada tile capturados en cada paint.
//! 3. Mouse up → si `hover_idx != dragging_idx` y son válidos, emitimos
//! [`TiledEvent::Reordered { from, to }`] para que el LayoutHost lo
//! persista (swap_children en el LayoutModel).
//!
//! Mientras dura el drag, el tile origen pinta un overlay translúcido y el
//! tile destino se resalta con border `accent_strong`. Sin el LayoutHost
//! persistiendo, el reorder es solo emisión — el `set_children` que viene
//! después del rebuild aplica el orden nuevo.
//!
//! Filosofía: el TiledContainer NO mantiene un orden propio en `Vec`, ni
//! reordena `self.children` localmente. Toda mutación va vía el modelo
//! (single source of truth). Eso garantiza que persiste, sobrevive a
//! reload y se ve consistente con el JSON.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::{
App, Bounds, Context, EventEmitter, IntoElement, Length, MouseButton, MouseDownEvent,
MouseMoveEvent, MouseUpEvent, Pixels, Point, Render, Window, canvas, div, prelude::*, px,
};
use yahweh_core::NodeId;
use yahweh_theme::Theme;
use yahweh_widget_container_core::ChildSlot;
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub enum TiledEvent {
/// Drag-and-drop terminó con un swap entre el tile en `from_index` y
/// el de `to_index`. Los IDs van por valor para que el suscriptor no
/// tenga que reconsultar el container.
Reordered {
from_index: usize,
from_id: NodeId,
to_index: usize,
to_id: NodeId,
},
}
#[derive(Clone, Debug)]
struct DragState {
from_index: usize,
/// Índice sobre el que el cursor está actualmente. `None` si está
/// fuera de cualquier tile.
hover_index: Option<usize>,
}
pub struct TiledContainer {
children: Vec<ChildSlot>,
drag: Option<DragState>,
/// Bounds de cada tile en el último frame, indexados por posición en
/// `children`. Capturados via canvas en cada tile para que el drag
/// pueda hit-testear sin reflexión sobre el árbol.
tile_bounds: Rc<RefCell<Vec<Option<Bounds<Pixels>>>>>,
}
impl EventEmitter<TiledEvent> for TiledContainer {}
impl TiledContainer {
pub fn new(cx: &mut Context<Self>) -> Self {
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
children: Vec::new(),
drag: None,
tile_bounds: Rc::new(RefCell::new(Vec::new())),
}
}
pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
// Resize el vector de bounds para que el index sea válido en cada
// paint; los bounds reales se llenan en el canvas.
let n = children.len();
self.tile_bounds.borrow_mut().resize(n, None);
self.children = children;
cx.notify();
}
pub fn children(&self) -> &[ChildSlot] {
&self.children
}
fn start_drag(&mut self, idx: usize, cx: &mut Context<Self>) {
if idx >= self.children.len() {
return;
}
self.drag = Some(DragState {
from_index: idx,
hover_index: None,
});
cx.notify();
}
fn update_hover(&mut self, position: Point<Pixels>, cx: &mut Context<Self>) {
let Some(drag) = &mut self.drag else { return };
// Hit-test contra los bounds capturados.
let bounds = self.tile_bounds.borrow();
let mut new_hover = None;
for (i, b) in bounds.iter().enumerate() {
if let Some(b) = b {
if b.contains(&position) {
new_hover = Some(i);
break;
}
}
}
if drag.hover_index != new_hover {
drag.hover_index = new_hover;
cx.notify();
}
}
fn end_drag(&mut self, cx: &mut Context<Self>) {
let Some(drag) = self.drag.take() else { return };
if let Some(to) = drag.hover_index {
if to != drag.from_index
&& to < self.children.len()
&& drag.from_index < self.children.len()
{
let from_id = self.children[drag.from_index].id.clone();
let to_id = self.children[to].id.clone();
cx.emit(TiledEvent::Reordered {
from_index: drag.from_index,
from_id,
to_index: to,
to_id,
});
}
}
cx.notify();
}
}
const TILE_GAP: f32 = 4.0;
const TITLE_BAR_HEIGHT: f32 = 20.0;
impl Render for TiledContainer {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let n = self.children.len();
if n == 0 {
return div()
.size_full()
.bg(theme.bg_panel.clone())
.flex()
.items_center()
.justify_center()
.text_size(px(11.0))
.text_color(theme.fg_muted)
.child("(tiled vacío)");
}
let cols = (n as f32).sqrt().ceil() as usize;
let cols = cols.max(1);
let rows = (n + cols - 1) / cols;
let drag = self.drag.clone();
let entity = cx.entity();
let bounds_holder = self.tile_bounds.clone();
let mut col_container = div()
.size_full()
.bg(theme.bg_app.clone())
.flex()
.flex_col()
.gap(px(TILE_GAP))
.p(px(TILE_GAP));
for r in 0..rows {
let mut row_div = div()
.w_full()
.flex()
.flex_row()
.flex_grow()
.gap(px(TILE_GAP));
row_div.style().min_size.height = Some(Length::Definite(px(0.0).into()));
for c in 0..cols {
let idx = r * cols + c;
let mut tile = div().h_full();
tile.style().flex_grow = Some(1.0);
tile.style().flex_shrink = Some(1.0);
tile.style().min_size.width = Some(Length::Definite(px(0.0).into()));
let is_dragging_src = drag.as_ref().map(|d| d.from_index) == Some(idx);
let is_drop_target = drag.as_ref().and_then(|d| d.hover_index) == Some(idx)
&& drag.as_ref().map(|d| d.from_index) != Some(idx);
let border_color = if is_drop_target {
theme.accent_strong
} else {
theme.border
};
let tile = tile
.bg(theme.bg_panel.clone())
.border_1()
.border_color(border_color)
.rounded(px(4.0))
.overflow_hidden();
let tile = if let Some(child) = self.children.get(idx) {
let child = child.clone();
let opacity = if is_dragging_src { 0.45 } else { 1.0 };
// Canvas que captura el bounds del tile entero (para
// hit-test del drop target).
let bounds_holder_inner = bounds_holder.clone();
let bounds_canvas = canvas(
move |bounds, _w, _cx| {
let mut b = bounds_holder_inner.borrow_mut();
if idx < b.len() {
b[idx] = Some(bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.size_full();
// Title bar — drag handle. Canvas con window-level
// mouse handlers, mismo patrón que SplitContainer.
let entity_for_canvas = entity.clone();
let title_canvas = canvas(
|_, _, _| (),
move |canvas_bounds: Bounds<Pixels>, _, window, _| {
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseDownEvent, _, _w: &mut Window, cx: &mut App| {
if ev.button != MouseButton::Left {
return;
}
if !canvas_bounds.contains(&ev.position) {
return;
}
entity.update(cx, |this, cx| this.start_drag(idx, cx));
}
});
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |ev: &MouseMoveEvent, _, _w: &mut Window, cx: &mut App| {
if !ev.dragging() {
return;
}
entity.update(cx, |this, cx| {
if this.drag.is_some() {
this.update_hover(ev.position, cx);
}
});
}
});
window.on_mouse_event({
let entity = entity_for_canvas.clone();
move |_: &MouseUpEvent, _, _w: &mut Window, cx: &mut App| {
entity.update(cx, |this, cx| this.end_drag(cx));
}
});
},
)
.size_full();
// El layout del tile: title bar arriba (con label +
// canvas drag), body abajo (con la AnyView del child).
let label_text = child
.label
.clone()
.unwrap_or_else(|| child.id.as_str().to_string());
tile.flex().flex_col().opacity(opacity).child(
div()
.h(px(TITLE_BAR_HEIGHT))
.w_full()
.px(px(8.0))
.bg(theme.bg_panel_alt.clone())
.border_b_1()
.border_color(theme.border)
.text_size(px(10.0))
.text_color(theme.fg_muted)
.cursor_move()
.relative()
.child(
// Label + drag canvas (canvas absolute
// sobre la franja entera).
div()
.flex()
.items_center()
.h_full()
.child(gpui::SharedString::from(label_text)),
)
.child(title_canvas),
)
.child(
// Body — overlay con bounds canvas + el AnyView.
div()
.flex_grow()
.min_h(px(0.0))
.relative()
.child(bounds_canvas)
.child(child.view.clone()),
)
.into_any_element()
} else {
tile.opacity(0.35).into_any_element()
};
row_div = row_div.child(tile);
}
col_container = col_container.child(row_div);
}
col_container
}
}
@@ -0,0 +1,10 @@
[package]
name = "yahweh-widget-tree"
version = { workspace = true }
edition = { workspace = true }
license = { workspace = true }
description = "TreeView genérico — widget agnóstico de dominio sobre GPUI."
[dependencies]
gpui = { workspace = true }
yahweh-theme = { workspace = true }
@@ -0,0 +1,415 @@
//! `yahweh_widget_tree` — TreeView genérico, agnóstico del dominio.
//!
//! Anatomía: el host (FileExplorer, DatabaseExplorer, …) calcula una lista
//! plana `Vec<TreeRow>` por DFS y la empuja con `set_rows`. El TreeView solo
//! renderea, captura interacciones y emite [`TreeEvent`]. Todo lo de
//! dominio (qué carga al expandir un branch, qué hacer en doble click, etc)
//! lo decide el host suscribiéndose vía `cx.subscribe`.
//!
//! Esta es la pieza que reemplaza al `gioser_tree::Tree` de Makepad. La
//! diferencia clave es de plomería: en GPUI no hay un global action queue
//! ni Buttons que capten clicks indebidamente — cada `div` tiene su
//! `.on_click` propio y la propagación se detiene explícitamente. Lo que
//! peleamos en Makepad acá no existe.
use std::collections::HashMap;
use std::ops::Range;
use gpui::{
ClickEvent, Context, ElementId, Entity, EventEmitter, Hsla, IntoElement, MouseButton,
MouseDownEvent, Pixels, Point, Render, SharedString, Window, div, prelude::*, px,
uniform_list,
};
use yahweh_theme::Theme;
// =====================================================================
// Modelo público
// =====================================================================
/// Identificador opaco de una fila. Wrapper sobre `String` — el host elige
/// la representación (path, primary key, GUID). El TreeView lo trata como
/// dato opaco y lo usa de key del HashMap interno.
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct RowId(pub String);
impl RowId {
pub fn new(s: impl Into<String>) -> Self {
Self(s.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for RowId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for RowId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
impl std::fmt::Display for RowId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum RowKind {
Branch,
#[default]
Leaf,
}
#[derive(Clone, Debug, Default)]
pub struct TreeRow {
pub id: RowId,
pub label: String,
pub depth: u32,
pub kind: RowKind,
/// Solo aplica a `Branch`. El TreeView NO muta este campo — el host lo
/// pasa derivado de su propio `expanded: HashSet`.
pub expanded: bool,
/// Icono opcional (emoji o glyph) que se renderea entre chevron y label.
pub icon: Option<String>,
}
impl Default for RowId {
fn default() -> Self {
Self(String::new())
}
}
/// Eventos que el TreeView emite hacia su parent (`cx.subscribe(&tree, …)`).
#[derive(Clone, Debug)]
pub enum TreeEvent {
/// Click primario sobre el cuerpo de la fila (NO el chevron). El
/// TreeView ya actualizó su `active_id` internamente — esto es
/// notificación.
RowClicked(RowId),
/// Doble click sobre el cuerpo. Para Branch se emite además el toggle.
RowDoubleClicked(RowId),
/// Click en chevron, o doble click sobre Branch.
ChevronToggled(RowId),
/// Right-click. `id == None` cuando fue área vacía debajo de la última
/// fila. La posición es absoluta para que el host posicione su menú.
ContextMenuRequested {
id: Option<RowId>,
position: Point<Pixels>,
},
/// Cambio del `active_id` interno (por click, set_active externo, etc).
/// Se emite incluso cuando el cambio fue inducido externamente.
ActiveChanged(Option<RowId>),
}
// =====================================================================
// Widget
// =====================================================================
pub struct TreeView {
rows: Vec<TreeRow>,
/// Mapa id → índice en `rows`. Se reconstruye en cada `set_rows`. Útil
/// para resolver `id → row` en O(1) cuando vienen acciones desde un row.
index: HashMap<RowId, usize>,
/// Fila activa (cursor row).
active_id: Option<RowId>,
/// Marker colors externos (cross-container highlighting).
selected: HashMap<RowId, Hsla>,
/// Id estable del elemento raíz para GPUI — lo necesita `uniform_list`
/// para mantener el scroll state entre frames.
list_id: SharedString,
}
impl EventEmitter<TreeEvent> for TreeView {}
impl TreeView {
/// Crea un TreeView vacío. El parámetro `id` es libre — se usa solo
/// para identificar el `uniform_list` interno (debe ser único por
/// instancia). Ej.: `"file-tree"`, `"db-tree"`.
pub fn new(id: impl Into<SharedString>, cx: &mut Context<Self>) -> Self {
// Observar el theme global — cuando cambia, redibujamos para que el
// hover/active/marker reflejen la paleta nueva sin esperar el próximo
// evento de input.
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
Self {
rows: Vec::new(),
index: HashMap::new(),
active_id: None,
selected: HashMap::new(),
list_id: id.into(),
}
}
/// API pública: el host pushea las filas. Triggerea redraw.
pub fn set_rows(&mut self, rows: Vec<TreeRow>, cx: &mut Context<Self>) {
self.index = rows
.iter()
.enumerate()
.map(|(i, r)| (r.id.clone(), i))
.collect();
self.rows = rows;
cx.notify();
}
pub fn rows(&self) -> &[TreeRow] {
&self.rows
}
pub fn set_active(&mut self, id: Option<RowId>, cx: &mut Context<Self>) {
if self.active_id != id {
self.active_id = id.clone();
cx.emit(TreeEvent::ActiveChanged(id));
cx.notify();
}
}
pub fn active_id(&self) -> Option<&RowId> {
self.active_id.as_ref()
}
pub fn set_selected(&mut self, sel: HashMap<RowId, Hsla>, cx: &mut Context<Self>) {
self.selected = sel;
cx.notify();
}
pub fn add_selected(&mut self, id: RowId, color: Hsla, cx: &mut Context<Self>) {
self.selected.insert(id, color);
cx.notify();
}
pub fn remove_selected(&mut self, id: &RowId, cx: &mut Context<Self>) {
if self.selected.remove(id).is_some() {
cx.notify();
}
}
// ----- internos -----
fn handle_row_click(&mut self, id: RowId, click: &ClickEvent, cx: &mut Context<Self>) {
// Activar.
let new_active = Some(id.clone());
if self.active_id != new_active {
self.active_id = new_active.clone();
cx.emit(TreeEvent::ActiveChanged(new_active));
}
cx.emit(TreeEvent::RowClicked(id.clone()));
if click.click_count() >= 2 {
cx.emit(TreeEvent::RowDoubleClicked(id.clone()));
// Doble click sobre Branch: toggle implícito.
if let Some(row) = self.index.get(&id).and_then(|i| self.rows.get(*i)) {
if matches!(row.kind, RowKind::Branch) {
cx.emit(TreeEvent::ChevronToggled(id));
}
}
}
cx.notify();
}
fn handle_chevron_click(&mut self, id: RowId, _click: &ClickEvent, cx: &mut Context<Self>) {
cx.emit(TreeEvent::ChevronToggled(id));
}
fn handle_right_click(
&mut self,
id: Option<RowId>,
event: &MouseDownEvent,
cx: &mut Context<Self>,
) {
cx.emit(TreeEvent::ContextMenuRequested {
id,
position: event.position,
});
}
}
// =====================================================================
// Render
// =====================================================================
const ROW_HEIGHT: f32 = 22.0;
const INDENT_PX: f32 = 14.0;
const CHEVRON_PX: f32 = 14.0;
impl Render for TreeView {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone();
let row_count = self.rows.len();
let entity = cx.entity();
// Snapshot inmutable para que el closure de uniform_list pueda
// accederlo sin tomar prestado `self`.
let rows = self.rows.clone();
let active_id = self.active_id.clone();
let selected = self.selected.clone();
let list_id: ElementId = self.list_id.clone().into();
div()
.id("yahweh-tree-root")
.key_context("YahwehTree")
.size_full()
.bg(theme.bg_panel.clone())
.text_color(theme.fg_text)
// Right-click sobre área vacía (debajo de las rows) — sin id de
// row. La capa de rows captura su propio right-click y stoppea
// propagación, así que esto solo se dispara en el "fondo".
.on_mouse_down(
MouseButton::Right,
cx.listener({
move |this, e: &MouseDownEvent, _, cx| {
this.handle_right_click(None, e, cx);
}
}),
)
.child(
uniform_list(list_id, row_count, move |range: Range<usize>, _w, _cx| {
range
.filter_map(|i| rows.get(i).cloned())
.map(|row| {
render_row(
row,
&theme,
&active_id,
&selected,
entity.clone(),
)
})
.collect()
})
.size_full(),
)
}
}
// =====================================================================
// Render por fila — fuera del `impl Render` para mantener el tamaño
// manejable y aislar el closure de uniform_list.
// =====================================================================
fn render_row(
row: TreeRow,
theme: &Theme,
active_id: &Option<RowId>,
selected: &HashMap<RowId, Hsla>,
entity: Entity<TreeView>,
) -> impl IntoElement {
let id_for_chev = row.id.clone();
let id_for_body = row.id.clone();
let id_for_ctx = row.id.clone();
let is_active = active_id.as_ref() == Some(&row.id);
let marker = selected.get(&row.id).copied();
let chevron_glyph = match (row.kind, row.expanded) {
(RowKind::Branch, true) => "",
(RowKind::Branch, false) => "",
(RowKind::Leaf, _) => " ",
};
let icon = row.icon.clone().unwrap_or_default();
let label = row.label.clone();
let depth = row.depth as f32;
let is_branch = matches!(row.kind, RowKind::Branch);
// Background del row. Capas: marker (si hay) → active → hover (gestionado
// por gpui via .hover()).
let row_bg = if is_active {
Some(theme.bg_row_active)
} else {
marker
};
// Element id estable por fila — uniform_list es virtualizado, los ids
// tienen que ser únicos para que GPUI re-use el cache de hitboxes.
let element_id: ElementId = SharedString::from(format!("row::{}", row.id)).into();
let mut row_div = div()
.id(element_id)
.flex()
.flex_row()
.items_center()
.h(px(ROW_HEIGHT))
.w_full()
.pl(px(depth * INDENT_PX))
.text_size(px(13.0))
.hover(|s| s.bg(theme.bg_row_hover));
if let Some(bg) = row_bg {
row_div = row_div.bg(bg);
}
// Chevron — área propia, click stop_propagation para no disparar el
// body click.
let chevron_id: ElementId =
SharedString::from(format!("chev::{}", id_for_chev)).into();
let chevron = {
let entity = entity.clone();
let id = id_for_chev.clone();
div()
.id(chevron_id)
.w(px(CHEVRON_PX))
.h_full()
.flex()
.items_center()
.justify_center()
.text_color(theme.fg_muted)
.text_size(px(11.0))
.child(SharedString::from(chevron_glyph.to_string()))
.when(is_branch, |this| {
this.on_click(move |click, _w, cx| {
cx.stop_propagation();
entity.update(cx, |tree, cx| {
tree.handle_chevron_click(id.clone(), click, cx);
});
})
})
};
// Body — icono opcional + label, captura el click primario.
let body = {
let entity_body = entity.clone();
let entity_ctx = entity.clone();
let id_body = id_for_body.clone();
let id_ctx = id_for_ctx.clone();
let body_id: ElementId =
SharedString::from(format!("body::{}", id_for_body)).into();
let mut content = div()
.id(body_id)
.flex()
.flex_row()
.items_center()
.gap(px(4.0))
.px(px(4.0))
.flex_grow()
.h_full()
.on_click(move |click, _w, cx| {
entity_body.update(cx, |tree, cx| {
tree.handle_row_click(id_body.clone(), click, cx);
});
})
.on_mouse_down(
MouseButton::Right,
move |e: &MouseDownEvent, _w, cx| {
cx.stop_propagation();
entity_ctx.update(cx, |tree, cx| {
tree.handle_right_click(Some(id_ctx.clone()), e, cx);
});
},
);
if !icon.is_empty() {
content = content.child(SharedString::from(icon.clone()));
}
content.child(SharedString::from(label.clone()))
};
row_div.child(chevron).child(body)
}