1use 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
15const DEFAULT_MOST_BYTES: u64 = 100 * 1024 * 1024;
17#[cfg(feature = "decode")]
19const DEFAULT_MOST_IMAGE_BYTES: u64 = 20 * 1024 * 1024;
20
21#[derive(Clone, PartialEq, Eq)]
23#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
24pub struct PickedFile {
25 pub name: String,
27 pub size: u64,
29 pub bytes: Rc<[u8]>,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl PickedFile {
35 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 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#[derive(Clone, Debug, Default, PartialEq, Eq)]
55#[non_exhaustive]
56#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
57pub struct FileRequest {
58 pub extensions: Vec<String>,
60 pub multiple: bool,
62 pub most_bytes: Option<u64>,
64}
65
66pub trait FilePickerProvider {
68 fn pick(&self, request: &FileRequest, done: Box<dyn FnOnce(Vec<PickedFile>)>);
71
72 fn save(&self, _name: &str, _bytes: &[u8]) {}
75}
76
77#[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#[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 pub fn new(provider: impl FilePickerProvider + 'static) -> Self {
97 Self(Rc::new(provider))
98 }
99
100 pub fn pick(&self, request: &FileRequest, done: impl FnOnce(Vec<PickedFile>) + 'static) {
102 self.0.pick(request, Box::new(done));
103 }
104
105 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
123pub(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#[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 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 *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
197fn 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
214fn 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
225const ZONE_STROKE: (f32, f32) = (1.0, 2.0);
227const ZONE_DASH: f32 = 6.0;
228const ZONE_GAP: f32 = 4.0;
229const ZONE_HINT_OPACITY: f32 = 0.88;
231
232pub 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 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 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 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 pub fn accept(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
307 self.rules.extensions = normalized(extensions);
308 self
309 }
310
311 pub fn most_bytes(mut self, bytes: u64) -> Self {
313 self.rules.most_bytes = bytes;
314 self
315 }
316
317 pub fn most_files(mut self, files: usize) -> Self {
319 self.rules.most_files = Some(files.max(1));
320 self
321 }
322
323 pub fn label(mut self, label: impl Into<String>) -> Self {
325 self.label = Some(label.into());
326 self
327 }
328
329 pub fn hint(mut self, hint: impl Into<String>) -> Self {
332 self.hint = Some(hint.into());
333 self
334 }
335
336 pub fn no_hint(mut self) -> Self {
338 self.show_hint = false;
339 self
340 }
341
342 pub fn choosable(mut self) -> Self {
346 self.choosable = true;
347 self
348 }
349
350 pub fn quiet(mut self) -> Self {
353 self.quiet = true;
354 self
355 }
356
357 pub fn padding(mut self, padding: f32) -> Self {
359 self.padding = Some(padding);
360 self
361 }
362
363 pub fn min_height(mut self, height: f32) -> Self {
365 self.min_height = Some(height);
366 self
367 }
368
369 pub fn fill(mut self) -> Self {
371 self.fill = true;
372 self
373 }
374
375 pub fn disabled(mut self, disabled: bool) -> Self {
377 self.disabled = disabled;
378 self
379 }
380}
381
382fn 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 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#[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 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 pub fn accept(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
552 self.rules.extensions = normalized(extensions);
553 self
554 }
555
556 pub fn most_bytes(mut self, bytes: u64) -> Self {
558 self.rules.most_bytes = bytes;
559 self
560 }
561
562 pub fn most_files(mut self, files: usize) -> Self {
564 self.rules.most_files = Some(files.max(1));
565 self
566 }
567
568 pub fn width(mut self, width: f32) -> Self {
570 self.width = Some(width);
571 self
572 }
573
574 pub fn label(mut self, label: impl Into<String>) -> Self {
576 self.label = Some(label.into());
577 self
578 }
579
580 pub fn disabled(mut self, disabled: bool) -> Self {
582 self.state.disabled = disabled;
583 self
584 }
585
586 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#[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 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 pub fn most_bytes(mut self, bytes: u64) -> Self {
783 self.most_bytes = bytes;
784 self
785 }
786
787 pub fn size(mut self, width: f32, height: f32) -> Self {
789 self.width = width;
790 self.height = height;
791 self
792 }
793
794 pub fn label(mut self, label: impl Into<String>) -> Self {
796 self.label = Some(label.into());
797 self
798 }
799
800 pub fn disabled(mut self, disabled: bool) -> Self {
802 self.state.disabled = disabled;
803 self
804 }
805
806 pub fn read_only(mut self, read_only: bool) -> Self {
808 self.state.read_only = read_only;
809 self
810 }
811}
812
813#[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, ¤t) {
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}