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 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 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 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 pub fn validation(mut self, validation: impl Into<Prop<ValidationState>>) -> Self {
166 self.validation = Some(validation.into());
167 self
168 }
169
170 pub fn enabled(mut self, on: impl Into<Prop<bool>>) -> Self {
173 self.enabled = on.into();
174 self
175 }
176
177 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 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 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 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 ctx.enabled_when(self_id, self.enabled.clone());
256
257 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 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 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 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}