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,
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/// 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                    .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                        // The trigger sits underneath; don't reopen the dialog.
206                        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}