Skip to main content

ravenrs_widgets/imaging/
viewing.rs

1//! Looking at whole pictures: two side by side or across a divider, and one over everything.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{
8    Color, CursorIcon, Effect, ImageData, Insets, Key, PathFill, Quad, Rect, Role, Scene, Size,
9};
10use ravenrs_layout::{Align, Axis, Justify, LayoutStyle};
11use ravenrs_reactive::{Binding, Observer};
12use ravenrs_style::{Semantic, TextStyle, Weight};
13
14use super::overlays::{fitted, gesture_paint, picture_inks};
15use super::{ZoomContent, ZoomControl, ZoomPanView, ZoomView, triangle};
16use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
17use crate::{ButtonSize, ButtonStyle, IconButton, IconName, Motion, View, util};
18
19// ---------------------------------------------------------------------------------------------
20// CompareView
21// ---------------------------------------------------------------------------------------------
22
23/// How a [`CompareView`] shows its two pictures.
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
25#[non_exhaustive]
26#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
27pub enum CompareMode {
28    /// One beside the other.
29    #[default]
30    SideBySide,
31    /// One over the other, each shown up to a divider that can be dragged.
32    Divider,
33}
34
35/// One of the two pictures of a [`CompareView`].
36#[derive(Clone, Copy, Debug, PartialEq, Eq)]
37#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
38pub enum CompareSide {
39    /// The first picture.
40    First,
41    /// The second picture.
42    Second,
43}
44
45/// The space between the two pictures side by side.
46const COMPARE_GAP: f32 = 2.0;
47const DIVIDER_KNOB: f32 = 15.0;
48
49/// Paints a whole picture fitted in `area`, shown only inside `window`.
50fn paint_fitted(scene: &mut Scene, picture: &ZoomContent, content: Size, area: Rect, window: Rect) {
51    let placed = fitted(area, content);
52    let scale = placed.width() / content.width.max(f32::EPSILON);
53    picture.paint(scene, window, placed, scale, 0.0);
54}
55
56/// Two pictures to tell apart: side by side, or one over the other with a divider between what
57/// shows of each. The divider follows a drag from anywhere, the arrows (with Shift, further),
58/// Home and End, and a double click puts it in the middle. A picture that does not have the
59/// proportions of the box is fitted in it, centered.
60///
61/// With [`chosen`](Self::chosen), side by side, a click or the arrows pick one of the two, which
62/// is marked with a check as well as a colored outline.
63///
64/// A drag of the divider paints without a build, so it costs no rebuilds if nothing else reads
65/// its position.
66///
67/// ```
68/// use ravenrs_core::{Color, ImageData};
69/// use ravenrs_widgets::{CompareMode, CompareView};
70///
71/// let before = ImageData::from_fn(64, 48, |_, _| Color::rgb(0.3, 0.3, 0.3));
72/// let after = ImageData::from_fn(64, 48, |_, _| Color::rgb(0.6, 0.5, 0.3));
73/// if let (Some(before), Some(after)) = (before, after) {
74///     let compare = CompareView::new(before, after).mode(CompareMode::Divider).size(480.0, 320.0);
75/// }
76/// ```
77#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
78pub struct CompareView {
79    first: ImageData,
80    second: ImageData,
81    mode: CompareMode,
82    divider: Option<Binding<f32>>,
83    chosen: Option<Binding<CompareSide>>,
84    captions: Option<(Option<String>, Option<String>)>,
85    width: Option<f32>,
86    height: Option<f32>,
87    grow: f32,
88    label: Option<String>,
89}
90
91impl fmt::Debug for CompareView {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.debug_struct("CompareView").field("mode", &self.mode).finish_non_exhaustive()
94    }
95}
96
97#[cfg_attr(feature = "live", ravenrs_live::expose)]
98impl CompareView {
99    /// Compares two pictures, side by side.
100    pub fn new(first: ImageData, second: ImageData) -> Self {
101        Self {
102            first,
103            second,
104            mode: CompareMode::SideBySide,
105            divider: None,
106            chosen: None,
107            captions: Some((None, None)),
108            width: None,
109            height: None,
110            grow: 1.0,
111            label: None,
112        }
113    }
114
115    /// How it shows them.
116    pub fn mode(mut self, mode: CompareMode) -> Self {
117        self.mode = mode;
118        self
119    }
120
121    /// Where the divider is, from 0 (all of the second) to 1 (all of the first). Without this it
122    /// keeps its own, starting in the middle.
123    pub fn divider(mut self, divider: Binding<f32>) -> Self {
124        self.divider = Some(divider);
125        self
126    }
127
128    /// Which of the two is picked, side by side.
129    pub fn chosen(mut self, chosen: Binding<CompareSide>) -> Self {
130        self.chosen = Some(chosen);
131        self
132    }
133
134    /// What the small captions over the pictures say, instead of those in
135    /// [`Labels`](crate::Labels).
136    pub fn captions(mut self, first: impl Into<String>, second: impl Into<String>) -> Self {
137        self.captions = Some((Some(first.into()), Some(second.into())));
138        self
139    }
140
141    /// Shows no captions.
142    pub fn no_captions(mut self) -> Self {
143        self.captions = None;
144        self
145    }
146
147    /// Fixed size in logical pixels, instead of taking the free space.
148    pub fn size(mut self, width: f32, height: f32) -> Self {
149        self.width = Some(width);
150        self.height = Some(height);
151        self.grow = 0.0;
152        self
153    }
154
155    /// How much of the free space along the parent stack it takes (1 by default).
156    pub fn grow(mut self, grow: f32) -> Self {
157        self.grow = grow;
158        self
159    }
160
161    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
162    pub fn label(mut self, label: impl Into<String>) -> Self {
163        self.label = Some(label.into());
164        self
165    }
166}
167
168/// A small caption over a picture.
169fn compare_caption(cx: &mut BuildCx<'_>, text: &str, x: f32, chosen: bool) -> NodeId {
170    let theme = *cx.theme();
171    let (fill, ink) = if chosen {
172        (theme.color(Semantic::Accent), theme.color(Semantic::OnAccent))
173    } else {
174        (theme.color(Semantic::SurfaceElevated), theme.color(Semantic::Ink))
175    };
176    let mut parts = Vec::with_capacity(2);
177    if chosen {
178        parts.push(util::icon(cx, IconName::Check, 12.0, ink));
179    }
180    parts.push(util::label(cx, text, TextStyle::Caption, Some(Weight::MEDIUM), ink));
181    cx.container(
182        LayoutStyle {
183            absolute: Some((x, theme.spacing.s2)),
184            padding: Insets::symmetric(2.0, 8.0),
185            spacing: theme.spacing.s1,
186            align: Align::Center,
187            ..LayoutStyle::stack(Axis::Horizontal)
188        },
189        &parts,
190        Paint::Quad(QuadPaint { corner_radius: theme.radii.sm, ..QuadPaint::new(fill) }),
191    )
192}
193
194/// The width of a caption with its padding, to place one against the right side of a box.
195fn caption_width(cx: &mut BuildCx<'_>, text: &str, chosen: bool) -> f32 {
196    let theme = *cx.theme();
197    let attrs = util::attrs(&theme, TextStyle::Caption, Some(Weight::MEDIUM));
198    let extra = if chosen { 12.0 + theme.spacing.s1 } else { 0.0 };
199    cx.measure_text(text, &attrs).width.ceil() + 16.0 + extra
200}
201
202impl View for CompareView {
203    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
204        let theme = *cx.theme();
205        let labels = cx.labels();
206        let live = cx.use_state(|| false);
207        let is_live = live.get();
208        let own = cx.use_state(|| 0.5_f32);
209        let divider = self.divider.clone().unwrap_or_else(|| own.binding());
210        let at = if is_live { Observer::untracked(|| divider.get()) } else { divider.get() };
211        let chosen = self.chosen.as_ref().map(Binding::get);
212        let side_by_side = self.mode == CompareMode::SideBySide;
213        let (first, second) =
214            (ZoomContent::Image(self.first.clone()), ZoomContent::Image(self.second.clone()));
215        let size_of = |image: &ImageData| Size::new(image.width() as f32, image.height() as f32);
216        let (first_size, second_size) = (size_of(&self.first), size_of(&self.second));
217        let (background, accent) = (theme.color(Semantic::Canvas), theme.color(Semantic::Accent));
218        let (light, dark) = picture_inks(&theme);
219        let shown_divider = divider.clone();
220        let paint = gesture_paint(is_live, move |frame: Rect, scene: &mut Scene| {
221            scene.push_quad(Quad::new(frame, background));
222            if side_by_side {
223                let half = (frame.width() - COMPARE_GAP) / 2.0;
224                let left = Rect::new(frame.x(), frame.y(), half, frame.height());
225                let right = Rect::new(left.max_x() + COMPARE_GAP, frame.y(), half, frame.height());
226                paint_fitted(scene, &first, first_size, left, left);
227                paint_fitted(scene, &second, second_size, right, right);
228                if let Some(side) = chosen {
229                    let pane = if side == CompareSide::First { left } else { right };
230                    scene.push_quad(Quad::new(pane, Color::TRANSPARENT).border(2.0, accent));
231                }
232                return;
233            }
234            let x = frame.x() + frame.width() * shown_divider.get().clamp(0.0, 1.0);
235            let left = Rect::new(frame.x(), frame.y(), x - frame.x(), frame.height());
236            let right = Rect::new(x, frame.y(), frame.max_x() - x, frame.height());
237            paint_fitted(scene, &first, first_size, frame, left);
238            paint_fitted(scene, &second, second_size, frame, right);
239            // A dark line each side, so that it shows over a bright picture as well.
240            scene.push_quad(Quad::new(
241                Rect::new(x - 1.5, frame.y(), 3.0, frame.height()),
242                dark.with_alpha(0.5),
243            ));
244            scene.push_quad(Quad::new(Rect::new(x - 0.5, frame.y(), 1.0, frame.height()), light));
245            let middle = frame.center().y;
246            let knob =
247                Rect::new(x - DIVIDER_KNOB, middle - DIVIDER_KNOB, DIVIDER_KNOB * 2.0, DIVIDER_KNOB * 2.0);
248            scene.push_quad(Quad::new(knob, light).radius(DIVIDER_KNOB).border(1.0, dark.with_alpha(0.5)));
249            for (tip, base) in [(-9.0, -3.5), (9.0, 3.5)] {
250                let arrow = triangle([(x + tip, middle), (x + base, middle - 4.5), (x + base, middle + 4.5)]);
251                scene.push_path(PathFill::solid(arrow, dark));
252            }
253        });
254        let width = cx.scroll_viewport().map_or(self.width.unwrap_or(0.0), |s| s.width);
255        let mut captions = Vec::with_capacity(2);
256        if let Some((a, b)) = &self.captions {
257            let a = a.clone().unwrap_or_else(|| labels.compare_first.clone());
258            let b = b.clone().unwrap_or_else(|| labels.compare_second.clone());
259            let inset = theme.spacing.s2;
260            let (a_chosen, b_chosen) =
261                (chosen == Some(CompareSide::First), chosen == Some(CompareSide::Second));
262            captions.push(compare_caption(cx, &a, inset, side_by_side && a_chosen));
263            let x = if side_by_side {
264                (width + COMPARE_GAP) / 2.0 + inset
265            } else {
266                width - inset - caption_width(cx, &b, false)
267            };
268            captions.push(compare_caption(cx, &b, x, side_by_side && b_chosen));
269        }
270        let node = cx.container(
271            LayoutStyle {
272                width: self.width,
273                height: self.height,
274                grow: self.grow,
275                align_self: Some(if self.width.is_some() { Align::Start } else { Align::Stretch }),
276                overflow_hidden: true,
277                ..LayoutStyle::stack(Axis::Vertical)
278            },
279            &captions,
280            paint,
281        );
282        cx.report_frame(node);
283        let name = self.label.clone().unwrap_or_else(|| labels.compare.clone());
284        cx.focusable(node, theme.radii.sm);
285        if side_by_side {
286            let mut semantics = Semantics::new(Role::Group).name(name);
287            if let (Some(side), Some((a, b))) = (chosen, &self.captions) {
288                let (a, b) = (
289                    a.clone().unwrap_or_else(|| labels.compare_first.clone()),
290                    b.clone().unwrap_or_else(|| labels.compare_second.clone()),
291                );
292                semantics = semantics.value(if side == CompareSide::First { a } else { b });
293            }
294            cx.semantics(node, semantics);
295            let Some(chosen) = self.chosen.clone() else { return node };
296            cx.cursor(node, CursorIcon::Pointer);
297            let clicked = chosen.clone();
298            cx.on_drag(
299                node,
300                Rc::new(move |d| {
301                    if d.phase == DragPhase::Start {
302                        let side = if d.position.x < d.frame.center().x {
303                            CompareSide::First
304                        } else {
305                            CompareSide::Second
306                        };
307                        if clicked.get() != side {
308                            clicked.set(side);
309                        }
310                    }
311                }),
312            );
313            cx.on_key(
314                node,
315                Rc::new(move |key, _| {
316                    let side = match key {
317                        Key::ArrowLeft => CompareSide::First,
318                        Key::ArrowRight => CompareSide::Second,
319                        _ => return false,
320                    };
321                    chosen.set(side);
322                    true
323                }),
324            );
325            return node;
326        }
327        let percent = (at.clamp(0.0, 1.0) * 100.0).round();
328        cx.semantics(
329            node,
330            Semantics::new(Role::Slider).name(name).value(format!("{percent}%")).numeric(
331                f64::from(percent),
332                0.0,
333                100.0,
334                Some(1.0),
335            ),
336        );
337        cx.cursor(node, CursorIcon::ColResize);
338        let dragged = divider.clone();
339        cx.on_drag(
340            node,
341            Rc::new(move |d| {
342                match d.phase {
343                    DragPhase::Start => live.set_if_changed(true),
344                    DragPhase::End => live.set_if_changed(false),
345                    DragPhase::Move => {}
346                }
347                let share = d.fraction_x();
348                if share != dragged.get() {
349                    dragged.set(share);
350                }
351            }),
352        );
353        let middle = divider.clone();
354        cx.on_double_click(node, Rc::new(move || middle.set(0.5)));
355        cx.on_key(
356            node,
357            Rc::new(move |key, modifiers| {
358                let step = if modifiers.shift { 0.1 } else { 0.01 };
359                let at = divider.get();
360                let next = match key {
361                    Key::ArrowLeft => at - step,
362                    Key::ArrowRight => at + step,
363                    Key::Home => 0.0,
364                    Key::End => 1.0,
365                    _ => return false,
366                };
367                divider.set(next.clamp(0.0, 1.0));
368                true
369            }),
370        );
371        node
372    }
373}
374
375// ---------------------------------------------------------------------------------------------
376// ImageLightbox
377// ---------------------------------------------------------------------------------------------
378
379/// One picture of an [`ImageLightbox`].
380#[derive(Clone, Debug)]
381#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
382pub struct LightboxItem {
383    image: ImageData,
384    caption: Option<String>,
385}
386
387#[cfg_attr(feature = "live", ravenrs_live::expose)]
388impl LightboxItem {
389    /// A picture with no caption.
390    pub fn new(image: ImageData) -> Self {
391        Self { image, caption: None }
392    }
393
394    /// What it says under the picture.
395    pub fn caption(mut self, caption: impl Into<String>) -> Self {
396        self.caption = Some(caption.into());
397        self
398    }
399}
400
401/// Pictures over the whole app, one at a time, with zoom by [`ZoomPanView`], a caption and a
402/// counter. It shows while `open` holds the index of a picture, and Escape or its X set it to
403/// `None`.
404///
405/// The arrows go to the previous and the next, and pan instead once zoomed in, Home and End go
406/// to the ends, and the keys of [`ZoomPanView`] zoom. Each picture starts fitted.
407///
408/// ```
409/// use ravenrs_core::{Color, ImageData};
410/// use ravenrs_reactive::State;
411/// use ravenrs_widgets::{ImageLightbox, LightboxItem};
412///
413/// let open = State::new(None::<usize>);
414/// let pictures: Vec<LightboxItem> = ImageData::from_fn(64, 48, |_, _| Color::rgb(0.3, 0.3, 0.3))
415///     .map(|image| LightboxItem::new(image).caption("IMG_0001.JPG"))
416///     .into_iter()
417///     .collect();
418/// let lightbox = ImageLightbox::new(open.binding(), pictures);
419/// ```
420#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
421pub struct ImageLightbox {
422    open: Binding<Option<usize>>,
423    items: Rc<[LightboxItem]>,
424    wrap: bool,
425    label: Option<String>,
426}
427
428impl fmt::Debug for ImageLightbox {
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        f.debug_struct("ImageLightbox").field("items", &self.items.len()).finish_non_exhaustive()
431    }
432}
433
434#[cfg_attr(feature = "live", ravenrs_live::expose)]
435impl ImageLightbox {
436    /// A lightbox over these pictures, shown while `open` holds the index of one.
437    pub fn new(open: Binding<Option<usize>>, items: impl IntoIterator<Item = LightboxItem>) -> Self {
438        Self { open, items: items.into_iter().collect(), wrap: false, label: None }
439    }
440
441    /// Whether going past the last picture comes back to the first (it does not by default).
442    pub fn wrap(mut self, wrap: bool) -> Self {
443        self.wrap = wrap;
444        self
445    }
446
447    /// The name screen readers announce, instead of the one in [`Labels`](crate::Labels).
448    pub fn label(mut self, label: impl Into<String>) -> Self {
449        self.label = Some(label.into());
450        self
451    }
452}
453
454struct LightboxLayer<'a> {
455    open: &'a Binding<Option<usize>>,
456    items: &'a Rc<[LightboxItem]>,
457    index: usize,
458    wrap: bool,
459    label: Option<&'a str>,
460}
461
462/// A row across the whole width, floating over the picture, that takes no input of its own.
463fn lightbox_row(cx: &mut BuildCx<'_>, y: f32, justify: Justify, children: &[NodeId]) -> NodeId {
464    let viewport = cx.viewport();
465    let inset = cx.theme().spacing.s3;
466    cx.container(
467        LayoutStyle {
468            absolute: Some((0.0, y)),
469            width: Some(viewport.width),
470            padding: Insets::symmetric(0.0, inset),
471            justify,
472            align: Align::Center,
473            ..LayoutStyle::stack(Axis::Horizontal)
474        },
475        children,
476        Paint::None,
477    )
478}
479
480/// A chip over the picture: on the surface of the theme, which keeps its words readable.
481fn lightbox_chip(cx: &mut BuildCx<'_>, children: &[NodeId]) -> NodeId {
482    let theme = *cx.theme();
483    let node = cx.container(
484        LayoutStyle {
485            padding: Insets::symmetric(theme.spacing.s1, theme.spacing.s2),
486            spacing: theme.spacing.s2,
487            align: Align::Center,
488            ..LayoutStyle::stack(Axis::Horizontal)
489        },
490        children,
491        Paint::Quad(QuadPaint {
492            corner_radius: theme.radii.md,
493            ..QuadPaint::new(theme.color(Semantic::SurfaceElevated))
494        }),
495    );
496    // A click on it must not reach the picture.
497    cx.cursor(node, CursorIcon::Default);
498    node
499}
500
501impl View for LightboxLayer<'_> {
502    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
503        let theme = *cx.theme();
504        let labels = cx.labels();
505        let viewport = cx.viewport();
506        let count = self.items.len();
507        let index = self.index;
508        let item = &self.items[index];
509        let view = cx.use_state(ZoomView::fit);
510        let seen = cx.use_state(|| Rc::new(Cell::new(index))).get();
511        // Each picture starts fitted, however it was reached.
512        if seen.replace(index) != index {
513            view.set(ZoomView::fit());
514        }
515        let scrim = {
516            let color = theme.color(Semantic::Scrim);
517            let node = cx.container(
518                LayoutStyle {
519                    absolute: Some((0.0, 0.0)),
520                    width: Some(viewport.width),
521                    height: Some(viewport.height),
522                    ..LayoutStyle::leaf()
523                },
524                &[],
525                // The picture is what counts here: the backdrop goes much darker than a dialog's.
526                Paint::Quad(QuadPaint::new(color.with_alpha(color.a.max(0.92)))),
527            );
528            let m = theme.motion;
529            cx.motion(node, Motion::new().enter(Effect::hidden(), m.base).exit(Effect::hidden(), m.base));
530            node
531        };
532        let mut stage =
533            ZoomPanView::image(view.binding(), item.image.clone()).size(viewport.width, viewport.height);
534        stage.defer_arrows = true;
535        stage.autofocus = true;
536        // Told apart by picture: its memory of a gesture must not carry over to the next one.
537        let stage = cx.keyed_child(index as u64, &stage);
538        let stage = cx.container(
539            LayoutStyle {
540                absolute: Some((0.0, 0.0)),
541                width: Some(viewport.width),
542                height: Some(viewport.height),
543                ..LayoutStyle::stack(Axis::Vertical)
544            },
545            &[stage],
546            Paint::None,
547        );
548        let open = self.open.clone();
549        let wrap = self.wrap;
550        let target = move |by: isize| -> Option<usize> {
551            let next = index as isize + by;
552            if (0..count as isize).contains(&next) {
553                Some(next as usize)
554            } else if wrap && count > 0 {
555                Some(next.rem_euclid(count as isize) as usize)
556            } else {
557                None
558            }
559        };
560        let go = |by: isize| {
561            let open = open.clone();
562            Rc::new(move || {
563                if let Some(next) = target(by) {
564                    open.set(Some(next));
565                }
566            })
567        };
568        let (previous, next) = (go(-1), go(1));
569        cx.shortcut(Key::ArrowLeft, false, previous.clone());
570        cx.shortcut(Key::ArrowRight, false, next.clone());
571        let to_first = open.clone();
572        cx.shortcut(Key::Home, false, Rc::new(move || to_first.set(Some(0))));
573        let to_last = open.clone();
574        cx.shortcut(Key::End, false, Rc::new(move || to_last.set(Some(count.saturating_sub(1)))));
575
576        let closing = open.clone();
577        let zoom = cx.child(&ZoomControl::new(view.binding()));
578        let close = cx.child(
579            &IconButton::new(IconName::X)
580                .label(labels.close.clone())
581                .size(ButtonSize::Small)
582                .on_click(move || closing.set(None)),
583        );
584        let top = lightbox_chip(cx, &[zoom, close]);
585        let top = lightbox_row(cx, theme.spacing.s3, Justify::End, &[top]);
586
587        let arrow = |cx: &mut BuildCx<'_>, icon: IconName, name: &str, enabled: bool, run: Rc<dyn Fn()>| {
588            let button = IconButton::new(icon)
589                .label(name.to_owned())
590                .style(ButtonStyle::Secondary)
591                .disabled(!enabled)
592                .on_click(move || run());
593            cx.child(&button)
594        };
595        let back =
596            arrow(cx, IconName::ChevronLeft, &labels.lightbox_previous, target(-1).is_some(), previous);
597        let onward = arrow(cx, IconName::ChevronRight, &labels.lightbox_next, target(1).is_some(), next);
598        let arrows = lightbox_row(cx, viewport.height / 2.0 - 18.0, Justify::SpaceBetween, &[back, onward]);
599
600        let ink = theme.color(Semantic::Ink);
601        let mut words = Vec::with_capacity(2);
602        if let Some(caption) = &item.caption {
603            words.push(util::label(cx, caption, TextStyle::Callout, None, ink));
604        }
605        let counter = labels.lightbox_counter(index + 1, count);
606        words.push(util::label(cx, &counter, TextStyle::Caption, None, theme.color(Semantic::InkMuted)));
607        let words = lightbox_chip(cx, &words);
608        let bottom = lightbox_row(cx, viewport.height - 44.0, Justify::Center, &[words]);
609
610        let root = cx.container(
611            LayoutStyle {
612                width: Some(viewport.width),
613                height: Some(viewport.height),
614                ..LayoutStyle::stack(Axis::Vertical)
615            },
616            &[scrim, stage, top, arrows, bottom],
617            Paint::None,
618        );
619        let name = self
620            .label
621            .map(str::to_owned)
622            .or_else(|| item.caption.clone())
623            .unwrap_or_else(|| labels.lightbox.clone());
624        cx.semantics(root, Semantics::new(Role::Dialog).name(name).value(counter).modal());
625        root
626    }
627}
628
629impl View for ImageLightbox {
630    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
631        let anchor = cx.empty();
632        let Some(index) = self.open.get().filter(|i| *i < self.items.len()) else { return anchor };
633        let open = self.open.clone();
634        cx.overlay(
635            anchor,
636            Placement::Fill,
637            &LightboxLayer {
638                open: &self.open,
639                items: &self.items,
640                index,
641                wrap: self.wrap,
642                label: self.label.as_deref(),
643            },
644            Some(Rc::new(move || open.set(None))),
645        );
646        anchor
647    }
648}