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>
This commit is contained in:
@@ -6,6 +6,62 @@ ratio/diff ver `git show <sha>`.
|
|||||||
|
|
||||||
## 2026-05-10
|
## 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
|
### 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
|
Iter 7 (mini-iter — el text-input ya estaba themed, faltaba sólo
|
||||||
el polish de focus visibility). Antes el border era siempre
|
el polish de focus visibility). Antes el border era siempre
|
||||||
|
|||||||
@@ -51,7 +51,67 @@ pub struct Theme {
|
|||||||
|
|
||||||
impl Global for 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 {
|
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 {
|
pub fn global(cx: &gpui::App) -> &Self {
|
||||||
cx.global::<Self>()
|
cx.global::<Self>()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -531,6 +531,11 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
|
let (confirm_bg, confirm_text) = themed_colors(Banner::Error, theme);
|
||||||
let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
|
let cancel_bg: gpui::Background = theme.bg_panel_alt.clone();
|
||||||
let cancel_text = theme.fg_text;
|
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(
|
Some(
|
||||||
div()
|
div()
|
||||||
@@ -552,7 +557,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.text_size(px(10.))
|
.text_size(px(10.))
|
||||||
.text_color(gpui::rgb(0xc0a070))
|
.text_color(hint_color)
|
||||||
.child("Esc para cancelar · click [Confirmar] para borrar"),
|
.child("Esc para cancelar · click [Confirmar] para borrar"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -566,7 +571,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.py(px(4.))
|
.py(px(4.))
|
||||||
.bg(cancel_bg)
|
.bg(cancel_bg)
|
||||||
.text_color(cancel_text)
|
.text_color(cancel_text)
|
||||||
.hover(|d| d.bg(gpui::rgb(0x3a3f48)))
|
.hover(move |d| d.bg(cancel_hover))
|
||||||
.child("Cancelar")
|
.child("Cancelar")
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
this.pending_delete = None;
|
this.pending_delete = None;
|
||||||
@@ -586,7 +591,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.py(px(4.))
|
.py(px(4.))
|
||||||
.bg(confirm_bg)
|
.bg(confirm_bg)
|
||||||
.text_color(confirm_text)
|
.text_color(confirm_text)
|
||||||
.hover(|d| d.bg(gpui::rgb(0x8a2828)))
|
.hover(move |d| d.bg(confirm_hover))
|
||||||
.child("Confirmar")
|
.child("Confirmar")
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
// Limpiar primero para que un fallo del
|
// Limpiar primero para que un fallo del
|
||||||
@@ -625,6 +630,10 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
text_dim: gpui::Hsla,
|
text_dim: gpui::Hsla,
|
||||||
accent_active: gpui::Hsla,
|
accent_active: gpui::Hsla,
|
||||||
) -> gpui::Div {
|
) -> 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()
|
let mut sidebar = div()
|
||||||
.w(px(240.))
|
.w(px(240.))
|
||||||
.h_full()
|
.h_full()
|
||||||
@@ -696,10 +705,8 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.py(px(6.))
|
.py(px(6.))
|
||||||
.text_size(px(12.))
|
.text_size(px(12.))
|
||||||
.text_color(if is_active { accent_active } else { text_dim })
|
.text_color(if is_active { accent_active } else { text_dim })
|
||||||
.when(is_active, |d| {
|
.when(is_active, move |d| d.bg(menu_active_bg).text_color(text))
|
||||||
d.bg(gpui::rgb(0x232a36)).text_color(text)
|
.hover(move |d| d.bg(menu_hover_bg))
|
||||||
})
|
|
||||||
.hover(|d| d.bg(gpui::rgb(0x1f2630)))
|
|
||||||
.child(label)
|
.child(label)
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
this.select_view(mod_idx, view_key.clone(), cx);
|
this.select_view(mod_idx, view_key.clone(), cx);
|
||||||
@@ -771,6 +778,14 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
text_dim: gpui::Hsla,
|
text_dim: gpui::Hsla,
|
||||||
accent: gpui::Hsla,
|
accent: gpui::Hsla,
|
||||||
) -> gpui::Div {
|
) -> 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()
|
let mut header = div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_row()
|
.flex_row()
|
||||||
@@ -799,11 +814,11 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
)))
|
)))
|
||||||
.px(px(10.))
|
.px(px(10.))
|
||||||
.py(px(4.))
|
.py(px(4.))
|
||||||
.bg(gpui::rgb(0x232a36))
|
.bg(action_bg)
|
||||||
.text_color(accent)
|
.text_color(accent)
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.rounded(px(3.))
|
.rounded(px(3.))
|
||||||
.hover(|d| d.bg(gpui::rgb(0x2c3540)))
|
.hover(move |d| d.bg(action_hover))
|
||||||
.child(label)
|
.child(label)
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
this.apply_action(action_clone.clone(), cx);
|
this.apply_action(action_clone.clone(), cx);
|
||||||
@@ -848,7 +863,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.flex_row()
|
.flex_row()
|
||||||
.py(px(6.))
|
.py(px(6.))
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
.border_color(gpui::rgb(0x232a36))
|
.border_color(row_separator)
|
||||||
.text_color(text)
|
.text_color(text)
|
||||||
.text_size(px(12.));
|
.text_size(px(12.));
|
||||||
for c in &lv.columns {
|
for c in &lv.columns {
|
||||||
@@ -883,7 +898,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.px(px(6.))
|
.px(px(6.))
|
||||||
.text_color(accent)
|
.text_color(accent)
|
||||||
.text_size(px(13.))
|
.text_size(px(13.))
|
||||||
.hover(|d| d.bg(gpui::rgb(0x2c3540)))
|
.hover(move |d| d.bg(action_hover))
|
||||||
.child("✎")
|
.child("✎")
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
this.open_edit(mod_idx, entity_for_edit.clone(), id_copy, 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}"
|
"row-del-{mod_idx}-{id_copy}"
|
||||||
)))
|
)))
|
||||||
.px(px(6.))
|
.px(px(6.))
|
||||||
.text_color(gpui::rgb(0xd07070))
|
.text_color(destructive_fg)
|
||||||
.text_size(px(13.))
|
.text_size(px(13.))
|
||||||
.hover(|d| d.bg(gpui::rgb(0x4a2020)))
|
.hover(move |d| d.bg(destructive_hover))
|
||||||
.child("✕")
|
.child("✕")
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
// Marca para borrar en lugar de borrar
|
// Marca para borrar en lugar de borrar
|
||||||
@@ -951,6 +966,10 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
accent: gpui::Hsla,
|
accent: gpui::Hsla,
|
||||||
) -> gpui::Div {
|
) -> gpui::Div {
|
||||||
let _ = text;
|
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 rows = self.list_rows(&target_entity);
|
||||||
let current = self
|
let current = self
|
||||||
.form_inputs
|
.form_inputs
|
||||||
@@ -962,7 +981,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.mt(px(4.))
|
.mt(px(4.))
|
||||||
.pl(px(8.))
|
.pl(px(8.))
|
||||||
.border_l_2()
|
.border_l_2()
|
||||||
.border_color(gpui::rgb(0x2a2f38))
|
.border_color(theme.border)
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.gap(px(2.));
|
.gap(px(2.));
|
||||||
@@ -1002,8 +1021,8 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.py(px(2.))
|
.py(px(2.))
|
||||||
.text_size(px(11.))
|
.text_size(px(11.))
|
||||||
.text_color(if is_selected { accent } else { text_dim })
|
.text_color(if is_selected { accent } else { text_dim })
|
||||||
.when(is_selected, |d| d.bg(gpui::rgb(0x232a36)))
|
.when(is_selected, move |d| d.bg(row_active))
|
||||||
.hover(|d| d.bg(gpui::rgb(0x1f2630)))
|
.hover(move |d| d.bg(row_hover))
|
||||||
.child(label)
|
.child(label)
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
if let Some(input) = this.form_inputs.get(&field_for_click) {
|
if let Some(input) = this.form_inputs.get(&field_for_click) {
|
||||||
@@ -1028,6 +1047,11 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
text_dim: gpui::Hsla,
|
text_dim: gpui::Hsla,
|
||||||
accent: gpui::Hsla,
|
accent: gpui::Hsla,
|
||||||
) -> gpui::Div {
|
) -> 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
|
// En modo edit, el título refleja eso para que el user no
|
||||||
// se confunda creyendo que hace alta nueva.
|
// se confunda creyendo que hace alta nueva.
|
||||||
let title = match self.editing.as_ref() {
|
let title = match self.editing.as_ref() {
|
||||||
@@ -1068,7 +1092,7 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
div()
|
div()
|
||||||
.px(px(8.))
|
.px(px(8.))
|
||||||
.py(px(6.))
|
.py(px(6.))
|
||||||
.bg(gpui::rgb(0x171a20))
|
.bg(input_bg)
|
||||||
.text_color(text_dim)
|
.text_color(text_dim)
|
||||||
.child("(input no inicializado)"),
|
.child("(input no inicializado)"),
|
||||||
);
|
);
|
||||||
@@ -1127,11 +1151,11 @@ impl<B: MetaBackend> MetaApp<B> {
|
|||||||
.id(SharedString::from(format!("form-submit-{mod_idx}")))
|
.id(SharedString::from(format!("form-submit-{mod_idx}")))
|
||||||
.px(px(12.))
|
.px(px(12.))
|
||||||
.py(px(6.))
|
.py(px(6.))
|
||||||
.bg(gpui::rgb(0x2c3540))
|
.bg(submit_bg)
|
||||||
.text_color(accent)
|
.text_color(accent)
|
||||||
.text_size(px(12.))
|
.text_size(px(12.))
|
||||||
.rounded(px(3.))
|
.rounded(px(3.))
|
||||||
.hover(|d| d.bg(gpui::rgb(0x3a4555)))
|
.hover(move |d| d.bg(submit_hover))
|
||||||
.child(submit_label)
|
.child(submit_label)
|
||||||
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
.on_click(cx.listener(move |this, _e: &ClickEvent, _w, cx| {
|
||||||
this.apply_action(submit_action.clone(), cx);
|
this.apply_action(submit_action.clone(), cx);
|
||||||
|
|||||||
@@ -20,13 +20,19 @@
|
|||||||
//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
|
//! Cuando necesitemos algo serio (selección, posiciones, IME), portamos el
|
||||||
//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
|
//! ejemplo `gpui::examples::input` o adoptamos `gpui-input` cuando exista.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
|
Context, EventEmitter, FocusHandle, Focusable, IntoElement, KeyDownEvent, Render,
|
||||||
SharedString, Window, div, prelude::*, px,
|
SharedString, Task, Window, div, prelude::*, px,
|
||||||
};
|
};
|
||||||
|
|
||||||
use yahweh_theme::Theme;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub enum TextInputEvent {
|
pub enum TextInputEvent {
|
||||||
/// El usuario apretó Enter. El payload es el texto actual.
|
/// El usuario apretó Enter. El payload es el texto actual.
|
||||||
@@ -40,6 +46,14 @@ pub struct TextInput {
|
|||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
/// Placeholder visible cuando `text` está vacío.
|
/// Placeholder visible cuando `text` está vacío.
|
||||||
placeholder: SharedString,
|
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 {}
|
impl EventEmitter<TextInputEvent> for TextInput {}
|
||||||
@@ -53,10 +67,30 @@ impl Focusable for TextInput {
|
|||||||
impl TextInput {
|
impl TextInput {
|
||||||
pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
|
pub fn new(initial: impl Into<String>, cx: &mut Context<Self>) -> Self {
|
||||||
cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
|
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 {
|
Self {
|
||||||
text: initial.into(),
|
text: initial.into(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
placeholder: SharedString::from(""),
|
placeholder: SharedString::from(""),
|
||||||
|
caret_visible: true,
|
||||||
|
_blink_task: blink_task,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +169,13 @@ impl Render for TextInput {
|
|||||||
} else {
|
} else {
|
||||||
theme.border
|
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 {
|
let display: SharedString = if is_empty {
|
||||||
self.placeholder.clone()
|
self.placeholder.clone()
|
||||||
} else if is_focused {
|
} else if show_caret {
|
||||||
// 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).
|
|
||||||
SharedString::from(format!("{}|", self.text))
|
SharedString::from(format!("{}|", self.text))
|
||||||
} else {
|
} else {
|
||||||
SharedString::from(self.text.clone())
|
SharedString::from(self.text.clone())
|
||||||
|
|||||||
Reference in New Issue
Block a user