tawasuyu

tawasuyu/brahman

Rust 321TOML 122JSON 14Markdown 114332 Sutichaykuna

⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd crates/modules/ui_engine/widgets/tabs/src/lib.rs Rust · 192 siq'ikuna · 6.2 KB Ñawpa willay Pi imata llamkarqan ch'uya

chore: monorepo inicial con arje + minga + yahweh absorbidos 53dbdf0f1 · Sergio ·

1//! `yahweh_widget_tabs` — `TabContainer`.
2//!
3//! `n` hijos `AnyView`, **uno visible** por vez (la pestaña activa). Header
4//! horizontal con un botón por hijo; click cambia la pestaña activa. La
5//! identidad del hijo activo se preserva por `NodeId`, así que swappear de
6//! Split → Tabs y volver no resetea cuál está abierto.
7//!
8//! API alineada con `SplitContainer` (mismo `set_children`) para que el
9//! LayoutHost los use intercambiablemente.
10
11use gpui::{
12    ClickEvent, Context, EventEmitter, IntoElement, Render, SharedString, Window, div, prelude::*,
13    px,
14};
15
16use yahweh_core::NodeId;
17use yahweh_theme::Theme;
18use yahweh_widget_container_core::ChildSlot;
19
20#[derive(Clone, Debug)]
21#[allow(dead_code)]
22pub enum TabsEvent {
23    /// Una pestaña distinta quedó activa (por click o `set_active`).
24    TabActivated { id: NodeId, index: usize },
25}
26
27pub struct TabContainer {
28    children: Vec<ChildSlot>,
29    /// Id del hijo activo. Lo guardamos por id (no por índice) para que
30    /// reorders/inserts no rompan la selección.
31    active_id: Option<NodeId>,
32}
33
34impl EventEmitter<TabsEvent> for TabContainer {}
35
36impl TabContainer {
37    pub fn new(cx: &mut Context<Self>) -> Self {
38        cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
39        Self {
40            children: Vec::new(),
41            active_id: None,
42        }
43    }
44
45    pub fn set_children(&mut self, children: Vec<ChildSlot>, cx: &mut Context<Self>) {
46        // Si el id activo previo sigue presente, preservarlo. Si no, caer
47        // al primero (o None si vacío).
48        let still_present = self
49            .active_id
50            .as_ref()
51            .map(|id| children.iter().any(|c| &c.id == id))
52            .unwrap_or(false);
53        if !still_present {
54            self.active_id = children.first().map(|c| c.id.clone());
55        }
56        self.children = children;
57        cx.notify();
58    }
59
60    pub fn set_active(&mut self, id: NodeId, cx: &mut Context<Self>) {
61        if self.children.iter().any(|c| c.id == id) && self.active_id.as_ref() != Some(&id) {
62            let index = self.children.iter().position(|c| c.id == id).unwrap_or(0);
63            self.active_id = Some(id.clone());
64            cx.emit(TabsEvent::TabActivated { id, index });
65            cx.notify();
66        }
67    }
68
69    pub fn active_id(&self) -> Option<&NodeId> {
70        self.active_id.as_ref()
71    }
72
73    fn active_index(&self) -> Option<usize> {
74        let id = self.active_id.as_ref()?;
75        self.children.iter().position(|c| &c.id == id)
76    }
77
78    fn on_tab_click(
79        &mut self,
80        id: NodeId,
81        _click: &ClickEvent,
82        _w: &mut Window,
83        cx: &mut Context<Self>,
84    ) {
85        self.set_active(id, cx);
86    }
87}
88
89const TAB_HEADER_HEIGHT: f32 = 30.0;
90
91impl Render for TabContainer {
92    fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
93        let theme = Theme::global(cx).clone();
94        let active_idx = self.active_index();
95
96        // Header — una "pestaña" por hijo. Cada tab usa una stripe inferior
97        // (un div hijo de 2px de alto) como indicador de "activa", porque
98        // gpui no expone `border_b_color` por separado del border global.
99        let mut header = div()
100            .h(px(TAB_HEADER_HEIGHT))
101            .w_full()
102            .border_b_1()
103            .border_color(theme.border)
104            .bg(theme.bg_panel.clone())
105            .flex()
106            .flex_row();
107
108        for (i, child) in self.children.iter().enumerate() {
109            let is_active = active_idx == Some(i);
110            let label_text = child
111                .label
112                .clone()
113                .unwrap_or_else(|| child.id.as_str().to_string());
114            let id_for_click = child.id.clone();
115            let tab_id: SharedString =
116                SharedString::from(format!("tab-{}", child.id));
117
118            let bg = if is_active {
119                theme.bg_panel_alt.clone()
120            } else {
121                theme.bg_panel.clone()
122            };
123            let fg = if is_active {
124                theme.fg_text
125            } else {
126                theme.fg_muted
127            };
128            let stripe_color = if is_active {
129                theme.accent_strong
130            } else {
131                gpui::hsla(0.0, 0.0, 0.0, 0.0)
132            };
133
134            header = header.child(
135                div()
136                    .id(tab_id)
137                    .h_full()
138                    .border_r_1()
139                    .border_color(theme.border)
140                    .bg(bg)
141                    .text_color(fg)
142                    .text_size(px(12.0))
143                    .hover(|s| s.opacity(0.85))
144                    .flex()
145                    .flex_col()
146                    .child(
147                        // Etiqueta + padding centrado.
148                        div()
149                            .flex_grow()
150                            .px(px(14.0))
151                            .flex()
152                            .items_center()
153                            .child(SharedString::from(label_text)),
154                    )
155                    .child(
156                        // Stripe inferior de 2px — indicador de activa.
157                        div().h(px(2.0)).w_full().bg(stripe_color),
158                    )
159                    .on_click(cx.listener(move |this, click, w, cx| {
160                        this.on_tab_click(id_for_click.clone(), click, w, cx);
161                    })),
162            );
163        }
164
165        // Cuerpo — solo el child activo. Si no hay ninguno (children
166        // vacío), pintamos un mensaje neutro.
167        let body = match active_idx.and_then(|i| self.children.get(i)) {
168            Some(child) => div()
169                .flex_grow()
170                .min_h(px(0.0))
171                .bg(theme.bg_panel_alt.clone())
172                .child(child.view.clone())
173                .into_any_element(),
174            None => div()
175                .flex_grow()
176                .flex()
177                .items_center()
178                .justify_center()
179                .text_color(theme.fg_muted)
180                .text_size(px(11.0))
181                .child("(sin hijos)")
182                .into_any_element(),
183        };
184
185        div()
186            .size_full()
187            .flex()
188            .flex_col()
189            .child(header)
190            .child(body)
191    }
192}