Skip to main content

ccf_gpui_widgets/widgets/
repeatable_text_input.rs

1//! Repeatable text input widget - allows multiple values with add/remove buttons
2//!
3//! A widget that manages a list of text inputs, allowing users to add and remove entries.
4//! Useful for inputs that accept multiple values like tags, email addresses, etc.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use ccf_gpui_widgets::widgets::{RepeatableTextInput, RepeatableTextInputEvent};
10//!
11//! let input = cx.new(|cx| {
12//!     RepeatableTextInput::new(cx)
13//!         .with_values(vec!["value1".to_string(), "value2".to_string()])
14//!         .placeholder("Enter value")
15//!         .min_entries(1)
16//! });
17//!
18//! cx.subscribe(&input, |this, _, event: &RepeatableTextInputEvent, cx| {
19//!     match event {
20//!         RepeatableTextInputEvent::Change(values) => {
21//!             println!("Values: {:?}", values);
22//!         }
23//!         RepeatableTextInputEvent::EntryAdded(index) => {
24//!             println!("Added entry at index {}", index);
25//!         }
26//!         RepeatableTextInputEvent::EntryRemoved(index) => {
27//!             println!("Removed entry at index {}", index);
28//!         }
29//!     }
30//! }).detach();
31//! ```
32
33use gpui::prelude::*;
34use gpui::*;
35use crate::theme::{get_theme, Theme};
36use super::text_input::{TextInput, TextInputEvent};
37use super::focus_navigation::{repeatable_add_button, repeatable_remove_button};
38
39// Actions for button activation
40actions!(ccf_repeatable_text_input, [ActivateButton]);
41
42/// Register key bindings for repeatable text input buttons
43///
44/// Call this once at application startup:
45/// ```ignore
46/// ccf_gpui_widgets::widgets::repeatable_text_input::register_keybindings(cx);
47/// ```
48pub fn register_keybindings(cx: &mut App) {
49    cx.bind_keys([
50        KeyBinding::new("enter", ActivateButton, Some("CcfRepeatableButton")),
51        KeyBinding::new("space", ActivateButton, Some("CcfRepeatableButton")),
52    ]);
53}
54
55/// Events emitted by RepeatableTextInput
56#[derive(Debug, Clone)]
57pub enum RepeatableTextInputEvent {
58    /// Values changed (includes all current values)
59    Change(Vec<String>),
60    /// A new entry was added at the given index
61    EntryAdded(usize),
62    /// An entry was removed from the given index
63    EntryRemoved(usize),
64}
65
66/// Repeatable text input widget
67///
68/// Manages a dynamic list of text inputs with add/remove buttons.
69pub struct RepeatableTextInput {
70    placeholder: Option<SharedString>,
71    /// Initial values to populate on first render
72    initial_values: Vec<String>,
73    /// Each entry is a TextInput entity
74    entries: Vec<Entity<TextInput>>,
75    /// Focus handles for remove buttons (one per entry)
76    remove_focus_handles: Vec<FocusHandle>,
77    /// Focus handle for the add button
78    add_focus_handle: FocusHandle,
79    /// Whether entries have been initialized
80    initialized: bool,
81    /// Minimum number of entries (cannot remove below this)
82    min_entries: usize,
83    custom_theme: Option<Theme>,
84    /// Whether the widget is enabled (interactive)
85    enabled: bool,
86    /// Prevents double-trigger when Space/Enter activates both on_action and on_click.
87    /// See focus_navigation.rs module comment for details. DO NOT REMOVE.
88    action_just_handled: bool,
89}
90
91impl RepeatableTextInput {
92    /// Create a new repeatable text input
93    pub fn new(cx: &mut Context<Self>) -> Self {
94        Self {
95            placeholder: None,
96            initial_values: Vec::new(),
97            entries: Vec::new(),
98            remove_focus_handles: Vec::new(),
99            add_focus_handle: cx.focus_handle().tab_stop(true),
100            initialized: false,
101            min_entries: 1,
102            custom_theme: None,
103            enabled: true,
104            action_just_handled: false,
105        }
106    }
107
108    /// Set initial values
109    #[must_use]
110    pub fn with_values(mut self, values: Vec<String>) -> Self {
111        self.initial_values = values;
112        self
113    }
114
115    /// Set placeholder text for each input
116    #[must_use]
117    pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
118        self.placeholder = Some(text.into());
119        self
120    }
121
122    /// Set minimum number of entries (default: 1)
123    ///
124    /// Users cannot remove entries below this count.
125    #[must_use]
126    pub fn min_entries(mut self, min: usize) -> Self {
127        self.min_entries = min.max(1); // At least 1
128        self
129    }
130
131    /// Set a custom theme for this widget
132    #[must_use]
133    pub fn theme(mut self, theme: Theme) -> Self {
134        self.custom_theme = Some(theme);
135        self
136    }
137
138    /// Set enabled state (builder pattern)
139    #[must_use]
140    pub fn with_enabled(mut self, enabled: bool) -> Self {
141        self.enabled = enabled;
142        self
143    }
144
145    /// Check if the widget is enabled
146    pub fn is_enabled(&self) -> bool {
147        self.enabled
148    }
149
150    /// Set enabled state programmatically
151    pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
152        if self.enabled != enabled {
153            self.enabled = enabled;
154            // Update child entries
155            for entry in &self.entries {
156                entry.update(cx, |e, cx| e.set_enabled(enabled, cx));
157            }
158            cx.notify();
159        }
160    }
161
162    /// Get all non-empty values
163    pub fn values(&self, cx: &App) -> Vec<String> {
164        self.entries
165            .iter()
166            .map(|entry| entry.read(cx).content().to_string())
167            .filter(|s| !s.is_empty())
168            .collect()
169    }
170
171    /// Create a new text input entry
172    fn create_entry(&self, value: Option<&str>, cx: &mut Context<Self>) -> Entity<TextInput> {
173        let placeholder = self.placeholder.clone();
174        let enabled = self.enabled;
175        let entry = cx.new(|cx| {
176            let mut state = TextInput::new(cx).with_enabled(enabled);
177            if let Some(ph) = placeholder {
178                state = state.placeholder(ph);
179            }
180            state
181        });
182
183        if let Some(v) = value.filter(|s| !s.is_empty()) {
184            entry.update(cx, |state, cx| {
185                state.set_value(v, cx);
186            });
187        }
188
189        // Subscribe to changes to emit our own change events
190        cx.subscribe(&entry, |this, _input, event: &TextInputEvent, cx| {
191            if matches!(event, TextInputEvent::Change) {
192                // Re-read all values and emit change
193                let values = this.values(cx);
194                cx.emit(RepeatableTextInputEvent::Change(values));
195            }
196        }).detach();
197
198        entry
199    }
200
201    /// Initialize entries from initial values (called during first render)
202    fn initialize_entries(&mut self, cx: &mut Context<Self>) {
203        if self.initialized {
204            return;
205        }
206        self.initialized = true;
207
208        let values = std::mem::take(&mut self.initial_values);
209        // Always have at least min_entries entries
210        let values = if values.is_empty() {
211            vec![String::new(); self.min_entries]
212        } else if values.len() < self.min_entries {
213            let mut v = values;
214            v.resize(self.min_entries, String::new());
215            v
216        } else {
217            values
218        };
219
220        for value in values {
221            let entry = self.create_entry(Some(&value), cx);
222            self.entries.push(entry);
223            // Create a focus handle for the remove button
224            self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
225        }
226    }
227
228    fn add_entry(&mut self, cx: &mut Context<Self>) {
229        let index = self.entries.len();
230        let entry = self.create_entry(None, cx);
231        self.entries.push(entry);
232        // Create a focus handle for the new remove button
233        self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
234        cx.emit(RepeatableTextInputEvent::EntryAdded(index));
235        cx.emit(RepeatableTextInputEvent::Change(self.values(cx)));
236        cx.notify();
237    }
238
239    fn remove_entry(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
240        if self.entries.len() > self.min_entries && index < self.entries.len() {
241            // Check if the remove button being pressed has focus
242            let had_focus = self.remove_focus_handles[index].is_focused(window);
243
244            self.entries.remove(index);
245            self.remove_focus_handles.remove(index);
246
247            // Move focus if the removed button had focus
248            if had_focus {
249                if self.entries.len() <= self.min_entries {
250                    // No more "-" buttons visible, focus the first entry's input
251                    self.entries[0].read(cx).focus_handle().focus(window);
252                } else if index > 0 {
253                    // Focus the previous entry's "-" button
254                    self.remove_focus_handles[index - 1].focus(window);
255                } else {
256                    // Removed first entry, focus the new first entry's "-" button
257                    self.remove_focus_handles[0].focus(window);
258                }
259            }
260
261            cx.emit(RepeatableTextInputEvent::EntryRemoved(index));
262            cx.emit(RepeatableTextInputEvent::Change(self.values(cx)));
263            cx.notify();
264        }
265    }
266
267    fn get_theme(&self, cx: &App) -> Theme {
268        self.custom_theme.unwrap_or_else(|| get_theme(cx))
269    }
270}
271
272impl EventEmitter<RepeatableTextInputEvent> for RepeatableTextInput {}
273
274impl Render for RepeatableTextInput {
275    fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
276        // Initialize entries on first render
277        self.initialize_entries(cx);
278
279        let theme = self.get_theme(cx);
280        let entries_count = self.entries.len();
281        let can_remove = entries_count > self.min_entries;
282        let add_focused = self.add_focus_handle.is_focused(window);
283        let enabled = self.enabled;
284
285        // Collect entries with their remove button focus handles
286        let entry_data: Vec<_> = self.entries.iter()
287            .zip(self.remove_focus_handles.iter())
288            .enumerate()
289            .map(|(index, (entry, focus_handle))| {
290                let is_focused = focus_handle.is_focused(window);
291                (index, entry.clone(), focus_handle.clone(), is_focused)
292            })
293            .collect();
294
295        div()
296            .flex()
297            .flex_col()
298            .gap_2()
299            .child(
300                // Entries list
301                div()
302                    .flex()
303                    .flex_col()
304                    .gap_2()
305                    .children(entry_data.into_iter().map(|(index, entry, focus_handle, is_focused)| {
306                        let remove_button = repeatable_remove_button(
307                            format!("repeatable_remove_{}", index),
308                            &focus_handle,
309                            &theme,
310                            enabled,
311                            is_focused,
312                            // on_action: set flag, then perform action
313                            move |this: &mut Self, window, cx| {
314                                this.action_just_handled = true;
315                                this.remove_entry(index, window, cx);
316                            },
317                            // on_click: skip if action just handled, otherwise perform action
318                            move |this: &mut Self, window, cx| {
319                                if this.action_just_handled {
320                                    this.action_just_handled = false;
321                                    return;
322                                }
323                                this.remove_entry(index, window, cx);
324                            },
325                            cx,
326                        );
327
328                        div()
329                            .flex()
330                            .flex_row()
331                            .items_center()
332                            .gap_2()
333                            .child(
334                                // Input field
335                                div()
336                                    .flex_1()
337                                    .child(entry)
338                            )
339                            .when(can_remove, |d| d.child(remove_button))
340                    }))
341            )
342            .child(
343                // Add button row - use flex to align left
344                div()
345                    .flex()
346                    .flex_row()
347                    .child(
348                        repeatable_add_button(
349                            "repeatable_add_button",
350                            &self.add_focus_handle,
351                            &theme,
352                            enabled,
353                            add_focused,
354                            // on_action: set flag, then perform action
355                            |this: &mut Self, _window, cx| {
356                                this.action_just_handled = true;
357                                this.add_entry(cx);
358                            },
359                            // on_click: skip if action just handled, otherwise perform action
360                            |this: &mut Self, _window, cx| {
361                                if this.action_just_handled {
362                                    this.action_just_handled = false;
363                                    return;
364                                }
365                                this.add_entry(cx);
366                            },
367                            cx,
368                        )
369                    )
370            )
371    }
372}