⎇ 53dbdf0f1deea10d6fc567ea2b43e1e75d6d60cd
crates/apps/yahweh-shell/src/status_panel.rs
History
Who touched what
raw
chore: monorepo inicial con arje + minga + yahweh absorbidos
1//! `StatusPanel` — panel lateral con info de la app, switcher de tema y
2//! controles de swap del kind del contenedor objetivo (Fase 5).
3//!
4//! Recibe el `Entity<LayoutModel>` para mutarlo: cuando el usuario clickea
5//! "Tabs", el StatusPanel hace
6//! `model.update(cx, |m, cx| m.set_kind(target_id, "Tabs", cx))`. El
7//! LayoutHost está suscripto al model y rebuildea preservando los hijos
8//! del contenedor por NodeId — eso es lo que demuestra que swappear el
9//! contenedor no resetea el contenido (las pestañas activas, expansiones
10//! del FileExplorer, scroll position, todo se mantiene).
11
12use gpui::{
13 ClickEvent, Context, Entity, IntoElement, Render, SharedString, Window, div, prelude::*, px,
14};
15
16use yahweh_core::{LayerConfig, NodeId};
17use yahweh_theme::Theme;
18
19use crate::layout_model::LayoutModel;
20
21/// Id del contenedor que el StatusPanel controla con sus botones de swap.
22/// Hardcoded por ahora; en el futuro lo podríamos leer de un param del
23/// JSON (e.g. `target_id`) para que cualquier StatusPanel apunte al
24/// container que querramos.
25const SWAP_TARGET_ID: &str = "explorers";
26
27const SWAPPABLE_KINDS: &[&str] = &["Split", "Tabs", "Tiled"];
28
29/// Path del JSON que el botón "Reload" relee. Mantenemos consistencia con
30/// `main.rs` — si llegamos a parametrizarlo, hacerlo en un solo lugar.
31const LAYOUT_PATH: &str = "layout.json";
32
33pub struct StatusPanel {
34 model: Entity<LayoutModel>,
35 last_event: SharedString,
36}
37
38impl StatusPanel {
39 pub fn new(model: Entity<LayoutModel>, cx: &mut Context<Self>) -> Self {
40 cx.observe_global::<Theme>(|_, cx| cx.notify()).detach();
41 // Subscripción al model para refrescar el "kind activo" del swap.
42 cx.observe(&model, |_, _, cx| cx.notify()).detach();
43 Self {
44 model,
45 last_event: "(esperando bus app-level — Fase 6)".into(),
46 }
47 }
48
49 /// Reservado para Fase 6: el AppBus va a actualizar este texto.
50 #[allow(dead_code)]
51 pub fn set_status(&mut self, text: SharedString, cx: &mut Context<Self>) {
52 self.last_event = text;
53 cx.notify();
54 }
55
56 fn cycle_theme(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
57 let current = Theme::global(cx).name.to_string();
58 Theme::set(cx, Theme::next_after(¤t));
59 cx.notify();
60 }
61
62 fn pick_theme(
63 &mut self,
64 name: SharedString,
65 _: &ClickEvent,
66 _: &mut Window,
67 cx: &mut Context<Self>,
68 ) {
69 if let Some(theme) = Theme::by_name(&name) {
70 Theme::set(cx, theme);
71 cx.notify();
72 }
73 }
74
75 fn swap_container(
76 &mut self,
77 kind: SharedString,
78 _: &ClickEvent,
79 _: &mut Window,
80 cx: &mut Context<Self>,
81 ) {
82 let target = NodeId::new(SWAP_TARGET_ID);
83 self.model.update(cx, |m, cx| {
84 m.set_kind(&target, &kind, cx);
85 });
86 }
87
88 fn reload_from_disk(
89 &mut self,
90 _: &ClickEvent,
91 _: &mut Window,
92 cx: &mut Context<Self>,
93 ) {
94 // Releemos el JSON. Si el parsing falla, `load_or_default` cae al
95 // árbol default — no panicamos. La preservación de hijos
96 // funciona vía el id JSON: lo que matchee, persiste; lo nuevo se
97 // instancia.
98 let tree = LayerConfig::load_or_default(LAYOUT_PATH);
99 self.model.update(cx, |m, cx| m.replace_tree(tree, cx));
100 }
101
102 /// Lee el `kind` actual del contenedor objetivo desde el model. Si el
103 /// id no existe (alguien cambió el JSON), devuelve `None` y no
104 /// resaltamos ningún chip.
105 fn current_target_kind(&self, cx: &Context<Self>) -> Option<String> {
106 find_kind(self.model.read(cx).tree(), SWAP_TARGET_ID)
107 }
108}
109
110fn find_kind(node: &yahweh_core::LayerConfig, target: &str) -> Option<String> {
111 if let Some(id) = &node.id {
112 if id == target {
113 return Some(node.kind.clone());
114 }
115 }
116 for child in &node.children {
117 if let Some(k) = find_kind(child, target) {
118 return Some(k);
119 }
120 }
121 None
122}
123
124impl Render for StatusPanel {
125 fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
126 let theme = Theme::global(cx).clone();
127 let active_theme = theme.name;
128 let theme_chips: Vec<_> = Theme::all()
129 .into_iter()
130 .map(|t| (t.name, t.name == active_theme))
131 .collect();
132
133 let current_kind = self.current_target_kind(cx);
134
135 // Theme chips.
136 let mut theme_row = div().flex().flex_row().flex_wrap().gap(px(6.0));
137 for (name, is_active) in theme_chips {
138 let bg = if is_active {
139 theme.bg_row_active
140 } else {
141 theme.bg_row_hover
142 };
143 let border = if is_active {
144 theme.accent_strong
145 } else {
146 theme.border
147 };
148 let chip_id = SharedString::from(format!("theme-chip-{}", name));
149 let chip_name = SharedString::from(name.to_string());
150 theme_row = theme_row.child(
151 div()
152 .id(chip_id)
153 .px(px(10.0))
154 .py(px(5.0))
155 .rounded(px(4.0))
156 .border_1()
157 .border_color(border)
158 .bg(bg)
159 .text_size(px(11.0))
160 .text_color(theme.fg_text)
161 .hover(|s| s.opacity(0.85))
162 .child(SharedString::from(name.to_string()))
163 .on_click(cx.listener(move |this, click, w, cx| {
164 this.pick_theme(chip_name.clone(), click, w, cx);
165 })),
166 );
167 }
168
169 // Swap chips — uno por kind (Split / Tabs / Tiled). El activo
170 // refleja el `kind` actual del nodo objetivo.
171 let mut swap_row = div().flex().flex_row().gap(px(6.0));
172 for &kind in SWAPPABLE_KINDS {
173 let is_active = current_kind.as_deref() == Some(kind);
174 let bg = if is_active {
175 theme.bg_row_active
176 } else {
177 theme.bg_row_hover
178 };
179 let border = if is_active {
180 theme.accent_strong
181 } else {
182 theme.border
183 };
184 let chip_id = SharedString::from(format!("swap-chip-{}", kind));
185 let kind_str = SharedString::from(kind.to_string());
186 swap_row = swap_row.child(
187 div()
188 .id(chip_id)
189 .px(px(12.0))
190 .py(px(6.0))
191 .rounded(px(4.0))
192 .border_1()
193 .border_color(border)
194 .bg(bg)
195 .text_size(px(12.0))
196 .text_color(theme.fg_text)
197 .hover(|s| s.opacity(0.85))
198 .child(SharedString::from(kind.to_string()))
199 .on_click(cx.listener(move |this, click, w, cx| {
200 this.swap_container(kind_str.clone(), click, w, cx);
201 })),
202 );
203 }
204
205 div()
206 .size_full()
207 .bg(theme.bg_panel_alt.clone())
208 .p(px(20.0))
209 .flex()
210 .flex_col()
211 .gap(px(14.0))
212 .child(
213 div()
214 .text_size(px(22.0))
215 .text_color(theme.accent_strong)
216 .child("Yahweh — Fase 5"),
217 )
218 .child(
219 div()
220 .text_color(theme.fg_muted)
221 .text_size(px(12.0))
222 .child("Contenedores intercambiables sin perder hijos."),
223 )
224 // ----- persistencia + reload -----
225 .child(
226 div()
227 .mt(px(14.0))
228 .text_color(theme.fg_muted)
229 .text_size(px(11.0))
230 .child("layout.json:"),
231 )
232 .child(
233 div()
234 .flex()
235 .flex_row()
236 .items_center()
237 .gap(px(8.0))
238 .child(
239 div()
240 .text_size(px(10.0))
241 .text_color(theme.fg_muted)
242 .child(
243 "auto-save al swap o al soltar un divisor.",
244 ),
245 )
246 .child(
247 div()
248 .id("reload-from-disk")
249 .px(px(10.0))
250 .py(px(4.0))
251 .rounded(px(4.0))
252 .border_1()
253 .border_color(theme.border_strong)
254 .bg(theme.bg_panel.clone())
255 .text_color(theme.fg_text)
256 .text_size(px(11.0))
257 .hover(|s| s.opacity(0.85))
258 .on_click(cx.listener(Self::reload_from_disk))
259 .child("⤓ reload"),
260 ),
261 )
262 // ----- swap del contenedor 'explorers' -----
263 .child(
264 div()
265 .mt(px(14.0))
266 .text_color(theme.fg_muted)
267 .text_size(px(11.0))
268 .child(SharedString::from(format!(
269 "contenedor '{}' — kind:",
270 SWAP_TARGET_ID
271 ))),
272 )
273 .child(swap_row)
274 .child(
275 div()
276 .mt(px(2.0))
277 .text_color(theme.fg_muted)
278 .text_size(px(10.0))
279 .child(
280 "click ⇒ swappea el kind del contenedor padre. Los hijos \
281 (FileExplorer, DatabaseExplorer) se preservan: cualquier \
282 folder expandido o entry seleccionado sigue así tras el swap.",
283 ),
284 )
285 // ----- log de evento (placeholder Fase 6) -----
286 .child(
287 div()
288 .mt(px(16.0))
289 .text_color(theme.fg_muted)
290 .text_size(px(11.0))
291 .child("último evento (bus Fase 6):"),
292 )
293 .child(
294 div()
295 .px(px(10.0))
296 .py(px(8.0))
297 .bg(theme.bg_panel.clone())
298 .border_1()
299 .border_color(theme.border)
300 .rounded(px(4.0))
301 .text_size(px(11.0))
302 .text_color(theme.fg_text)
303 .child(self.last_event.clone()),
304 )
305 // ----- theme switcher -----
306 .child(
307 div()
308 .mt(px(20.0))
309 .text_color(theme.fg_muted)
310 .text_size(px(11.0))
311 .child("tema activo:"),
312 )
313 .child(
314 div()
315 .flex()
316 .flex_row()
317 .items_center()
318 .gap(px(10.0))
319 .child(
320 div()
321 .text_color(theme.accent)
322 .text_size(px(15.0))
323 .child(SharedString::from(theme.name.to_string())),
324 )
325 .child(
326 div()
327 .id("cycle-theme")
328 .px(px(10.0))
329 .py(px(4.0))
330 .rounded(px(4.0))
331 .border_1()
332 .border_color(theme.border_strong)
333 .bg(theme.bg_panel.clone())
334 .text_color(theme.fg_text)
335 .text_size(px(11.0))
336 .hover(|s| s.opacity(0.85))
337 .on_click(cx.listener(Self::cycle_theme))
338 .child("⇄ siguiente"),
339 ),
340 )
341 .child(theme_row)
342 }
343}