whisker_image/lib.rs
1//! `whisker-image` — networked image component.
2//!
3//! **API shape — 1 (pure Component).** See
4//! [`docs/module-api-design.md`](https://github.com/whiskerrs/whisker/blob/main/docs/module-api-design.md)
5//! §"Shape 1". All state is captured by props; no imperative
6//! handle. Mounts a `whisker-image:Image` element backed by:
7//!
8//! - **iOS**: `UIImageView` + [Kingfisher](https://github.com/onevcat/Kingfisher)
9//! for URL fetching, in-memory `NSCache`, and on-disk cache.
10//! - **Android**: `ImageView` + [Coil](https://coil-kt.github.io/coil/)
11//! for URL fetching, `LruCache`, and disk cache.
12//!
13//! ## Why a separate module instead of Lynx's `<image>`?
14//!
15//! Lynx ships a `LynxServiceImageProtocol` interface that's expected
16//! to be implemented + registered by the host app (Lynx's own
17//! `LynxImageService` uses SDWebImage on iOS / Fresco on Android,
18//! but it's a separate subspec that consumers wire themselves). The
19//! Whisker iOS / Android distribution doesn't include any
20//! implementation, so a bare `<image src="…">` mounts a `UIImageView`
21//! whose `image` property never gets assigned. `whisker-image` skips
22//! the Lynx image stack entirely and drives the URL load from the
23//! native module directly — same idea as `whisker-video` for media
24//! playback.
25//!
26//! ## Usage
27//!
28//! ```ignore
29//! use whisker::prelude::*;
30//! use whisker_image::{Image, ImageMode, ImageProps};
31//!
32//! #[whisker::main]
33//! fn app() -> Element {
34//! render! {
35//! Image(
36//! src: "https://example.com/cover.jpg",
37//! mode: ImageMode::AspectFill,
38//! style: "width: 240px; height: 240px; border-radius: 8px;",
39//! )
40//! }
41//! }
42//! ```
43//!
44//! ## Props
45//!
46//! - `src` — image URL (HTTPS recommended; `http://` works if the
47//! host app's network security config allows cleartext).
48//! - `mode` — content fit. Takes the typed [`ImageMode`] enum;
49//! defaults to [`ImageMode::AspectFill`].
50//! - `style` — standard Whisker style string. Width / height must be
51//! set on the element (or via flex sizing) — Kingfisher / Coil
52//! target-size the fetched bitmap against the rendered size, so an
53//! element with `width: 0; height: 0;` would never paint.
54//!
55//! ## Native source
56//!
57//! Contributors: the matching platform module lives at
58//!
59//! - iOS: `packages/whisker-image/ios/Sources/WhiskerImage/ImageModule.swift`
60//! (view: `ImageView.swift`)
61//! - Android: `packages/whisker-image/android/src/main/kotlin/rs/whisker/elements/image/ImageModule.kt`
62//! (view: `WhiskerImageView.kt`)
63
64use whisker::Signal;
65
66/// Content-fit mode for an [`Image`]. The variant names mirror the
67/// camelCase wire strings the iOS and Android image-view modules
68/// dispatch on (`packages/whisker-image/ios/Sources/WhiskerImage/`,
69/// `packages/whisker-image/android/src/main/kotlin/.../WhiskerImageView.kt`).
70///
71/// `#[non_exhaustive]` so a future fit mode (cover, contain, …) can
72/// be added without breaking exhaustive matches downstream.
73#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash, Default)]
74#[non_exhaustive]
75pub enum ImageMode {
76 /// `"aspectFill"` — scale to fill the box while preserving aspect
77 /// ratio, cropping the long edge. The default.
78 #[default]
79 AspectFill,
80 /// `"aspectFit"` — scale to fit inside the box while preserving
81 /// aspect ratio, letterboxing the short edge.
82 AspectFit,
83 /// `"scaleToFill"` — stretch to exactly fill the box, ignoring
84 /// the aspect ratio.
85 ScaleToFill,
86 /// `"center"` — render at the source's intrinsic size, centered.
87 Center,
88}
89
90impl ImageMode {
91 /// Canonical wire string. Locked by unit tests against the
92 /// native module's string dispatch table.
93 pub const fn as_str(self) -> &'static str {
94 match self {
95 Self::AspectFill => "aspectFill",
96 Self::AspectFit => "aspectFit",
97 Self::ScaleToFill => "scaleToFill",
98 Self::Center => "center",
99 }
100 }
101}
102
103impl std::fmt::Display for ImageMode {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.write_str(self.as_str())
106 }
107}
108
109/// `whisker-image:Image` element. All props are reactive — the
110/// platform-side setters re-apply whenever the bound signals change,
111/// so a `src` swap re-fetches and a `mode` swap re-lays-out without
112/// remount. Corners follow the standard CSS `border-radius` in the
113/// `style:` cascade (iOS clips via `UIView.layer.cornerRadius` +
114/// `clipsToBounds`; Android extracts the parsed radius from Lynx's
115/// `onBorderRadiusUpdated` callback and feeds it to Coil's
116/// `RoundedCornersTransformation`).
117#[whisker::module_component("Image")]
118pub fn image(src: Signal<String>, mode: Signal<ImageMode>, style: whisker::Style) {}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn image_mode_wire_strings() {
126 assert_eq!(ImageMode::AspectFill.as_str(), "aspectFill");
127 assert_eq!(ImageMode::AspectFit.as_str(), "aspectFit");
128 assert_eq!(ImageMode::ScaleToFill.as_str(), "scaleToFill");
129 assert_eq!(ImageMode::Center.as_str(), "center");
130 }
131
132 #[test]
133 fn image_mode_default_is_aspect_fill() {
134 assert_eq!(ImageMode::default(), ImageMode::AspectFill);
135 }
136
137 #[test]
138 fn image_mode_display_matches_as_str() {
139 assert_eq!(ImageMode::AspectFill.to_string(), "aspectFill");
140 }
141}