Skip to main content

ccf_gpui_widgets/widgets/
sidebar_nav.rs

1//! Generic sidebar navigation widget for switching between sections
2//!
3//! A vertical navigation sidebar that can display any type implementing the `SelectionItem` trait.
4//! Supports click-to-select and keyboard navigation with Up/Down arrows.
5//! Use `register_keybindings()` at app startup to enable keyboard shortcuts.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use ccf_gpui_widgets::widgets::{SidebarNav, SidebarNavEvent, SelectionItem};
11//! use gpui::*;
12//!
13//! // Register keybindings at app startup
14//! ccf_gpui_widgets::widgets::sidebar_nav::register_keybindings(cx);
15//!
16//! #[derive(Debug, Clone, Copy, PartialEq, Eq)]
17//! pub enum MySection {
18//!     Overview,
19//!     Details,
20//!     Settings,
21//! }
22//!
23//! impl SelectionItem for MySection {
24//!     fn label(&self) -> SharedString {
25//!         match self {
26//!             MySection::Overview => "Overview".into(),
27//!             MySection::Details => "Details".into(),
28//!             MySection::Settings => "Settings".into(),
29//!         }
30//!     }
31//!
32//!     fn id(&self) -> ElementId {
33//!         match self {
34//!             MySection::Overview => "sidebar_overview".into(),
35//!             MySection::Details => "sidebar_details".into(),
36//!             MySection::Settings => "sidebar_settings".into(),
37//!         }
38//!     }
39//! }
40//!
41//! let sidebar_nav = cx.new(|cx| {
42//!     SidebarNav::new(
43//!         vec![MySection::Overview, MySection::Details, MySection::Settings],
44//!         MySection::Overview,
45//!         cx,
46//!     )
47//! });
48//!
49//! cx.subscribe(&sidebar_nav, |this, _, event: &SidebarNavEvent<MySection>, cx| {
50//!     match event {
51//!         SidebarNavEvent::Change(section) => this.switch_to(*section, cx),
52//!     }
53//! }).detach();
54//! ```
55//!
56//! # API Changes (2025-02)
57//!
58//! - Replaced `SidebarItem` trait with `SelectionItem` (unified trait across all selection widgets)
59//! - Added index-based selection: `selected_index()`, `set_selected_index()`
60//! - Renamed event: `Select(T)` → `Change(T)`
61//! - Note: Navigation widgets (TabBar, SidebarNav) do NOT emit events from set_* methods
62
63use gpui::prelude::*;
64use gpui::*;
65use crate::theme::{get_theme_or, Theme};
66use super::focus_navigation::{with_focus_actions, EnabledCursorExt};
67use super::selection::SelectionItem;
68
69// Actions for keyboard navigation
70actions!(ccf_sidebar_nav, [SelectPrevious, SelectNext]);
71
72/// Register key bindings for sidebar nav components
73///
74/// Call this once at application startup:
75/// ```ignore
76/// ccf_gpui_widgets::widgets::sidebar_nav::register_keybindings(cx);
77/// ```
78pub fn register_keybindings(cx: &mut App) {
79    cx.bind_keys([
80        KeyBinding::new("up", SelectPrevious, Some("CcfSidebarNav")),
81        KeyBinding::new("down", SelectNext, Some("CcfSidebarNav")),
82    ]);
83}
84
85/// Events emitted by SidebarNav
86///
87/// Note: `set_selected()` and `set_selected_index()` do NOT emit events.
88/// Navigation widgets represent UI navigation state where the consumer typically
89/// controls transitions and doesn't need redundant event notifications.
90#[derive(Debug, Clone)]
91pub enum SidebarNavEvent<T> {
92    /// An item was selected
93    ///
94    /// Previously named `Select(T)`.
95    Change(T),
96}
97
98/// Generic sidebar navigation widget
99pub struct SidebarNav<T: SelectionItem> {
100    items: Vec<T>,
101    selected: T,
102    focus_handle: FocusHandle,
103    custom_theme: Option<Theme>,
104    /// Whether the widget is enabled (interactive)
105    enabled: bool,
106    /// Fixed width for the sidebar
107    width: Option<Pixels>,
108}
109
110impl<T: SelectionItem> SidebarNav<T> {
111    /// Create a new sidebar nav with the given items
112    ///
113    /// # Arguments
114    ///
115    /// * `items` - List of items to display
116    /// * `selected` - The initially selected item
117    /// * `cx` - Context for creating the focus handle
118    pub fn new(items: Vec<T>, selected: T, cx: &mut Context<Self>) -> Self {
119        Self {
120            items,
121            selected,
122            focus_handle: cx.focus_handle().tab_stop(true),
123            custom_theme: None,
124            enabled: true,
125            width: None,
126        }
127    }
128
129    /// Set a custom theme for this widget
130    #[must_use]
131    pub fn theme(mut self, theme: Theme) -> Self {
132        self.custom_theme = Some(theme);
133        self
134    }
135
136    /// Set enabled state (builder pattern)
137    #[must_use]
138    pub fn with_enabled(mut self, enabled: bool) -> Self {
139        self.enabled = enabled;
140        self
141    }
142
143    /// Set a fixed width for the sidebar
144    #[must_use]
145    pub fn with_width(mut self, width: Pixels) -> Self {
146        self.width = Some(width);
147        self
148    }
149
150    /// Get the currently selected item
151    pub fn selected(&self) -> &T {
152        &self.selected
153    }
154
155    /// Get the currently selected index
156    pub fn selected_index(&self) -> usize {
157        self.items.iter().position(|i| *i == self.selected).unwrap_or(0)
158    }
159
160    /// Set the selected item
161    ///
162    /// Note: Does NOT emit Change event. Navigation widgets represent UI state
163    /// where the consumer controls transitions.
164    pub fn set_selected(&mut self, item: T, cx: &mut Context<Self>) {
165        self.selected = item;
166        cx.notify();
167    }
168
169    /// Set selected by index
170    ///
171    /// Note: Does NOT emit Change event.
172    pub fn set_selected_index(&mut self, index: usize, cx: &mut Context<Self>) {
173        if let Some(item) = self.items.get(index).cloned() {
174            self.selected = item;
175            cx.notify();
176        }
177    }
178
179    /// Get the focus handle
180    pub fn focus_handle(&self) -> &FocusHandle {
181        &self.focus_handle
182    }
183
184    /// Check if the sidebar is enabled
185    pub fn is_enabled(&self) -> bool {
186        self.enabled
187    }
188
189    /// Set enabled state programmatically
190    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
191        if self.enabled != enabled {
192            self.enabled = enabled;
193            cx.notify();
194        }
195    }
196
197    /// Select the previous item (wraps around)
198    fn select_previous(&mut self, cx: &mut Context<Self>) {
199        if self.items.is_empty() {
200            return;
201        }
202        let current_index = self.items.iter().position(|t| *t == self.selected).unwrap_or(0);
203        let new_index = if current_index == 0 {
204            self.items.len() - 1
205        } else {
206            current_index - 1
207        };
208        if let Some(item) = self.items.get(new_index) {
209            self.selected = item.clone();
210            cx.emit(SidebarNavEvent::Change(self.selected.clone()));
211            cx.notify();
212        }
213    }
214
215    /// Select the next item (wraps around)
216    fn select_next(&mut self, cx: &mut Context<Self>) {
217        if self.items.is_empty() {
218            return;
219        }
220        let current_index = self.items.iter().position(|t| *t == self.selected).unwrap_or(0);
221        let new_index = if current_index >= self.items.len() - 1 {
222            0
223        } else {
224            current_index + 1
225        };
226        if let Some(item) = self.items.get(new_index) {
227            self.selected = item.clone();
228            cx.emit(SidebarNavEvent::Change(self.selected.clone()));
229            cx.notify();
230        }
231    }
232}
233
234impl<T: SelectionItem> EventEmitter<SidebarNavEvent<T>> for SidebarNav<T> {}
235
236impl<T: SelectionItem> Focusable for SidebarNav<T> {
237    fn focus_handle(&self, _cx: &App) -> FocusHandle {
238        self.focus_handle.clone()
239    }
240}
241
242impl<T: SelectionItem> Render for SidebarNav<T> {
243    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
244        let theme = get_theme_or(cx, self.custom_theme.as_ref());
245        let selected_item = self.selected.clone();
246        let is_focused = self.focus_handle.is_focused(window);
247        let enabled = self.enabled;
248
249        with_focus_actions(
250            div()
251                .id("ccf_sidebar_nav")
252                .key_context("CcfSidebarNav")
253                .track_focus(&self.focus_handle)
254                .tab_stop(enabled),
255            cx,
256        )
257        .flex()
258        .flex_col()
259        .when_some(self.width, |d, w| d.w(w))
260        .when(enabled, |d| d.bg(rgb(theme.bg_input)))
261        .when(!enabled, |d| d.bg(rgb(theme.disabled_bg)))
262        .border_r_1()
263        .border_color(rgb(theme.border_default))
264        .p_2()
265        // Keyboard navigation (Up / Down arrows)
266        .on_action(cx.listener(|this, _: &SelectPrevious, _window, cx| {
267            if this.enabled {
268                this.select_previous(cx);
269            }
270        }))
271        .on_action(cx.listener(|this, _: &SelectNext, _window, cx| {
272            if this.enabled {
273                this.select_next(cx);
274            }
275        }))
276        .children(self.items.iter().map(|item| {
277            let item = item.clone();
278            let is_selected = item == selected_item;
279            let show_focus = is_selected && is_focused && enabled;
280
281            div()
282                .id(item.id())
283                .cursor_for_enabled(enabled)
284                .px_2()
285                .py_1()
286                .mb_1()
287                .rounded(px(4.0))
288                .when(enabled, |d| {
289                    let item_clone = item.clone();
290                    d.on_click({
291                        cx.listener(move |this, _event: &ClickEvent, _window, cx| {
292                            this.selected = item_clone.clone();
293                            cx.emit(SidebarNavEvent::Change(item_clone.clone()));
294                            cx.notify();
295                        })
296                    })
297                })
298                // Selected state
299                .when(is_selected && enabled, |d| {
300                    d.bg(rgb(theme.bg_hover))
301                        .text_color(rgb(theme.accent))
302                })
303                // Unselected state
304                .when(!is_selected && enabled, |d| {
305                    d.bg(rgb(theme.bg_input))
306                        .text_color(rgb(theme.text_primary))
307                        .hover(|d| {
308                            d.bg(rgb(theme.bg_secondary))
309                        })
310                })
311                // Disabled states
312                .when(is_selected && !enabled, |d| {
313                    d.bg(rgb(theme.disabled_bg))
314                        .text_color(rgb(theme.disabled_text))
315                })
316                .when(!is_selected && !enabled, |d| {
317                    d.bg(rgb(theme.disabled_bg))
318                        .text_color(rgb(theme.disabled_text))
319                })
320                // Text content with focus ring
321                .child(
322                    div()
323                        .px_1()
324                        .border_1()
325                        .rounded_sm()
326                        .when(show_focus, |d| d.border_color(rgb(theme.border_focus)))
327                        .when(!show_focus, |d| d.border_color(rgba(0x00000000)))
328                        .child(item.label())
329                )
330        }))
331    }
332}