Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:03:14.800Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

status_bar.rs

507 lines · 17.7 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.