Skip to main content

ccf_gpui_widgets/widgets/
repeatable_directory_picker.rs

1//! Repeatable directory picker widget - allows selecting multiple directories with add/remove buttons
2//!
3//! A widget that manages a list of directory path inputs, allowing users to add and remove entries.
4//! Each entry is a full DirectoryPicker widget supporting folder browsing dialogs and drag-drop.
5//!
6//! Requires the `file-picker` feature.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use ccf_gpui_widgets::widgets::{
12//!     RepeatableDirectoryPicker, RepeatableDirectoryPickerEvent
13//! };
14//!
15//! let picker = cx.new(|cx| {
16//!     RepeatableDirectoryPicker::new(cx)
17//!         .with_values(vec!["/path/to/dir1".to_string()])
18//!         .placeholder("Select directory...")
19//!         .min_entries(1)
20//! });
21//!
22//! cx.subscribe(&picker, |this, _, event: &RepeatableDirectoryPickerEvent, cx| {
23//!     match event {
24//!         RepeatableDirectoryPickerEvent::Change(values) => {
25//!             println!("Directories: {:?}", values);
26//!         }
27//!         RepeatableDirectoryPickerEvent::EntryAdded(index) => {
28//!             println!("Added entry at index {}", index);
29//!         }
30//!         RepeatableDirectoryPickerEvent::EntryRemoved(index) => {
31//!             println!("Removed entry at index {}", index);
32//!         }
33//!     }
34//! }).detach();
35//! ```
36
37use gpui::prelude::*;
38use gpui::*;
39use crate::theme::{get_theme, Theme};
40use super::directory_picker::{
41    DirectoryPicker, DirectoryPickerEvent,
42    DirectoryPickerValidation, ValidationDisplay,
43};
44use super::focus_navigation::{repeatable_add_button, repeatable_remove_button};
45
46/// Events emitted by RepeatableDirectoryPicker
47#[derive(Debug, Clone)]
48pub enum RepeatableDirectoryPickerEvent {
49    /// Values changed (includes all current values)
50    Change(Vec<String>),
51    /// A new entry was added at the given index
52    EntryAdded(usize),
53    /// An entry was removed from the given index
54    EntryRemoved(usize),
55}
56
57/// Repeatable directory picker widget
58///
59/// Manages a dynamic list of DirectoryPicker widgets with add/remove controls.
60pub struct RepeatableDirectoryPicker {
61    // Configuration (applied to all entries)
62    placeholder: Option<SharedString>,
63    browse_shortcut_enabled: bool,
64    validation_display: ValidationDisplay,
65
66    /// Initial values to populate on first render
67    initial_values: Vec<String>,
68    /// Each entry is a DirectoryPicker entity
69    entries: Vec<Entity<DirectoryPicker>>,
70    /// Focus handles for remove buttons (one per entry)
71    remove_focus_handles: Vec<FocusHandle>,
72    /// Focus handle for the add button
73    add_focus_handle: FocusHandle,
74    /// Whether entries have been initialized
75    initialized: bool,
76    /// Minimum number of entries (cannot remove below this)
77    min_entries: usize,
78    custom_theme: Option<Theme>,
79    /// Whether the widget is enabled (interactive)
80    enabled: bool,
81    /// Prevents double-trigger when Space/Enter activates both on_action and on_click.
82    /// See focus_navigation.rs module comment for details. DO NOT REMOVE.
83    action_just_handled: bool,
84}
85
86impl RepeatableDirectoryPicker {
87    /// Create a new repeatable directory picker
88    pub fn new(cx: &mut Context<Self>) -> Self {
89        Self {
90            placeholder: None,
91            browse_shortcut_enabled: true,
92            validation_display: ValidationDisplay::default(),
93            initial_values: Vec::new(),
94            entries: Vec::new(),
95            remove_focus_handles: Vec::new(),
96            add_focus_handle: cx.focus_handle().tab_stop(true),
97            initialized: false,
98            min_entries: 1,
99            custom_theme: None,
100            enabled: true,
101            action_just_handled: false,
102        }
103    }
104
105    /// Set initial values
106    #[must_use]
107    pub fn with_values(mut self, values: Vec<String>) -> Self {
108        self.initial_values = values;
109        self
110    }
111
112    /// Set placeholder text
113    #[must_use]
114    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
115        self.placeholder = Some(text.into());
116        self
117    }
118
119    /// Enable or disable Cmd+O / Ctrl+O browse shortcut (builder pattern)
120    ///
121    /// The shortcut is enabled by default. Disable it if your application
122    /// uses Cmd+O for another purpose (e.g., opening files at the app level).
123    #[must_use]
124    pub fn browse_shortcut(mut self, enabled: bool) -> Self {
125        self.browse_shortcut_enabled = enabled;
126        self
127    }
128
129    /// Set how validation feedback is displayed (builder pattern)
130    ///
131    /// Controls whether path coloring and/or explanation messages are shown.
132    /// Default is `ValidationDisplay::Full` (show both).
133    #[must_use]
134    pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
135        self.validation_display = display;
136        self
137    }
138
139    /// Set minimum number of entries (default: 1)
140    #[must_use]
141    pub fn min_entries(mut self, min: usize) -> Self {
142        self.min_entries = min.max(1);
143        self
144    }
145
146    /// Set a custom theme for this widget
147    #[must_use]
148    pub fn theme(mut self, theme: Theme) -> Self {
149        self.custom_theme = Some(theme);
150        self
151    }
152
153    /// Set enabled state (builder pattern)
154    #[must_use]
155    pub fn with_enabled(mut self, enabled: bool) -> Self {
156        self.enabled = enabled;
157        self
158    }
159
160    /// Check if the widget is enabled
161    pub fn is_enabled(&self) -> bool {
162        self.enabled
163    }
164
165    /// Set enabled state programmatically
166    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
167        if self.enabled != enabled {
168            self.enabled = enabled;
169            // Update child entries
170            for entry in &self.entries {
171                entry.update(cx, |e, cx| e.set_enabled(enabled, cx));
172            }
173            cx.notify();
174        }
175    }
176
177    /// Get all non-empty values
178    pub fn values(&self, cx: &App) -> Vec<String> {
179        self.entries
180            .iter()
181            .map(|entry| entry.read(cx).value().to_string())
182            .filter(|s| !s.is_empty())
183            .collect()
184    }
185
186    /// Get access to the underlying DirectoryPicker entries
187    pub fn entries(&self) -> &[Entity<DirectoryPicker>] {
188        &self.entries
189    }
190
191    /// Validate all entries and return their validation states
192    pub fn validate_all(&self, cx: &App) -> Vec<DirectoryPickerValidation> {
193        self.entries
194            .iter()
195            .map(|entry| entry.read(cx).validate())
196            .collect()
197    }
198
199    /// Returns true if all non-empty entries are valid
200    pub fn is_all_valid(&self, cx: &App) -> bool {
201        self.entries
202            .iter()
203            .all(|entry| {
204                let picker = entry.read(cx);
205                picker.value().is_empty() || picker.is_valid()
206            })
207    }
208
209    /// Create a new DirectoryPicker entry with current configuration
210    fn create_entry(&self, value: Option<&str>, cx: &mut Context<Self>) -> Entity<DirectoryPicker> {
211        let placeholder = self.placeholder.clone();
212        let browse_shortcut_enabled = self.browse_shortcut_enabled;
213        let validation_display = self.validation_display.clone();
214        let theme = self.custom_theme;
215        let enabled = self.enabled;
216
217        let picker = cx.new(|cx| {
218            let mut p = DirectoryPicker::new(cx)
219                .browse_shortcut(browse_shortcut_enabled)
220                .validation_display(validation_display)
221                .with_enabled(enabled);
222
223            if let Some(ph) = placeholder {
224                p = p.placeholder(ph);
225            }
226            if let Some(th) = theme {
227                p = p.theme(th);
228            }
229            p
230        });
231
232        if let Some(v) = value.filter(|s| !s.is_empty()) {
233            picker.update(cx, |p, cx| {
234                p.set_value(v, cx);
235            });
236        }
237
238        // Subscribe to changes to emit our own change events
239        cx.subscribe(&picker, |this, _picker, event: &DirectoryPickerEvent, cx| {
240            let DirectoryPickerEvent::Change(_) = event;
241            let values = this.values(cx);
242            cx.emit(RepeatableDirectoryPickerEvent::Change(values));
243        }).detach();
244
245        picker
246    }
247
248    /// Initialize entries from initial values (called during first render)
249    fn initialize_entries(&mut self, cx: &mut Context<Self>) {
250        if self.initialized {
251            return;
252        }
253        self.initialized = true;
254
255        let values = std::mem::take(&mut self.initial_values);
256        let values = if values.is_empty() {
257            vec![String::new(); self.min_entries]
258        } else if values.len() < self.min_entries {
259            let mut v = values;
260            v.resize(self.min_entries, String::new());
261            v
262        } else {
263            values
264        };
265
266        for value in values {
267            let entry = self.create_entry(Some(&value), cx);
268            self.entries.push(entry);
269            self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
270        }
271    }
272
273    fn add_entry(&mut self, cx: &mut Context<Self>) {
274        let index = self.entries.len();
275        let entry = self.create_entry(None, cx);
276        self.entries.push(entry);
277        self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
278        cx.emit(RepeatableDirectoryPickerEvent::EntryAdded(index));
279        cx.emit(RepeatableDirectoryPickerEvent::Change(self.values(cx)));
280        cx.notify();
281    }
282
283    fn remove_entry(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
284        if self.entries.len() > self.min_entries && index < self.entries.len() {
285            // Check if the remove button being pressed has focus
286            let had_focus = self.remove_focus_handles[index].is_focused(window);
287
288            self.entries.remove(index);
289            self.remove_focus_handles.remove(index);
290
291            // Move focus if the removed button had focus
292            if had_focus {
293                if self.entries.len() <= self.min_entries {
294                    // No more "-" buttons visible, focus the first entry's picker
295                    self.entries[0].update(cx, |picker, cx| picker.focus(cx));
296                } else if index > 0 {
297                    // Focus the previous entry's "-" button
298                    self.remove_focus_handles[index - 1].focus(window);
299                } else {
300                    // Removed first entry, focus the new first entry's "-" button
301                    self.remove_focus_handles[0].focus(window);
302                }
303            }
304
305            cx.emit(RepeatableDirectoryPickerEvent::EntryRemoved(index));
306            cx.emit(RepeatableDirectoryPickerEvent::Change(self.values(cx)));
307            cx.notify();
308        }
309    }
310
311    fn get_theme(&self, cx: &App) -> Theme {
312        self.custom_theme.unwrap_or_else(|| get_theme(cx))
313    }
314}
315
316impl EventEmitter<RepeatableDirectoryPickerEvent> for RepeatableDirectoryPicker {}
317
318impl Render for RepeatableDirectoryPicker {
319    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
320        // Initialize entries on first render
321        self.initialize_entries(cx);
322
323        let theme = self.get_theme(cx);
324        let entries_count = self.entries.len();
325        let can_remove = entries_count > self.min_entries;
326        let add_focused = self.add_focus_handle.is_focused(window);
327        let enabled = self.enabled;
328
329        // Collect entries with their remove button focus handles
330        let entry_data: Vec<_> = self.entries.iter()
331            .zip(self.remove_focus_handles.iter())
332            .enumerate()
333            .map(|(index, (entry, focus_handle))| {
334                let is_focused = focus_handle.is_focused(window);
335                (index, entry.clone(), focus_handle.clone(), is_focused)
336            })
337            .collect();
338
339        div()
340            .flex()
341            .flex_col()
342            .gap_2()
343            .child(
344                // Entries list
345                div()
346                    .flex()
347                    .flex_col()
348                    .gap_2()
349                    .children(entry_data.into_iter().map(|(index, entry, focus_handle, is_focused)| {
350                        let remove_button = repeatable_remove_button(
351                            format!("dir_remove_{}", index),
352                            &focus_handle,
353                            &theme,
354                            enabled,
355                            is_focused,
356                            // on_action: set flag, then perform action
357                            move |this: &mut Self, window, cx| {
358                                this.action_just_handled = true;
359                                this.remove_entry(index, window, cx);
360                            },
361                            // on_click: skip if action just handled, otherwise perform action
362                            move |this: &mut Self, window, cx| {
363                                if this.action_just_handled {
364                                    this.action_just_handled = false;
365                                    return;
366                                }
367                                this.remove_entry(index, window, cx);
368                            },
369                            cx,
370                        );
371
372                        div()
373                            .flex()
374                            .flex_row()
375                            .items_center()
376                            .gap_2()
377                            .child(
378                                // DirectoryPicker widget
379                                div()
380                                    .flex_1()
381                                    .child(entry)
382                            )
383                            .when(can_remove, |d| d.child(remove_button))
384                    }))
385            )
386            .child(
387                // Add button row
388                div()
389                    .flex()
390                    .flex_row()
391                    .child(
392                        repeatable_add_button(
393                            "repeatable_dir_add_button",
394                            &self.add_focus_handle,
395                            &theme,
396                            enabled,
397                            add_focused,
398                            // on_action: set flag, then perform action
399                            |this: &mut Self, _window, cx| {
400                                this.action_just_handled = true;
401                                this.add_entry(cx);
402                            },
403                            // on_click: skip if action just handled, otherwise perform action
404                            |this: &mut Self, _window, cx| {
405                                if this.action_just_handled {
406                                    this.action_just_handled = false;
407                                    return;
408                                }
409                                this.add_entry(cx);
410                            },
411                            cx,
412                        )
413                    )
414            )
415    }
416}