gioser

tawasuyu/brahman

Rust 582TOML 222Markdown 73JSON 198635 Sutichaykuna

feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed

Iters 8-9 combinadas. Tres mejoras pequeñas que cierran la
integración del theme:

1) text-input caret blinking: caret_visible bool toggea cada 500ms
   via cx.spawn loop. _blink_task se mantiene en self para que
   drop cancele. render dibuja | sólo si focused && caret_visible.

2) yahweh-theme: 5 slots ornament secundario como methods (no
   fields) derivados de is_dark via ornament_slots() helper:
   bg_input/bg_button/bg_button_hover/accent_destructive/
   bg_destructive_hover. No requiere modificar los 6 presets.

3) MetaApp ornament cleanup: 11 rgb(0x...) hardcoded → slots del
   theme. Sidebar menu items, list row separator/buttons, icon ✕
   delete y su hover, EntityRef selector hovers, form submit
   button + fallback input bg, confirm modal hint y hovers.

Pattern: let X = theme.slot() antes de las closures + move |d|
d.bg(X) en hover/when para tomar ownership.

Antes MetaApp tenía la paleta principal themed (iter 5) pero el
ornament secundario seguía hardcoded. Ahora el theme switcher
cambia absolutamente todo el chrome.

Tests: 117 verdes. Downstream compila. Smoke nakui-ui: bootstrap
OK.

Limitación: nouser-explorer todavía hardcoded — próxima iter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

ea074d7d576114d3335c4b84d81725dffe09a2ed · ruwaq Sergio · (5 killa ñawpaq) · tayta fc4da751c · kay commitpi sach'ata qhaway

Semántico sayk'uy · 15

crates/modules/ui_engine/libs/theme/src/lib.rs

crates/modules/ui_engine/widgets/meta-form/src/lib.rs

crates/modules/ui_engine/widgets/text_input/src/lib.rs

4 qillqakuna · +199 −24

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09d790a..ecbbc1a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,62 @@ ratio/diff ver `git show <sha>`.
 
 ## 2026-05-10
 
+### feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed
+Iters 8-9 combinadas. Tres mejoras pequeñas que cierran la
+integración del theme:
+
+**1. Caret blinking en text-input** (`yahweh-widget-text-input`):
+- Nuevo field `caret_visible: bool` que toggea cada 500ms.
+- Nuevo field `_blink_task: Task<()>` mantiene el loop de blink
+  vivo y lo cancela al drop del widget.
+- En `new()`, `cx.spawn(...)` arranca el loop: `timer.timer(500ms)`
+  + `this.update(...)` que toggea + `cx.notify()`. Si el update
+  falla (entity drop), break.
+- En `render()`, caret `|` se dibuja sólo si
+  `is_focused && self.caret_visible`. Familiar feel del SO.
+
+**2. Slots ornament en yahweh-theme** (5 nuevos):
+- `bg_input() -> Hsla` — bg sutil para fields editables.
+- `bg_button() -> Hsla` + `bg_button_hover() -> Hsla` — controls
+  clickable secundarios.
+- `accent_destructive() -> Hsla` — rojo para acciones peligrosas.
+- `bg_destructive_hover() -> Hsla` — bg de hover sobre destructive.
+- Implementados como **methods** del `Theme` (no fields del
+  struct), derivados via `ornament_slots(self.is_dark)`. Esto
+  evita modificar los 6 presets — el slot vive donde uno lo
+  invoca.
+
+**3. MetaApp ornament cleanup** (`yahweh-widget-meta-form`):
+- 11 colores hardcoded `gpui::rgb(0x...)` migrados a slots del
+  theme:
+  - Sidebar menu items (selected/hover) → `bg_row_active` /
+    `bg_row_hover`.
+  - List row separator + button bgs → `bg_row_active` /
+    `bg_button()` / `bg_button_hover()`.
+  - Icon ✕ delete + hover → `accent_destructive()` /
+    `bg_destructive_hover()`.
+  - EntityRef selector hover/selected → `bg_row_active` /
+    `bg_row_hover`.
+  - EntityRef selector border → `theme.border` (slot existente).
+  - Form fallback input bg + submit button → `bg_input()` /
+    `bg_button()` / `bg_button_hover()`.
+  - Confirm modal hint subtitle + hovers de Cancel/Confirm →
+    `theme.fg_muted` / `bg_button_hover()` / `bg_destructive_hover()`.
+- Pattern: `let X = theme.slot()` antes de las closures + `move |d|
+  d.bg(X)` en hover/when para que el cierre tome ownership.
+
+Antes de este commit MetaApp tenía la **paleta principal** themed
+(iter 5) pero el ornament secundario (hovers, separators, botones
+inline) seguía hardcoded. Ahora el theme switcher cambia
+**absolutamente todo** el chrome del MetaApp en runtime.
+
+Tests: 117 verdes (sin cambios numéricos, pero downstream sigue
+compilando). Smoke run de nakui-ui: bootstrap completo OK.
+
+Limitación restante: `nouser-explorer` todavía no migra al stack
+yahweh themed — patrón idéntico a `nakui-explorer` aplicado pero
+más nuevo. Próxima iter.
+
 ### feat(yahweh-widget-text-input): focus-aware border + caret sólo on focus
 Iter 7 (mini-iter — el text-input ya estaba themed, faltaba sólo
 el polish de focus visibility). Antes el border era siempre
