diff --git a/CHANGELOG.md b/CHANGELOG.md index c91679a..7648706 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,60 @@ ratio/diff ver `git show `. ## 2026-05-10 +### feat(yahweh-widget-theme-switcher): control para ciclar themes en runtime +Iter 6. Cierra el ciclo del theme: ya teníamos paleta themed + +widgets que la consumen, faltaba el control UI para rotar entre +presets en vivo. Ahora hay un botón yahweh que muestra el theme +actual y al click avanza al siguiente. `nakui-ui` y `nakui-explorer` +lo incrustan en sus headers — un click cambia toda la paleta. + +Crate nuevo: `crates/modules/ui_engine/widgets/theme-switcher/` +(`yahweh-widget-theme-switcher`): +- **Deps**: `gpui` + `yahweh-theme`. Sin nada más. +- **`pub fn theme_switcher(cx: &mut App) -> impl IntoElement`**: + botón clickable con `id`, padding consistente (`px(8/4)`), + bg = `theme.bg_panel_alt`, hover = `bg_row_hover`. Muestra + `"Tema: ▸"` y al click hace + `Theme::set(cx, Theme::next_after(current.name))`. +- 2 tests `#[gpui::test]`: + - `switcher_constructs_with_theme_installed` — smoke: el + constructor lee el global y devuelve un IntoElement sin panic. + - `theme_set_changes_global` — verifica que `Theme::set` reemplaza + el global y que el siguiente `Theme::global` devuelve el nuevo. +- Dev-dep `gpui` con `test-support` para habilitar TestAppContext. + +Migración de consumers: +- **`nakui-explorer`**: nueva dep `yahweh-widget-theme-switcher`. + El header pasa de `div().px().py()...child(text)` a + `div().flex_row().child(div().flex_grow().child(text)).child(theme_switcher(cx))`. + El switcher queda alineado a la derecha vía `flex_grow` del label. +- **`yahweh-widget-meta-form`**: nueva dep. El sidebar header + ("Nakui" + 12px padding) gana el switcher con el mismo patrón + flex_row + flex_grow. + +Tests stack: 115 → **117** (+2 del switcher). Cada crate compila +individualmente. + +Beneficio operativo: +- Click en el switcher cambia toda la paleta en vivo: bg del app, + panels, banners (los que usan `_themed`), confirm modal, todo. +- 6 presets disponibles via `Theme::all()` (Nebula, Aurora, + Sunset, Flat Dark, Solarized Light, High Contrast). El switcher + cicla circularmente. +- Apps adoptantes del `Theme` heredan el switch sin esfuerzo. + +Decisión técnica: el handler usa `Theme::set(cx, ...)` que +invalida el global. GPUI marca todos los views como dirty y +re-renderea — los widgets que leen `Theme::global` en su `render` +ven el nuevo automáticamente. No requiere `cx.observe_global` +explícito en cada widget consumidor. + +Limitación: TextInput entities ya creadas no se actualizan visualmente +si el theme cambia los colors del input bg/border (esos colors +están hardcoded en `yahweh-widget-text-input`). Migrar text_input +al theme es una iter futura — bajo scope porque actualmente vive +suficientemente bien con sus defaults dark. + ### feat(yahweh-widget-meta-form): paleta del chrome migrada a `Theme::global(cx)` Iter 5 de integración. El `MetaApp::render` tenía 7 vars locales con colors hardcoded (`bg/panel/border/text/text_dim/accent/ diff --git a/Cargo.lock b/Cargo.lock index ee7dc05..9970626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6463,6 +6463,7 @@ dependencies = [ "yahweh-theme", "yahweh-widget-banner", "yahweh-widget-card", + "yahweh-widget-theme-switcher", ] [[package]] @@ -13037,6 +13038,7 @@ dependencies = [ "yahweh-theme", "yahweh-widget-banner", "yahweh-widget-text-input", + "yahweh-widget-theme-switcher", ] [[package]] @@ -13067,6 +13069,14 @@ dependencies = [ "yahweh-theme", ] +[[package]] +name = "yahweh-widget-theme-switcher" +version = "0.1.0" +dependencies = [ + "gpui", + "yahweh-theme", +] + [[package]] name = "yahweh-widget-tiled" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c0a9f1..4c716a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ members = [ "crates/modules/ui_engine/widgets/meta-form", "crates/modules/ui_engine/widgets/banner", "crates/modules/ui_engine/widgets/card", + "crates/modules/ui_engine/widgets/theme-switcher", # ============================================================ # modules/nakui/ — ERP matemático (nakui absorbido) diff --git a/crates/apps/nakui-explorer/Cargo.toml b/crates/apps/nakui-explorer/Cargo.toml index adbaa88..a290333 100644 --- a/crates/apps/nakui-explorer/Cargo.toml +++ b/crates/apps/nakui-explorer/Cargo.toml @@ -11,6 +11,7 @@ yahweh-meta-runtime = { path = "../../modules/ui_engine/libs/meta-runtime" } yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } yahweh-widget-card = { path = "../../modules/ui_engine/widgets/card" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } +yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } gpui = { workspace = true } serde_json = { workspace = true } uuid = { workspace = true, features = ["serde"] } diff --git a/crates/apps/nakui-explorer/src/main.rs b/crates/apps/nakui-explorer/src/main.rs index 07a3f73..2b47e93 100644 --- a/crates/apps/nakui-explorer/src/main.rs +++ b/crates/apps/nakui-explorer/src/main.rs @@ -34,6 +34,7 @@ use yahweh_meta_runtime::{preview_value, short_hash, short_uuid}; use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, Banner}; use yahweh_widget_card::card_themed; +use yahweh_widget_theme_switcher::theme_switcher; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); @@ -167,7 +168,13 @@ impl Render for Explorer { self.last_load_ms, ); + // Header con título a la izquierda + theme switcher a la + // derecha. flex_row + flex_grow del label empuja el switcher + // al borde. let header = div() + .flex() + .flex_row() + .items_center() .px(px(16.)) .py(px(12.)) .bg(theme.bg_panel.clone()) @@ -175,7 +182,8 @@ impl Render for Explorer { .border_color(theme.border) .text_color(text) .text_size(px(14.)) - .child(header_text); + .child(div().flex_grow().child(header_text)) + .child(theme_switcher(cx)); let breakdown_line = if top_breakdown.is_empty() { String::new() diff --git a/crates/modules/ui_engine/widgets/meta-form/Cargo.toml b/crates/modules/ui_engine/widgets/meta-form/Cargo.toml index c1868d4..c18d2eb 100644 --- a/crates/modules/ui_engine/widgets/meta-form/Cargo.toml +++ b/crates/modules/ui_engine/widgets/meta-form/Cargo.toml @@ -13,6 +13,7 @@ yahweh-meta-runtime = { path = "../../libs/meta-runtime" } yahweh-meta-schema = { path = "../../libs/meta-schema" } yahweh-theme = { path = "../../libs/theme" } yahweh-widget-banner = { path = "../banner" } +yahweh-widget-theme-switcher = { path = "../theme-switcher" } yahweh-widget-text-input = { path = "../text_input" } [dev-dependencies] 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 1c8f2fa..557858c 100644 --- a/crates/modules/ui_engine/widgets/meta-form/src/lib.rs +++ b/crates/modules/ui_engine/widgets/meta-form/src/lib.rs @@ -37,6 +37,7 @@ use yahweh_meta_schema::{Action, FieldKind, FieldSpec, FormView, ListView, Modul use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, themed_colors, Banner}; use yahweh_widget_text_input::TextInput; +use yahweh_widget_theme_switcher::theme_switcher; /// Estado del runtime de UI. Toda la persistencia/ejecución está /// detrás del trait [`MetaBackend`]; este struct sólo conoce GPUI @@ -633,13 +634,18 @@ impl MetaApp { .flex() .flex_col(); + // Sidebar header: título + theme switcher en una row. sidebar = sidebar.child( div() + .flex() + .flex_row() + .items_center() .px(px(12.)) .py(px(10.)) .text_color(text) .text_size(px(13.)) - .child("Nakui"), + .child(div().flex_grow().child("Nakui")) + .child(theme_switcher(cx)), ); if self.modules.is_empty() { diff --git a/crates/modules/ui_engine/widgets/theme-switcher/Cargo.toml b/crates/modules/ui_engine/widgets/theme-switcher/Cargo.toml new file mode 100644 index 0000000..129491a --- /dev/null +++ b/crates/modules/ui_engine/widgets/theme-switcher/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yahweh-widget-theme-switcher" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Yahweh — widget para ciclar entre presets de Theme en runtime. Botón que muestra el nombre del theme actual y al click avanza al siguiente preset." + +[dependencies] +gpui = { workspace = true } +yahweh-theme = { path = "../../libs/theme" } + +[dev-dependencies] +# TestAppContext + #[gpui::test] para tests del switcher. +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/modules/ui_engine/widgets/theme-switcher/src/lib.rs b/crates/modules/ui_engine/widgets/theme-switcher/src/lib.rs new file mode 100644 index 0000000..2758e07 --- /dev/null +++ b/crates/modules/ui_engine/widgets/theme-switcher/src/lib.rs @@ -0,0 +1,91 @@ +//! `yahweh-widget-theme-switcher` — botón clickable para ciclar +//! entre los presets de `Theme`. +//! +//! El botón muestra el nombre del theme actual; al click avanza al +//! siguiente preset según [`Theme::next_after`] (rotación circular +//! sobre [`Theme::all`]). +//! +//! El cambio se aplica con `Theme::set(cx, ...)` que invalida el +//! global y dispara redraws en todos los widgets que observan el +//! theme via `cx.observe_global::()`. Para widgets que NO +//! observan el theme (ej. los themed wrappers de banner/card en su +//! versión actual, que leen el theme dentro de `render`), basta con +//! que el render se vuelva a invocar — esto sucede automáticamente +//! tras `cx.set_global` que marca todos los views como dirty. +//! +//! # Uso +//! +//! ```ignore +//! use yahweh_widget_theme_switcher::theme_switcher; +//! +//! // Adentro de Render::render: +//! let switcher = theme_switcher(cx); +//! header.child(switcher) +//! ``` + +#![forbid(unsafe_code)] + +use gpui::{div, prelude::*, px, App, ClickEvent, IntoElement, SharedString, Window}; +use yahweh_theme::Theme; + +/// Construye el switcher: una `Div` clickable con el nombre del +/// theme actual + flecha indicadora. Al click rota al siguiente +/// preset. +/// +/// Estilo: padding consistente con el resto de los chrome controls +/// del repo (`px(8/4)`), `bg(theme.bg_panel_alt)`, `text_color(fg_text)`. +/// Sin border, hover sutil con `bg_row_hover`. +/// +/// El handler del click usa `cx.update_global::` para +/// reemplazar el theme global; los widgets que leen +/// `Theme::global` en su próximo render verán el nuevo. +pub fn theme_switcher(cx: &mut App) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + let label = format!("Tema: {} ▸", theme.name); + + div() + .id("yahweh-theme-switcher") + .px(px(8.)) + .py(px(4.)) + .bg(theme.bg_panel_alt.clone()) + .text_color(theme.fg_text) + .text_size(px(11.)) + .rounded(px(3.)) + .hover(move |d| d.bg(theme.bg_row_hover)) + .child(SharedString::from(label)) + .on_click(|_event: &ClickEvent, _window: &mut Window, cx: &mut App| { + let current_name = Theme::global(cx).name; + let next = Theme::next_after(current_name); + Theme::set(cx, next); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn switcher_constructs_with_theme_installed(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::install_default(cx); + let _div = theme_switcher(cx); + // Smoke: si llegamos aquí sin panic, el constructor lee + // el global, deriva colors, y construye un Div. + }); + } + + #[gpui::test] + fn theme_set_changes_global(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::install_default(cx); + let initial_name = Theme::global(cx).name; + // Ciclo manual sin pasar por el handler del click. + let next = Theme::next_after(initial_name); + Theme::set(cx, next.clone()); + let after = Theme::global(cx).name; + assert_eq!(after, next.name); + assert_ne!(after, initial_name, "el ciclo debe cambiar el name"); + }); + } +}