Skip to main content

guise/input/
fileinput.rs

1//! `FileInput` — a stateful file-picking field (gpui entity).
2//!
3//! A field-styled trigger that opens the platform file dialog and holds the
4//! chosen paths. Emits [`FileInputEvent`] on every selection change (an empty
5//! vec means cleared).
6
7use std::path::PathBuf;
8
9use gpui::prelude::*;
10use gpui::{
11  div, px, Context, EventEmitter, FocusHandle, IntoElement, PathPromptOptions, SharedString, Window,
12};
13
14use super::accept::{filter_paths, normalize_ext};
15use super::control_metrics;
16use crate::devtools::Probed;
17use crate::icon::{Icon, IconName};
18use crate::style::TextOverflowExt;
19use crate::theme::{theme, Size};
20
21/// Emitted when the selection changes. Empty means cleared.
22#[derive(Debug, Clone)]
23pub struct FileInputEvent(pub Vec<PathBuf>);
24
25/// A file-picker field. Create with `cx.new(|cx| FileInput::new(cx))`.
26pub struct FileInput {
27  focus: FocusHandle,
28  paths: Vec<PathBuf>,
29  multiple: bool,
30  directories: bool,
31  accept: Vec<String>,
32  placeholder: SharedString,
33  label: Option<SharedString>,
34  size: Size,
35  disabled: bool,
36}
37
38impl EventEmitter<FileInputEvent> for FileInput {}
39
40impl FileInput {
41  pub fn new(cx: &mut Context<Self>) -> Self {
42    FileInput {
43      focus: cx.focus_handle(),
44      paths: Vec::new(),
45      multiple: false,
46      directories: false,
47      accept: Vec::new(),
48      placeholder: SharedString::new_static("Choose a file"),
49      label: None,
50      size: Size::Sm,
51      disabled: false,
52    }
53  }
54
55  pub fn multiple(mut self) -> Self {
56    self.multiple = true;
57    self.placeholder = SharedString::new_static("Choose files");
58    self
59  }
60
61  /// Pick directories instead of files.
62  pub fn directories(mut self) -> Self {
63    self.directories = true;
64    self.placeholder = SharedString::new_static("Choose a folder");
65    self
66  }
67
68  /// Allowed extensions ("png", ".jpg", case-insensitive). Empty = any.
69  pub fn accept<I, S>(mut self, entries: I) -> Self
70  where
71    I: IntoIterator<Item = S>,
72    S: AsRef<str>,
73  {
74    self.accept = entries
75      .into_iter()
76      .map(|e| normalize_ext(e.as_ref()))
77      .collect();
78    self
79  }
80
81  pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
82    self.placeholder = placeholder.into();
83    self
84  }
85
86  pub fn label(mut self, label: impl Into<SharedString>) -> Self {
87    self.label = Some(label.into());
88    self
89  }
90
91  pub fn size(mut self, size: Size) -> Self {
92    self.size = size;
93    self
94  }
95
96  pub fn disabled(mut self, disabled: bool) -> Self {
97    self.disabled = disabled;
98    self
99  }
100
101  pub fn paths(&self) -> &[PathBuf] {
102    &self.paths
103  }
104
105  fn browse(&mut self, cx: &mut Context<Self>) {
106    let receiver = cx.prompt_for_paths(PathPromptOptions {
107      files: !self.directories,
108      directories: self.directories,
109      multiple: self.multiple,
110      prompt: None,
111    });
112    cx.spawn(async move |this, cx| {
113      if let Ok(Ok(Some(paths))) = receiver.await {
114        this.update(cx, |input, cx| input.set_paths(paths, cx)).ok();
115      }
116    })
117    .detach();
118  }
119
120  fn set_paths(&mut self, paths: Vec<PathBuf>, cx: &mut Context<Self>) {
121    let kept = filter_paths(paths, &self.accept);
122    if kept.is_empty() {
123      return;
124    }
125    self.paths = kept;
126    cx.emit(FileInputEvent(self.paths.clone()));
127    cx.notify();
128  }
129
130  fn clear(&mut self, cx: &mut Context<Self>) {
131    if !self.paths.is_empty() {
132      self.paths.clear();
133      cx.emit(FileInputEvent(Vec::new()));
134      cx.notify();
135    }
136  }
137
138  fn shown_text(&self) -> Option<SharedString> {
139    match self.paths.as_slice() {
140      [] => None,
141      [single] => Some(
142        single
143          .file_name()
144          .map(|n| n.to_string_lossy().into_owned())
145          .unwrap_or_else(|| single.display().to_string())
146          .into(),
147      ),
148      many => Some(format!("{} files", many.len()).into()),
149    }
150  }
151}
152
153impl Render for FileInput {
154  fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
155    let t = theme(cx);
156    let (height, pad_x, font) = control_metrics(self.size);
157    let radius = t.radius(t.default_radius);
158    let surface = t.surface().hsla();
159    let surface_hover = t.surface_hover().hsla();
160    let border = t.border().hsla();
161    let text_color = t.text().hsla();
162    let dimmed = t.dimmed().hsla();
163    let font_sm = t.font_size(Size::Sm);
164
165    let shown = self.shown_text();
166    let has_value = shown.is_some();
167    let value_text = shown.unwrap_or_else(|| self.placeholder.clone());
168
169    let mut trigger = div()
170      .id("guise-fileinput-trigger")
171      .track_focus(&self.focus)
172      .flex()
173      .items_center()
174      .gap(px(8.0))
175      .h(px(height))
176      .px(px(pad_x))
177      .rounded(px(radius))
178      .border_1()
179      .border_color(border)
180      .bg(surface)
181      .text_size(px(font))
182      .text_color(if has_value { text_color } else { dimmed })
183      .hover(move |s| s.bg(surface_hover))
184      .child(
185        div()
186          .flex_none()
187          .text_color(dimmed)
188          .child(Icon::new(IconName::Paperclip).size(Size::Sm)),
189      )
190      .child(div().flex_1().truncate_text().child(value_text))
191      .on_click(cx.listener(|this, _ev, _window, cx| {
192        if !this.disabled {
193          this.browse(cx);
194        }
195      }));
196
197    if has_value {
198      trigger = trigger.child(
199        div()
200          .id("guise-fileinput-clear")
201          .flex()
202          .flex_none()
203          .items_center()
204          .text_color(dimmed)
205          .child(Icon::new(IconName::X).size(Size::Xs))
206          .on_click(cx.listener(|this, _ev, _window, cx| {
207            // The trigger sits underneath; don't reopen the dialog.
208            cx.stop_propagation();
209            this.clear(cx);
210          })),
211      );
212    }
213
214    let mut column = div().flex().flex_col().min_w(px(0.0)).gap(px(4.0));
215    if let Some(label) = self.label.clone() {
216      column = column.child(
217        div()
218          .min_w(px(0.0))
219          .text_size(px(font_sm))
220          .text_color(text_color)
221          .child(label),
222      );
223    }
224    column = column.child(trigger);
225
226    let element = if self.disabled {
227      column.opacity(0.6)
228    } else {
229      column
230    };
231
232    element.probe("FileInput")
233  }
234}