use crate::{ ItemHandle, MultiWorkspace, Pane, SidebarSide, ToggleWorkspaceSidebar, sidebar_side_context_menu, }; use gpui::{ Anchor, AnyView, App, Context, Decorations, Entity, FocusHandle, Focusable, IntoElement, ParentElement, Render, Role, SharedString, Styled, Subscription, WeakEntity, Window, }; use settings::{SettingsContent, update_settings_file}; use std::{any::TypeId, sync::Arc}; use theme::CLIENT_SIDE_DECORATION_ROUNDING; use ui::{ContextMenu, Divider, IconPosition, Indicator, Tooltip, prelude::*, right_click_menu}; /// Describes how a status-bar item can be hidden by the user. /// /// Every [`StatusItemView`] must either provide this (so that the user gets a /// "Hide Button" entry in the right-click menu) or explicitly return `None` /// to opt out. Returning `None` should be reserved for items that are /// already conditional on some other setting exposed elsewhere (e.g., the /// activity indicator, which disappears on its own once there's no work to /// display). #[derive(Clone)] pub struct HideStatusItem { hide: Arc, } impl HideStatusItem { pub fn new(hide: impl Fn(&mut SettingsContent) + Send + Sync + 'static) -> Self { Self { hide: Arc::new(hide), } } /// Persists the hide by updating the user settings file. pub fn apply(&self, cx: &App) { let hide = self.hide.clone(); let fs = ::global(cx); update_settings_file(fs, cx, move |settings, _cx| (hide)(settings)); } } pub trait StatusItemView: Render { /// Event callback that is triggered when the active pane item changes. fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn crate::ItemHandle>, window: &mut Window, cx: &mut Context, ); /// Returns metadata describing how this item can be hidden from the /// status bar by writing to the user settings file. /// /// Implementors that return `None` must be inherently conditional on /// another user-exposed setting; otherwise, they should return `Some` so /// that the status bar can show a "Hide Button" entry in its /// right-click menu. fn hide_setting(&self, cx: &App) -> Option; } trait StatusItemViewHandle: Send { fn to_any(&self) -> AnyView; fn set_active_pane_item( &self, active_pane_item: Option<&dyn ItemHandle>, window: &mut Window, cx: &mut App, ); fn item_type(&self) -> TypeId; fn hide_setting(&self, cx: &App) -> Option; } #[derive(Default)] struct SidebarStatus { open: bool, side: SidebarSide, has_notifications: bool, show_toggle: bool, } impl SidebarStatus { fn query(multi_workspace: &Option>, cx: &App) -> Self { multi_workspace .as_ref() .and_then(|mw| mw.upgrade()) .map(|mw| { let mw = mw.read(cx); let enabled = mw.multi_workspace_enabled(cx); Self { open: mw.sidebar_open() && enabled, side: mw.sidebar_side(cx), has_notifications: mw.sidebar_has_notifications(cx), show_toggle: enabled, } }) .unwrap_or_default() } } pub struct StatusBar { left_items: Vec>, right_items: Vec>, active_pane: Entity, multi_workspace: Option>, focus_handle: FocusHandle, _observe_active_pane: Subscription, } impl Focusable for StatusBar { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for StatusBar { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let sidebar = SidebarStatus::query(&self.multi_workspace, cx); h_flex() .id("status-bar") .track_focus(&self.focus_handle) .key_context("StatusBar") // Expose the status bar as an ARIA toolbar so assistive technology // announces it as a toolbar and region navigation can reach its // controls. The controls inside form a tab group: region navigation // lands on the first control (per the ARIA toolbar pattern), Tab // steps through them, and arrow keys move between them once focus is // inside. .role(Role::Toolbar) .aria_label("Status bar") .tab_group() .on_key_down( cx.listener(|status_bar, event: &gpui::KeyDownEvent, window, cx| { if event.keystroke.modifiers.modified() { return; } match event.keystroke.key.as_str() { "right" => { status_bar.move_item_focus(true, window, cx); cx.stop_propagation(); } "left" => { status_bar.move_item_focus(false, window, cx); cx.stop_propagation(); } _ => {} } }), ) .w_full() .justify_between() .gap(DynamicSpacing::Base08.rems(cx)) .p(DynamicSpacing::Base04.rems(cx)) .bg(cx.theme().colors().status_bar_background) .map(|el| match window.window_decorations() { Decorations::Server => el, Decorations::Client { tiling, .. } => el .when( !(tiling.bottom || tiling.right) && !(sidebar.open && sidebar.side == SidebarSide::Right), |el| el.rounded_br(CLIENT_SIDE_DECORATION_ROUNDING), ) .when( !(tiling.bottom || tiling.left) && !(sidebar.open && sidebar.side == SidebarSide::Left), |el| el.rounded_bl(CLIENT_SIDE_DECORATION_ROUNDING), ) // This border is to avoid a transparent gap in the rounded corners .mb(px(-1.)) .mt({ #[cfg(target_os = "linux")] let needs_gap_fix = { // Running on Wayland and using some scaling levels other than 100% causes a // 1px gap above the status bar; adding a margin avoids this. gpui::guess_compositor() == "Wayland" && window.scale_factor() != 1.0 }; #[cfg(not(target_os = "linux"))] let needs_gap_fix = false; if needs_gap_fix { px(-1.) } else { px(0.) } }) .border_b(px(1.0)) .border_color(cx.theme().colors().status_bar_background), }) .child(self.render_left_tools(&sidebar, cx)) .child(self.render_right_tools(&sidebar, cx)) } } impl StatusBar { fn render_left_tools( &self, sidebar: &SidebarStatus, cx: &mut Context, ) -> impl IntoElement { h_flex() .gap_1() .min_w_0() .overflow_x_hidden() .when( sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Left, |this| this.child(self.render_sidebar_toggle(sidebar, cx)), ) .children(self.left_items.iter().enumerate().map(|(index, item)| { render_hideable_item("status-bar-left", index, item.as_ref(), cx) })) } fn render_right_tools( &self, sidebar: &SidebarStatus, cx: &mut Context, ) -> impl IntoElement { h_flex() .flex_shrink_0() .gap_1() .overflow_x_hidden() .children( self.right_items .iter() .enumerate() .rev() .map(|(index, item)| { render_hideable_item("status-bar-right", index, item.as_ref(), cx) }), ) .when( sidebar.show_toggle && !sidebar.open && sidebar.side == SidebarSide::Right, |this| this.child(self.render_sidebar_toggle(sidebar, cx)), ) } fn render_sidebar_toggle( &self, sidebar: &SidebarStatus, cx: &mut Context, ) -> impl IntoElement { let on_right = sidebar.side == SidebarSide::Right; let has_notifications = sidebar.has_notifications; let indicator_border = cx.theme().colors().status_bar_background; let toggle = sidebar_side_context_menu("sidebar-status-toggle-menu", cx) .anchor(if on_right { Anchor::BottomRight } else { Anchor::BottomLeft }) .attach(if on_right { Anchor::TopRight } else { Anchor::TopLeft }) .trigger(move |_is_active, _window, _cx| { IconButton::new( "toggle-workspace-sidebar", if on_right { IconName::ThreadsSidebarRightClosed } else { IconName::ThreadsSidebarLeftClosed }, ) .icon_size(IconSize::Small) .tab_index(0isize) .aria_label("Open threads sidebar") .when(has_notifications, |this| { this.indicator(Indicator::dot().color(Color::Accent)) .indicator_border_color(Some(indicator_border)) }) .tooltip(move |_, cx| { Tooltip::for_action("Open Threads Sidebar", &ToggleWorkspaceSidebar, cx) }) .on_click(move |_, window, cx| { if let Some(multi_workspace) = window.root::().flatten() { multi_workspace.update(cx, |multi_workspace, cx| { multi_workspace.toggle_sidebar(window, cx); }); } }) }); h_flex() .gap_0p5() .when(on_right, |this| { this.child(Divider::vertical().color(ui::DividerColor::Border)) }) .child(toggle) .when(!on_right, |this| { this.child(Divider::vertical().color(ui::DividerColor::Border)) }) } } fn render_hideable_item( side: &'static str, index: usize, item: &dyn StatusItemViewHandle, cx: &App, ) -> impl IntoElement { let view = item.to_any(); let Some(hide) = item.hide_setting(cx) else { return view.into_any_element(); }; let menu_id: SharedString = format!("{side}-item-menu-{index}").into(); right_click_menu(menu_id) .trigger(move |_is_active, _window, _cx| view) .menu(move |window, cx| { let hide = hide.clone(); ContextMenu::build(window, cx, move |menu, _window, _cx| { add_hide_button_entry(menu, hide) }) }) .into_any_element() } /// Appends a "Hide Button" entry aligned with surrounding toggleable entries. pub fn add_hide_button_entry(menu: ContextMenu, hide: HideStatusItem) -> ContextMenu { menu.toggleable_entry( "Hide Button", false, IconPosition::Start, None, move |_window, cx| hide.apply(cx), ) } impl StatusBar { pub fn new( active_pane: &Entity, multi_workspace: Option>, window: &mut Window, cx: &mut Context, ) -> Self { let mut this = Self { left_items: Default::default(), right_items: Default::default(), active_pane: active_pane.clone(), multi_workspace, focus_handle: cx.focus_handle(), _observe_active_pane: cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }), }; this.update_active_pane_item(window, cx); this } pub fn set_multi_workspace( &mut self, multi_workspace: WeakEntity, cx: &mut Context, ) { self.multi_workspace = Some(multi_workspace); cx.notify(); } pub fn add_left_item(&mut self, item: Entity, window: &mut Window, cx: &mut Context) where T: 'static + StatusItemView, { let active_pane_item = self.active_pane.read(cx).active_item(); item.set_active_pane_item(active_pane_item.as_deref(), window, cx); self.left_items.push(Box::new(item)); cx.notify(); } pub fn item_of_type(&self) -> Option> { self.left_items .iter() .chain(self.right_items.iter()) .find_map(|item| item.to_any().downcast().ok()) } pub fn position_of_item(&self) -> Option where T: StatusItemView, { for (index, item) in self.left_items.iter().enumerate() { if item.item_type() == TypeId::of::() { return Some(index); } } for (index, item) in self.right_items.iter().enumerate() { if item.item_type() == TypeId::of::() { return Some(index + self.left_items.len()); } } None } pub fn insert_item_after( &mut self, position: usize, item: Entity, window: &mut Window, cx: &mut Context, ) where T: 'static + StatusItemView, { let active_pane_item = self.active_pane.read(cx).active_item(); item.set_active_pane_item(active_pane_item.as_deref(), window, cx); if position < self.left_items.len() { self.left_items.insert(position + 1, Box::new(item)) } else { self.right_items .insert(position + 1 - self.left_items.len(), Box::new(item)) } cx.notify() } pub fn remove_item_at(&mut self, position: usize, cx: &mut Context) { if position < self.left_items.len() { self.left_items.remove(position); } else { self.right_items.remove(position - self.left_items.len()); } cx.notify(); } pub fn add_right_item( &mut self, item: Entity, window: &mut Window, cx: &mut Context, ) where T: 'static + StatusItemView, { let active_pane_item = self.active_pane.read(cx).active_item(); item.set_active_pane_item(active_pane_item.as_deref(), window, cx); self.right_items.push(Box::new(item)); cx.notify(); } pub fn set_active_pane( &mut self, active_pane: &Entity, window: &mut Window, cx: &mut Context, ) { self.active_pane = active_pane.clone(); self._observe_active_pane = cx.observe_in(active_pane, window, |this, _, window, cx| { this.update_active_pane_item(window, cx) }); self.update_active_pane_item(window, cx); } fn update_active_pane_item(&mut self, window: &mut Window, cx: &mut Context) { let active_pane_item = self.active_pane.read(cx).active_item(); for item in self.left_items.iter().chain(&self.right_items) { item.set_active_pane_item(active_pane_item.as_deref(), window, cx); } } /// Moves focus between the interactive controls within the status bar in /// response to arrow keys. Navigation is clamped to the status bar so /// arrows move between items and stop at the ends (ARIA toolbar semantics); /// Tab is still used to leave the toolbar. fn move_item_focus(&mut self, forward: bool, window: &mut Window, cx: &mut Context) { let previous = window.focused(cx); if forward { window.focus_next(cx); } else { window.focus_prev(cx); } let landed_in_status_bar = window .focused(cx) .is_some_and(|handle| self.focus_handle.contains(&handle, window)); if !landed_in_status_bar && let Some(previous) = previous { window.focus(&previous, cx); } cx.notify(); } } impl StatusItemViewHandle for Entity { fn to_any(&self) -> AnyView { self.clone().into() } fn set_active_pane_item( &self, active_pane_item: Option<&dyn ItemHandle>, window: &mut Window, cx: &mut App, ) { self.update(cx, |this, cx| { this.set_active_pane_item(active_pane_item, window, cx) }); } fn item_type(&self) -> TypeId { TypeId::of::() } fn hide_setting(&self, cx: &App) -> Option { self.read(cx).hide_setting(cx) } } impl From<&dyn StatusItemViewHandle> for AnyView { fn from(val: &dyn StatusItemViewHandle) -> Self { val.to_any() } }