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}