1use std::path::PathBuf;
8
9use gpui::prelude::*;
10use gpui::{
11 div, px, Context, EventEmitter, FocusHandle, IntoElement, PathPromptOptions, SharedString,
12 Window,
13};
14
15use super::accept::{filter_paths, normalize_ext};
16use super::control_metrics;
17use crate::devtools::Probed;
18use crate::icon::{Icon, IconName};
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 .text_color(dimmed)
187 .child(Icon::new(IconName::Paperclip).size(Size::Sm)),
188 )
189 .child(div().flex_1().truncate().child(value_text))
190 .on_click(cx.listener(|this, _ev, _window, cx| {
191 if !this.disabled {
192 this.browse(cx);
193 }
194 }));
195
196 if has_value {
197 trigger = trigger.child(
198 div()
199 .id("guise-fileinput-clear")
200 .flex()
201 .items_center()
202 .text_color(dimmed)
203 .child(Icon::new(IconName::X).size(Size::Xs))
204 .on_click(cx.listener(|this, _ev, _window, cx| {
205 cx.stop_propagation();
207 this.clear(cx);
208 })),
209 );
210 }
211
212 let mut column = div().flex().flex_col().gap(px(4.0));
213 if let Some(label) = self.label.clone() {
214 column = column.child(
215 div()
216 .text_size(px(font_sm))
217 .text_color(text_color)
218 .child(label),
219 );
220 }
221 column = column.child(trigger);
222
223 let element = if self.disabled {
224 column.opacity(0.6)
225 } else {
226 column
227 };
228
229 element.probe("FileInput")
230 }
231}