1use 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#[derive(Debug, Clone)]
23pub struct FileInputEvent(pub Vec<PathBuf>);
24
25pub 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 pub fn directories(mut self) -> Self {
63 self.directories = true;
64 self.placeholder = SharedString::new_static("Choose a folder");
65 self
66 }
67
68 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 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}