1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum FilePickerKind {
41 #[default]
43 OpenFile,
44 PickFolder,
46 SaveFile,
48}
49
50type FilterEntry = (String, Vec<String>);
51
52pub 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 validation: Option<Prop<ValidationState>>,
68 enabled: Prop<bool>,
70 root_child_id: Option<WidgetId>,
71 tooltip_text: Option<LocalizedString>,
75 rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
77 composite_tooltip_content: Option<Box<dyn Widget>>,
79}
80
81impl FilePickerField {
82 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 pub fn kind(mut self, kind: FilePickerKind) -> Self {
106 self.kind = kind;
107 self
108 }
109
110 pub fn dialog_title(mut self, title: impl Into<LocalizedString>) -> Self {
112 self.title = Some(title.into());
113 self
114 }
115
116 pub fn starting_dir(mut self, path: impl Into<PathBuf>) -> Self {
118 self.starting_dir = Some(path.into());
119 self
120 }
121
122 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 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 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 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 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 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 pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
182 self.validation = Some(validation.into());
183 self
184 }
185
186 pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
189 self.enabled = on.into();
190 self
191 }
192
193 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 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 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 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 ctx.enabled_when(self_id, self.enabled.clone());
272
273 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 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 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 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 #[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}