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    /// Append several extension filters from an iterator of
140    /// `(label, extensions)` pairs, in order.
141    ///
142    /// The loop form of [`add_filter`](Self::add_filter), for a filter list that
143    /// comes from data. The second element of each pair is anything that reads
144    /// as a `&[&str]`, so both `["txt", "md"]` and `&["txt", "md"][..]` work.
145    pub fn add_filters<'a, L, E>(self, filters: impl IntoIterator<Item = (L, E)>) -> Self
146    where
147        L: Into<String>,
148        E: AsRef<[&'a str]>,
149    {
150        filters.into_iter().fold(self, |field, (label, exts)| {
151            field.add_filter(label, exts.as_ref())
152        })
153    }
154
155    /// Hook invoked with the raw [`FileDialogResult`] after the dialog
156    /// closes — useful when the caller needs to react to cancellation
157    /// or backend errors. The bound text signal is already updated by
158    /// the time this fires (on success).
159    pub fn on_pick(mut self, f: impl Fn(&FileDialogResult, &mut EventContext) + 'static) -> Self {
160        self.on_pick = Some(Box::new(f));
161        self
162    }
163
164    /// Placeholder text shown when the field is empty.
165    pub fn placeholder(mut self, text: impl Into<LocalizedString>) -> Self {
166        let ls: LocalizedString = text.into();
167        self.placeholder = Some(ls);
168        self
169    }
170
171    /// Accessible name for the path field.
172    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
173        let ls: LocalizedString = label.into();
174        self.label = Some(ls);
175        self
176    }
177
178    /// Bind an external [`ValidationState`] signal — shown as the same inline
179    /// error/warning strip and border tint the inner [`TextInput`] renders (e.g.
180    /// "the chosen folder does not exist / is not writable").
181    pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
182        self.validation = Some(validation.into());
183        self
184    }
185
186    /// Set the initial enabled state for the text field and Browse button.
187    /// Forwarded to the arena at build time.
188    pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
189        self.enabled = on.into();
190        self
191    }
192
193    /// Attach a plain single-line tooltip shown after the hover delay.
194    /// Clears any previously set rich or composite tooltip (last call wins).
195    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
196        self.tooltip_text = Some(text.into());
197        self.rich_tooltip_source = None;
198        self.composite_tooltip_content = None;
199        self
200    }
201
202    /// Attach a rich tooltip by registry key.
203    /// Clears any previously set plain or composite tooltip (last call wins).
204    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
205        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
206        self.tooltip_text = None;
207        self.composite_tooltip_content = None;
208        self
209    }
210
211    /// Attach a rich tooltip from inline [`crate::tooltip::TooltipContent`].
212    /// Clears any previously set plain or composite tooltip (last call wins).
213    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
214        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
215        self.tooltip_text = None;
216        self.composite_tooltip_content = None;
217        self
218    }
219
220    /// Attach a composite tooltip whose body is an arbitrary widget tree.
221    /// Clears any previously set plain or rich tooltip (last call wins).
222    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
223        self.composite_tooltip_content = Some(Box::new(content));
224        self.tooltip_text = None;
225        self.rich_tooltip_source = None;
226        self
227    }
228}
229
230impl std::fmt::Debug for FilePickerField {
231    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
232        f.debug_struct("FilePickerField")
233            .field("kind", &self.kind)
234            .field("filters", &self.filters)
235            .finish_non_exhaustive()
236    }
237}
238
239fn build_request_owned(
240    kind: FilePickerKind,
241    title: Option<LocalizedString>,
242    starting_dir: Option<PathBuf>,
243    default_file_name: Option<String>,
244    filters: &[FilterEntry],
245) -> FileDialogRequest {
246    let mut req = match kind {
247        FilePickerKind::OpenFile => FileDialogRequest::pick_file(),
248        FilePickerKind::PickFolder => FileDialogRequest::pick_folder(),
249        FilePickerKind::SaveFile => FileDialogRequest::save_file(),
250    };
251    if let Some(title) = title {
252        req = req.title(title.resolve_now());
253    }
254    if let Some(dir) = starting_dir {
255        req = req.starting_dir(dir);
256    }
257    if let Some(name) = default_file_name {
258        req = req.default_file_name(name);
259    }
260    for (label, extensions) in filters {
261        let exts: Vec<&str> = extensions.iter().map(|s| s.as_str()).collect();
262        req = req.add_filter(label.clone(), &exts);
263    }
264    req
265}
266
267impl Widget for FilePickerField {
268    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
269        let self_id = ctx.self_id();
270        // Forward initial-enabled into the arena; see IconButton.
271        ctx.enabled_when(self_id, self.enabled.clone());
272
273        // Snapshot dialog config + result writer for the Browse-button
274        // closure (which can't borrow `self`).
275        let kind = self.kind;
276        let title = self.title.clone();
277        let starting_dir = self.starting_dir.clone();
278        let default_file_name = self.default_file_name.clone();
279        let filters = self.filters.clone();
280        // Convert Box<dyn Fn> into Rc<dyn Fn> once so the inner
281        // callback can be cloned into each per-tap result closure
282        // (which must be FnOnce).
283        let on_pick: Option<std::rc::Rc<dyn Fn(&FileDialogResult, &mut EventContext)>> =
284            self.on_pick.take().map(std::rc::Rc::from);
285        let text_signal = self.text.clone();
286
287        let browse = IconButton::browse()
288            .embedded()
289            .enabled(self.enabled.clone())
290            .on_activate_fn(move |ctx| {
291                let request = build_request_owned(
292                    kind,
293                    title.clone(),
294                    starting_dir.clone(),
295                    default_file_name.clone(),
296                    &filters,
297                );
298                let text_signal = text_signal.clone();
299                let on_pick = on_pick.clone();
300                let result_cb = move |result: FileDialogResult, ctx: &mut EventContext| {
301                    apply_result(&result, &text_signal, kind);
302                    if let Some(handler) = &on_pick {
303                        handler(&result, ctx);
304                    }
305                };
306                let _ = match kind {
307                    FilePickerKind::OpenFile => ctx.pick_file(request, result_cb),
308                    FilePickerKind::PickFolder => ctx.pick_folder(request, result_cb),
309                    FilePickerKind::SaveFile => ctx.save_file(request, result_cb),
310                };
311            });
312
313        // Build the TextInput inline (matching DateEdit / TimeEdit) —
314        // no Option<TextInput> storage, no map_input plumbing, just
315        // direct construction from the FilePickerField's own config.
316        let mut input = TextInput::new(self.text.clone())
317            .enabled(self.enabled.clone())
318            .trailing_slot(browse);
319        if let Some(ph) = self.placeholder.clone() {
320            input = input.placeholder(ph);
321        }
322        if let Some(label) = self.label.clone() {
323            input = input.label(label);
324        }
325        if let Some(validation) = self.validation.clone() {
326            input = input.validation(validation);
327        }
328        let root_id = ctx.add(input);
329        self.root_child_id = Some(root_id);
330
331        if let Some(content) = self.composite_tooltip_content.take() {
332            let delay = ctx.theme().motion.tooltip_delay_heavy;
333            crate::tooltip::attach_composite_tooltip_boxed(ctx, root_id, content, delay);
334        } else if let Some(source) = self.rich_tooltip_source.clone() {
335            let delay = ctx.theme().motion.tooltip_delay;
336            crate::tooltip::attach_rich_tooltip_source(ctx, root_id, source, delay);
337        } else if let Some(text) = self.tooltip_text.clone() {
338            let delay = ctx.theme().motion.tooltip_delay;
339            crate::tooltip::attach_plain_tooltip(ctx, root_id, text, delay);
340        }
341
342        self.children()
343    }
344
345    fn layout_response(
346        &self,
347        proposal: SizeProposal,
348        ctx: &LayoutContext,
349    ) -> teksilo_core::widget::LayoutResponse {
350        self.root_child_id
351            .and_then(|id| ctx.child_size(id, proposal))
352            .unwrap_or_else(|| proposal.resolve(0.0, 0.0))
353            .into()
354    }
355
356    fn place_children(
357        &self,
358        bounds: Rect,
359        _proposal: SizeProposal,
360        children: &mut [WidgetPlacement],
361        _ctx: &LayoutContext,
362    ) {
363        for child in children.iter_mut() {
364            child.origin = bounds.origin();
365            child.size = bounds.size();
366        }
367    }
368
369    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
370        // The inner TextInput owns the text-edit role + value. The
371        // outer container is a layout shell.
372        builder.set_role(teksilo_core::accesskit::Role::GenericContainer);
373    }
374
375    fn children(&self) -> Vec<WidgetId> {
376        self.root_child_id.into_iter().collect()
377    }
378}
379
380fn apply_result(result: &FileDialogResult, text: &Signal<String>, kind: FilePickerKind) {
381    let path = match result {
382        FileDialogResult::File(Some(p)) if matches!(kind, FilePickerKind::OpenFile) => Some(p),
383        FileDialogResult::Folder(Some(p)) if matches!(kind, FilePickerKind::PickFolder) => Some(p),
384        FileDialogResult::Saved(Some(p)) if matches!(kind, FilePickerKind::SaveFile) => Some(p),
385        _ => None,
386    };
387    if let Some(p) = path {
388        text.set(p.to_string_lossy().into_owned());
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use teksilo_core::widget_tree::WidgetTree;
396    use teksilo_i18n::lit;
397
398    /// `add_filters` is a fold over `add_filter`, so N singular calls and one
399    /// plural call over the same pairs must leave the same filter list behind.
400    /// The filters never reach the arena (they go to the file dialog at click
401    /// time), so this reads the field rather than the tree.
402    #[test]
403    fn add_filters_plural_matches_the_singular_chain() {
404        let singular = FilePickerField::new(Signal::new(String::new()))
405            .add_filter("Images", &["png", "jpg"])
406            .add_filter("Text", &["txt"]);
407        let plural = FilePickerField::new(Signal::new(String::new())).add_filters([
408            ("Images", ["png", "jpg"].as_slice()),
409            ("Text", ["txt"].as_slice()),
410        ]);
411        assert_eq!(
412            singular.filters,
413            vec![
414                (
415                    "Images".to_string(),
416                    vec!["png".to_string(), "jpg".to_string()]
417                ),
418                ("Text".to_string(), vec!["txt".to_string()]),
419            ]
420        );
421        assert_eq!(singular.filters, plural.filters);
422    }
423
424    #[test]
425    fn file_picker_builds() {
426        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
427        let path = Signal::new(String::new());
428        let id = tree.add(
429            FilePickerField::new(path)
430                .placeholder(lit!("Choose a file…"))
431                .add_filter("Images", &["png", "jpg"]),
432        );
433        tree.layout(SizeProposal {
434            width: Some(420.0),
435            height: None,
436        });
437        let b = tree.bounds(id);
438        assert!(b.width > 0.0);
439        assert!(b.height > 0.0);
440    }
441
442    #[test]
443    fn tooltip_appears_on_hover() {
444        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
445        let path = Signal::new(String::new());
446        let id = tree.add(FilePickerField::new(path).tooltip(lit!("Tip")));
447        tree.layout(SizeProposal {
448            width: Some(300.0),
449            height: Some(200.0),
450        });
451        tree.pointer_move(tree.bounds(id).center());
452        tree.advance_time(std::time::Duration::from_secs(1));
453        assert_eq!(
454            tree.active_overlays().len(),
455            1,
456            "tooltip should appear on hover"
457        );
458        assert!(tree.find_by_label("Tip").is_some());
459    }
460}