zng_wgt_image/
node.rs

1//! UI nodes used for building the image widget.
2
3use zng_ext_image::{IMAGES, ImageCacheMode, ImageOptions, ImageRenderArgs};
4use zng_wgt_stack::stack_nodes;
5
6use super::image_properties::{
7    IMAGE_ALIGN_VAR, IMAGE_AUTO_SCALE_VAR, IMAGE_CACHE_VAR, IMAGE_CROP_VAR, IMAGE_DOWNSCALE_VAR, IMAGE_ERROR_FN_VAR, IMAGE_FIT_VAR,
8    IMAGE_LIMITS_VAR, IMAGE_LOADING_FN_VAR, IMAGE_OFFSET_VAR, IMAGE_RENDERING_VAR, IMAGE_SCALE_VAR, ImageFit, ImgErrorArgs, ImgLoadingArgs,
9};
10use super::*;
11
12context_var! {
13    /// Image acquired by [`image_source`], or `"no image source in context"` error by default.
14    ///
15    /// Note that if [`CONTEXT_IMAGE_REDUCED_VAR`] is set it is rendered, not this image.
16    ///
17    /// [`image_source`]: fn@image_source
18    pub static CONTEXT_IMAGE_VAR: ImageEntry = no_context_image();
19
20    /// Reduced alternate entry used for rendering instead of [`CONTEXT_IMAGE_VAR`].
21    ///
22    /// Context is set to a mutable variable in [`image_source`] and value is set by [`image_presenter`]
23    /// when the image has multiple entries.
24    pub static CONTEXT_IMAGE_REDUCED_VAR: Option<ImageEntry> = None;
25}
26fn no_context_image() -> ImageEntry {
27    ImageEntry::new_error(Txt::from_static("no image source in context"))
28}
29
30/// Requests an image from [`IMAGES`] and sets [`CONTEXT_IMAGE_VAR`].
31///
32/// Caches the image if [`img_cache`] is `true` in the context.
33///
34/// The image is not rendered by this property, the [`image_presenter`] renders the image in [`CONTEXT_IMAGE_VAR`].
35///
36/// In a widget this should be placed inside context properties and before event properties.
37///
38/// [`img_cache`]: fn@crate::img_cache
39/// [`IMAGES`]: zng_ext_image::IMAGES
40pub fn image_source(child: impl IntoUiNode, source: impl IntoVar<ImageSource>) -> UiNode {
41    let source = source.into_var();
42    let ctx_img = var(ImageEntry::new_loading());
43    let child = with_context_var(child, CONTEXT_IMAGE_REDUCED_VAR, var(None));
44    let child = with_context_var(child, CONTEXT_IMAGE_VAR, ctx_img.read_only());
45    let mut img = var(ImageEntry::new_loading()).read_only();
46    let mut _ctx_binding = None;
47
48    match_node(child, move |child, op| match op {
49        UiNodeOp::Init => {
50            WIDGET
51                .sub_var(&source)
52                .sub_var(&IMAGE_CACHE_VAR)
53                .sub_var(&IMAGE_DOWNSCALE_VAR)
54                .sub_var(&IMAGE_ENTRIES_MODE_VAR);
55
56            let mode = if IMAGE_CACHE_VAR.get() {
57                ImageCacheMode::Cache
58            } else {
59                ImageCacheMode::Ignore
60            };
61
62            let mut source = source.get();
63            if let ImageSource::Render(_, args) = &mut source {
64                *args = Some(ImageRenderArgs::new(WINDOW.id()));
65            }
66            let opt = ImageOptions::new(mode, IMAGE_DOWNSCALE_VAR.get(), None, IMAGE_ENTRIES_MODE_VAR.get());
67            img = IMAGES.image(source, opt, IMAGE_LIMITS_VAR.get());
68
69            ctx_img.set_from(&img);
70            _ctx_binding = Some(img.bind(&ctx_img));
71        }
72        UiNodeOp::Deinit => {
73            child.deinit();
74
75            ctx_img.set(no_context_image());
76            img = var(no_context_image()).read_only();
77            _ctx_binding = None;
78        }
79        UiNodeOp::Update { .. } => {
80            if source.is_new() || IMAGE_DOWNSCALE_VAR.is_new() || IMAGE_ENTRIES_MODE_VAR.is_new() {
81                // source update:
82
83                let mut source = source.get();
84
85                if let ImageSource::Render(_, args) = &mut source {
86                    *args = Some(ImageRenderArgs::new(WINDOW.id()));
87                }
88
89                let mode = if IMAGE_CACHE_VAR.get() {
90                    ImageCacheMode::Cache
91                } else {
92                    ImageCacheMode::Ignore
93                };
94                let opt = ImageOptions::new(mode, IMAGE_DOWNSCALE_VAR.get(), None, IMAGE_ENTRIES_MODE_VAR.get());
95                img = IMAGES.image(source, opt, IMAGE_LIMITS_VAR.get());
96
97                ctx_img.set_from(&img);
98                _ctx_binding = Some(img.bind(&ctx_img));
99            } else if let Some(enabled) = IMAGE_CACHE_VAR.get_new() {
100                // cache-mode update:
101                let is_cached = ctx_img.with(|img| IMAGES.is_cached(img));
102                if enabled != is_cached {
103                    let source = source.get();
104                    let mut opt = ImageOptions::new(ImageCacheMode::Cache, IMAGE_DOWNSCALE_VAR.get(), None, IMAGE_ENTRIES_MODE_VAR.get());
105
106                    if is_cached {
107                        img = const_var(ImageEntry::new_loading());
108                        if let Some(h) = source.hash128(&opt) {
109                            IMAGES.clean(h);
110                        }
111                        opt.cache_mode = ImageCacheMode::Ignore;
112                    }
113                    img = IMAGES.image(source, opt, IMAGE_LIMITS_VAR.get());
114
115                    ctx_img.set_from(&img);
116                    _ctx_binding = Some(img.bind(&ctx_img));
117                }
118            }
119        }
120        _ => {}
121    })
122}
123
124context_local! {
125    /// Used to avoid recursion in [`image_error_presenter`].
126    static IN_ERROR_VIEW: bool = false;
127    /// Used to avoid recursion in [`image_loading_presenter`].
128    static IN_LOADING_VIEW: bool = false;
129}
130
131/// Presents the contextual [`IMAGE_ERROR_FN_VAR`] if the [`CONTEXT_IMAGE_VAR`] is an error.
132///
133/// The error view is rendered under the `child`.
134///
135/// The image widget adds this node around the [`image_presenter`] node.
136pub fn image_error_presenter(child: impl IntoUiNode) -> UiNode {
137    let view = CONTEXT_IMAGE_VAR
138        .map(|i| i.error().map(|e| ImgErrorArgs { error: e }))
139        .present_opt(IMAGE_ERROR_FN_VAR.map(|f| {
140            wgt_fn!(f, |e| {
141                if IN_ERROR_VIEW.get_clone() {
142                    UiNode::nil()
143                } else {
144                    with_context_local(f(e), &IN_ERROR_VIEW, true)
145                }
146            })
147        }));
148
149    stack_nodes(ui_vec![view, child], 1, |constraints, _, img_size| {
150        if img_size == PxSize::zero() {
151            constraints
152        } else {
153            PxConstraints2d::new_fill_size(img_size)
154        }
155    })
156}
157
158/// Presents the contextual [`IMAGE_LOADING_FN_VAR`] if [`is_loading`].
159///
160/// The loading view is rendered under the `child`.
161///
162/// The image widget adds this node around the [`image_error_presenter`] node.
163///
164/// [`is_loading`]: fn@crate::is_loading
165pub fn image_loading_presenter(child: impl IntoUiNode) -> UiNode {
166    let args = expr_var! {
167        let is_loading = match #{CONTEXT_IMAGE_REDUCED_VAR} {
168            Some(img) => img.is_loading(),
169            None => #{CONTEXT_IMAGE_VAR}.is_loading(),
170        };
171        if is_loading { Some(ImgLoadingArgs {}) } else { None }
172    };
173    let view = args.present_opt(IMAGE_LOADING_FN_VAR.map(|f| {
174        wgt_fn!(f, |a| {
175            if IN_LOADING_VIEW.get_clone() {
176                UiNode::nil()
177            } else {
178                with_context_local(f(a), &IN_LOADING_VIEW, true)
179            }
180        })
181    }));
182
183    stack_nodes(ui_vec![view, child], 1, |constraints, _, img_size| {
184        if img_size == PxSize::zero() {
185            constraints
186        } else {
187            PxConstraints2d::new_fill_size(img_size)
188        }
189    })
190}
191
192/// Renders the [`CONTEXT_IMAGE_VAR`] if set.
193///
194/// This is the inner-most node of an image widget, it is fully configured by context variables:
195///
196/// * [`CONTEXT_IMAGE_VAR`]: Defines the image to render.
197/// * [`IMAGE_CROP_VAR`]: Clip the image before layout.
198/// * [`IMAGE_AUTO_SCALE_VAR`]: If the image desired size is scaled by pixel density.
199/// * [`IMAGE_SCALE_VAR`]: Custom scale applied to the desired size.
200/// * [`IMAGE_FIT_VAR`]: Defines the image final size.
201/// * [`IMAGE_ALIGN_VAR`]: Defines the image alignment in the presenter final size.
202/// * [`IMAGE_RENDERING_VAR`]: Defines the image resize algorithm used in the GPU.
203/// * [`IMAGE_OFFSET_VAR`]: Defines an offset applied to the image after all measure and arrange.
204/// * [`CONTEXT_IMAGE_REDUCED_VAR`]: Var set by this node to a reduced size alternate entry in multi entry images.
205pub fn image_presenter() -> UiNode {
206    let mut img_size = PxSize::zero();
207    let mut render_clip = PxRect::zero();
208    let mut render_img_size = PxSize::zero();
209    let mut render_tile_size = PxSize::zero();
210    let mut render_tile_spacing = PxSize::zero();
211    let mut render_offset = PxVector::zero();
212    let spatial_id = SpatialFrameId::new_unique();
213    let mut reduced_img = None;
214
215    match_node_leaf(move |op| match op {
216        UiNodeOp::Init => {
217            WIDGET
218                .sub_var(&CONTEXT_IMAGE_VAR)
219                .sub_var_layout(&IMAGE_CROP_VAR)
220                .sub_var_layout(&IMAGE_AUTO_SCALE_VAR)
221                .sub_var_layout(&IMAGE_SCALE_VAR)
222                .sub_var_layout(&IMAGE_FIT_VAR)
223                .sub_var_layout(&IMAGE_ALIGN_VAR)
224                .sub_var_layout(&IMAGE_OFFSET_VAR)
225                .sub_var_layout(&IMAGE_REPEAT_VAR)
226                .sub_var_layout(&IMAGE_REPEAT_SPACING_VAR)
227                .sub_var_render(&IMAGE_RENDERING_VAR);
228
229            img_size = CONTEXT_IMAGE_VAR.with(ImageEntry::size);
230        }
231        UiNodeOp::Deinit => {
232            if reduced_img.take().is_some() {
233                CONTEXT_IMAGE_REDUCED_VAR.set(None);
234            }
235        }
236        UiNodeOp::Update { .. } => {
237            if let Some(img) = CONTEXT_IMAGE_VAR.get_new() {
238                let ig_size = img.size();
239                if img_size != ig_size {
240                    img_size = ig_size;
241                    if reduced_img.take().is_some() {
242                        CONTEXT_IMAGE_REDUCED_VAR.set(None);
243                    }
244                    WIDGET.layout();
245                } else {
246                    if img.has_entries() {
247                        // same size, means same tile size, means can rebound reduced here
248                        let r = img.best_reduce(render_tile_size);
249                        let h = r.subscribe(UpdateOp::Render, WIDGET.id());
250                        r.set_bind_map(&CONTEXT_IMAGE_REDUCED_VAR, |i| Some(i.clone())).perm();
251                        reduced_img = Some((r, h));
252                    } else if reduced_img.take().is_some() {
253                        CONTEXT_IMAGE_REDUCED_VAR.set(None);
254                    }
255                    WIDGET.render();
256                }
257            }
258        }
259        UiNodeOp::Measure { desired_size, .. } => {
260            // Similar to `layout` Part 1.
261
262            let metrics = LAYOUT.metrics();
263
264            let mut scale = IMAGE_SCALE_VAR.get();
265            match IMAGE_AUTO_SCALE_VAR.get() {
266                ImageAutoScale::Pixel => {}
267                ImageAutoScale::Factor => {
268                    scale *= metrics.scale_factor();
269                }
270                ImageAutoScale::Density => {
271                    let screen = metrics.screen_density();
272                    let image = CONTEXT_IMAGE_VAR.with(ImageEntry::density).unwrap_or(PxDensity2d::splat(screen));
273                    scale *= Factor2d::new(screen.ppcm() / image.width.ppcm(), screen.ppcm() / image.height.ppcm());
274                }
275            }
276
277            let img_rect = PxRect::from_size(img_size);
278            let crop = LAYOUT.with_constraints(PxConstraints2d::new_fill_size(img_size), || {
279                let mut r = IMAGE_CROP_VAR.get();
280                r.replace_default(&img_rect.into());
281                r.layout()
282            });
283            let render_clip = img_rect.intersection(&crop).unwrap_or_default() * scale;
284
285            let min_size = metrics.constraints().clamp_size(render_clip.size);
286            let wgt_ratio = metrics.constraints().with_min_size(min_size).fill_ratio(render_clip.size);
287
288            *desired_size = metrics.constraints().inner().fill_size_or(wgt_ratio);
289        }
290        UiNodeOp::Layout { final_size, .. } => {
291            // Part 1 - Scale & Crop
292            // - Starting from the image pixel size, apply scaling then crop.
293
294            let metrics = LAYOUT.metrics();
295
296            let mut scale = IMAGE_SCALE_VAR.get();
297            match IMAGE_AUTO_SCALE_VAR.get() {
298                ImageAutoScale::Pixel => {}
299                ImageAutoScale::Factor => {
300                    scale *= metrics.scale_factor();
301                }
302                ImageAutoScale::Density => {
303                    let screen = metrics.screen_density();
304                    let image = CONTEXT_IMAGE_VAR.with(ImageEntry::density).unwrap_or(PxDensity2d::splat(screen));
305                    scale *= Factor2d::new(screen.ppcm() / image.width.ppcm(), screen.ppcm() / image.height.ppcm());
306                }
307            }
308
309            // webrender needs the full image size, we offset and clip it to render the final image.
310            let mut r_img_size = img_size * scale;
311
312            // crop is relative to the unscaled pixel size, then applied scaled as the clip.
313            let img_rect = PxRect::from_size(img_size);
314            let crop = LAYOUT.with_constraints(PxConstraints2d::new_fill_size(img_size), || {
315                let mut r = IMAGE_CROP_VAR.get();
316                r.replace_default(&img_rect.into());
317                r.layout()
318            });
319            let mut r_clip = img_rect.intersection(&crop).unwrap_or_default() * scale;
320            let mut r_offset = -r_clip.origin.to_vector();
321
322            // Part 2 - Fit, Align & Clip
323            // - Fit the cropped and scaled image to the constraints, add a bounds clip to the crop clip.
324
325            let mut align = IMAGE_ALIGN_VAR.get();
326
327            let constraints = metrics.constraints();
328            let min_size = constraints.clamp_size(r_clip.size);
329            let wgt_ratio = constraints.with_min_size(min_size).fill_ratio(r_clip.size);
330            let wgt_size = constraints.inner().fill_size_or(wgt_ratio);
331
332            let mut fit = IMAGE_FIT_VAR.get();
333            if let ImageFit::ScaleDown = fit {
334                if r_clip.size.width < wgt_size.width && r_clip.size.height < wgt_size.height {
335                    fit = ImageFit::None;
336                } else {
337                    fit = ImageFit::Contain;
338                }
339            }
340            match fit {
341                ImageFit::Fill => {
342                    align = Align::FILL;
343                }
344                ImageFit::Contain => {
345                    let container = wgt_size.to_f32();
346                    let content = r_clip.size.to_f32();
347                    let scale = (container.width / content.width).min(container.height / content.height).fct();
348                    r_clip *= scale;
349                    r_img_size *= scale;
350                    r_offset *= scale;
351                }
352                ImageFit::Cover => {
353                    let container = wgt_size.to_f32();
354                    let content = r_clip.size.to_f32();
355                    let scale = (container.width / content.width).max(container.height / content.height).fct();
356                    r_clip *= scale;
357                    r_img_size *= scale;
358                    r_offset *= scale;
359                }
360                ImageFit::None => {}
361                ImageFit::ScaleDown => unreachable!(),
362            }
363
364            if align.is_fill_x() {
365                let factor = wgt_size.width.0 as f32 / r_clip.size.width.0 as f32;
366                r_clip.size.width = wgt_size.width;
367                r_clip.origin.x *= factor;
368                r_img_size.width *= factor;
369                r_offset.x = -r_clip.origin.x;
370            } else {
371                let diff = wgt_size.width - r_clip.size.width;
372                let offset = diff * align.x(metrics.direction());
373                r_offset.x += offset;
374                if diff < Px(0) {
375                    r_clip.origin.x -= offset;
376                    r_clip.size.width += diff;
377                }
378            }
379            if align.is_fill_y() {
380                let factor = wgt_size.height.0 as f32 / r_clip.size.height.0 as f32;
381                r_clip.size.height = wgt_size.height;
382                r_clip.origin.y *= factor;
383                r_img_size.height *= factor;
384                r_offset.y = -r_clip.origin.y;
385            } else {
386                let diff = wgt_size.height - r_clip.size.height;
387                let offset = diff * align.y();
388                r_offset.y += offset;
389                if diff < Px(0) {
390                    r_clip.origin.y -= offset;
391                    r_clip.size.height += diff;
392                }
393            }
394
395            // Part 3 - Custom Offset and Update
396            let offset = LAYOUT.with_constraints(PxConstraints2d::new_fill_size(wgt_size), || IMAGE_OFFSET_VAR.layout());
397            if offset != PxVector::zero() {
398                r_offset += offset;
399
400                let screen_clip = PxRect::new(-r_offset.to_point(), wgt_size);
401                r_clip.origin -= offset;
402                r_clip = r_clip.intersection(&screen_clip).unwrap_or_default();
403            }
404
405            // Part 4 - Repeat
406            let mut r_tile_size = r_img_size;
407            let mut r_tile_spacing = PxSize::zero();
408            if matches!(IMAGE_REPEAT_VAR.get(), ImageRepeat::Repeat) {
409                r_clip = PxRect::from_size(wgt_size);
410                r_tile_size = r_img_size;
411                r_img_size = wgt_size;
412                r_offset = PxVector::zero();
413
414                let leftover = tile_leftover(r_tile_size, wgt_size);
415                r_tile_spacing = LAYOUT.with_constraints(PxConstraints2d::new_fill_size(r_tile_size), || {
416                    LAYOUT.with_leftover(Some(leftover.width), Some(leftover.height), || IMAGE_REPEAT_SPACING_VAR.layout())
417                });
418            }
419
420            if render_tile_size != r_tile_size {
421                CONTEXT_IMAGE_VAR.with(|img| {
422                    if img.has_entries() {
423                        let r = img.best_reduce(r_tile_size);
424                        let h = r.subscribe(UpdateOp::Render, WIDGET.id());
425                        r.set_bind_map(&CONTEXT_IMAGE_REDUCED_VAR, |i| Some(i.clone())).perm();
426                        reduced_img = Some((r, h));
427                    }
428                });
429            }
430
431            if render_clip != r_clip
432                || render_img_size != r_img_size
433                || render_offset != r_offset
434                || render_tile_size != r_tile_size
435                || render_tile_spacing != r_tile_spacing
436            {
437                render_clip = r_clip;
438                render_img_size = r_img_size;
439                render_offset = r_offset;
440                render_tile_size = r_tile_size;
441                render_tile_spacing = r_tile_spacing;
442                WIDGET.render();
443            }
444
445            *final_size = wgt_size;
446        }
447        UiNodeOp::Render { frame } => {
448            if render_clip.is_empty() {
449                return;
450            }
451
452            let render = |img: &ImageEntry| {
453                if render_offset != PxVector::zero() {
454                    let transform = PxTransform::from(render_offset);
455                    frame.push_reference_frame(spatial_id.into(), FrameValue::Value(transform), true, false, |frame| {
456                        frame.push_image(
457                            render_clip,
458                            render_img_size,
459                            render_tile_size,
460                            render_tile_spacing,
461                            img,
462                            IMAGE_RENDERING_VAR.get(),
463                        )
464                    });
465                } else {
466                    frame.push_image(
467                        render_clip,
468                        render_img_size,
469                        render_tile_size,
470                        render_tile_spacing,
471                        img,
472                        IMAGE_RENDERING_VAR.get(),
473                    );
474                }
475            };
476
477            if let Some((img, _)) = &reduced_img {
478                img.with(render);
479            } else {
480                CONTEXT_IMAGE_VAR.with(render);
481            }
482        }
483        _ => {}
484    })
485}
486
487fn tile_leftover(tile_size: PxSize, wgt_size: PxSize) -> PxSize {
488    if tile_size.is_empty() || wgt_size.is_empty() {
489        return PxSize::zero();
490    }
491
492    let full_leftover_x = wgt_size.width % tile_size.width;
493    let full_leftover_y = wgt_size.height % tile_size.height;
494    let full_tiles_x = wgt_size.width / tile_size.width;
495    let full_tiles_y = wgt_size.height / tile_size.height;
496    let spaces_x = full_tiles_x - Px(1);
497    let spaces_y = full_tiles_y - Px(1);
498    PxSize::new(
499        if spaces_x > Px(0) { full_leftover_x / spaces_x } else { Px(0) },
500        if spaces_y > Px(0) { full_leftover_y / spaces_y } else { Px(0) },
501    )
502}