Skip to repository content507 lines · 17.7 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T05:01:56.009Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
status_bar.rs
1use crate::{
2 ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar,
3 sidebar_side_context_menu,
4};
5use gpui::{
6 Anchor, AnyView, App, Context, Decorations, Entity, FocusHandle, Focusable, IntoElement,
7 ParentElement, Render, Role, SharedString, Styled, Subscription, WeakEntity, Window,
8};
9use settings::{SettingsContent, update_settings_file};
10use std::{any::TypeId, sync::Arc};
11use theme::CLIENT_SIDE_DECORATION_ROUNDING;
12use ui::{ContextMenu, Divider, IconPosition, Indicator, Tooltip, prelude::*, right_click_menu};
13
14/// Describes how a status-bar item can be hidden by the user.
15///
16/// Every [`StatusItemView`] must either provide this (so that the user gets a
17/// "Hide Button" entry in the right-click menu) or explicitly return `None`
18/// to opt out. Returning `None` should be reserved for items that are
19/// already conditional on some other setting exposed elsewhere (e.g., the
20/// activity indicator, which disappears on its own once there's no work to
21/// display).
22#[derive(Clone)]
23pub struct HideStatusItem {
24 hide: Arc<dyn Fn(&mut SettingsContent) + Send + Sync>,
25}
26
27impl HideStatusItem {
28 pub fn new(hide: impl Fn(&mut SettingsContent) + Send + Sync + 'static) -> Self {
29 Self {
30 hide: Arc::new(hide),
31 }
32 }
33
34 /// Persists the hide by updating the user settings file.
35 pub fn apply(&self, cx: &App) {
36 let hide = self.hide.clone();
37 let fs = <dyn fs::Fs>::global(cx);
38 update_settings_file(fs, cx, move |settings, _cx| (hide)(settings));
39 }
40}
41
42pub trait StatusItemView: Render {
43 /// Event callback that is triggered when the active pane item changes.
44 fn set_active_pane_item(
45 &mut self,
46 active_pane_item: Option<&dyn crate::ItemHandle>,
47 window: &mut Window,
48 cx: &mut Context<Self>,
49 );
50
51 /// Returns metadata describing how this item can be hidden from the
52 /// status bar by writing to the user settings file.
53 ///
54 /// Implementors that return `None` must be inherently conditional on
55 /// another user-exposed setting; otherwise, they should return `Some` so
56 /// that the status bar can show a "Hide Button" entry in its
57 /// right-click menu.
58 fn hide_setting(&self, cx: &App) -> Option<HideStatusItem>;
59}
60
61trait StatusItemViewHandle: Send {
62 fn to_any(&self) -> AnyView;
63 fn set_active_pane_item(
64 &self,
65 active_pane_item: Option<&dyn ItemHandle>,
66 window: &mut Window,
67 cx: &mut App,
68 );
69 fn item_type(&self) -> TypeId;
70 fn hide_setting(&self, cx: &App) -> Option<HideStatusItem>;
71}
72
73#[derive(Default)]
74struct SidebarStatus {
75 open: bool,
76 side: SidebarSide,
77 has_notifications: bool,
78 show_toggle: bool,
79}
80
81impl SidebarStatus {
82 fn query(multi_workspace: &Option<WeakEntity<MultiWorkspace>>, cx: &App) -> Self {
83 multi_workspace
84 .as_ref()
85 .and_then(|mw| mw.upgrade())
86 .map(|mw| {
87 let mw = mw.read(cx);
88 let enabled = mw.multi_workspace_enabled(cx);
89 Self {
90 open: mw.sidebar_open() && enabled,
91 side: mw.sidebar_side(cx),
92 has_notifications: mw.sidebar_has_notifications(cx),
93 show_toggle: enabled,
94 }
95 })
96 .unwrap_or_default()
97 }
98}
99
100pub struct StatusBar {
101 left_items: Vec<Box<dyn StatusItemViewHandle>>,
102 right_items: Vec<Box<dyn StatusItemViewHandle>>,
103 active_pane: Entity<Pane>,
104 multi_workspace: Option<WeakEntity<MultiWorkspace>>,
105 focus_handle: FocusHandle,
106 _observe_active_pane: Subscription,
107}
108
109impl Focusable for StatusBar {
110 fn focus_handle(&self, _cx: &App) -> FocusHandle {
111 self.focus_handle.clone()
112 }
113}
114
115impl Render for StatusBar {
116 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
117 let sidebar = SidebarStatus::query(&self.multi_workspace, cx);
118
119 h_flex()
120 .id("status-bar")
121 .track_focus(&self.focus_handle)
122 .key_context("StatusBar")
123 // Expose the status bar as an ARIA toolbar so assistive technology
124 // announces it as a toolbar and region navigation can reach its
125 // controls. The controls inside form a tab group: region navigation
126 // lands on the first control (per the ARIA toolbar pattern), Tab
127 // steps through them, and arrow keys move between them once focus is
128 // inside.
129 .role(Role::Toolbar)
130 .aria_label("Status bar")
131 .tab_group()
132 .on_key_down(
133 cx.listener(|status_bar, event: &gpui::KeyDownEvent, window, cx| {
134 if event.keystroke.modifiers.modified() {
135 return;
136 }
137 match event.keystroke.key.as_str() {
138 "right" => {
139 status_bar.move_item_focus(true, window, cx);
140 cx.stop_propagation();
141 }
142 "left" => {
143 status_bar.move_item_focus(false, window, cx);
144 cx.stop_propagation();
145 }
146 _ => {}
147 }
148 }),
149 )
150 .w_full()
151 .justify_between()
152 .gap(DynamicSpacing::Base08.rems(cx))
153 .p(DynamicSpacing::Base04.rems(cx))
154 .bg(cx.theme().colors().status_bar_background)
155 .map(|el| match window.window_decorations() {
156 Decorations::Server => el,
157 Decorations::Client { tiling, .. } => el
158 .when(
159 !(tiling.bottom || tiling.right)
160 && !(sidebar.open && sidebar.side == SidebarSide::Right),
161 |el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING),
162 )
163 .when(
164 !(tiling.bottom || tiling.left)
165 && !(sidebar.open && sidebar.side == SidebarSide::Left),
166 |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING),
167 )
168 // This border is to avoid a transparent gap in the rounded corners
169 .mb(px(-1.))
170 .mt({
171 #[cfg(target_os = "linux")]
172 let needs_gap_fix = {
173 // Running on Wayland and using some scaling levels other than 100% causes a
174 // 1px gap above the status bar; adding a margin avoids this.
175 gpui::guess_compositor() == "Wayland" && window.scale_factor() != 1.0
176 };
177 #[cfg(not(target_os = "linux"))]
178 let needs_gap_fix = false;
179 if needs_gap_fix { px(-1.) } else { px(0.) }
180 })
181 .border_b(px(1.0))
182 .border_color(cx.theme().colors().status_bar_background),
183 })
184 .child(self.render_left_tools(&sidebar, cx))
185 .child(self.render_right_tools(&sidebar, cx))
186 }
187}
188
189impl StatusBar {
190 fn render_left_tools(
191 &self,
192 sidebar: &SidebarStatus,
193 cx: &mut Context<Self>,
194 ) -> impl IntoElement {
195 h_flex()
196 .gap_1()
197 .min_w_0()
198 .overflow_x_hidden()
199 .when(
200 sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left,
201 |this| this.child(self.render_sidebar_toggle(sidebar, cx)),
202 )
203 .children(self.left_items.iter().enumerate().map(|(index, item)| {
204 render_hideable_item("status-bar-left", index, item.as_ref(), cx)
205 }))
206 }
207
208 fn render_right_tools(
209 &self,
210 sidebar: &SidebarStatus,
211 cx: &mut Context<Self>,
212 ) -> impl IntoElement {
213 h_flex()
214 .flex_shrink_0()
215 .gap_1()
216 .overflow_x_hidden()
217 .children(
218 self.right_items
219 .iter()
220 .enumerate()
221 .rev()
222 .map(|(index, item)| {
223 render_hideable_item("status-bar-right", index, item.as_ref(), cx)
224 }),
225 )
226 .when(
227 sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right,
228 |this| this.child(self.render_sidebar_toggle(sidebar, cx)),
229 )
230 }
231
232 fn render_sidebar_toggle(
233 &self,
234 sidebar: &SidebarStatus,
235 cx: &mut Context<Self>,
236 ) -> impl IntoElement {
237 let on_right = sidebar.side == SidebarSide::Right;
238 let has_notifications = sidebar.has_notifications;
239 let indicator_border = cx.theme().colors().status_bar_background;
240
241 let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx)
242 .anchor(if on_right {
243 Anchor::BottomRight
244 } else {
245 Anchor::BottomLeft
246 })
247 .attach(if on_right {
248 Anchor::TopRight
249 } else {
250 Anchor::TopLeft
251 })
252 .trigger(move |_is_active, _window, _cx| {
253 IconButton::new(
254 "toggle-workspace-sidebar",
255 if on_right {
256 IconName::ThreadsSidebarRightClosed
257 } else {
258 IconName::ThreadsSidebarLeftClosed
259 },
260 )
261 .icon_size(IconSize::Small)
262 .tab_index(0isize)
263 .aria_label("Open threads sidebar")
264 .when(has_notifications, |this| {
265 this.indicator(Indicator::dot().color(Color::Accent))
266 .indicator_border_color(Some(indicator_border))
267 })
268 .tooltip(move |_, cx| {
269 Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx)
270 })
271 .on_click(move |_, window, cx| {
272 if let Some(multi_workspace) = window.root::<MultiWorkspace>().flatten() {
273 multi_workspace.update(cx, |multi_workspace, cx| {
274 multi_workspace.toggle_sidebar(window, cx);
275 });
276 }
277 })
278 });
279
280 h_flex()
281 .gap_0p5()
282 .when(on_right, |this| {
283 this.child(Divider::vertical().color(ui::DividerColor::Border))
284 })
285 .child(toggle)
286 .when(!on_right, |this| {
287 this.child(Divider::vertical().color(ui::DividerColor::Border))
288 })
289 }
290}
291
292fn render_hideable_item(
293 side: &'static str,
294 index: usize,
295 item: &dyn StatusItemViewHandle,
296 cx: &App,
297) -> impl IntoElement {
298 let view = item.to_any();
299 let Some(hide) = item.hide_setting(cx) else {
300 return view.into_any_element();
301 };
302
303 let menu_id: SharedString = format!("{side}-item-menu-{index}").into();
304 right_click_menu(menu_id)
305 .trigger(move |_is_active, _window, _cx| view)
306 .menu(move |window, cx| {
307 let hide = hide.clone();
308 ContextMenu::build(window, cx, move |menu, _window, _cx| {
309 add_hide_button_entry(menu, hide)
310 })
311 })
312 .into_any_element()
313}
314
315/// Appends a "Hide Button" entry aligned with surrounding toggleable entries.
316pub fn add_hide_button_entry(menu: ContextMenu, hide: HideStatusItem) -> ContextMenu {
317 menu.toggleable_entry(
318 "Hide Button",
319 false,
320 IconPosition::Start,
321 None,
322 move |_window, cx| hide.apply(cx),
323 )
324}
325
326impl StatusBar {
327 pub fn new(
328 active_pane: &Entity<Pane>,
329 multi_workspace: Option<WeakEntity<MultiWorkspace>>,
330 window: &mut Window,
331 cx: &mut Context<Self>,
332 ) -> Self {
333 let mut this = Self {
334 left_items: Default::default(),
335 right_items: Default::default(),
336 active_pane: active_pane.clone(),
337 multi_workspace,
338 focus_handle: cx.focus_handle(),
339 _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| {
340 this.update_active_pane_item(window, cx)
341 }),
342 };
343 this.update_active_pane_item(window, cx);
344 this
345 }
346
347 pub fn set_multi_workspace(
348 &mut self,
349 multi_workspace: WeakEntity<MultiWorkspace>,
350 cx: &mut Context<Self>,
351 ) {
352 self.multi_workspace = Some(multi_workspace);
353 cx.notify();
354 }
355
356 pub fn add_left_item<T>(&mut self, item: Entity<T>, window: &mut Window, cx: &mut Context<Self>)
357 where
358 T: 'static + StatusItemView,
359 {
360 let active_pane_item = self.active_pane.read(cx).active_item();
361 item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
362
363 self.left_items.push(Box::new(item));
364 cx.notify();
365 }
366
367 pub fn item_of_type<T: StatusItemView>(&self) -> Option<Entity<T>> {
368 self.left_items
369 .iter()
370 .chain(self.right_items.iter())
371 .find_map(|item| item.to_any().downcast().ok())
372 }
373
374 pub fn position_of_item<T>(&self) -> Option<usize>
375 where
376 T: StatusItemView,
377 {
378 for (index, item) in self.left_items.iter().enumerate() {
379 if item.item_type() == TypeId::of::<T>() {
380 return Some(index);
381 }
382 }
383 for (index, item) in self.right_items.iter().enumerate() {
384 if item.item_type() == TypeId::of::<T>() {
385 return Some(index + self.left_items.len());
386 }
387 }
388 None
389 }
390
391 pub fn insert_item_after<T>(
392 &mut self,
393 position: usize,
394 item: Entity<T>,
395 window: &mut Window,
396 cx: &mut Context<Self>,
397 ) where
398 T: 'static + StatusItemView,
399 {
400 let active_pane_item = self.active_pane.read(cx).active_item();
401 item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
402
403 if position < self.left_items.len() {
404 self.left_items.insert(position + 1, Box::new(item))
405 } else {
406 self.right_items
407 .insert(position + 1 - self.left_items.len(), Box::new(item))
408 }
409 cx.notify()
410 }
411
412 pub fn remove_item_at(&mut self, position: usize, cx: &mut Context<Self>) {
413 if position < self.left_items.len() {
414 self.left_items.remove(position);
415 } else {
416 self.right_items.remove(position - self.left_items.len());
417 }
418 cx.notify();
419 }
420
421 pub fn add_right_item<T>(
422 &mut self,
423 item: Entity<T>,
424 window: &mut Window,
425 cx: &mut Context<Self>,
426 ) where
427 T: 'static + StatusItemView,
428 {
429 let active_pane_item = self.active_pane.read(cx).active_item();
430 item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
431
432 self.right_items.push(Box::new(item));
433 cx.notify();
434 }
435
436 pub fn set_active_pane(
437 &mut self,
438 active_pane: &Entity<Pane>,
439 window: &mut Window,
440 cx: &mut Context<Self>,
441 ) {
442 self.active_pane = active_pane.clone();
443 self._observe_active_pane = cx.observe_in(active_pane, window, |this, _, window, cx| {
444 this.update_active_pane_item(window, cx)
445 });
446 self.update_active_pane_item(window, cx);
447 }
448
449 fn update_active_pane_item(&mut self, window: &mut Window, cx: &mut Context<Self>) {
450 let active_pane_item = self.active_pane.read(cx).active_item();
451 for item in self.left_items.iter().chain(&self.right_items) {
452 item.set_active_pane_item(active_pane_item.as_deref(), window, cx);
453 }
454 }
455
456 /// Moves focus between the interactive controls within the status bar in
457 /// response to arrow keys. Navigation is clamped to the status bar so
458 /// arrows move between items and stop at the ends (ARIA toolbar semantics);
459 /// Tab is still used to leave the toolbar.
460 fn move_item_focus(&mut self, forward: bool, window: &mut Window, cx: &mut Context<Self>) {
461 let previous = window.focused(cx);
462 if forward {
463 window.focus_next(cx);
464 } else {
465 window.focus_prev(cx);
466 }
467 let landed_in_status_bar = window
468 .focused(cx)
469 .is_some_and(|handle| self.focus_handle.contains(&handle, window));
470 if !landed_in_status_bar && let Some(previous) = previous {
471 window.focus(&previous, cx);
472 }
473 cx.notify();
474 }
475}
476
477impl<T: StatusItemView> StatusItemViewHandle for Entity<T> {
478 fn to_any(&self) -> AnyView {
479 self.clone().into()
480 }
481
482 fn set_active_pane_item(
483 &self,
484 active_pane_item: Option<&dyn ItemHandle>,
485 window: &mut Window,
486 cx: &mut App,
487 ) {
488 self.update(cx, |this, cx| {
489 this.set_active_pane_item(active_pane_item, window, cx)
490 });
491 }
492
493 fn item_type(&self) -> TypeId {
494 TypeId::of::<T>()
495 }
496
497 fn hide_setting(&self, cx: &App) -> Option<HideStatusItem> {
498 self.read(cx).hide_setting(cx)
499 }
500}
501
502impl From<&dyn StatusItemViewHandle> for AnyView {
503 fn from(val: &dyn StatusItemViewHandle) -> Self {
504 val.to_any()
505 }
506}
507