From ea074d7d576114d3335c4b84d81725dffe09a2ed Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 11:05:39 +0000 Subject: [PATCH] feat(yahweh): caret blinking + slots ornament en theme + MetaApp full themed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 56 +++++++++++++++++ .../modules/ui_engine/libs/theme/src/lib.rs | 60 ++++++++++++++++++ .../ui_engine/widgets/meta-form/src/lib.rs | 62 +++++++++++++------ .../ui_engine/widgets/text_input/src/lib.rs | 45 ++++++++++++-- 4 files changed, 199 insertions(+), 24 deletions(-) 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 `. ## 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::() } 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 MetaApp { 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 MetaApp { .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 MetaApp { .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 MetaApp { .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 MetaApp { 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 MetaApp { .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 MetaApp { 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 MetaApp { ))) .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 MetaApp { .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 MetaApp { .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 MetaApp { "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 MetaApp { 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 MetaApp { .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 MetaApp { .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 MetaApp { 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 MetaApp { 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 MetaApp { .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 for TextInput {} @@ -53,10 +67,30 @@ impl Focusable for TextInput { impl TextInput { pub fn new(initial: impl Into, cx: &mut Context) -> Self { cx.observe_global::(|_, 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())