From be3a0e78fc894587313cb5e23631daf89ce52976 Mon Sep 17 00:00:00 2001 From: Sergio Date: Sun, 10 May 2026 12:40:56 +0000 Subject: [PATCH] feat(yahweh-widget-app-header): promover header standard de explorers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Iter 16. Patrón con 4 consumers idénticos: nakui-explorer, nouser-explorer, minga-explorer, brahman-broker-explorer todos declaraban un header flex_row + flex_grow(label) + theme_switcher + bg(panel) + border-bottom + text_size(14) + padding(16/12). Ahora es 1 línea. crates/modules/ui_engine/widgets/app-header/: - pub fn app_header(cx, label: impl Into) -> impl IntoElement. - pub fn app_header_with(cx, label_child: impl IntoElement) — variante para left side no-text. - 3 tests #[gpui::test]. Migración 4 consumers: - Cada uno: bloque de ~13 líneas → 1 línea app_header(cx, text). - Borra dep yahweh-widget-theme-switcher (incluida vía app-header). - Reemplaza import. Ahorro ~50 líneas UI hardcoded. Cambios visuales del header (padding, border, text_size) en un solo lugar. Decisión: sidebar header del MetaApp NO se migra — es de sidebar, no de app top, styling distinto. Diferente patrón → diferente widget. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 41 ++++++++ Cargo.lock | 17 +++- Cargo.toml | 1 + .../apps/brahman-broker-explorer/Cargo.toml | 2 +- .../apps/brahman-broker-explorer/src/main.rs | 17 +--- crates/apps/minga-explorer/Cargo.toml | 2 +- crates/apps/minga-explorer/src/main.rs | 17 +--- crates/apps/nakui-explorer/Cargo.toml | 2 +- crates/apps/nakui-explorer/src/main.rs | 22 +---- crates/apps/nouser-explorer/Cargo.toml | 2 +- crates/apps/nouser-explorer/src/main.rs | 19 +--- .../ui_engine/widgets/app-header/Cargo.toml | 14 +++ .../ui_engine/widgets/app-header/src/lib.rs | 96 +++++++++++++++++++ 13 files changed, 183 insertions(+), 69 deletions(-) create mode 100644 crates/modules/ui_engine/widgets/app-header/Cargo.toml create mode 100644 crates/modules/ui_engine/widgets/app-header/src/lib.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d22388b..99b166e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ ratio/diff ver `git show `. ## 2026-05-10 +### feat(yahweh-widget-app-header): promover el header standard de explorers +Iter 16. Patrón con 4 consumers idénticos: `nakui-explorer`, +`nouser-explorer`, `minga-explorer`, `brahman-broker-explorer` +todos declaraban un header `flex_row + flex_grow(label) + +theme_switcher + bg(panel) + border-bottom + text_size(14) + +padding(16/12)`. Ahora es 1 línea. + +Crate nuevo `crates/modules/ui_engine/widgets/app-header/` +(`yahweh-widget-app-header`): +- **Deps**: `gpui`, `yahweh-theme`, `yahweh-widget-theme-switcher`. + El switcher se incluye automáticamente. +- **`pub fn app_header(cx: &mut App, label: impl Into) + -> impl IntoElement`**: caso simple con texto plano. +- **`pub fn app_header_with(cx, label_child: impl IntoElement)`**: + variante para cuando el lado izquierdo no es texto plano (icon + + text, múltiples spans, etc.). +- 3 tests `#[gpui::test]`: smoke con string label, con custom + child IntoElement, type-check de label con literal/owned/format!. + +Migración de los 4 consumers: +- Cada uno reemplaza un bloque `let header = div().flex().flex_row()... + .child(theme_switcher(cx))` (~13 líneas) por + `let header = app_header(cx, header_text)` (~1 línea). +- Cada uno borra dep `yahweh-widget-theme-switcher` (ya no la + necesita directo — el `app_header` la incluye internamente). +- Cada uno reemplaza `use yahweh_widget_theme_switcher::theme_switcher` + por `use yahweh_widget_app_header::app_header`. + +Total ahorro: ~50 líneas de código UI hardcoded en consumers. +Cambios visuales en el header (padding, border, text_size) ahora +viven en un solo lugar. + +Tests stack: 3 nuevos en app-header; suites de los 4 consumers +intactas. Todo verde. + +Decisión: el sidebar header del `MetaApp` (que también incluye +theme_switcher) NO se migra — es un header de sidebar, no de app +top, y tiene styling distinto (px(12/10/13), sin bg/border-bottom +porque ya está dentro del panel). Diferente patrón → diferente +widget si emerge segundo consumer. + ### feat(yahweh-widget-stat-card): promover el patrón stat card como widget Iter 15. El patrón "tarjeta de dashboard con border-l accent + label + valor grande + descripción + listing opcional" tenía 2 diff --git a/Cargo.lock b/Cargo.lock index 48ae7d4..3a464e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1243,9 +1243,9 @@ dependencies = [ "brahman-sidecar", "gpui", "yahweh-theme", + "yahweh-widget-app-header", "yahweh-widget-banner", "yahweh-widget-stat-card", - "yahweh-widget-theme-switcher", ] [[package]] @@ -6216,9 +6216,9 @@ dependencies = [ "gpui", "minga-store", "yahweh-theme", + "yahweh-widget-app-header", "yahweh-widget-banner", "yahweh-widget-stat-card", - "yahweh-widget-theme-switcher", ] [[package]] @@ -6486,9 +6486,9 @@ dependencies = [ "uuid", "yahweh-meta-runtime", "yahweh-theme", + "yahweh-widget-app-header", "yahweh-widget-banner", "yahweh-widget-card", - "yahweh-widget-theme-switcher", ] [[package]] @@ -6883,9 +6883,9 @@ dependencies = [ "gpui", "nouser-card", "yahweh-theme", + "yahweh-widget-app-header", "yahweh-widget-banner", "yahweh-widget-card", - "yahweh-widget-theme-switcher", ] [[package]] @@ -13031,6 +13031,15 @@ dependencies = [ "gpui", ] +[[package]] +name = "yahweh-widget-app-header" +version = "0.1.0" +dependencies = [ + "gpui", + "yahweh-theme", + "yahweh-widget-theme-switcher", +] + [[package]] name = "yahweh-widget-banner" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 051b226..76e62f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,7 @@ members = [ "crates/modules/ui_engine/widgets/banner", "crates/modules/ui_engine/widgets/card", "crates/modules/ui_engine/widgets/stat-card", + "crates/modules/ui_engine/widgets/app-header", "crates/modules/ui_engine/widgets/theme-switcher", # ============================================================ diff --git a/crates/apps/brahman-broker-explorer/Cargo.toml b/crates/apps/brahman-broker-explorer/Cargo.toml index 4f667ab..7c5eda7 100644 --- a/crates/apps/brahman-broker-explorer/Cargo.toml +++ b/crates/apps/brahman-broker-explorer/Cargo.toml @@ -11,7 +11,7 @@ brahman-sidecar = { path = "../../shared/brahman-sidecar" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" } -yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } +yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" } gpui = { workspace = true } [[bin]] diff --git a/crates/apps/brahman-broker-explorer/src/main.rs b/crates/apps/brahman-broker-explorer/src/main.rs index bde211d..0db0433 100644 --- a/crates/apps/brahman-broker-explorer/src/main.rs +++ b/crates/apps/brahman-broker-explorer/src/main.rs @@ -35,7 +35,7 @@ use gpui::{ use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, Banner}; use yahweh_widget_stat_card::stat_card; -use yahweh_widget_theme_switcher::theme_switcher; +use yahweh_widget_app_header::app_header; const POLL_INTERVAL: Duration = Duration::from_secs(5); const PROBE_TIMEOUT: Duration = Duration::from_secs(1); @@ -170,19 +170,8 @@ impl Render for Explorer { self.last_probe_ms, ); - let header = div() - .flex() - .flex_row() - .items_center() - .px(px(16.)) - .py(px(12.)) - .bg(theme.bg_panel.clone()) - .border_b_1() - .border_color(theme.border) - .text_color(text) - .text_size(px(14.)) - .child(div().flex_grow().child(header_text)) - .child(theme_switcher(cx)); + // Header standard via widget compartido. + let header = app_header(cx, header_text); // Banner permanente debajo del header con el estado actual. // Severidad acorde al kind. diff --git a/crates/apps/minga-explorer/Cargo.toml b/crates/apps/minga-explorer/Cargo.toml index 8db54d1..68304c5 100644 --- a/crates/apps/minga-explorer/Cargo.toml +++ b/crates/apps/minga-explorer/Cargo.toml @@ -10,7 +10,7 @@ minga-store = { path = "../../modules/semantic_dht/minga-store" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } yahweh-widget-stat-card = { path = "../../modules/ui_engine/widgets/stat-card" } -yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } +yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" } gpui = { workspace = true } [[bin]] diff --git a/crates/apps/minga-explorer/src/main.rs b/crates/apps/minga-explorer/src/main.rs index 2a5875b..335e53d 100644 --- a/crates/apps/minga-explorer/src/main.rs +++ b/crates/apps/minga-explorer/src/main.rs @@ -34,7 +34,7 @@ use minga_store::PersistentRepo; use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, Banner}; use yahweh_widget_stat_card::stat_card; -use yahweh_widget_theme_switcher::theme_switcher; +use yahweh_widget_app_header::app_header; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); const REPO_DIRNAME: &str = "repo"; @@ -224,19 +224,8 @@ impl Render for Explorer { None => format!("Buscando repo en {}…", self.repo_path.display()), }; - let header = div() - .flex() - .flex_row() - .items_center() - .px(px(16.)) - .py(px(12.)) - .bg(theme.bg_panel.clone()) - .border_b_1() - .border_color(theme.border) - .text_color(text) - .text_size(px(14.)) - .child(div().flex_grow().child(header_text)) - .child(theme_switcher(cx)); + // Header standard via widget compartido. + let header = app_header(cx, header_text); let error_banner = self.error.as_ref().map(|e| { banner_themed(cx, Banner::Error, e.clone()) diff --git a/crates/apps/nakui-explorer/Cargo.toml b/crates/apps/nakui-explorer/Cargo.toml index a290333..141df07 100644 --- a/crates/apps/nakui-explorer/Cargo.toml +++ b/crates/apps/nakui-explorer/Cargo.toml @@ -11,7 +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" } +yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" } 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 2b47e93..54d54d4 100644 --- a/crates/apps/nakui-explorer/src/main.rs +++ b/crates/apps/nakui-explorer/src/main.rs @@ -33,8 +33,8 @@ use nakui_core::event_log::{EventLog, LogEntry}; use yahweh_meta_runtime::{preview_value, short_hash, short_uuid}; use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, Banner}; +use yahweh_widget_app_header::app_header; use yahweh_widget_card::card_themed; -use yahweh_widget_theme_switcher::theme_switcher; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); @@ -168,22 +168,10 @@ 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()) - .border_b_1() - .border_color(theme.border) - .text_color(text) - .text_size(px(14.)) - .child(div().flex_grow().child(header_text)) - .child(theme_switcher(cx)); + // Header standard via widget compartido yahweh-widget-app-header + // (label flex_grow + theme switcher derecha + bg panel + border + // bottom + text styling consistente). + let header = app_header(cx, header_text); let breakdown_line = if top_breakdown.is_empty() { String::new() diff --git a/crates/apps/nouser-explorer/Cargo.toml b/crates/apps/nouser-explorer/Cargo.toml index 9e0490b..f79dae0 100644 --- a/crates/apps/nouser-explorer/Cargo.toml +++ b/crates/apps/nouser-explorer/Cargo.toml @@ -12,7 +12,7 @@ nouser-card = { path = "../../modules/nouser/card" } yahweh-theme = { path = "../../modules/ui_engine/libs/theme" } yahweh-widget-banner = { path = "../../modules/ui_engine/widgets/banner" } yahweh-widget-card = { path = "../../modules/ui_engine/widgets/card" } -yahweh-widget-theme-switcher = { path = "../../modules/ui_engine/widgets/theme-switcher" } +yahweh-widget-app-header = { path = "../../modules/ui_engine/widgets/app-header" } gpui = { workspace = true } [[bin]] diff --git a/crates/apps/nouser-explorer/src/main.rs b/crates/apps/nouser-explorer/src/main.rs index 3404519..39418aa 100644 --- a/crates/apps/nouser-explorer/src/main.rs +++ b/crates/apps/nouser-explorer/src/main.rs @@ -32,7 +32,7 @@ use nouser_card::Lens; use yahweh_theme::Theme; use yahweh_widget_banner::{banner_themed, Banner}; use yahweh_widget_card::card_themed; -use yahweh_widget_theme_switcher::theme_switcher; +use yahweh_widget_app_header::app_header; const REFRESH_INTERVAL: Duration = Duration::from_secs(2); const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); @@ -226,21 +226,8 @@ impl Render for Explorer { _ => "Buscando daemon nouser vía brahman-broker…".to_string(), }; - // Header con título a la izquierda + theme switcher a la - // derecha (mismo pattern que nakui-explorer). - let header = div() - .flex() - .flex_row() - .items_center() - .px(px(16.)) - .py(px(12.)) - .bg(theme.bg_panel.clone()) - .border_b_1() - .border_color(theme.border) - .text_color(text) - .text_size(px(14.)) - .child(div().flex_grow().child(header_text)) - .child(theme_switcher(cx)); + // Header standard via widget compartido. + let header = app_header(cx, header_text); let error_banner = self.error.as_ref().map(|e| { banner_themed(cx, Banner::Error, e.clone()) diff --git a/crates/modules/ui_engine/widgets/app-header/Cargo.toml b/crates/modules/ui_engine/widgets/app-header/Cargo.toml new file mode 100644 index 0000000..8b12c5e --- /dev/null +++ b/crates/modules/ui_engine/widgets/app-header/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "yahweh-widget-app-header" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Yahweh — widget app-header: tira superior con label flex_grow + theme switcher a la derecha + bg panel + border bottom. Patrón compartido por las apps explorer del repo." + +[dependencies] +gpui = { workspace = true } +yahweh-theme = { path = "../../libs/theme" } +yahweh-widget-theme-switcher = { path = "../theme-switcher" } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/modules/ui_engine/widgets/app-header/src/lib.rs b/crates/modules/ui_engine/widgets/app-header/src/lib.rs new file mode 100644 index 0000000..fa7f533 --- /dev/null +++ b/crates/modules/ui_engine/widgets/app-header/src/lib.rs @@ -0,0 +1,96 @@ +//! `yahweh-widget-app-header` — tira superior estándar de las apps +//! del repo. +//! +//! Compone: +//! - Label dinámico a la izquierda (flex_grow). +//! - [`theme_switcher`] a la derecha. +//! - bg = `theme.bg_panel`, text = `theme.fg_text`, +//! border-bottom = `theme.border`. +//! - Padding 16/12, text_size 14. +//! +//! Patrón emergente: `nakui-explorer`, `nouser-explorer`, +//! `minga-explorer`, `brahman-broker-explorer` declaran headers +//! idénticos sólo cambiando el label. Ahora es 1 línea. +//! +//! # Ejemplo +//! +//! ```ignore +//! use yahweh_widget_app_header::app_header; +//! +//! let header = app_header(cx, format!("Log: {} · {} entries", path, n)); +//! div().child(header).child(body) +//! ``` + +#![forbid(unsafe_code)] + +use gpui::{div, prelude::*, px, App, IntoElement, SharedString}; +use yahweh_theme::Theme; +use yahweh_widget_theme_switcher::theme_switcher; + +/// Construye el header standard. Lee `Theme::global(cx)` para los +/// colors; falla si no hay theme instalado (panic propagado de +/// `Theme::global`). +/// +/// `label` es texto plano. Para labels más ricos (ej. icon + text, +/// múltiples spans), usar [`app_header_with`] que acepta +/// cualquier child element. +pub fn app_header(cx: &mut App, label: impl Into) -> impl IntoElement { + let label: SharedString = label.into(); + app_header_with(cx, div().child(label)) +} + +/// Variante de [`app_header`] que acepta cualquier `IntoElement` +/// como contenido del lado izquierdo. El widget envuelve el child +/// en un `div().flex_grow()` para que el switcher quede pegado a +/// la derecha. +pub fn app_header_with(cx: &mut App, label_child: impl IntoElement) -> impl IntoElement { + let theme = Theme::global(cx).clone(); + div() + .flex() + .flex_row() + .items_center() + .px(px(16.)) + .py(px(12.)) + .bg(theme.bg_panel.clone()) + .border_b_1() + .border_color(theme.border) + .text_color(theme.fg_text) + .text_size(px(14.)) + .child(div().flex_grow().child(label_child)) + .child(theme_switcher(cx)) +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test] + fn app_header_constructs_with_string_label(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::install_default(cx); + let _h = app_header(cx, "Test header"); + }); + } + + #[gpui::test] + fn app_header_with_accepts_arbitrary_child(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::install_default(cx); + let _h = app_header_with( + cx, + div().child(SharedString::from("Custom child")), + ); + }); + } + + #[gpui::test] + fn app_header_label_accepts_owned_or_borrowed(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::install_default(cx); + let _ = app_header(cx, "literal"); + let _ = app_header(cx, "owned".to_string()); + let _ = app_header(cx, format!("formatted {}", 42)); + }); + } +}