Skip to main content

ravenrs_widgets/
files.rs

1//! Files from outside the app: the service that brings them, a place to drop them, and an
2//! image field.
3
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, PathFill, Quad, Rect, Role, Scene};
8use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
13use crate::{Button, ButtonSize, ButtonStyle, IconButton, IconName, View, util};
14
15/// The largest file a [`FileUpload`] takes unless told otherwise: 100 MiB.
16const DEFAULT_MOST_BYTES: u64 = 100 * 1024 * 1024;
17/// The largest picture an [`ImageField`] takes unless told otherwise: 20 MiB.
18#[cfg(feature = "decode")]
19const DEFAULT_MOST_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
20
21/// A file brought into the app, whole, in memory.
22#[derive(Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct PickedFile {
25    /// Its name, with no folder. Never trust it as a path.
26    pub name: String,
27    /// Its size on disk. A file bigger than the request allowed comes with no bytes.
28    pub size: u64,
29    /// Its content.
30    pub bytes: Rc<[u8]>,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl PickedFile {
35    /// A file with this name and content.
36    pub fn new(name: impl Into<String>, bytes: impl Into<Rc<[u8]>>) -> Self {
37        let bytes = bytes.into();
38        Self { name: name.into(), size: bytes.len() as u64, bytes }
39    }
40
41    /// The part of the name after the last dot, in lower case.
42    pub fn extension(&self) -> Option<String> {
43        self.name.rsplit_once('.').map(|(_, extension)| extension.to_lowercase())
44    }
45}
46
47impl fmt::Debug for PickedFile {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("PickedFile").field("name", &self.name).field("size", &self.size).finish()
50    }
51}
52
53/// What to ask of a [`FilePicker`].
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55#[non_exhaustive]
56#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
57pub struct FileRequest {
58    /// The extensions offered, such as `pdf`; empty offers every file.
59    pub extensions: Vec<String>,
60    /// Whether more than one can be chosen.
61    pub multiple: bool,
62    /// Files above this size are not read, and come with no bytes.
63    pub most_bytes: Option<u64>,
64}
65
66/// Asks the person for files. Platform layers install one with [`crate::Ui::set_file_picker`].
67pub trait FilePickerProvider {
68    /// Shows the system's chooser, and calls `done` with what was chosen, now or later
69    /// on the UI thread. Canceling gives an empty list.
70    fn pick(&self, request: &FileRequest, done: Box<dyn FnOnce(Vec<PickedFile>)>);
71
72    /// Hands a file over to the person: the system's dialog on a desktop, a download in a
73    /// browser. Nothing happens where there is no system to ask.
74    fn save(&self, _name: &str, _bytes: &[u8]) {}
75}
76
77/// A picker that chooses nothing, for where there is no system to ask (tests, headless use).
78#[derive(Clone, Copy, Debug, Default)]
79#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
80pub struct NoFilePicker;
81
82impl FilePickerProvider for NoFilePicker {
83    fn pick(&self, _request: &FileRequest, done: Box<dyn FnOnce(Vec<PickedFile>)>) {
84        done(Vec::new());
85    }
86}
87
88/// A shared handle to the file chooser, from [`crate::BuildCx::file_picker`].
89#[derive(Clone)]
90#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
91pub struct FilePicker(Rc<dyn FilePickerProvider>);
92
93#[cfg_attr(feature = "live", ravenrs_live::expose)]
94impl FilePicker {
95    /// Wraps a provider.
96    pub fn new(provider: impl FilePickerProvider + 'static) -> Self {
97        Self(Rc::new(provider))
98    }
99
100    /// Asks for files; `done` runs with what was chosen.
101    pub fn pick(&self, request: &FileRequest, done: impl FnOnce(Vec<PickedFile>) + 'static) {
102        self.0.pick(request, Box::new(done));
103    }
104
105    /// Hands a file over to the person.
106    pub fn save(&self, name: &str, bytes: &[u8]) {
107        self.0.save(name, bytes);
108    }
109}
110
111impl Default for FilePicker {
112    fn default() -> Self {
113        Self::new(NoFilePicker)
114    }
115}
116
117impl fmt::Debug for FilePicker {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.write_str("FilePicker")
120    }
121}
122
123/// A size as people read it.
124pub(crate) fn size_text(bytes: u64) -> String {
125    const UNITS: [&str; 4] = ["KB", "MB", "GB", "TB"];
126    if bytes < 1024 {
127        return format!("{bytes} B");
128    }
129    let mut value = bytes as f64;
130    let mut unit = "";
131    for name in UNITS {
132        value /= 1024.0;
133        unit = name;
134        if value < 1024.0 {
135            break;
136        }
137    }
138    format!("{value:.1} {unit}")
139}
140
141/// What a file field accepts, and why it would refuse a file.
142#[derive(Clone, Debug)]
143struct Rules {
144    extensions: Vec<String>,
145    most_bytes: u64,
146    most_files: Option<usize>,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
150enum Refusal {
151    Kind,
152    Size,
153    Count,
154}
155
156#[cfg_attr(feature = "live", ravenrs_live::expose)]
157impl Rules {
158    fn refusal(&self, file: &PickedFile) -> Option<Refusal> {
159        if !self.extensions.is_empty() && !file.extension().is_some_and(|e| self.extensions.contains(&e)) {
160            return Some(Refusal::Kind);
161        }
162        (file.size > self.most_bytes).then_some(Refusal::Size)
163    }
164
165    /// Splits what arrived: what stays with `kept`, and what was refused and why.
166    fn take(&self, kept: &mut Vec<PickedFile>, arrived: Vec<PickedFile>) -> Vec<(String, Refusal)> {
167        let mut refused = Vec::new();
168        for file in arrived {
169            if let Some(reason) = self.refusal(&file) {
170                refused.push((file.name, reason));
171            } else if let Some(same) = kept.iter_mut().find(|k| k.name == file.name) {
172                // The same name again is a newer version of that file.
173                *same = file;
174            } else if self.most_files.is_some_and(|most| kept.len() >= most) {
175                refused.push((file.name, Refusal::Count));
176            } else {
177                kept.push(file);
178            }
179        }
180        refused
181    }
182}
183
184fn refusal_text(labels: &crate::Labels, name: &str, reason: Refusal) -> String {
185    let why = match reason {
186        Refusal::Kind => &labels.file_wrong_kind,
187        Refusal::Size => &labels.file_too_large,
188        Refusal::Count => &labels.file_too_many,
189    };
190    format!("{name}: {why}")
191}
192
193fn normalized(extensions: impl IntoIterator<Item = impl Into<String>>) -> Vec<String> {
194    extensions.into_iter().map(|e| e.into().trim_start_matches('.').to_lowercase()).collect()
195}
196
197/// What opens the system's chooser for these rules, and hands over what comes back.
198fn chooser(cx: &BuildCx<'_>, rules: &Rules, on_files: Rc<dyn Fn(Vec<PickedFile>)>) -> Rc<dyn Fn()> {
199    let (picker, waker) = (cx.file_picker(), cx.ui_waker());
200    let request = FileRequest {
201        extensions: rules.extensions.clone(),
202        multiple: rules.most_files != Some(1),
203        most_bytes: Some(rules.most_bytes),
204    };
205    Rc::new(move || {
206        let (on_files, waker) = (on_files.clone(), waker.clone());
207        picker.pick(&request, move |files| {
208            on_files(files);
209            waker.wake();
210        });
211    })
212}
213
214/// One line for each refused file, saying why.
215fn push_refusals(cx: &mut BuildCx<'_>, refused: &[(String, Refusal)], rows: &mut Vec<NodeId>) {
216    let (theme, labels) = (*cx.theme(), cx.labels());
217    for (name, reason) in refused {
218        let line = refusal_text(&labels, name, *reason);
219        let text = util::label(cx, &line, TextStyle::Callout, None, theme.color(Semantic::Danger));
220        cx.semantics(text, Semantics::new(Role::Alert).name(line));
221        rows.push(text);
222    }
223}
224
225/// How thick the dashes of a [`DropZone`] are, in logical pixels, at rest and with files over it.
226const ZONE_STROKE: (f32, f32) = (1.0, 2.0);
227const ZONE_DASH: f32 = 6.0;
228const ZONE_GAP: f32 = 4.0;
229/// How much of what is under the hint of a [`DropZone`] still shows.
230const ZONE_HINT_OPACITY: f32 = 0.88;
231
232/// A dashed area around any content, which takes files dropped on it and shows when they are
233/// over the window. Unlike [`FileUpload`] it keeps no list: the files go to
234/// [`DropZone::on_files`], and what it holds and how it looks are up to the content.
235///
236/// A dropped file lands in the zone under the pointer. While it is being dragged, every zone
237/// on the screen lights up, because the platforms say only that files are over the window, and
238/// not where. A keyboard cannot drop anything, so [`DropZone::choosable`] is there for that.
239///
240/// ```
241/// use ravenrs_widgets::{DropZone, Text, Ui};
242///
243/// let zone = DropZone::new(Text::new("Photos of the session"))
244///     .accept(["jpg", "png"])
245///     .on_files(|files| println!("{} files", files.len()));
246/// let mut ui = Ui::new(zone, ravenrs_style::Theme::raven_dark());
247/// ui.update();
248/// ```
249pub struct DropZone<C> {
250    content: C,
251    rules: Rules,
252    on_files: Option<Rc<dyn Fn(Vec<PickedFile>)>>,
253    on_refused: Option<Rc<dyn Fn(Vec<String>)>>,
254    label: Option<String>,
255    hint: Option<String>,
256    show_hint: bool,
257    choosable: bool,
258    quiet: bool,
259    padding: Option<f32>,
260    min_height: Option<f32>,
261    fill: bool,
262    disabled: bool,
263}
264
265impl<C> fmt::Debug for DropZone<C> {
266    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
267        f.debug_struct("DropZone").field("rules", &self.rules).finish_non_exhaustive()
268    }
269}
270
271#[cfg_attr(feature = "live", ravenrs_live::expose)]
272impl<C: View> DropZone<C> {
273    /// A zone around `content`, taking any file.
274    pub fn new(content: C) -> Self {
275        Self {
276            content,
277            rules: Rules { extensions: Vec::new(), most_bytes: DEFAULT_MOST_BYTES, most_files: None },
278            on_files: None,
279            on_refused: None,
280            label: None,
281            hint: None,
282            show_hint: true,
283            choosable: false,
284            quiet: false,
285            padding: None,
286            min_height: None,
287            fill: false,
288            disabled: false,
289        }
290    }
291
292    /// Called with the files it took. It is not called when all of them were refused.
293    pub fn on_files(mut self, handler: impl Fn(Vec<PickedFile>) + 'static) -> Self {
294        self.on_files = Some(Rc::new(handler));
295        self
296    }
297
298    /// Called with one sentence for each refused file, in the words of [`crate::Labels`], for
299    /// showing them elsewhere. Without it they show inside the zone, under the content.
300    pub fn on_refused(mut self, handler: impl Fn(Vec<String>) + 'static) -> Self {
301        self.on_refused = Some(Rc::new(handler));
302        self
303    }
304
305    /// Only these extensions, such as `["pdf", "png"]`. Any file by default.
306    pub fn accept(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
307        self.rules.extensions = normalized(extensions);
308        self
309    }
310
311    /// The largest file taken, in bytes. 100 MiB by default.
312    pub fn most_bytes(mut self, bytes: u64) -> Self {
313        self.rules.most_bytes = bytes;
314        self
315    }
316
317    /// The most files taken from one drop. No limit by default.
318    pub fn most_files(mut self, files: usize) -> Self {
319        self.rules.most_files = Some(files.max(1));
320        self
321    }
322
323    /// The name screen readers announce. By default, the words of the hint.
324    pub fn label(mut self, label: impl Into<String>) -> Self {
325        self.label = Some(label.into());
326        self
327    }
328
329    /// What shows over the content while files are over the window. By default,
330    /// [`file_drop`](crate::Labels::file_drop) of the labels.
331    pub fn hint(mut self, hint: impl Into<String>) -> Self {
332        self.hint = Some(hint.into());
333        self
334    }
335
336    /// Shows nothing over the content while files are over the window, only the stronger edge.
337    pub fn no_hint(mut self) -> Self {
338        self.show_hint = false;
339        self
340    }
341
342    /// Makes the whole zone a button that opens the system's chooser, with a click, Enter or
343    /// Space, so that the keyboard can bring files too. Leave it off when the content has
344    /// its own way of choosing.
345    pub fn choosable(mut self) -> Self {
346        self.choosable = true;
347        self
348    }
349
350    /// Draws the dashes only while files are over the window, for a zone that is a whole
351    /// screen and should not look like one until then.
352    pub fn quiet(mut self) -> Self {
353        self.quiet = true;
354        self
355    }
356
357    /// The space around the content, in logical pixels (`space-4` by default).
358    pub fn padding(mut self, padding: f32) -> Self {
359        self.padding = Some(padding);
360        self
361    }
362
363    /// The shortest it gets, in logical pixels.
364    pub fn min_height(mut self, height: f32) -> Self {
365        self.min_height = Some(height);
366        self
367    }
368
369    /// Takes the free space of the parent, for a zone that is a whole screen.
370    pub fn fill(mut self) -> Self {
371        self.fill = true;
372        self
373    }
374
375    /// Turns it off: it takes no files and does not react to them.
376    pub fn disabled(mut self, disabled: bool) -> Self {
377        self.disabled = disabled;
378        self
379    }
380}
381
382/// What a [`DropZone`] shows over its content while files are over the window.
383fn zone_hint(cx: &mut BuildCx<'_>, words: &str, size: ravenrs_core::Size) -> NodeId {
384    let theme = *cx.theme();
385    let accent = theme.color(Semantic::Accent);
386    let icon = util::icon(cx, IconName::CloudUpload, 22.0, accent);
387    let text = util::label(cx, words, TextStyle::Callout, None, theme.color(Semantic::Ink));
388    cx.semantics(text, Semantics::hidden());
389    let fill = theme.color(Semantic::Surface);
390    let node = cx.container(
391        LayoutStyle {
392            absolute: Some((0.0, 0.0)),
393            width: Some(size.width),
394            height: Some(size.height),
395            spacing: theme.spacing.s2,
396            align: Align::Center,
397            justify: Justify::Center,
398            ..LayoutStyle::stack(Axis::Vertical)
399        },
400        &[icon, text],
401        Paint::Quad(QuadPaint {
402            corner_radius: theme.radii.md,
403            ..QuadPaint::new(fill.with_alpha(fill.a * ZONE_HINT_OPACITY))
404        }),
405    );
406    cx.semantics(node, Semantics::new(Role::Status).name(words));
407    node
408}
409
410impl<C: View> View for DropZone<C> {
411    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
412        let theme = *cx.theme();
413        let labels = cx.labels();
414        let refused: State<Vec<(String, Refusal)>> = cx.use_state(Vec::new);
415        let over = cx.files_over() && !self.disabled;
416        let bring: Rc<dyn Fn(Vec<PickedFile>)> = {
417            let (rules, shown) = (self.rules.clone(), refused.clone());
418            let (on_files, on_refused) = (self.on_files.clone(), self.on_refused.clone());
419            let labels = labels.clone();
420            Rc::new(move |arrived| {
421                if arrived.is_empty() {
422                    return;
423                }
424                let mut kept = Vec::new();
425                let out = rules.take(&mut kept, arrived);
426                match &on_refused {
427                    Some(tell) if !out.is_empty() => {
428                        tell(out.iter().map(|(name, why)| refusal_text(&labels, name, *why)).collect());
429                    }
430                    Some(_) => {}
431                    None => shown.set(out),
432                }
433                if let (Some(on_files), false) = (&on_files, kept.is_empty()) {
434                    on_files(kept);
435                }
436            })
437        };
438
439        let mut rows = vec![cx.child(&self.content)];
440        push_refusals(cx, &refused.get(), &mut rows);
441        let words = self.hint.as_deref().unwrap_or(&labels.file_drop);
442        if over && self.show_hint {
443            let size = cx.scroll_viewport().unwrap_or(ravenrs_core::Size::ZERO);
444            rows.push(zone_hint(cx, words, size));
445        }
446
447        let accent = theme.color(Semantic::Accent);
448        let base = theme.color(Semantic::Border);
449        let edge = if over {
450            accent
451        } else if self.disabled {
452            util::faded(base)
453        } else {
454            base
455        };
456        let stroke = if over { ZONE_STROKE.1 } else { ZONE_STROKE.0 };
457        let tint = over.then(|| accent.with_alpha(0.08));
458        let dashed = over || !self.quiet;
459        let radius = theme.radii.md;
460        let root = cx.container(
461            LayoutStyle {
462                padding: Insets::uniform(self.padding.unwrap_or(theme.spacing.s4)),
463                spacing: theme.spacing.s2,
464                min_height: self.min_height.or(self.fill.then_some(0.0)),
465                min_width: self.fill.then_some(0.0),
466                grow: if self.fill { 1.0 } else { 0.0 },
467                align: Align::Stretch,
468                align_self: Some(Align::Stretch),
469                ..LayoutStyle::stack(Axis::Vertical)
470            },
471            &rows,
472            Paint::Custom(Rc::new(move |frame: Rect, scene: &mut Scene| {
473                if let Some(tint) = tint {
474                    scene.push_quad(Quad::new(frame, tint).radius(radius));
475                }
476                if dashed {
477                    let outline = util::dashed_outline(frame, radius, stroke, ZONE_DASH, ZONE_GAP);
478                    scene.push_path(PathFill::solid(outline, edge));
479                }
480            })),
481        );
482        // The hint needs the size; and a drop looks for its place among what can be pointed at.
483        cx.report_frame(root);
484        cx.cursor(
485            root,
486            if self.choosable && !self.disabled { CursorIcon::Pointer } else { CursorIcon::Default },
487        );
488        let name = self.label.as_deref().unwrap_or(words);
489        if self.disabled {
490            cx.semantics(root, Semantics::new(Role::Group).name(name).disabled(true));
491            return root;
492        }
493        if self.choosable {
494            let choose = chooser(cx, &self.rules, bring.clone());
495            cx.on_click(root, choose);
496            cx.focusable(root, radius);
497            cx.semantics(
498                root,
499                Semantics::new(Role::Button).name(name).description(labels.file_choose.clone()),
500            );
501        } else {
502            cx.semantics(root, Semantics::new(Role::Group).name(name));
503        }
504        cx.on_file_drop(root, bring);
505        root
506    }
507}
508
509/// A place to bring files, with the system's chooser or by dropping them, and the list
510/// of what came in. Refused files say why. The app reads [`PickedFile::bytes`], and
511/// sending them somewhere is up to it.
512///
513/// ```
514/// use ravenrs_reactive::State;
515/// use ravenrs_widgets::{FileUpload, PickedFile, Ui};
516///
517/// let files = State::new(Vec::<PickedFile>::new());
518/// let upload = FileUpload::new(files.binding()).accept(["pdf", "png"]).most_files(3);
519/// let mut ui = Ui::new(upload, ravenrs_style::Theme::raven_dark());
520/// ui.update();
521/// ```
522#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
523pub struct FileUpload {
524    files: Binding<Vec<PickedFile>>,
525    rules: Rules,
526    width: Option<f32>,
527    label: Option<String>,
528    state: util::EditorState,
529}
530
531impl fmt::Debug for FileUpload {
532    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
533        f.debug_struct("FileUpload").field("rules", &self.rules).finish_non_exhaustive()
534    }
535}
536
537#[cfg_attr(feature = "live", ravenrs_live::expose)]
538impl FileUpload {
539    /// A field bound to the files that came in.
540    pub fn new(files: Binding<Vec<PickedFile>>) -> Self {
541        Self {
542            files,
543            rules: Rules { extensions: Vec::new(), most_bytes: DEFAULT_MOST_BYTES, most_files: None },
544            width: None,
545            label: None,
546            state: util::EditorState::default(),
547        }
548    }
549
550    /// Only these extensions, such as `["pdf", "png"]`. Any file by default.
551    pub fn accept(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
552        self.rules.extensions = normalized(extensions);
553        self
554    }
555
556    /// The largest file taken, in bytes. 100 MiB by default.
557    pub fn most_bytes(mut self, bytes: u64) -> Self {
558        self.rules.most_bytes = bytes;
559        self
560    }
561
562    /// The most files kept at once. No limit by default.
563    pub fn most_files(mut self, files: usize) -> Self {
564        self.rules.most_files = Some(files.max(1));
565        self
566    }
567
568    /// Width in logical pixels. It takes the width of its parent by default.
569    pub fn width(mut self, width: f32) -> Self {
570        self.width = Some(width);
571        self
572    }
573
574    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
575    pub fn label(mut self, label: impl Into<String>) -> Self {
576        self.label = Some(label.into());
577        self
578    }
579
580    /// Turns the field off: it fades, leaves the tab order and ignores input.
581    pub fn disabled(mut self, disabled: bool) -> Self {
582        self.state.disabled = disabled;
583        self
584    }
585
586    /// Shows the files without letting them change.
587    pub fn read_only(mut self, read_only: bool) -> Self {
588        self.state.read_only = read_only;
589        self
590    }
591}
592
593struct FileRow<'a> {
594    file: &'a PickedFile,
595    files: Option<Binding<Vec<PickedFile>>>,
596}
597
598impl View for FileRow<'_> {
599    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
600        let theme = *cx.theme();
601        let icon = util::icon(cx, IconName::File, 16.0, theme.color(Semantic::InkMuted));
602        let name = cx.text_truncated(
603            &self.file.name,
604            &util::attrs(&theme, TextStyle::Body, None),
605            theme.color(Semantic::Ink),
606            LayoutStyle { grow: 1.0, min_width: Some(0.0), ..LayoutStyle::leaf() },
607        );
608        let size = util::label(
609            cx,
610            &size_text(self.file.size),
611            TextStyle::Callout,
612            None,
613            theme.color(Semantic::InkMuted),
614        );
615        let mut parts = vec![icon, name, size];
616        if let Some(files) = &self.files {
617            let (files, gone) = (files.clone(), self.file.name.clone());
618            let label = format!("{} {}", cx.labels().file_remove, self.file.name);
619            parts.push(
620                cx.child(
621                    &IconButton::new(IconName::X)
622                        .label(label)
623                        .size(ButtonSize::Small)
624                        .on_click(move || files.update(|all| all.retain(|f| f.name != gone))),
625                ),
626            );
627        }
628        let node = cx.container(
629            LayoutStyle {
630                min_height: Some(32.0),
631                spacing: 8.0,
632                align: Align::Center,
633                align_self: Some(Align::Stretch),
634                ..LayoutStyle::stack(Axis::Horizontal)
635            },
636            &parts,
637            Paint::None,
638        );
639        cx.semantics(node, Semantics::new(Role::ListItem).name(self.file.name.clone()));
640        node
641    }
642}
643
644impl View for FileUpload {
645    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
646        let theme = *cx.theme();
647        let labels = cx.labels();
648        let state = self.state;
649        let refused: State<Vec<(String, Refusal)>> = cx.use_state(Vec::new);
650        let bring: Rc<dyn Fn(Vec<PickedFile>)> = {
651            let (files, rules, refused) = (self.files.clone(), self.rules.clone(), refused.clone());
652            Rc::new(move |arrived| {
653                if arrived.is_empty() {
654                    return;
655                }
656                let mut kept = files.get();
657                refused.set(rules.take(&mut kept, arrived));
658                files.set(kept);
659            })
660        };
661        let mut rows = Vec::new();
662        if !state.read_only {
663            let over = cx.files_over() && state.accepts_input();
664            let muted = state.ink(theme.color(Semantic::InkMuted));
665            let icon = util::icon(cx, IconName::CloudUpload, 22.0, muted);
666            let hint = util::label(cx, &labels.file_drop, TextStyle::Callout, None, muted);
667            let choose = chooser(cx, &self.rules, bring.clone());
668            let mut button = Button::new(labels.file_choose.clone())
669                .style(ButtonStyle::Secondary)
670                .size(ButtonSize::Small)
671                .disabled(state.disabled);
672            if state.accepts_input() {
673                button = button.on_click(move || choose());
674            }
675            let button = cx.child(&button);
676            let mut hints = vec![hint];
677            if !self.rules.extensions.is_empty() {
678                let kinds = self.rules.extensions.join(", ");
679                hints.push(util::label(cx, &kinds, TextStyle::Caption, None, muted));
680            }
681            let words = cx.container(
682                LayoutStyle { spacing: 2.0, align: Align::Center, ..LayoutStyle::stack(Axis::Vertical) },
683                &hints,
684                Paint::None,
685            );
686            let accent = theme.color(Semantic::Accent);
687            let edge = if over { accent } else { state.ink(theme.color(Semantic::Border)) };
688            let zone = cx.container(
689                LayoutStyle {
690                    padding: Insets::uniform(16.0),
691                    spacing: 8.0,
692                    align: Align::Center,
693                    justify: Justify::Center,
694                    align_self: Some(Align::Stretch),
695                    ..LayoutStyle::stack(Axis::Vertical)
696                },
697                &[icon, words, button],
698                Paint::Quad(QuadPaint {
699                    corner_radius: theme.radii.md,
700                    border: Some(Border { width: if over { 1.5 } else { 1.0 }, color: edge }),
701                    ..QuadPaint::new(if over { accent.with_alpha(0.08) } else { Color::TRANSPARENT })
702                }),
703            );
704            if state.accepts_input() {
705                cx.on_file_drop(zone, bring.clone());
706            }
707            rows.push(zone);
708        }
709        push_refusals(cx, &refused.get(), &mut rows);
710        let files = self.files.get();
711        let can_remove = state.accepts_input();
712        for file in &files {
713            let row = FileRow { file, files: can_remove.then(|| self.files.clone()) };
714            rows.push(cx.keyed_child(util::hash_text(&file.name), &row));
715        }
716        let node = cx.container(
717            LayoutStyle {
718                width: self.width,
719                spacing: 8.0,
720                align: Align::Stretch,
721                align_self: Some(if self.width.is_some() { Align::Fit } else { Align::Stretch }),
722                ..LayoutStyle::stack(Axis::Vertical)
723            },
724            &rows,
725            Paint::None,
726        );
727        let mut semantics = Semantics::new(Role::List);
728        if let Some(label) = &self.label {
729            semantics = semantics.name(label.clone());
730        }
731        cx.semantics(node, state.semantics(semantics));
732        node
733    }
734}
735
736/// A picture chosen by the person: what it looks like now, and buttons to change it or
737/// take it off. Takes PNG and JPEG, and refuses what it cannot show.
738///
739/// ```
740/// use ravenrs_reactive::State;
741/// use ravenrs_widgets::{ImageField, PickedFile, Ui};
742///
743/// let photo = State::new(None::<PickedFile>);
744/// let field = ImageField::new(photo.binding()).size(160.0, 120.0);
745/// let mut ui = Ui::new(field, ravenrs_style::Theme::raven_dark());
746/// ui.update();
747/// ```
748#[cfg(feature = "decode")]
749#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
750pub struct ImageField {
751    value: Binding<Option<PickedFile>>,
752    most_bytes: u64,
753    width: f32,
754    height: f32,
755    label: Option<String>,
756    state: util::EditorState,
757}
758
759#[cfg(feature = "decode")]
760impl fmt::Debug for ImageField {
761    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
762        f.debug_struct("ImageField").field("label", &self.label).finish_non_exhaustive()
763    }
764}
765
766#[cfg(feature = "decode")]
767#[cfg_attr(feature = "live", ravenrs_live::expose)]
768impl ImageField {
769    /// A field bound to the picture's file.
770    pub fn new(value: Binding<Option<PickedFile>>) -> Self {
771        Self {
772            value,
773            most_bytes: DEFAULT_MOST_IMAGE_BYTES,
774            width: 160.0,
775            height: 120.0,
776            label: None,
777            state: util::EditorState::default(),
778        }
779    }
780
781    /// The largest file taken, in bytes. 20 MiB by default.
782    pub fn most_bytes(mut self, bytes: u64) -> Self {
783        self.most_bytes = bytes;
784        self
785    }
786
787    /// The size of the picture in logical pixels. 160 by 120 by default.
788    pub fn size(mut self, width: f32, height: f32) -> Self {
789        self.width = width;
790        self.height = height;
791        self
792    }
793
794    /// The name screen readers announce, and the picture's alternative text.
795    pub fn label(mut self, label: impl Into<String>) -> Self {
796        self.label = Some(label.into());
797        self
798    }
799
800    /// Turns the field off: it fades, leaves the tab order and ignores input.
801    pub fn disabled(mut self, disabled: bool) -> Self {
802        self.state.disabled = disabled;
803        self
804    }
805
806    /// Shows the picture without letting it change.
807    pub fn read_only(mut self, read_only: bool) -> Self {
808        self.state.read_only = read_only;
809        self
810    }
811}
812
813/// A decoded picture, kept so a rebuild does not decode it again.
814#[cfg(feature = "decode")]
815#[derive(Clone)]
816struct Decoded {
817    source: Rc<[u8]>,
818    image: Result<ravenrs_core::ImageData, String>,
819}
820
821#[cfg(feature = "decode")]
822impl View for ImageField {
823    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
824        let theme = *cx.theme();
825        let labels = cx.labels();
826        let state = self.state;
827        let refused: State<Option<String>> = cx.use_state(|| None);
828        let decoded: State<Option<Decoded>> = cx.use_state(|| None);
829        let current = self.value.get();
830        let image = current.as_ref().and_then(|file| {
831            let cached = decoded.peek().clone().filter(|d| Rc::ptr_eq(&d.source, &file.bytes));
832            let entry = cached.unwrap_or_else(|| {
833                let image = crate::image::decode_image(&file.bytes).map_err(|e| e.to_string());
834                let entry = Decoded { source: file.bytes.clone(), image };
835                decoded.set(Some(entry.clone()));
836                entry
837            });
838            entry.image.ok()
839        });
840        let picture = match (&image, &current) {
841            (Some(data), _) => {
842                let mut picture = crate::Image::new(data.clone())
843                    .content_mode(crate::ContentMode::Fit)
844                    .size(self.width, self.height)
845                    .corner_radius(theme.radii.md);
846                if let Some(label) = &self.label {
847                    picture = picture.alt(label.clone());
848                }
849                cx.child(&picture)
850            }
851            (None, broken) => {
852                let muted = state.ink(theme.color(Semantic::InkMuted));
853                let icon = util::icon(cx, IconName::Image, 28.0, muted);
854                let mut parts = vec![icon];
855                if broken.is_some() {
856                    parts.push(
857                        cx.child(&crate::Text::new(labels.image_invalid.clone()).style(TextStyle::Caption)),
858                    );
859                }
860                let node = cx.container(
861                    LayoutStyle {
862                        width: Some(self.width),
863                        height: Some(self.height),
864                        spacing: 4.0,
865                        align: Align::Center,
866                        justify: Justify::Center,
867                        ..LayoutStyle::stack(Axis::Vertical)
868                    },
869                    &parts,
870                    Paint::Quad(QuadPaint {
871                        corner_radius: theme.radii.md,
872                        border: Some(Border { width: 1.0, color: state.ink(theme.color(Semantic::Border)) }),
873                        ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
874                    }),
875                );
876                let name = self.label.clone().unwrap_or_else(|| labels.image_none.clone());
877                cx.semantics(node, Semantics::new(Role::Image).name(name));
878                node
879            }
880        };
881        let mut rows = vec![picture];
882        let rules = Rules {
883            extensions: normalized(["png", "jpg", "jpeg"]),
884            most_bytes: self.most_bytes,
885            most_files: Some(1),
886        };
887        let bring: Rc<dyn Fn(Vec<PickedFile>)> = {
888            let (value, rules, refused, words) =
889                (self.value.clone(), rules.clone(), refused.clone(), labels.clone());
890            Rc::new(move |arrived| {
891                let Some(file) = arrived.into_iter().next() else { return };
892                let bad = rules.refusal(&file).map(|why| refusal_text(&words, &file.name, why));
893                refused.set(bad.clone());
894                if bad.is_none() {
895                    value.set(Some(file));
896                }
897            })
898        };
899        if state.accepts_input() {
900            cx.on_file_drop(picture, bring.clone());
901        }
902        if !state.read_only {
903            let (picker, on_files, waker) = (cx.file_picker(), bring, cx.ui_waker());
904            let request = FileRequest {
905                extensions: rules.extensions,
906                multiple: false,
907                most_bytes: Some(self.most_bytes),
908            };
909            let text = if current.is_some() { &labels.image_replace } else { &labels.image_choose };
910            let mut button = Button::new(text.clone())
911                .style(ButtonStyle::Secondary)
912                .size(ButtonSize::Small)
913                .disabled(state.disabled);
914            if state.accepts_input() {
915                button = button.on_click(move || {
916                    let (on_files, waker) = (on_files.clone(), waker.clone());
917                    picker.pick(&request, move |files| {
918                        on_files(files);
919                        waker.wake();
920                    });
921                });
922            }
923            let mut buttons = vec![cx.child(&button)];
924            if current.is_some() && state.accepts_input() {
925                let (value, refused) = (self.value.clone(), refused.clone());
926                buttons.push(
927                    cx.child(
928                        &Button::new(labels.file_remove.clone())
929                            .style(ButtonStyle::Ghost)
930                            .size(ButtonSize::Small)
931                            .on_click(move || {
932                                refused.set(None);
933                                value.set(None);
934                            }),
935                    ),
936                );
937            }
938            rows.push(cx.container(
939                LayoutStyle { spacing: 4.0, ..LayoutStyle::stack(Axis::Horizontal) },
940                &buttons,
941                Paint::None,
942            ));
943        }
944        if let Some(line) = refused.get() {
945            let text = util::label(cx, &line, TextStyle::Callout, None, theme.color(Semantic::Danger));
946            cx.semantics(text, Semantics::new(Role::Alert).name(line));
947            rows.push(text);
948        }
949        cx.container(
950            LayoutStyle { spacing: 8.0, align_self: Some(Align::Fit), ..LayoutStyle::stack(Axis::Vertical) },
951            &rows,
952            Paint::None,
953        )
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960
961    fn file(name: &str, size: usize) -> PickedFile {
962        PickedFile::new(name, vec![0u8; size])
963    }
964
965    #[test]
966    fn sizes_read_like_people_say_them() {
967        assert_eq!(size_text(0), "0 B");
968        assert_eq!(size_text(1023), "1023 B");
969        assert_eq!(size_text(1536), "1.5 KB");
970        assert_eq!(size_text(5 * 1024 * 1024), "5.0 MB");
971    }
972
973    #[test]
974    fn extensions_ignore_case_and_need_a_dot() {
975        assert_eq!(file("Foto.PNG", 0).extension().as_deref(), Some("png"));
976        assert_eq!(file("sem-extensao", 0).extension(), None);
977    }
978
979    #[test]
980    fn what_arrives_is_kept_or_refused_with_a_reason() {
981        let rules = Rules { extensions: normalized([".PDF", "png"]), most_bytes: 10, most_files: Some(2) };
982        let mut kept = vec![];
983        let refused = rules.take(
984            &mut kept,
985            vec![file("a.pdf", 1), file("b.exe", 1), file("c.png", 11), file("d.png", 1), file("e.png", 1)],
986        );
987        assert_eq!(kept.iter().map(|f| f.name.as_str()).collect::<Vec<_>>(), ["a.pdf", "d.png"]);
988        assert_eq!(
989            refused,
990            [
991                ("b.exe".to_owned(), Refusal::Kind),
992                ("c.png".to_owned(), Refusal::Size),
993                ("e.png".to_owned(), Refusal::Count),
994            ]
995        );
996    }
997
998    #[test]
999    fn the_same_name_again_replaces_the_file() {
1000        let rules = Rules { extensions: vec![], most_bytes: 100, most_files: Some(1) };
1001        let mut kept = vec![file("a.pdf", 1)];
1002        assert!(rules.take(&mut kept, vec![file("a.pdf", 2)]).is_empty(), "takes no more room");
1003        assert_eq!(kept[0].size, 2);
1004    }
1005}