diff --git a/crates/modules/ui_engine/libs/theme/src/lib.rs b/crates/modules/ui_engine/libs/theme/src/lib.rs
index 5715b31..441ff53 100644
--- a/crates/modules/ui_engine/libs/theme/src/lib.rs
+++ b/crates/modules/ui_engine/libs/theme/src/lib.rs
@@ -51,7 +51,67 @@ pub struct Theme {
 
 impl Global for Theme {}
 
+/// Helper privado: deriva los 5 slots "ornament secundario"
+/// (bg_input/button/button_hover + accent_destructive +
+/// bg_destructive_hover) según `is_dark`.
+///
+/// Devuelve los slots en el orden de los métodos públicos del
+/// `Theme`. Los métodos del impl los exponen individualmente.
+fn ornament_slots(is_dark: bool) -> (Hsla, Hsla, Hsla, Hsla, Hsla) {
+    if is_dark {
+        (
+            // bg_input: muy oscuro, sutil tinte azul/gris
+            hsla(220.0 / 360.0, 0.20, 0.07, 1.0),
+            // bg_button: medio oscuro
+            hsla(220.0 / 360.0, 0.18, 0.20, 1.0),
+            // bg_button_hover: un poco más claro
+            hsla(220.0 / 360.0, 0.20, 0.27, 1.0),
+            // accent_destructive: rojo medio-claro para visibilidad
+            hsla(0.0, 0.55, 0.65, 1.0),
+            // bg_destructive_hover: rojo oscuro de fondo
+            hsla(0.0, 0.55, 0.18, 1.0),
+        )
+    } else {
+        (
+            hsla(220.0 / 360.0, 0.10, 0.97, 1.0),
+            hsla(220.0 / 360.0, 0.15, 0.85, 1.0),
+            hsla(220.0 / 360.0, 0.20, 0.75, 1.0),
+            hsla(0.0, 0.65, 0.45, 1.0),
+            hsla(0.0, 0.55, 0.92, 1.0),
+        )
+    }
+}
+
 impl Theme {
+    /// Bg sutil para fields editables que se quieren marcar como
+    /// "input target" sin ser un panel. Derivado de `is_dark`.
+    pub fn bg_input(&self) -> Hsla {
+        ornament_slots(self.is_dark).0
+    }
+
+    /// Bg para clickable controls (botones secundarios, edit/delete
+    /// icons en filas). Más prominente que `bg_panel_alt`, menos que
+    /// `accent`. Derivado de `is_dark`.
+    pub fn bg_button(&self) -> Hsla {
+        ornament_slots(self.is_dark).1
+    }
+
+    /// Hover de [`Self::bg_button`].
+    pub fn bg_button_hover(&self) -> Hsla {
+        ornament_slots(self.is_dark).2
+    }
+
+    /// Accent rojo para acciones destructivas (delete, drop, force).
+    pub fn accent_destructive(&self) -> Hsla {
+        ornament_slots(self.is_dark).3
+    }
+
+    /// Bg de hover sobre clickable destructive elements (icon ✕,
+    /// botones de "borrar"). Más oscuro que `accent_destructive`.
+    pub fn bg_destructive_hover(&self) -> Hsla {
+        ornament_slots(self.is_dark).4
+    }
+
     pub fn global(cx: &gpui::App) -> &Self {
         cx.global::<Self>()
     }
diff --git a/crates/modules/ui_engine/widgets/meta-form/src/lib.rs b/crates/modules/ui_engine/widgets/meta-form/src/lib.rs
index 557858c..b72caba 100644
--- a/crates/modules/ui_engine/widgets/meta-form/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/meta-form/src/lib.rs
@@ -531,6 +531,11 @@ impl<B: MetaBackend> MetaApp<B> {
         let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
         let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
         let cancel_text = theme.fg_text;
+        // Hover colors capturados antes de las closures para que el
+        // move |d| d.bg(...) los cierre.
+        let cancel_hover = theme.bg_button_hover();
+        let confirm_hover = theme.bg_destructive_hover();
+        let hint_color = theme.fg_muted;
 
         Some(
             div()
@@ -552,7 +557,7 @@ impl<B: MetaBackend> MetaApp<B> {
                         .child(
                             div()
                                 .text_size(px(10.))
-                                .text_color(gpui::rgb(0xc0a070))
+                                .text_color(hint_color)
                                 .child("Esc para cancelar · click [Confirmar] para borrar"),
                         ),
                 )
@@ -566,7 +571,7 @@ impl<B: MetaBackend> MetaApp<B> {
                         .py(px(4.))
                         .bg(cancel_bg)
                         .text_color(cancel_text)
-                        .hover(|d| d.bg(gpui::rgb(0x3a3f48)))
+                        .hover(move |d| d.bg(cancel_hover))
                         .child("Cancelar")
                         .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                             this.pending_delete = None;
@@ -586,7 +591,7 @@ impl<B: MetaBackend> MetaApp<B> {
                         .py(px(4.))
                         .bg(confirm_bg)
                         .text_color(confirm_text)
-                        .hover(|d| d.bg(gpui::rgb(0x8a2828)))
+                        .hover(move |d| d.bg(confirm_hover))
                         .child("Confirmar")
                         .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                             // Limpiar primero para que un fallo del
@@ -625,6 +630,10 @@ impl<B: MetaBackend> MetaApp<B> {
         text_dim: gpui::Hsla,
         accent_active: gpui::Hsla,
     ) -> gpui::Div {
+        // Slots ornament del theme para los menu items de abajo.
+        let theme = Theme::global(cx);
+        let menu_active_bg = theme.bg_row_active;
+        let menu_hover_bg = theme.bg_row_hover;
         let mut sidebar = div()
             .w(px(240.))
             .h_full()
@@ -696,10 +705,8 @@ impl<B: MetaBackend> MetaApp<B> {
                         .py(px(6.))
                         .text_size(px(12.))
                         .text_color(if is_active { accent_active } else { text_dim })
-                        .when(is_active, |d| {
-                            d.bg(gpui::rgb(0x232a36)).text_color(text)
-                        })
-                        .hover(|d| d.bg(gpui::rgb(0x1f2630)))
+                        .when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
+                        .hover(move |d| d.bg(menu_hover_bg))
                         .child(label)
                         .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                             this.select_view(mod_idx, view_key.clone(), cx);
@@ -771,6 +778,14 @@ impl<B: MetaBackend> MetaApp<B> {
         text_dim: gpui::Hsla,
         accent: gpui::Hsla,
     ) -> gpui::Div {
+        // Ornament secundarios del theme para hovers, row separators,
+        // botones inline (edit ✎, delete ✕).
+        let theme = Theme::global(cx);
+        let row_separator = theme.bg_row_active;
+        let action_bg = theme.bg_button();
+        let action_hover = theme.bg_button_hover();
+        let destructive_fg = theme.accent_destructive();
+        let destructive_hover = theme.bg_destructive_hover();
         let mut header = div()
             .flex()
             .flex_row()
@@ -799,11 +814,11 @@ impl<B: MetaBackend> MetaApp<B> {
                     )))
                     .px(px(10.))
                     .py(px(4.))
-                    .bg(gpui::rgb(0x232a36))
+                    .bg(action_bg)
                     .text_color(accent)
                     .text_size(px(11.))
                     .rounded(px(3.))
-                    .hover(|d| d.bg(gpui::rgb(0x2c3540)))
+                    .hover(move |d| d.bg(action_hover))
                     .child(label)
                     .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                         this.apply_action(action_clone.clone(), cx);
@@ -848,7 +863,7 @@ impl<B: MetaBackend> MetaApp<B> {
                 .flex_row()
                 .py(px(6.))
                 .border_b_1()
-                .border_color(gpui::rgb(0x232a36))
+                .border_color(row_separator)
                 .text_color(text)
                 .text_size(px(12.));
             for c in &lv.columns {
@@ -883,7 +898,7 @@ impl<B: MetaBackend> MetaApp<B> {
                             .px(px(6.))
                             .text_color(accent)
                             .text_size(px(13.))
-                            .hover(|d| d.bg(gpui::rgb(0x2c3540)))
+                            .hover(move |d| d.bg(action_hover))
                             .child("✎")
                             .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                                 this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, cx);
@@ -895,9 +910,9 @@ impl<B: MetaBackend> MetaApp<B> {
                                 "row-del-{mod_idx}-{id_copy}"
                             )))
                             .px(px(6.))
-                            .text_color(gpui::rgb(0xd07070))
+                            .text_color(destructive_fg)
                             .text_size(px(13.))
-                            .hover(|d| d.bg(gpui::rgb(0x4a2020)))
+                            .hover(move |d| d.bg(destructive_hover))
                             .child("✕")
                             .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                                 // Marca para borrar en lugar de borrar
@@ -951,6 +966,10 @@ impl<B: MetaBackend> MetaApp<B> {
         accent: gpui::Hsla,
     ) -> gpui::Div {
         let _ = text;
+        // Slots ornament para hover/selected del selector + border.
+        let theme = Theme::global(cx);
+        let row_active = theme.bg_row_active;
+        let row_hover = theme.bg_row_hover;
         let rows = self.list_rows(&target_entity);
         let current = self
             .form_inputs
@@ -962,7 +981,7 @@ impl<B: MetaBackend> MetaApp<B> {
             .mt(px(4.))
             .pl(px(8.))
             .border_l_2()
-            .border_color(gpui::rgb(0x2a2f38))
+            .border_color(theme.border)
             .flex()
             .flex_col()
             .gap(px(2.));
@@ -1002,8 +1021,8 @@ impl<B: MetaBackend> MetaApp<B> {
                     .py(px(2.))
                     .text_size(px(11.))
                     .text_color(if is_selected { accent } else { text_dim })
-                    .when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
-                    .hover(|d| d.bg(gpui::rgb(0x1f2630)))
+                    .when(is_selected, move |d| d.bg(row_active))
+                    .hover(move |d| d.bg(row_hover))
                     .child(label)
                     .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                         if let Some(input) = this.form_inputs.get(&field_for_click) {
@@ -1028,6 +1047,11 @@ impl<B: MetaBackend> MetaApp<B> {
         text_dim: gpui::Hsla,
         accent: gpui::Hsla,
     ) -> gpui::Div {
+        // Slots ornament para el botón submit + bg de fallback inputs.
+        let theme = Theme::global(cx);
+        let submit_bg = theme.bg_button();
+        let submit_hover = theme.bg_button_hover();
+        let input_bg = theme.bg_input();
         // En modo edit, el título refleja eso para que el user no
         // se confunda creyendo que hace alta nueva.
         let title = match self.editing.as_ref() {
@@ -1068,7 +1092,7 @@ impl<B: MetaBackend> MetaApp<B> {
                     div()
                         .px(px(8.))
                         .py(px(6.))
-                        .bg(gpui::rgb(0x171a20))
+                        .bg(input_bg)
                         .text_color(text_dim)
                         .child("(input no inicializado)"),
                 );
@@ -1127,11 +1151,11 @@ impl<B: MetaBackend> MetaApp<B> {
                     .id(SharedString::from(format!("form-submit-{mod_idx}")))
                     .px(px(12.))
                     .py(px(6.))
-                    .bg(gpui::rgb(0x2c3540))
+                    .bg(submit_bg)
                     .text_color(accent)
                     .text_size(px(12.))
                     .rounded(px(3.))
-                    .hover(|d| d.bg(gpui::rgb(0x3a4555)))
+                    .hover(move |d| d.bg(submit_hover))
                     .child(submit_label)
                     .on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
                         this.apply_action(submit_action.clone(), cx);
diff --git a/crates/modules/ui_engine/widgets/text_input/src/lib.rs b/crates/modules/ui_engine/widgets/text_input/src/lib.rs
index 806f47c..b1d81eb 100644
--- a/crates/modules/ui_engine/widgets/text_input/src/lib.rs
+++ b/crates/modules/ui_engine/widgets/text_input/src/lib.rs
@@ -20,13 +20,19 @@
 //! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
 //! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
 
+use std::time::Duration;
+
 use gpui::{
     Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
-    SharedString, Window, div, prelude::*, px,
+    SharedString, Task, Window, div, prelude::*, px,
 };
 
 use yahweh_theme::Theme;
 
+/// Período de toggle del caret. 500ms es el estándar de los inputs
+/// del SO; ni rápido demasiado (distrae) ni lento (parece muerto).
+const CARET_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+
 #[derive(Clone, Debug)]
 pub enum TextInputEvent {
     /// El usuario apretó Enter. El payload es el texto actual.
@@ -40,6 +46,14 @@ pub struct TextInput {
     focus_handle: FocusHandle,
     /// Placeholder visible cuando `text` está vacío.
     placeholder: SharedString,
+    /// Toggle del caret: alterna cada [`CARET_BLINK_INTERVAL`]
+    /// entre `true` (visible) y `false` (oculto). El render lo
+    /// considera junto con focus para decidir si dibujar `|`.
+    caret_visible: bool,
+    /// Task del loop de blink. Se mantiene en self para que el
+    /// drop del widget cancele el loop (sino seguiría tickeando
+    /// y notificando contra un Entity ya muerto).
+    _blink_task: Task<()>,
 }
 
 impl EventEmitter<TextInputEvent> for TextInput {}
@@ -53,10 +67,30 @@ impl Focusable for TextInput {
 impl TextInput {
     pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
         cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
+        // Loop de blink: alterna `caret_visible` y notifica para
+        // re-render. Vive en `_blink_task` (drop = cancel).
+        let blink_task = cx.spawn(async move |this, cx| {
+            let timer = cx.background_executor().clone();
+            loop {
+                timer.timer(CARET_BLINK_INTERVAL).await;
+                let updated = this
+                    .update(cx, |me, cx| {
+                        me.caret_visible = !me.caret_visible;
+                        cx.notify();
+                    })
+                    .is_ok();
+                if !updated {
+                    // Entity drop → salimos del loop.
+                    break;
+                }
+            }
+        });
         Self {
             text: initial.into(),
             focus_handle: cx.focus_handle(),
             placeholder: SharedString::from(""),
+            caret_visible: true,
+            _blink_task: blink_task,
         }
     }
 
@@ -135,12 +169,13 @@ impl Render for TextInput {
         } else {
             theme.border
         };
+        // Caret visible cuando: (1) input tiene focus AND (2) el
+        // toggle del blink loop está en `true`. El loop alterna
+        // cada 500ms — feel familiar a los inputs del SO.
+        let show_caret = is_focused && self.caret_visible;
         let display: SharedString = if is_empty {
             self.placeholder.clone()
-        } else if is_focused {
-            // Caret al final, sólo cuando el input tiene focus —
-            // así el usuario ve dónde va a aparecer el siguiente
-            // char. Inputs sin focus no muestran caret (es ruido).
+        } else if show_caret {
             SharedString::from(format!("{}|", self.text))
         } else {
             SharedString::from(self.text.clone())