Skip to main content

cranpose_services/
image_picker.rs

1//! Photo/image picker: choose a single image from the user's photo library.
2//!
3//! On iOS "photos" and "files" are distinct — a scanner's document photos live
4//! in the Photos library, which the file picker (`UIDocumentPicker`) cannot
5//! reach. The iOS backend registers a `UIImagePickerController`-based photo
6//! picker through [`set_platform_image_picker`]. The compiled-in default
7//! delegates to the file picker with an image filter, which is the right
8//! behavior on desktop (a file dialog), the web (`<input accept="image/*">`),
9//! and Android (SAF), where a plain file picker already surfaces images.
10
11use crate::file_picker::{default_file_picker, FileFilter, FilePickerOptions, PickerFuture};
12use cranpose_core::compositionLocalOfWithPolicy;
13use cranpose_core::CompositionLocal;
14use cranpose_core::CompositionLocalProvider;
15use cranpose_macros::composable;
16use std::cell::RefCell;
17use std::rc::Rc;
18
19/// Image extensions offered when the picker falls back to the file picker.
20pub const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "heic", "heif"];
21
22#[derive(thiserror::Error, Debug)]
23pub enum ImagePickerError {
24    #[error("image picking is not supported on this platform")]
25    Unsupported,
26    #[error("failed to pick an image: {0}")]
27    Failed(String),
28}
29
30/// Where an image comes from.
31#[derive(Clone, Copy, PartialEq, Eq, Debug)]
32pub enum ImageSource {
33    /// The user's photo library (or a file dialog on platforms without one).
34    PhotoLibrary,
35    /// A photo captured with the device camera.
36    Camera,
37}
38
39/// Picks or captures a single image and returns its encoded bytes.
40pub trait ImagePicker {
41    /// Present a picker/camera for `source` and resolve to the chosen image's
42    /// encoded bytes, or `None` if the user cancelled.
43    fn pick_image(
44        &self,
45        source: ImageSource,
46    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>>;
47}
48
49pub type ImagePickerRef = Rc<dyn ImagePicker>;
50
51thread_local! {
52    static PLATFORM_IMAGE_PICKER: RefCell<Option<ImagePickerRef>> = const { RefCell::new(None) };
53}
54
55/// Installs a platform image picker, replacing any previously installed one.
56/// The iOS backend registers a photo-library picker here.
57pub fn set_platform_image_picker(picker: ImagePickerRef) {
58    PLATFORM_IMAGE_PICKER.with(|cell| *cell.borrow_mut() = Some(picker));
59}
60
61/// Removes any registered platform image picker (tests and teardown).
62pub fn clear_platform_image_picker() {
63    PLATFORM_IMAGE_PICKER.with(|cell| *cell.borrow_mut() = None);
64}
65
66fn registered_platform_image_picker() -> Option<ImagePickerRef> {
67    PLATFORM_IMAGE_PICKER.with(|cell| cell.borrow().clone())
68}
69
70struct PlatformImagePicker;
71
72impl ImagePicker for PlatformImagePicker {
73    fn pick_image(
74        &self,
75        source: ImageSource,
76    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>> {
77        if let Some(picker) = registered_platform_image_picker() {
78            return picker.pick_image(source);
79        }
80        // No platform picker: a live camera needs one, but a library pick can
81        // fall back to the file picker filtered to images.
82        if source == ImageSource::Camera {
83            return Box::pin(async { Err(ImagePickerError::Unsupported) });
84        }
85        Box::pin(async {
86            let picker = default_file_picker();
87            let options = FilePickerOptions::default()
88                .with_title("Choose image")
89                .with_filter(FileFilter::new("Images", IMAGE_EXTENSIONS));
90            match picker.pick_file(options).await {
91                Ok(Some(entry)) => match entry.read_bytes().await {
92                    Ok(bytes) => Ok(Some(bytes)),
93                    Err(error) => Err(ImagePickerError::Failed(error.to_string())),
94                },
95                Ok(None) => Ok(None),
96                Err(error) => Err(ImagePickerError::Failed(error.to_string())),
97            }
98        })
99    }
100}
101
102pub fn default_image_picker() -> ImagePickerRef {
103    Rc::new(PlatformImagePicker)
104}
105
106pub fn local_image_picker() -> CompositionLocal<ImagePickerRef> {
107    thread_local! {
108        static LOCAL_IMAGE_PICKER: RefCell<Option<CompositionLocal<ImagePickerRef>>> = const { RefCell::new(None) };
109    }
110
111    LOCAL_IMAGE_PICKER.with(|cell| {
112        let mut local = cell.borrow_mut();
113        local
114            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_image_picker, Rc::ptr_eq))
115            .clone()
116    })
117}
118
119#[allow(non_snake_case)]
120#[composable]
121pub fn ProvideImagePicker(content: impl FnOnce()) {
122    let picker = cranpose_core::remember(default_image_picker).with(|state| state.clone());
123    let local = local_image_picker();
124
125    CompositionLocalProvider(vec![local.provides(picker)], move || {
126        content();
127    });
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use std::cell::RefCell;
134
135    struct FixedImagePicker {
136        bytes: RefCell<Option<Vec<u8>>>,
137    }
138
139    impl ImagePicker for FixedImagePicker {
140        fn pick_image(
141            &self,
142            _source: ImageSource,
143        ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>> {
144            let bytes = self.bytes.borrow().clone();
145            Box::pin(async move { Ok(bytes) })
146        }
147    }
148
149    #[test]
150    fn registered_image_picker_takes_precedence() {
151        clear_platform_image_picker();
152        set_platform_image_picker(Rc::new(FixedImagePicker {
153            bytes: RefCell::new(Some(vec![1, 2, 3])),
154        }));
155        let picker = default_image_picker();
156        let result = pollster::block_on(picker.pick_image(ImageSource::Camera));
157        assert_eq!(result.unwrap(), Some(vec![1, 2, 3]));
158        clear_platform_image_picker();
159    }
160
161    #[test]
162    fn camera_is_unsupported_without_a_platform_picker() {
163        clear_platform_image_picker();
164        let result = pollster::block_on(default_image_picker().pick_image(ImageSource::Camera));
165        assert!(matches!(result, Err(ImagePickerError::Unsupported)));
166    }
167}