ccf_gpui_widgets/widgets/
repeatable_file_picker.rs1use gpui::prelude::*;
37use gpui::*;
38use std::path::PathBuf;
39use crate::theme::{get_theme, Theme};
40use super::file_picker::{
41 FilePicker, FilePickerEvent, FileMode, MissingDirectories,
42 FilePickerValidation, ValidationDisplay,
43};
44use super::focus_navigation::{repeatable_add_button, repeatable_remove_button};
45
46#[derive(Debug, Clone)]
48pub enum RepeatableFilePickerEvent {
49 Change(Vec<String>),
51 EntryAdded(usize),
53 EntryRemoved(usize),
55}
56
57pub struct RepeatableFilePicker {
61 placeholder: Option<SharedString>,
63 extensions: Vec<String>,
64 mode: FileMode,
65 missing_directories: MissingDirectories,
66 browse_shortcut_enabled: bool,
67 validation_display: ValidationDisplay,
68
69 initial_values: Vec<String>,
71 entries: Vec<Entity<FilePicker>>,
73 remove_focus_handles: Vec<FocusHandle>,
75 add_focus_handle: FocusHandle,
77 initialized: bool,
79 min_entries: usize,
81 custom_theme: Option<Theme>,
82 enabled: bool,
84 action_just_handled: bool,
87}
88
89impl RepeatableFilePicker {
90 pub fn new(cx: &mut Context<Self>) -> Self {
92 Self {
93 placeholder: None,
94 extensions: Vec::new(),
95 mode: FileMode::Open,
96 missing_directories: MissingDirectories::Error,
97 browse_shortcut_enabled: true,
98 validation_display: ValidationDisplay::default(),
99 initial_values: Vec::new(),
100 entries: Vec::new(),
101 remove_focus_handles: Vec::new(),
102 add_focus_handle: cx.focus_handle().tab_stop(true),
103 initialized: false,
104 min_entries: 1,
105 custom_theme: None,
106 enabled: true,
107 action_just_handled: false,
108 }
109 }
110
111 #[must_use]
113 pub fn with_values(mut self, values: Vec<String>) -> Self {
114 self.initial_values = values;
115 self
116 }
117
118 #[must_use]
120 pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
121 self.placeholder = Some(text.into());
122 self
123 }
124
125 #[must_use]
127 pub fn extensions(mut self, exts: Vec<String>) -> Self {
128 self.extensions = exts;
129 self
130 }
131
132 #[must_use]
134 pub fn mode(mut self, mode: FileMode) -> Self {
135 self.mode = mode;
136 self
137 }
138
139 #[must_use]
141 pub fn missing_directories(mut self, handling: MissingDirectories) -> Self {
142 self.missing_directories = handling;
143 self
144 }
145
146 #[must_use]
151 pub fn browse_shortcut(mut self, enabled: bool) -> Self {
152 self.browse_shortcut_enabled = enabled;
153 self
154 }
155
156 #[must_use]
161 pub fn validation_display(mut self, display: ValidationDisplay) -> Self {
162 self.validation_display = display;
163 self
164 }
165
166 #[must_use]
168 pub fn min_entries(mut self, min: usize) -> Self {
169 self.min_entries = min.max(1);
170 self
171 }
172
173 #[must_use]
175 pub fn theme(mut self, theme: Theme) -> Self {
176 self.custom_theme = Some(theme);
177 self
178 }
179
180 #[must_use]
182 pub fn with_enabled(mut self, enabled: bool) -> Self {
183 self.enabled = enabled;
184 self
185 }
186
187 pub fn is_enabled(&self) -> bool {
189 self.enabled
190 }
191
192 pub fn set_enabled(&mut self, enabled: bool, cx: &mut Context<Self>) {
194 if self.enabled != enabled {
195 self.enabled = enabled;
196 for entry in &self.entries {
198 entry.update(cx, |e, cx| e.set_enabled(enabled, cx));
199 }
200 cx.notify();
201 }
202 }
203
204 pub fn values(&self, cx: &App) -> Vec<String> {
206 self.entries
207 .iter()
208 .map(|entry| entry.read(cx).value().to_string())
209 .filter(|s| !s.is_empty())
210 .collect()
211 }
212
213 pub fn entries(&self) -> &[Entity<FilePicker>] {
215 &self.entries
216 }
217
218 pub fn validate_all(&self, cx: &App) -> Vec<FilePickerValidation> {
220 self.entries
221 .iter()
222 .map(|entry| entry.read(cx).validate())
223 .collect()
224 }
225
226 pub fn is_all_valid(&self, cx: &App) -> bool {
228 self.entries
229 .iter()
230 .all(|entry| {
231 let picker = entry.read(cx);
232 picker.value().is_empty() || picker.is_valid()
233 })
234 }
235
236 pub fn directories_to_create(&self, cx: &App) -> Vec<PathBuf> {
238 self.entries
239 .iter()
240 .filter_map(|entry| entry.read(cx).directory_to_create())
241 .collect()
242 }
243
244 fn create_entry(&self, value: Option<&str>, cx: &mut Context<Self>) -> Entity<FilePicker> {
246 let placeholder = self.placeholder.clone();
247 let extensions = self.extensions.clone();
248 let mode = self.mode.clone();
249 let missing_directories = self.missing_directories.clone();
250 let browse_shortcut_enabled = self.browse_shortcut_enabled;
251 let validation_display = self.validation_display.clone();
252 let theme = self.custom_theme;
253 let enabled = self.enabled;
254
255 let picker = cx.new(|cx| {
256 let mut p = FilePicker::new(cx)
257 .mode(mode)
258 .extensions(extensions)
259 .missing_directories(missing_directories)
260 .browse_shortcut(browse_shortcut_enabled)
261 .validation_display(validation_display)
262 .with_enabled(enabled);
263
264 if let Some(ph) = placeholder {
265 p = p.placeholder(ph);
266 }
267 if let Some(th) = theme {
268 p = p.theme(th);
269 }
270 p
271 });
272
273 if let Some(v) = value.filter(|s| !s.is_empty()) {
274 picker.update(cx, |p, cx| {
275 p.set_value(v, cx);
276 });
277 }
278
279 cx.subscribe(&picker, |this, _picker, event: &FilePickerEvent, cx| {
281 let FilePickerEvent::Change(_) = event;
282 let values = this.values(cx);
283 cx.emit(RepeatableFilePickerEvent::Change(values));
284 }).detach();
285
286 picker
287 }
288
289 fn initialize_entries(&mut self, cx: &mut Context<Self>) {
291 if self.initialized {
292 return;
293 }
294 self.initialized = true;
295
296 let values = std::mem::take(&mut self.initial_values);
297 let values = if values.is_empty() {
298 vec![String::new(); self.min_entries]
299 } else if values.len() < self.min_entries {
300 let mut v = values;
301 v.resize(self.min_entries, String::new());
302 v
303 } else {
304 values
305 };
306
307 for value in values {
308 let entry = self.create_entry(Some(&value), cx);
309 self.entries.push(entry);
310 self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
311 }
312 }
313
314 fn add_entry(&mut self, cx: &mut Context<Self>) {
315 let index = self.entries.len();
316 let entry = self.create_entry(None, cx);
317 self.entries.push(entry);
318 self.remove_focus_handles.push(cx.focus_handle().tab_stop(true));
319 cx.emit(RepeatableFilePickerEvent::EntryAdded(index));
320 cx.emit(RepeatableFilePickerEvent::Change(self.values(cx)));
321 cx.notify();
322 }
323
324 fn remove_entry(&mut self, index: usize, window: &mut Window, cx: &mut Context<Self>) {
325 if self.entries.len() > self.min_entries && index < self.entries.len() {
326 let had_focus = self.remove_focus_handles[index].is_focused(window);
328
329 self.entries.remove(index);
330 self.remove_focus_handles.remove(index);
331
332 if had_focus {
334 if self.entries.len() <= self.min_entries {
335 self.entries[0].update(cx, |picker, cx| picker.focus(cx));
337 } else if index > 0 {
338 self.remove_focus_handles[index - 1].focus(window);
340 } else {
341 self.remove_focus_handles[0].focus(window);
343 }
344 }
345
346 cx.emit(RepeatableFilePickerEvent::EntryRemoved(index));
347 cx.emit(RepeatableFilePickerEvent::Change(self.values(cx)));
348 cx.notify();
349 }
350 }
351
352 fn get_theme(&self, cx: &App) -> Theme {
353 self.custom_theme.unwrap_or_else(|| get_theme(cx))
354 }
355}
356
357impl EventEmitter<RepeatableFilePickerEvent> for RepeatableFilePicker {}
358
359impl Render for RepeatableFilePicker {
360 fn render(&mut self, window: &mut Window, cx: &mut Context<'_, Self>) -> impl IntoElement {
361 self.initialize_entries(cx);
363
364 let theme = self.get_theme(cx);
365 let entries_count = self.entries.len();
366 let can_remove = entries_count > self.min_entries;
367 let add_focused = self.add_focus_handle.is_focused(window);
368 let enabled = self.enabled;
369
370 let entry_data: Vec<_> = self.entries.iter()
372 .zip(self.remove_focus_handles.iter())
373 .enumerate()
374 .map(|(index, (entry, focus_handle))| {
375 let is_focused = focus_handle.is_focused(window);
376 (index, entry.clone(), focus_handle.clone(), is_focused)
377 })
378 .collect();
379
380 div()
381 .flex()
382 .flex_col()
383 .gap_2()
384 .child(
385 div()
387 .flex()
388 .flex_col()
389 .gap_2()
390 .children(entry_data.into_iter().map(|(index, entry, focus_handle, is_focused)| {
391 let remove_button = repeatable_remove_button(
392 format!("file_remove_{}", index),
393 &focus_handle,
394 &theme,
395 enabled,
396 is_focused,
397 move |this: &mut Self, window, cx| {
399 this.action_just_handled = true;
400 this.remove_entry(index, window, cx);
401 },
402 move |this: &mut Self, window, cx| {
404 if this.action_just_handled {
405 this.action_just_handled = false;
406 return;
407 }
408 this.remove_entry(index, window, cx);
409 },
410 cx,
411 );
412
413 div()
414 .flex()
415 .flex_row()
416 .items_center()
417 .gap_2()
418 .child(
419 div()
421 .flex_1()
422 .child(entry)
423 )
424 .when(can_remove, |d| d.child(remove_button))
425 }))
426 )
427 .child(
428 div()
430 .flex()
431 .flex_row()
432 .child(
433 repeatable_add_button(
434 "repeatable_file_add_button",
435 &self.add_focus_handle,
436 &theme,
437 enabled,
438 add_focused,
439 |this: &mut Self, _window, cx| {
441 this.action_just_handled = true;
442 this.add_entry(cx);
443 },
444 |this: &mut Self, _window, cx| {
446 if this.action_just_handled {
447 this.action_just_handled = false;
448 return;
449 }
450 this.add_entry(cx);
451 },
452 cx,
453 )
454 )
455 )
456 }
457}