Skip to main content

teksilo_widgets/
file_picker_field.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `FilePickerField` — a text-input preset for path entry with a Browse button.
5//!
6//! Combines a `TextInput` with a trailing `IconButton` (the folder/browse glyph)
7//! that opens a native file dialog and writes the chosen path back into the bound
8//! `Signal<String>`. The three [`FilePickerKind`] variants map to the three
9//! single-result dialog modes: open a file, pick a folder, or save a file.
10//! Multi-file selection does not fit the "one editable line" pattern; use the
11//! file-dialog API directly for that.
12//!
13//! ```ignore
14//! // Requires ctx.signal() — shown as ignore per convention.
15//! let path = ctx.signal(String::new());
16//! let _f = FilePickerField::new(path.clone())
17//!     .kind(FilePickerKind::OpenFile)
18//!     .add_filter("Images", &["png", "jpg"])
19//!     .placeholder(lit!("Choose a file…"));
20//! ```
21
22use std::path::PathBuf;
23
24use teksilo_canvas::{Rect, SizeProposal};
25use teksilo_core::accessibility::AccessNodeBuilder;
26use teksilo_core::build_context::BuildContext;
27use teksilo_core::signal::{Prop, Signal};
28use teksilo_core::widget::{EventContext, LayoutContext, Widget, WidgetPlacement};
29use teksilo_core::widget_id::WidgetId;
30use teksilo_platform::file_dialog::{
31    EventContextFileDialogExt, FileDialogRequest, FileDialogResult,
32};
33
34use crate::icon_button::IconButton;
35use crate::text_input::{TextInput, ValidationState};
36use teksilo_i18n::LocalizedString;
37
38/// Which file-dialog kind the trailing button opens.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum FilePickerKind {
41    /// Open an existing file. Default.
42    #[default]
43    OpenFile,
44    /// Pick an existing folder.
45    PickFolder,
46    /// Pick a new or existing file location for saving.
47    SaveFile,
48}
49
50type FilterEntry = (String, Vec<String>);
51
52/// A single-line path entry field with a trailing Browse button that invokes the
53/// native file dialog and writes the chosen path back into the bound `Signal<String>`.
54pub struct FilePickerField {
55    text: Signal<String>,
56    kind: FilePickerKind,
57    title: Option<LocalizedString>,
58    starting_dir: Option<PathBuf>,
59    default_file_name: Option<String>,
60    filters: Vec<FilterEntry>,
61    on_pick: Option<Box<dyn Fn(&FileDialogResult, &mut EventContext)>>,
62    placeholder: Option<LocalizedString>,
63    label: Option<LocalizedString>,
64    /// Optional external validation state, forwarded to the inner `TextInput`
65    /// (renders the same inline error/warning strip + border tint as a plain
66    /// text field).
67    validation: Option<Prop<ValidationState>>,
68    /// Initial enabled-state; forwarded to the arena at build time.
69    enabled: Prop<bool>,
70    root_child_id: Option<WidgetId>,
71    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
72    /// with the rich / composite slots — every setter clears the other two so
73    /// the last call wins.
74    tooltip_text: Option<LocalizedString>,
75    /// Optional rich tooltip source (registry key or inline content).
76    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
77    /// Optional composite tooltip body (arbitrary widget tree).
78    composite_tooltip_content: Option<Box<dyn Widget>>,
79}
80
81impl FilePickerField {
82    /// Construct a `FilePickerField` bound to `text`. The visible string
83    /// is updated on a successful pick; existing content is shown as-is.
84    pub fn new(text: Signal<String>) -> Self {
85        Self {
86            text,
87            kind: FilePickerKind::OpenFile,
88            title: None,
89            starting_dir: None,
90            default_file_name: None,
91            filters: Vec::new(),
92            on_pick: None,
93            placeholder: None,
94            label: None,
95            validation: None,
96            enabled: Prop::Static(true),
97            root_child_id: None,
98            tooltip_text: None,
99            rich_tooltip_source: None,
100            composite_tooltip_content: None,
101        }
102    }
103
104    /// Pick the dialog kind opened by the Browse button.
105    pub fn kind(mut self, kind: FilePickerKind) -> Self {
106        self.kind = kind;
107        self
108    }
109
110    /// Title shown in the file-dialog window caption.
111    pub fn dialog_title(mut self, title: impl Into<LocalizedString>) -> Self {
112        self.title = Some(title.into());
113        self
114    }
115
116    /// Directory the dialog opens in. If not set, the OS default is used.
117    pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
118        self.starting_dir = Some(path.into());
119        self
120    }
121
122    /// Pre-filled file name for the [`FilePickerKind::SaveFile`] dialog.
123    /// No-op for `OpenFile` / `PickFolder`.
124    pub fn default_file_name(mut self, name: impl Into<String>) -> Self {
125        self.default_file_name = Some(name.into());
126        self
127    }
128
129    /// Append an extension filter (label + extensions without leading dots).
130    /// Repeat to add multiple rows.
131    pub fn add_filter(mut self, label: impl Into<String>, extensions: &[&str]) -> Self {
132        self.filters.push((
133            label.into(),
134            extensions.iter().map(|s| (*s).to_string()).collect(),
135        ));
136        self
137    }
138
139    /// Hook invoked with the raw [`FileDialogResult`] after the dialog
140    /// closes — useful when the caller needs to react to cancellation
141    /// or backend errors. The bound text signal is already updated by
142    /// the time this fires (on success).
143    pub fn on_pick(mut self, f: impl Fn(&FileDialogResult, &mut EventContext) + 'static) -> Self {
144        self.on_pick = Some(Box::new(f));
145        self
146    }
147
148    /// Placeholder text shown when the field is empty.
149    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
150        let ls: LocalizedString = text.into();
151        self.placeholder = Some(ls);
152        self
153    }
154
155    /// Accessible name for the path field.
156    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
157        let ls: LocalizedString = label.into();
158        self.label = Some(ls);
159        self
160    }
161
162    /// Bind an external [`ValidationState`] signal — shown as the same inline
163    /// error/warning strip and border tint the inner [`TextInput`] renders (e.g.
164    /// "the chosen folder does not exist / is not writable").
165    pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
166        self.validation = Some(validation.into());
167        self
168    }
169
170    /// Set the initial enabled state for the text field and Browse button.
171    /// Forwarded to the arena at build time.
172    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
173        self.enabled = on.into();
174        self
175    }
176
177    /// Attach a plain single-line tooltip shown after the hover delay.
178    /// Clears any previously set rich or composite tooltip (last call wins).
179    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
180        self.tooltip_text = Some(text.into());
181        self.rich_tooltip_source = None;
182        self.composite_tooltip_content = None;
183        self
184    }
185
186    /// Attach a rich tooltip by registry key.
187    /// Clears any previously set plain or composite tooltip (last call wins).
188    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
189        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
190        self.tooltip_text = None;
191        self.composite_tooltip_content = None;
192        self
193    }
194
195    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
196    /// Clears any previously set plain or composite tooltip (last call wins).
197    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
198        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
199        self.tooltip_text = None;
200        self.composite_tooltip_content = None;
201        self
202    }
203
204    /// Attach a composite tooltip whose body is an arbitrary widget tree.
205    /// Clears any previously set plain or rich tooltip (last call wins).
206    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
207        self.composite_tooltip_content = Some(Box::new(content));
208        self.tooltip_text = None;
209        self.rich_tooltip_source = None;
210        self
211    }
212}
213
214impl std::fmt::Debug for FilePickerField {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        f.debug_struct("FilePickerField")
217            .field("kind", &self.kind)
218            .field("filters", &self.filters)
219            .finish_non_exhaustive()
220    }
221}
222
223fn build_request_owned(
224    kind: FilePickerKind,
225    title: Option<LocalizedString>,
226    starting_dir: Option<PathBuf>,
227    default_file_name: Option<String>,
228    filters: &[FilterEntry],
229) -> FileDialogRequest {
230    let mut req = match kind {
231        FilePickerKind::OpenFile => FileDialogRequest::pick_file(),
232        FilePickerKind::PickFolder => FileDialogRequest::pick_folder(),
233        FilePickerKind::SaveFile => FileDialogRequest::save_file(),
234    };
235    if let Some(title) = title {
236        req = req.title(title.resolve_now());
237    }
238    if let Some(dir) = starting_dir {
239        req = req.starting_dir(dir);
240    }
241    if let Some(name) = default_file_name {
242        req = req.default_file_name(name);
243    }
244    for (label, extensions) in filters {
245        let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
246        req = req.add_filter(label.clone(), &exts);
247    }
248    req
249}
250
251impl Widget for FilePickerField {
252    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
253        let self_id = ctx.self_id();
254        // Forward initial-enabled into the arena; see IconButton.
255        ctx.enabled_when(self_id, self.enabled.clone());
256
257        // Snapshot dialog config + result writer for the Browse-button
258        // closure (which can't borrow `self`).
259        let kind = self.kind;
260        let title = self.title.clone();
261        let starting_dir = self.starting_dir.clone();
262        let default_file_name = self.default_file_name.clone();
263        let filters = self.filters.clone();
264        // Convert Box<dyn Fn> into Rc<dyn Fn> once so the inner
265        // callback can be cloned into each per-tap result closure
266        // (which must be FnOnce).
267        let on_pick: Option<std::rc::Rc<dyn Fn(&FileDialogResult, &mut EventContext)>> =
268            self.on_pick.take().map(std::rc::Rc::from);
269        let text_signal = self.text.clone();
270
271        let browse = IconButton::browse()
272            .embedded()
273            .enabled(self.enabled.clone())
274            .on_activate_fn(move |ctx| {
275                let request = build_request_owned(
276                    kind,
277                    title.clone(),
278                    starting_dir.clone(),
279                    default_file_name.clone(),
280                    &filters,
281                );
282                let text_signal = text_signal.clone();
283                let on_pick = on_pick.clone();
284                let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
285                    apply_result(&result, &text_signal, kind);
286                    if let Some(handler) = &on_pick {
287                        handler(&result, ctx);
288                    }
289                };
290                let _ = match kind {
291                    FilePickerKind::OpenFile => ctx.pick_file(request, result_cb),
292                    FilePickerKind::PickFolder => ctx.pick_folder(request, result_cb),
293                    FilePickerKind::SaveFile => ctx.save_file(request, result_cb),
294                };
295            });
296
297        // Build the TextInput inline (matching DateEdit / TimeEdit) —
298        // no Option<TextInput> storage, no map_input plumbing, just
299        // direct construction from the FilePickerField's own config.
300        let mut input = TextInput::new(self.text.clone())
301            .enabled(self.enabled.clone())
302            .trailing_slot(browse);
303        if let Some(ph) = self.placeholder.clone() {
304            input = input.placeholder(ph);
305        }
306        if let Some(label) = self.label.clone() {
307            input = input.label(label);
308        }
309        if let Some(validation) = self.validation.clone() {
310            input = input.validation(validation);
311        }
312        let root_id = ctx.add(input);
313        self.root_child_id = Some(root_id);
314
315        if let Some(content) = self.composite_tooltip_content.take() {
316            let delay = ctx.theme().motion.tooltip_delay_heavy;
317            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
318        } else if let Some(source) = self.rich_tooltip_source.clone() {
319            let delay = ctx.theme().motion.tooltip_delay;
320            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
321        } else if let Some(text) = self.tooltip_text.clone() {
322            let delay = ctx.theme().motion.tooltip_delay;
323            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
324        }
325
326        self.children()
327    }
328
329    fn layout_response(
330        &self,
331        proposal: SizeProposal,
332        ctx: &LayoutContext,
333    ) -> teksilo_core::widget::LayoutResponse {
334        self.root_child_id
335            .and_then(|id| ctx.child_size(id, proposal))
336            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
337            .into()
338    }
339
340    fn place_children(
341        &self,
342        bounds: Rect,
343        _proposal: SizeProposal,
344        children: &mut [WidgetPlacement],
345        _ctx: &LayoutContext,
346    ) {
347        for child in children.iter_mut() {
348            child.origin = bounds.origin();
349            child.size = bounds.size();
350        }
351    }
352
353    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
354        // The inner TextInput owns the text-edit role + value. The
355        // outer container is a layout shell.
356        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
357    }
358
359    fn children(&self) -> Vec<WidgetId> {
360        self.root_child_id.into_iter().collect()
361    }
362}
363
364fn apply_result(result: &FileDialogResult, text: &Signal<String>, kind: FilePickerKind) {
365    let path = match result {
366        FileDialogResult::File(Some(p)) if matches!(kind, FilePickerKind::OpenFile) => Some(p),
367        FileDialogResult::Folder(Some(p)) if matches!(kind, FilePickerKind::PickFolder) => Some(p),
368        FileDialogResult::Saved(Some(p)) if matches!(kind, FilePickerKind::SaveFile) => Some(p),
369        _ => None,
370    };
371    if let Some(p) = path {
372        text.set(p.to_string_lossy().into_owned());
373    }
374}
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use teksilo_core::widget_tree::WidgetTree;
380    use teksilo_i18n::lit;
381
382    #[test]
383    fn file_picker_builds() {
384        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
385        let path = Signal::new(String::new());
386        let id = tree.add(
387            FilePickerField::new(path)
388                .placeholder(lit!("Choose a file…"))
389                .add_filter("Images", &["png", "jpg"]),
390        );
391        tree.layout(SizeProposal {
392            width: Some(420.0),
393            height: None,
394        });
395        let b = tree.bounds(id);
396        assert!(b.width > 0.0);
397        assert!(b.height > 0.0);
398    }
399
400    #[test]
401    fn tooltip_appears_on_hover() {
402        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
403        let path = Signal::new(String::new());
404        let id = tree.add(FilePickerField::new(path).tooltip(lit!("Tip")));
405        tree.layout(SizeProposal {
406            width: Some(300.0),
407            height: Some(200.0),
408        });
409        tree.pointer_move(tree.bounds(id).center());
410        tree.advance_time(std::time::Duration::from_secs(1));
411        assert_eq!(
412            tree.active_overlays().len(),
413            1,
414            "tooltip should appear on hover"
415        );
416        assert!(tree.find_by_label("Tip").is_some());
417    }
418}