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 std::sync::Arc;
12
13use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
14use cranpose_macros::composable;
15
16use crate::{
17    file_picker::{FileFilter, FilePickerOptions, PickerFuture, default_file_picker},
18    registry::ServiceRegistry,
19};
20
21/// Image extensions offered when the picker falls back to the file picker.
22pub const IMAGE_EXTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "bmp", "heic", "heif"];
23
24/// Why an image could not be picked.
25#[derive(thiserror::Error, Debug)]
26pub enum ImagePickerError {
27    /// The platform has no way to pick an image.
28    #[error("image picking is not supported on this platform")]
29    Unsupported,
30    /// The platform failed to show the picker or to read the chosen image. The text describes the
31    /// cause.
32    #[error("failed to pick an image: {0}")]
33    Failed(String),
34}
35
36/// Where an image comes from.
37#[derive(Clone, Copy, PartialEq, Eq, Debug)]
38pub enum ImageSource {
39    /// The user's photo library (or a file dialog on platforms without one).
40    PhotoLibrary,
41    /// A photo captured with the device camera.
42    Camera,
43}
44
45/// Picks or captures a single image and returns its encoded bytes.
46pub trait ImagePicker: Send + Sync {
47    /// Present a picker/camera for `source` and resolve to the chosen image's
48    /// encoded bytes, or `None` if the user cancelled.
49    fn pick_image(
50        &self,
51        source: ImageSource,
52    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>>;
53}
54
55/// Shared handle to an [`ImagePicker`].
56pub type ImagePickerRef = Arc<dyn ImagePicker>;
57
58static PLATFORM_IMAGE_PICKER: ServiceRegistry<dyn ImagePicker> = ServiceRegistry::new();
59
60/// Installs a platform image picker, replacing any previously installed one.
61/// The iOS backend registers a photo-library picker here.
62pub fn set_platform_image_picker(picker: ImagePickerRef) {
63    PLATFORM_IMAGE_PICKER.set(picker);
64}
65
66/// Removes any registered platform image picker (tests and teardown).
67pub fn clear_platform_image_picker() {
68    PLATFORM_IMAGE_PICKER.clear();
69}
70
71fn registered_platform_image_picker() -> Option<ImagePickerRef> {
72    PLATFORM_IMAGE_PICKER.get_or_warn("image picker")
73}
74
75struct PlatformImagePicker;
76
77impl ImagePicker for PlatformImagePicker {
78    fn pick_image(
79        &self,
80        source: ImageSource,
81    ) -> PickerFuture<Result<Option<Vec<u8>>, ImagePickerError>> {
82        if let Some(picker) = registered_platform_image_picker() {
83            return picker.pick_image(source);
84        }
85        if source == ImageSource::Camera {
86            return Box::pin(async { Err(ImagePickerError::Unsupported) });
87        }
88        Box::pin(async {
89            let picker = default_file_picker();
90            let options = FilePickerOptions::default()
91                .with_title("Choose image")
92                .with_filter(FileFilter::new("Images", IMAGE_EXTENSIONS));
93            match picker.pick_file(options).await {
94                Ok(Some(entry)) => match entry.read_all().await {
95                    Ok(bytes) => Ok(Some(bytes)),
96                    Err(error) => Err(ImagePickerError::Failed(error.to_string())),
97                },
98                Ok(None) => Ok(None),
99                Err(error) => Err(ImagePickerError::Failed(error.to_string())),
100            }
101        })
102    }
103}
104
105/// Returns the image picker that forwards to the installed platform picker, or else to the file
106/// picker with an image filter.
107pub fn default_image_picker() -> ImagePickerRef {
108    Arc::new(PlatformImagePicker)
109}
110
111/// The image picker a composable reads. It is the platform picker unless a composition local
112/// provider above it supplies another one.
113pub fn local_image_picker() -> CompositionLocal<ImagePickerRef> {
114    crate::composition_locals::cached_local(
115        |locals| &locals.image_picker,
116        || compositionLocalOfWithPolicy(default_image_picker, Arc::ptr_eq),
117    )
118}
119
120/// Provides the platform image picker to `content` through [`local_image_picker`].
121#[composable]
122pub fn ProvideImagePicker(content: impl FnOnce()) {
123    let picker = cranpose_core::remember(default_image_picker).with(|state| state.clone());
124    let local = local_image_picker();
125
126    CompositionLocalProvider([local.provides(picker)], move || {
127        content();
128    });
129}
130
131#[cfg(test)]
132#[path = "tests/image_picker_tests.rs"]
133mod tests;