Skip to main content

gpui_kit/content/
image_viewer.rs

1//! One image at a time, framed, zoomed and panned — and never fetched.
2//!
3//! # What this component will not do
4//!
5//! **It does not fetch anything.** This crate has no network and no asset
6//! resolution, which is the same reason [`crate::content::Markdown`] draws an
7//! image reference as a placeholder naming its source. A host that holds the
8//! bytes hands an element back through [`ImageViewer::image`]; a host that
9//! does not has said so, and the frame names what is missing rather than
10//! showing a grey box.
11//!
12//! **It does not measure the source.** Natural dimensions are a caller input.
13//! An image whose pixel size nobody stated reads `Size unknown`, and the zoom
14//! and fit controls are refused with that as their reason, because a scale is
15//! a ratio against a size and there is no size. Reporting the rendered size as
16//! though it were the source's would be inventing the fact the host declined
17//! to give.
18//!
19//! **It does not decide what a refusal means.** Loading, an image the host
20//! could not supply, an image that failed to decode, and a ready one are four
21//! renderings, and the middle two carry the host's own sentence.
22//!
23//! **It does not wrap.** Stepping past the last image would put the reader
24//! back at the first without saying so, so the control at each end is refused
25//! and the position is published instead.
26
27use std::collections::HashSet;
28use std::rc::Rc;
29
30use gpui::{
31    AnyElement, App, Bounds, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels,
32    Point, RenderOnce, ScrollDelta, SharedString, Size, Styled, Window, div, point,
33    prelude::FluentBuilder, px, size,
34};
35use gpui_kit_assets::Icon;
36use gpui_kit_semantics::{NodeSpec, Role, Semantic};
37use gpui_kit_theme::{
38    ActiveTheme, ControlSize, Elevation, Radius, Space, Surface, Theme, TypeScale,
39};
40
41use crate::controls::button::IconButton;
42use crate::controls::segmented::{Segment, SegmentedControl};
43use crate::foundation::{Disableable, FocusRing, Ident, Sizable, StyledExt};
44use crate::layout::measure;
45use crate::motion::keyed;
46use crate::strings::{ActiveStrings, StringKey};
47
48/// How tall the frame is when the caller says nothing. The value occurs once.
49const DEFAULT_HEIGHT: f32 = 320.0;
50
51/// How far one keystroke or one wheel notch moves the zoom.
52const ZOOM_STEP: f32 = 1.25;
53
54/// How the image is sized inside the frame.
55#[derive(Debug, Clone, Copy, PartialEq, Default)]
56pub enum FitMode {
57    /// The whole image, inside the frame.
58    #[default]
59    Contain,
60    /// The whole frame, covered by the image.
61    Cover,
62    /// One image pixel per frame pixel.
63    Actual,
64    /// The caller's own scale, where one is the actual size.
65    Zoom(f32),
66}
67
68impl FitMode {
69    /// The name a semantic node publishes, so a test reads the mode rather
70    /// than the geometry it produced.
71    pub fn name(self) -> &'static str {
72        match self {
73            Self::Contain => "contain",
74            Self::Cover => "cover",
75            Self::Actual => "actual",
76            Self::Zoom(_) => "zoom",
77        }
78    }
79}
80
81/// The pixel size of a source, as the host stated it.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct ImageSize {
84    pub width: u32,
85    pub height: u32,
86}
87
88impl ImageSize {
89    pub fn new(width: u32, height: u32) -> Self {
90        Self { width, height }
91    }
92
93    fn as_f32(self) -> Size<f32> {
94        size(self.width.max(1) as f32, self.height.max(1) as f32)
95    }
96}
97
98/// What the host knows about one image right now.
99///
100/// This is the library's [`crate::state::Loadable`] vocabulary specialized to
101/// a picture: an image the host refused and an image that arrived broken fail
102/// differently and read differently, so they are not one variant.
103#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub enum ImageState {
105    /// The host is still fetching it.
106    Loading,
107    /// The host could not supply it, in its own words.
108    Unavailable(SharedString),
109    /// The bytes arrived and could not be read, in the host's words.
110    Failed(SharedString),
111    /// The host has it.
112    #[default]
113    Ready,
114}
115
116impl ImageState {
117    fn name(&self) -> &'static str {
118        match self {
119            Self::Loading => "loading",
120            Self::Unavailable(_) => "unavailable",
121            Self::Failed(_) => "failed",
122            Self::Ready => "ready",
123        }
124    }
125}
126
127/// One image the viewer can show.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct ImageFrame {
130    id: SharedString,
131    label: SharedString,
132    source: SharedString,
133    natural: Option<ImageSize>,
134    state: ImageState,
135}
136
137impl ImageFrame {
138    /// `id` is the image's own identity, never its place in the list.
139    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
140        Self {
141            id: id.into(),
142            label: label.into(),
143            source: SharedString::default(),
144            natural: None,
145            state: ImageState::default(),
146        }
147    }
148
149    /// Where the image came from, shown whenever it is not on screen.
150    pub fn source(mut self, source: impl Into<SharedString>) -> Self {
151        self.source = source.into();
152        self
153    }
154
155    /// The source's own pixel dimensions.
156    ///
157    /// Without this the viewer says the size is unknown and refuses to scale,
158    /// because a scale nobody can compute is not a scale.
159    pub fn natural(mut self, width: u32, height: u32) -> Self {
160        self.natural = Some(ImageSize::new(width, height));
161        self
162    }
163
164    pub fn state(mut self, state: ImageState) -> Self {
165        self.state = state;
166        self
167    }
168
169    /// The host could not supply this image, and this is why.
170    pub fn unavailable(self, reason: impl Into<SharedString>) -> Self {
171        self.state(ImageState::Unavailable(reason.into()))
172    }
173
174    /// The bytes arrived and could not be read, and this is why.
175    pub fn failed(self, reason: impl Into<SharedString>) -> Self {
176        self.state(ImageState::Failed(reason.into()))
177    }
178
179    pub fn loading(self) -> Self {
180        self.state(ImageState::Loading)
181    }
182
183    pub fn id(&self) -> &SharedString {
184        &self.id
185    }
186
187    pub fn label(&self) -> &SharedString {
188        &self.label
189    }
190}
191
192/// An image the viewer had to draw and the host has not supplied.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ImageRequest {
195    pub id: SharedString,
196    pub label: SharedString,
197    pub source: SharedString,
198    /// What the host said the source measures, when it said anything.
199    pub natural: Option<ImageSize>,
200}
201
202/// What a viewer reports. It applies none of it.
203#[derive(Debug, Clone, PartialEq)]
204pub enum ImageViewerEvent {
205    /// The reader asked for a different fit, by a control, the wheel, or a
206    /// key. The viewer draws whatever the caller says is showing.
207    FitChanged(FitMode),
208    /// The reader asked for another image, by its own id.
209    Stepped { id: SharedString },
210    /// An image the host has not supplied was drawn as a placeholder.
211    ///
212    /// Reported once per image per viewer rather than once per frame, so a
213    /// host that answers by supplying it is not asked again for answering.
214    ImageRequested(ImageRequest),
215}
216
217type EventHandler = Rc<dyn Fn(&ImageViewerEvent, &mut Window, &mut App)>;
218type ImageSupplier = Rc<dyn Fn(&ImageFrame, &mut Window, &mut App) -> Option<AnyElement>>;
219
220/// The image point pinned under a point of the frame.
221///
222/// Both are normalized — the image's own extent, and the frame's — which is
223/// what makes the pin survive a zoom the caller has not applied yet. A wheel
224/// notch records the image point that was under the pointer; the frame it is
225/// applied on derives the offset from whatever scale the caller settled on, so
226/// a refused zoom moves nothing.
227#[derive(Debug, Clone, Copy, PartialEq)]
228struct Pin {
229    image: Point<f32>,
230    frame: Point<f32>,
231}
232
233impl Default for Pin {
234    fn default() -> Self {
235        Self {
236            image: point(0.5, 0.5),
237            frame: point(0.5, 0.5),
238        }
239    }
240}
241
242/// What the frame remembers between two builds of the same viewer.
243#[derive(Debug, Default)]
244struct Viewport {
245    pin: Pin,
246    /// Set while the pointer is holding the image, so a pan that leaves the
247    /// frame ends rather than continuing on the next unrelated move.
248    panning: bool,
249    at: Option<Point<Pixels>>,
250}
251
252/// The images already reported as unsupplied for one viewer.
253#[derive(Debug, Default)]
254struct Requested(HashSet<SharedString>);
255
256/// Where the image sits inside the frame, once every caller-owned fact is in.
257#[derive(Debug, Clone, Copy, PartialEq)]
258struct Geometry {
259    scale: f32,
260    drawn: Size<f32>,
261    /// The image's top-left corner, in frame pixels.
262    offset: Point<f32>,
263}
264
265impl Geometry {
266    fn pannable(self, frame: Size<f32>) -> bool {
267        self.drawn.width > frame.width + 0.5 || self.drawn.height > frame.height + 0.5
268    }
269}
270
271/// The scale a fit mode asks for, given a frame and a source that measure
272/// something.
273fn scale_for(fit: FitMode, frame: Size<f32>, natural: Size<f32>, min: f32, max: f32) -> f32 {
274    if frame.width <= 0.0 || frame.height <= 0.0 {
275        return 0.0;
276    }
277    let horizontal = frame.width / natural.width;
278    let vertical = frame.height / natural.height;
279    let raw = match fit {
280        FitMode::Contain => horizontal.min(vertical),
281        FitMode::Cover => horizontal.max(vertical),
282        FitMode::Actual => 1.0,
283        FitMode::Zoom(zoom) => zoom,
284    };
285    raw.clamp(min, max)
286}
287
288/// Where the image lands, with the pin honored as far as the clamp allows.
289///
290/// An image smaller than the frame is centred, and one larger than it cannot
291/// be dragged past its own edge, so no gesture can push the picture out of
292/// view and leave the reader with nothing to drag back.
293fn place(frame: Size<f32>, drawn: Size<f32>, pin: Pin) -> Point<f32> {
294    let axis = |frame: f32, drawn: f32, image: f32, at: f32| {
295        if drawn <= frame {
296            (frame - drawn) / 2.0
297        } else {
298            (at * frame - image * drawn).clamp(frame - drawn, 0.0)
299        }
300    };
301    point(
302        axis(frame.width, drawn.width, pin.image.x, pin.frame.x),
303        axis(frame.height, drawn.height, pin.image.y, pin.frame.y),
304    )
305}
306
307fn layout(
308    fit: FitMode,
309    frame: Size<f32>,
310    natural: Size<f32>,
311    pin: Pin,
312    zoom: (f32, f32),
313) -> Geometry {
314    let scale = scale_for(fit, frame, natural, zoom.0, zoom.1);
315    let drawn = size(natural.width * scale, natural.height * scale);
316    Geometry {
317        scale,
318        drawn,
319        offset: place(frame, drawn, pin),
320    }
321}
322
323/// The pin that keeps the image point currently under `at` under it.
324fn pin_at(frame: Size<f32>, geometry: Geometry, at: Point<f32>) -> Pin {
325    if frame.width <= 0.0 || frame.height <= 0.0 || geometry.drawn.width <= 0.0 {
326        return Pin::default();
327    }
328    Pin {
329        image: point(
330            ((at.x - geometry.offset.x) / geometry.drawn.width).clamp(0.0, 1.0),
331            ((at.y - geometry.offset.y) / geometry.drawn.height).clamp(0.0, 1.0),
332        ),
333        frame: point(at.x / frame.width, at.y / frame.height),
334    }
335}
336
337/// The pin after the image has been dragged by `delta`.
338///
339/// The result is expressed against the centre of the frame, so a pan that ran
340/// into the clamp does not leave a pin describing somewhere the image cannot
341/// go.
342fn pan_by(frame: Size<f32>, geometry: Geometry, delta: Point<f32>) -> Pin {
343    if frame.width <= 0.0 || frame.height <= 0.0 || geometry.drawn.width <= 0.0 {
344        return Pin::default();
345    }
346    let moved = point(geometry.offset.x + delta.x, geometry.offset.y + delta.y);
347    let settled = place(
348        frame,
349        geometry.drawn,
350        Pin {
351            image: point(0.0, 0.0),
352            frame: point(moved.x / frame.width, moved.y / frame.height),
353        },
354    );
355    Pin {
356        image: point(
357            (frame.width / 2.0 - settled.x) / geometry.drawn.width,
358            (frame.height / 2.0 - settled.y) / geometry.drawn.height,
359        ),
360        frame: point(0.5, 0.5),
361    }
362}
363
364/// A framed viewer for one image at a time.
365#[derive(IntoElement)]
366pub struct ImageViewer {
367    ident: Ident,
368    frames: Vec<ImageFrame>,
369    showing: Option<SharedString>,
370    fit: FitMode,
371    min_zoom: f32,
372    max_zoom: f32,
373    height: f32,
374    disabled: bool,
375    image: Option<ImageSupplier>,
376    on_event: Option<EventHandler>,
377}
378
379impl std::fmt::Debug for ImageViewer {
380    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
381        formatter
382            .debug_struct("ImageViewer")
383            .field("ident", &self.ident)
384            .field("images", &self.frames.len())
385            .field("showing", &self.showing)
386            .field("fit", &self.fit)
387            .field("zoom", &(self.min_zoom, self.max_zoom))
388            .field("has_images", &self.image.is_some())
389            .field("has_handler", &self.on_event.is_some())
390            .finish()
391    }
392}
393
394impl ImageViewer {
395    pub fn new(ident: impl Into<Ident>, frames: impl IntoIterator<Item = ImageFrame>) -> Self {
396        Self {
397            ident: ident.into(),
398            frames: frames.into_iter().collect(),
399            showing: None,
400            fit: FitMode::default(),
401            min_zoom: 0.1,
402            max_zoom: 8.0,
403            height: DEFAULT_HEIGHT,
404            disabled: false,
405            image: None,
406            on_event: None,
407        }
408    }
409
410    /// Which image the caller says is showing. Without one the first is.
411    pub fn showing(mut self, id: impl Into<SharedString>) -> Self {
412        self.showing = Some(id.into());
413        self
414    }
415
416    /// How the caller says the image is sized. The viewer draws this and
417    /// reports every request to change it.
418    pub fn fit(mut self, fit: FitMode) -> Self {
419        self.fit = fit;
420        self
421    }
422
423    /// How far in and out the reader may go. Both ends are the caller's
424    /// judgement: a thumbnail and a scan of a page have different answers.
425    pub fn zoom_range(mut self, min: f32, max: f32) -> Self {
426        let (min, max) = if min <= max { (min, max) } else { (max, min) };
427        self.min_zoom = min.max(f32::EPSILON);
428        self.max_zoom = max.max(self.min_zoom);
429        self
430    }
431
432    pub fn height(mut self, height: f32) -> Self {
433        self.height = height.max(1.0);
434        self
435    }
436
437    /// Supplies the element to draw for an image the host already holds.
438    ///
439    /// Answering `None` leaves a placeholder naming the source, so a host that
440    /// cannot supply one has said so rather than left a gap.
441    pub fn image(
442        mut self,
443        supplier: impl Fn(&ImageFrame, &mut Window, &mut App) -> Option<AnyElement> + 'static,
444    ) -> Self {
445        self.image = Some(Rc::new(supplier));
446        self
447    }
448
449    pub fn on_event(
450        mut self,
451        handler: impl Fn(&ImageViewerEvent, &mut Window, &mut App) + 'static,
452    ) -> Self {
453        self.on_event = Some(Rc::new(handler));
454        self
455    }
456
457    fn index(&self) -> usize {
458        self.showing
459            .as_ref()
460            .and_then(|id| self.frames.iter().position(|frame| &frame.id == id))
461            .unwrap_or(0)
462    }
463}
464
465impl Disableable for ImageViewer {
466    fn disabled(mut self, disabled: bool) -> Self {
467        self.disabled = disabled;
468        self
469    }
470}
471
472impl RenderOnce for ImageViewer {
473    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
474        let theme = cx.theme().clone();
475        let ident = self.ident.clone();
476
477        let Some(frame) = self.frames.get(self.index()).cloned() else {
478            return empty_viewer(&ident, &theme, self.height, cx);
479        };
480
481        let index = self.index();
482        let count = self.frames.len();
483        let position = cx.strings().format(
484            StringKey::CountOfTotal,
485            &[&(index + 1).to_string(), &count.to_string()],
486        );
487
488        let measured = measure::cell(&ident.child("frame").semantic_id(), cx);
489        let state = keyed::slot::<Viewport>(&ident.semantic_id(), cx);
490        let bounds = measured.get();
491        let extent = size(f32::from(bounds.size.width), f32::from(bounds.size.height));
492        let pin = state.borrow().pin;
493
494        // A source whose size nobody stated has no scale to compute, so the
495        // whole zoom vocabulary is refused rather than answered with a number
496        // taken from the frame it happens to be drawn in. A frame the layout
497        // has not produced yet has no scale either, and states none until it
498        // has one.
499        let measurable = frame
500            .natural
501            .filter(|_| extent.width > 0.0 && extent.height > 0.0)
502            .map(ImageSize::as_f32);
503        let geometry = measurable.map(|natural| {
504            layout(
505                self.fit,
506                extent,
507                natural,
508                pin,
509                (self.min_zoom, self.max_zoom),
510            )
511        });
512        let actionable = !self.disabled && self.on_event.is_some();
513        let zoomable = actionable && geometry.is_some();
514
515        let supplied = matches!(frame.state, ImageState::Ready)
516            .then(|| {
517                self.image
518                    .as_ref()
519                    .and_then(|supplier| supplier(&frame, window, cx))
520            })
521            .flatten();
522
523        if matches!(frame.state, ImageState::Ready) && supplied.is_none() {
524            report_missing(&ident, &frame, self.on_event.as_ref(), window, cx);
525        }
526
527        let body = match (&frame.state, supplied) {
528            (ImageState::Ready, Some(element)) => match geometry {
529                Some(geometry) => div()
530                    .absolute()
531                    .left(px(geometry.offset.x))
532                    .top(px(geometry.offset.y))
533                    .w(px(geometry.drawn.width))
534                    .h(px(geometry.drawn.height))
535                    .overflow_hidden()
536                    .child(element)
537                    .into_any_element(),
538                // With no natural size there is no scaled box to place the
539                // image in, so it is given the frame and nothing is claimed
540                // about how much of the source that shows.
541                None => div()
542                    .absolute()
543                    .inset_0()
544                    .overflow_hidden()
545                    .child(element)
546                    .into_any_element(),
547            },
548            (ImageState::Ready, None) => notice(
549                &theme,
550                theme.colors.text_muted,
551                frame.label.clone(),
552                cx.strings()
553                    .format(StringKey::ImageViewerNotSupplied, &[frame.source.as_ref()]),
554            ),
555            (ImageState::Loading, _) => notice(
556                &theme,
557                theme.colors.text_muted,
558                frame.label.clone(),
559                cx.strings().text(StringKey::Loading),
560            ),
561            (ImageState::Unavailable(reason), _) => notice(
562                &theme,
563                theme.colors.warning,
564                frame.label.clone(),
565                reason.clone(),
566            ),
567            (ImageState::Failed(reason), _) => notice(
568                &theme,
569                theme.colors.danger,
570                frame.label.clone(),
571                reason.clone(),
572            ),
573        };
574
575        let report = {
576            let handler = self.on_event.clone().filter(|_| actionable);
577            move |event: ImageViewerEvent, window: &mut Window, cx: &mut App| {
578                if let Some(handler) = &handler {
579                    handler(&event, window, cx);
580                }
581            }
582        };
583        let report = Rc::new(report);
584
585        let mut viewport = div()
586            .id(ident.child("frame").element_id())
587            .relative()
588            .w_full()
589            .h(px(self.height))
590            .overflow_hidden()
591            .radius(&theme, Radius::Card)
592            .frame(&theme, Surface::Raised, Elevation::Raised)
593            .child(body);
594
595        if let (true, Some(geometry)) = (zoomable, geometry) {
596            let pannable = geometry.pannable(extent);
597            let report_wheel = Rc::clone(&report);
598            let wheel_bounds = Rc::clone(&measured);
599            let wheel_state = Rc::clone(&state);
600            let (min, max) = (self.min_zoom, self.max_zoom);
601            viewport = viewport.on_scroll_wheel(move |event, window, cx| {
602                let bounds = wheel_bounds.get();
603                let extent = frame_extent(bounds);
604                if extent.width <= 0.0 {
605                    return;
606                }
607                let notches = wheel_notches(event.delta);
608                if notches == 0.0 {
609                    return;
610                }
611                let next = (geometry.scale * ZOOM_STEP.powf(notches)).clamp(min, max);
612                if (next - geometry.scale).abs() < f32::EPSILON {
613                    return;
614                }
615                let at = point(
616                    f32::from(event.position.x - bounds.left()),
617                    f32::from(event.position.y - bounds.top()),
618                );
619                wheel_state.borrow_mut().pin = pin_at(extent, geometry, at);
620                report_wheel(
621                    ImageViewerEvent::FitChanged(FitMode::Zoom(next)),
622                    window,
623                    cx,
624                );
625            });
626
627            if pannable {
628                let down_state = Rc::clone(&state);
629                viewport = viewport.cursor_pointer().on_mouse_down(
630                    MouseButton::Left,
631                    move |event, _, _| {
632                        let mut state = down_state.borrow_mut();
633                        state.panning = true;
634                        state.at = Some(event.position);
635                    },
636                );
637
638                let move_state = Rc::clone(&state);
639                let move_bounds = Rc::clone(&measured);
640                viewport = viewport.on_mouse_move(move |event, window, _| {
641                    let mut state = move_state.borrow_mut();
642                    if !state.panning {
643                        return;
644                    }
645                    if event.pressed_button != Some(MouseButton::Left) {
646                        state.panning = false;
647                        state.at = None;
648                        return;
649                    }
650                    let Some(previous) = state.at else {
651                        state.at = Some(event.position);
652                        return;
653                    };
654                    let delta = point(
655                        f32::from(event.position.x - previous.x),
656                        f32::from(event.position.y - previous.y),
657                    );
658                    state.at = Some(event.position);
659                    state.pin = pan_by(frame_extent(move_bounds.get()), geometry, delta);
660                    // Panning is this component's own transient state, so the
661                    // frame that shows it has to be asked for.
662                    window.refresh();
663                });
664
665                let up_state = Rc::clone(&state);
666                viewport = viewport.on_mouse_up(MouseButton::Left, move |_, _, _| {
667                    let mut state = up_state.borrow_mut();
668                    state.panning = false;
669                    state.at = None;
670                });
671            }
672        }
673
674        let strings = cx.strings().clone();
675        let step =
676            |name: &'static str, glyph: Icon, key: StringKey, target: Option<&ImageFrame>| {
677                let label = strings.text(key);
678                let id = target.map(|frame| frame.id.clone());
679                let mut control = IconButton::new(ident.child(name), glyph, label)
680                    .ghost()
681                    .control_size(ControlSize::Sm)
682                    .semantic_parent(ident.semantic_id())
683                    .disabled(id.is_none() || !actionable);
684                // Stepping past the end would wrap without saying so, so the
685                // control at the end holds no handler at all.
686                if let (Some(id), true) = (id, actionable) {
687                    let report = Rc::clone(&report);
688                    control = control.on_click(move |window, cx| {
689                        report(ImageViewerEvent::Stepped { id: id.clone() }, window, cx)
690                    });
691                }
692                control
693            };
694
695        let previous = step(
696            "previous",
697            Icon::ArrowLeft,
698            StringKey::ImageViewerPrevious,
699            index
700                .checked_sub(1)
701                .and_then(|index| self.frames.get(index)),
702        );
703        let next = step(
704            "next",
705            Icon::ArrowRight,
706            StringKey::ImageViewerNext,
707            self.frames.get(index + 1),
708        );
709
710        let fits = {
711            let report = Rc::clone(&report);
712            let mut control = SegmentedControl::new(ident.child("fit"))
713                .control_size(ControlSize::Sm)
714                .segments([
715                    Segment::new("contain", strings.text(StringKey::ImageViewerContain)),
716                    Segment::new("cover", strings.text(StringKey::ImageViewerCover)),
717                    Segment::new("actual", "1:1"),
718                ])
719                .disabled(!zoomable);
720            if !matches!(self.fit, FitMode::Zoom(_)) {
721                control = control.selected(self.fit.name());
722            }
723            if zoomable {
724                control = control.on_select(move |id, window, cx| {
725                    let fit = match id.as_ref() {
726                        "cover" => FitMode::Cover,
727                        "actual" => FitMode::Actual,
728                        _ => FitMode::Contain,
729                    };
730                    report(ImageViewerEvent::FitChanged(fit), window, cx);
731                });
732            }
733            control
734        };
735
736        // Every number here is one the host stated. A source nobody measured
737        // says so, rather than reporting the box it was drawn in.
738        let measurement = match (frame.natural, geometry) {
739            (Some(natural), Some(geometry)) => SharedString::from(format!(
740                "{} × {} · {}%",
741                natural.width,
742                natural.height,
743                (geometry.scale * 100.0).round() as i64
744            )),
745            (Some(natural), None) => {
746                SharedString::from(format!("{} × {}", natural.width, natural.height))
747            }
748            _ => strings.text(StringKey::ImageViewerSizeUnknown),
749        };
750
751        let caption = div()
752            .row()
753            .w_full()
754            .justify_between()
755            .gap_token(&theme, Space::Sm)
756            .type_scale(&theme, TypeScale::Caption)
757            .text_color(theme.colors.text_muted)
758            .child(
759                div().child(measurement.clone()).semantic_in(
760                    cx,
761                    NodeSpec::new(ident.child("measurement").semantic_id(), Role::Text)
762                        .parent(ident.semantic_id())
763                        .text(measurement),
764                ),
765            )
766            .child(
767                div().child(position.clone()).semantic_in(
768                    cx,
769                    NodeSpec::new(ident.child("position").semantic_id(), Role::Text)
770                        .parent(ident.semantic_id())
771                        .text(position.clone()),
772                ),
773            );
774
775        let mut root = div()
776            .id(ident.element_id())
777            .column()
778            .w_full()
779            .gap_token(&theme, Space::Sm)
780            .when(self.disabled, |element| {
781                element.opacity(theme.opacity.disabled)
782            })
783            .when(zoomable, |element| element.tab_index(0).focus_ring(&theme))
784            .child(
785                div()
786                    .row()
787                    .w_full()
788                    .gap_token(&theme, Space::Sm)
789                    .justify_between()
790                    .child(
791                        div()
792                            .row()
793                            .gap_token(&theme, Space::Xs)
794                            .child(previous)
795                            .child(next)
796                            .child(
797                                div()
798                                    .type_scale(&theme, TypeScale::Label)
799                                    .text_color(theme.colors.text)
800                                    .child(frame.label.clone()),
801                            ),
802                    )
803                    .child(fits),
804            )
805            // The frame is measured through a plain wrapper, because only that
806            // element carries the prepaint hook and only prepaint knows how
807            // big the frame turned out — which is what zooming at a point and
808            // clamping a pan are both computed against.
809            .child(
810                div()
811                    .w_full()
812                    .on_children_prepainted({
813                        let measured = Rc::clone(&measured);
814                        move |bounds, window, _| {
815                            if let Some(first) = bounds.first() {
816                                measure::record(&measured, *first, window);
817                            }
818                        }
819                    })
820                    .child(viewport)
821                    .semantic_in(
822                        cx,
823                        NodeSpec::new(ident.child("frame").semantic_id(), Role::Image)
824                            .parent(ident.semantic_id())
825                            .text(frame.label.clone())
826                            .busy(matches!(frame.state, ImageState::Loading))
827                            .invalid(matches!(frame.state, ImageState::Failed(_)))
828                            .value(frame.state.name()),
829                    ),
830            )
831            .child(caption);
832
833        if let (true, Some(geometry)) = (zoomable, geometry) {
834            let report = Rc::clone(&report);
835            let (min, max) = (self.min_zoom, self.max_zoom);
836            let state = Rc::clone(&state);
837            root.interactivity().on_key_down(move |event, window, cx| {
838                let fit = match event.keystroke.key.as_str() {
839                    "+" | "=" => FitMode::Zoom((geometry.scale * ZOOM_STEP).clamp(min, max)),
840                    "-" => FitMode::Zoom((geometry.scale / ZOOM_STEP).clamp(min, max)),
841                    // Resetting is the fit the viewer starts in, and it puts
842                    // the picture back in the middle as well as back to size.
843                    "0" => {
844                        state.borrow_mut().pin = Pin::default();
845                        FitMode::Contain
846                    }
847                    _ => return,
848                };
849                cx.stop_propagation();
850                report(ImageViewerEvent::FitChanged(fit), window, cx);
851            });
852        }
853
854        root.semantic_in(
855            cx,
856            NodeSpec::new(ident.semantic_id(), Role::Group)
857                .disabled(self.disabled)
858                .text(frame.label.clone())
859                .value(position),
860        )
861        .into_any_element()
862    }
863}
864
865fn frame_extent(bounds: Bounds<Pixels>) -> Size<f32> {
866    size(f32::from(bounds.size.width), f32::from(bounds.size.height))
867}
868
869/// How many notches of zoom one wheel event asks for.
870///
871/// A line-based wheel reports whole notches and a precise trackpad reports
872/// pixels, so the pixels are divided by the same step a notch travels rather
873/// than being read as notches themselves.
874fn wheel_notches(delta: ScrollDelta) -> f32 {
875    match delta {
876        ScrollDelta::Lines(lines) => lines.y,
877        ScrollDelta::Pixels(pixels) => f32::from(pixels.y) / 40.0,
878    }
879}
880
881/// Reports an unsupplied image once for as long as the viewer is on screen.
882fn report_missing(
883    ident: &Ident,
884    frame: &ImageFrame,
885    handler: Option<&EventHandler>,
886    window: &mut Window,
887    cx: &mut App,
888) {
889    let Some(handler) = handler.cloned() else {
890        return;
891    };
892    let cell = keyed::slot::<Requested>(&ident.child("requested").semantic_id(), cx);
893    if !cell.borrow_mut().0.insert(frame.id.clone()) {
894        return;
895    }
896    handler(
897        &ImageViewerEvent::ImageRequested(ImageRequest {
898            id: frame.id.clone(),
899            label: frame.label.clone(),
900            source: frame.source.clone(),
901            natural: frame.natural,
902        }),
903        window,
904        cx,
905    );
906}
907
908/// What stands in the frame when there is no picture in it.
909///
910/// The name and the host's own sentence, never a grey rectangle: a reader who
911/// is shown a blank frame cannot tell a refusal from an image of nothing.
912fn notice(
913    theme: &Theme,
914    tint: gpui::Hsla,
915    title: SharedString,
916    detail: SharedString,
917) -> AnyElement {
918    div()
919        .absolute()
920        .inset_0()
921        .column()
922        .items_center()
923        .justify_center()
924        .gap_token(theme, Space::Xs)
925        .p_token(theme, Space::Lg)
926        .text_align(gpui::TextAlign::Center)
927        .child(
928            div()
929                .type_scale(theme, TypeScale::Label)
930                .text_color(theme.colors.text)
931                .child(title),
932        )
933        .child(
934            div()
935                .max_w(px(360.0))
936                .type_scale(theme, TypeScale::Caption)
937                .text_color(tint)
938                .child(detail),
939        )
940        .into_any_element()
941}
942
943/// A viewer the caller gave nothing to show.
944fn empty_viewer(ident: &Ident, theme: &Theme, height: f32, cx: &mut App) -> AnyElement {
945    div()
946        .id(ident.element_id())
947        .column()
948        .w_full()
949        .h(px(height))
950        .items_center()
951        .justify_center()
952        .radius(theme, Radius::Card)
953        .frame(theme, Surface::Raised, Elevation::Raised)
954        .type_scale(theme, TypeScale::Label)
955        .text_color(theme.colors.text_muted)
956        .child(cx.strings().text(StringKey::ImageViewerEmpty))
957        .semantic_in(
958            cx,
959            NodeSpec::new(ident.semantic_id(), Role::Group).value("empty"),
960        )
961        .into_any_element()
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    const FRAME: Size<f32> = Size {
969        width: 400.0,
970        height: 300.0,
971    };
972    const NATURAL: Size<f32> = Size {
973        width: 800.0,
974        height: 400.0,
975    };
976
977    fn zoom() -> (f32, f32) {
978        (0.1, 8.0)
979    }
980
981    #[test]
982    fn contain_shows_the_whole_image_and_cover_fills_the_frame() {
983        assert_eq!(scale_for(FitMode::Contain, FRAME, NATURAL, 0.1, 8.0), 0.5);
984        assert_eq!(scale_for(FitMode::Cover, FRAME, NATURAL, 0.1, 8.0), 0.75);
985        assert_eq!(scale_for(FitMode::Actual, FRAME, NATURAL, 0.1, 8.0), 1.0);
986    }
987
988    #[test]
989    fn a_zoom_never_leaves_the_bounds_the_caller_set() {
990        assert_eq!(
991            scale_for(FitMode::Zoom(40.0), FRAME, NATURAL, 0.1, 4.0),
992            4.0
993        );
994        assert_eq!(
995            scale_for(FitMode::Zoom(0.001), FRAME, NATURAL, 0.25, 4.0),
996            0.25
997        );
998    }
999
1000    #[test]
1001    fn an_unmeasured_frame_has_no_scale_to_report() {
1002        let empty = Size {
1003            width: 0.0,
1004            height: 0.0,
1005        };
1006        assert_eq!(scale_for(FitMode::Contain, empty, NATURAL, 0.1, 8.0), 0.0);
1007    }
1008
1009    #[test]
1010    fn an_image_smaller_than_the_frame_is_centred_whatever_the_pin_says() {
1011        let drawn = Size {
1012            width: 200.0,
1013            height: 100.0,
1014        };
1015        let pushed = Pin {
1016            image: point(0.0, 0.0),
1017            frame: point(1.0, 1.0),
1018        };
1019        assert_eq!(place(FRAME, drawn, pushed), point(100.0, 100.0));
1020    }
1021
1022    #[test]
1023    fn an_image_larger_than_the_frame_cannot_be_dragged_off_it() {
1024        let drawn = Size {
1025            width: 800.0,
1026            height: 400.0,
1027        };
1028        let far = Pin {
1029            image: point(0.0, 0.0),
1030            frame: point(1.0, 1.0),
1031        };
1032        assert_eq!(place(FRAME, drawn, far), point(0.0, 0.0));
1033        let further = Pin {
1034            image: point(1.0, 1.0),
1035            frame: point(0.0, 0.0),
1036        };
1037        assert_eq!(place(FRAME, drawn, further), point(-400.0, -100.0));
1038    }
1039
1040    #[test]
1041    fn zooming_keeps_the_point_under_the_pointer_under_it() {
1042        let before = layout(FitMode::Actual, FRAME, NATURAL, Pin::default(), zoom());
1043        let at = point(320.0, 60.0);
1044        let pinned = pin_at(FRAME, before, at);
1045        let after = layout(FitMode::Zoom(2.0), FRAME, NATURAL, pinned, zoom());
1046
1047        let image_point_before = point(
1048            (at.x - before.offset.x) / before.drawn.width,
1049            (at.y - before.offset.y) / before.drawn.height,
1050        );
1051        let drawn_after = point(
1052            after.offset.x + image_point_before.x * after.drawn.width,
1053            after.offset.y + image_point_before.y * after.drawn.height,
1054        );
1055        assert!((drawn_after.x - at.x).abs() < 0.01, "{drawn_after:?}");
1056        assert!((drawn_after.y - at.y).abs() < 0.01, "{drawn_after:?}");
1057    }
1058
1059    #[test]
1060    fn a_refused_zoom_moves_nothing() {
1061        let before = layout(FitMode::Actual, FRAME, NATURAL, Pin::default(), zoom());
1062        let pinned = pin_at(FRAME, before, point(320.0, 60.0));
1063        // The caller did not apply the new scale, so the frame renders at the
1064        // one that still holds and the picture must not have shifted.
1065        let after = layout(FitMode::Actual, FRAME, NATURAL, pinned, zoom());
1066        assert!((after.offset.x - before.offset.x).abs() < 0.01);
1067        assert!((after.offset.y - before.offset.y).abs() < 0.01);
1068    }
1069
1070    #[test]
1071    fn panning_moves_the_picture_and_stops_at_its_edge() {
1072        let start = layout(FitMode::Actual, FRAME, NATURAL, Pin::default(), zoom());
1073        let nudged = pan_by(FRAME, start, point(50.0, 0.0));
1074        let moved = layout(FitMode::Actual, FRAME, NATURAL, nudged, zoom());
1075        assert!((moved.offset.x - (start.offset.x + 50.0)).abs() < 0.01);
1076
1077        let shoved = pan_by(FRAME, start, point(5000.0, 0.0));
1078        let stopped = layout(FitMode::Actual, FRAME, NATURAL, shoved, zoom());
1079        assert_eq!(stopped.offset.x, 0.0);
1080    }
1081
1082    #[test]
1083    fn only_a_picture_larger_than_the_frame_can_be_panned() {
1084        let contained = layout(FitMode::Contain, FRAME, NATURAL, Pin::default(), zoom());
1085        assert!(!contained.pannable(FRAME));
1086        let magnified = layout(FitMode::Zoom(2.0), FRAME, NATURAL, Pin::default(), zoom());
1087        assert!(magnified.pannable(FRAME));
1088    }
1089
1090    #[test]
1091    fn a_precise_wheel_and_a_notched_one_both_read_as_notches() {
1092        assert_eq!(wheel_notches(ScrollDelta::Lines(point(0.0, 1.0))), 1.0);
1093        assert_eq!(
1094            wheel_notches(ScrollDelta::Pixels(point(px(0.0), px(40.0)))),
1095            1.0
1096        );
1097    }
1098}