Skip to main content

azul_layout/widgets/
file_input.rs

1//! File input button, same as `Button`, but triggers a
2//! user-supplied path-change callback when clicked
3
4use azul_core::{
5    callbacks::{CoreCallbackData, Update},
6    dom::Dom,
7    refany::RefAny,
8    resources::OptionImageRef,
9};
10#[allow(clippy::wildcard_imports)] // widget/render module pulls in the css property/value types it builds with
11use azul_css::{
12    dynamic_selector::CssPropertyWithConditionsVec,
13    props::{
14        basic::*,
15        layout::*,
16        property::{CssProperty, *},
17        style::*,
18    },
19    *,
20};
21
22use crate::{
23    callbacks::{Callback, CallbackInfo},
24    widgets::button::{Button, ButtonOnClick, ButtonOnClickCallback},
25};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28#[repr(C)]
29pub struct FileInput {
30    /// State of the file input
31    pub file_input_state: FileInputStateWrapper,
32    /// Default text to display when no file has been selected
33    /// (default = "Select File...")
34    pub default_text: AzString,
35
36    /// Optional image that is displayed next to the label
37    pub image: OptionImageRef,
38    /// Style for this button container
39    pub container_style: CssPropertyWithConditionsVec,
40    /// Style of the label
41    pub label_style: CssPropertyWithConditionsVec,
42    /// Style of the image
43    pub image_style: CssPropertyWithConditionsVec,
44}
45
46impl Default for FileInput {
47    fn default() -> Self {
48        let default_button = Button::create(AzString::from_const_str(""));
49        Self {
50            file_input_state: FileInputStateWrapper::default(),
51            default_text: "Select File...".into(),
52            image: None.into(),
53            container_style: default_button.container_style,
54            label_style: default_button.label_style,
55            image_style: default_button.image_style,
56        }
57    }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
61#[repr(C)]
62pub struct FileInputStateWrapper {
63    pub inner: FileInputState,
64    pub on_path_change: OptionFileInputOnPathChange,
65    /// Title displayed in the file selection dialog
66    pub file_dialog_title: AzString,
67    /// Default directory of file input
68    pub default_dir: OptionString,
69}
70
71impl Default for FileInputStateWrapper {
72    fn default() -> Self {
73        Self {
74            inner: FileInputState::default(),
75            on_path_change: None.into(),
76            file_dialog_title: "Select File".into(),
77            default_dir: None.into(),
78        }
79    }
80}
81
82/// Current state of the file input (selected path)
83#[derive(Debug, Clone, PartialEq, Eq)]
84#[repr(C)]
85pub struct FileInputState {
86    pub path: OptionString,
87}
88
89impl Default for FileInputState {
90    fn default() -> Self {
91        Self { path: None.into() }
92    }
93}
94
95/// Callback type invoked when the file input path changes
96pub type FileInputOnPathChangeCallbackType =
97    extern "C" fn(RefAny, CallbackInfo, FileInputState) -> Update;
98
99impl_widget_callback!(
100    FileInputOnPathChange,
101    OptionFileInputOnPathChange,
102    FileInputOnPathChangeCallback,
103    FileInputOnPathChangeCallbackType
104);
105
106azul_core::impl_managed_callback! {
107    wrapper:        FileInputOnPathChangeCallback,
108    info_ty:        CallbackInfo,
109    return_ty:      Update,
110    default_ret:    Update::DoNothing,
111    invoker_static: FILE_INPUT_ON_PATH_CHANGE_INVOKER,
112    invoker_ty:     AzFileInputOnPathChangeCallbackInvoker,
113    thunk_fn:       az_file_input_on_path_change_callback_thunk,
114    setter_fn:      AzApp_setFileInputOnPathChangeCallbackInvoker,
115    from_handle_fn: AzFileInputOnPathChangeCallback_createFromHostHandle,
116    extra_args:     [ state: FileInputState ],
117}
118
119impl FileInput {
120    #[must_use] pub fn create(path: OptionString) -> Self {
121        Self {
122            file_input_state: FileInputStateWrapper {
123                inner: FileInputState { path },
124                ..Default::default()
125            },
126            ..Default::default()
127        }
128    }
129
130    #[inline]
131    #[must_use]
132    pub fn swap_with_default(&mut self) -> Self {
133        let mut s = Self::create(None.into());
134        core::mem::swap(&mut s, self);
135        s
136    }
137
138    #[inline]
139    pub fn set_default_text(&mut self, default_text: AzString) {
140        self.default_text = default_text;
141    }
142
143    #[inline]
144    #[must_use] pub fn with_default_text(mut self, default_text: AzString) -> Self {
145        self.set_default_text(default_text);
146        self
147    }
148
149    #[inline]
150    pub fn set_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
151        &mut self,
152        refany: RefAny,
153        callback: I,
154    ) {
155        self.file_input_state.on_path_change = Some(FileInputOnPathChange {
156            callback: callback.into(),
157            refany,
158        })
159        .into();
160    }
161
162    #[inline]
163    #[must_use]
164    pub fn with_on_path_change<I: Into<FileInputOnPathChangeCallback>>(
165        mut self,
166        refany: RefAny,
167        callback: I,
168    ) -> Self {
169        self.set_on_path_change(refany, callback);
170        self
171    }
172
173    #[inline]
174    #[must_use] pub fn dom(self) -> Dom {
175        // either show the default text or the file name
176        // including the extension as the button label
177        let button_label = match self.file_input_state.inner.path.as_ref() {
178            Some(path) => std::path::Path::new(path.as_str())
179                .file_name()
180                .map_or_else(
181                    || self.default_text.as_str().to_string(),
182                    |s| s.to_string_lossy().to_string(),
183                )
184                .into(),
185            None => self.default_text.clone(),
186        };
187
188        Button {
189            label: button_label,
190            image: self.image,
191            button_type: crate::widgets::button::ButtonType::Default,
192            container_style: self.container_style,
193            label_style: self.label_style,
194            image_style: self.image_style,
195            on_click: Some(ButtonOnClick {
196                refany: RefAny::new(self.file_input_state),
197                callback: ButtonOnClickCallback {
198                    cb: fileinput_on_click,
199                    ctx: azul_core::refany::OptionRefAny::None,
200                },
201            })
202            .into(),
203        }
204        .dom()
205    }
206}
207
208extern "C" fn fileinput_on_click(mut refany: RefAny, mut info: CallbackInfo) -> Update {
209    let Some(mut fileinputstatewrapper) = refany.downcast_mut::<FileInputStateWrapper>() else {
210        return Update::DoNothing;
211    };
212    let fileinputstatewrapper = &mut *fileinputstatewrapper;
213
214    // `tfd` is desktop-only (target-gated in Cargo.toml to not(android|ios)); the
215    // `extra` feature does nothing on mobile, so gate the dialog block by the same
216    // target cfg to avoid referencing the unlinked `tfd` crate on iOS/Android.
217    #[cfg(all(feature = "extra", not(any(target_os = "android", target_os = "ios"))))]
218    {
219        let mut dialog = tfd::FileDialog::new(fileinputstatewrapper.file_dialog_title.as_str());
220        if let Some(dir) = fileinputstatewrapper.default_dir.as_ref() {
221            dialog = dialog.with_path(dir.as_str());
222        }
223        let Some(selected_path) = dialog.open_file() else {
224            return Update::DoNothing;
225        };
226        fileinputstatewrapper.inner.path = Some(selected_path.into()).into();
227    }
228
229    let inner = fileinputstatewrapper.inner.clone();
230    let mut result = match fileinputstatewrapper.on_path_change.as_mut() {
231        Some(FileInputOnPathChange { refany, callback }) => {
232            (callback.cb)(refany.clone(), info, inner)
233        }
234        None => Update::RefreshDom,
235    };
236
237    result.max_self(Update::RefreshDom);
238
239    result
240}