1use 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
48const DEFAULT_HEIGHT: f32 = 320.0;
50
51const ZOOM_STEP: f32 = 1.25;
53
54#[derive(Debug, Clone, Copy, PartialEq, Default)]
56pub enum FitMode {
57 #[default]
59 Contain,
60 Cover,
62 Actual,
64 Zoom(f32),
66}
67
68impl FitMode {
69 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#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
104pub enum ImageState {
105 Loading,
107 Unavailable(SharedString),
109 Failed(SharedString),
111 #[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#[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 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 pub fn source(mut self, source: impl Into<SharedString>) -> Self {
151 self.source = source.into();
152 self
153 }
154
155 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 pub fn unavailable(self, reason: impl Into<SharedString>) -> Self {
171 self.state(ImageState::Unavailable(reason.into()))
172 }
173
174 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#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ImageRequest {
195 pub id: SharedString,
196 pub label: SharedString,
197 pub source: SharedString,
198 pub natural: Option<ImageSize>,
200}
201
202#[derive(Debug, Clone, PartialEq)]
204pub enum ImageViewerEvent {
205 FitChanged(FitMode),
208 Stepped { id: SharedString },
210 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#[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#[derive(Debug, Default)]
244struct Viewport {
245 pin: Pin,
246 panning: bool,
249 at: Option<Point<Pixels>>,
250}
251
252#[derive(Debug, Default)]
254struct Requested(HashSet<SharedString>);
255
256#[derive(Debug, Clone, Copy, PartialEq)]
258struct Geometry {
259 scale: f32,
260 drawn: Size<f32>,
261 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
271fn 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
288fn 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
323fn 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
337fn 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#[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 pub fn showing(mut self, id: impl Into<SharedString>) -> Self {
412 self.showing = Some(id.into());
413 self
414 }
415
416 pub fn fit(mut self, fit: FitMode) -> Self {
419 self.fit = fit;
420 self
421 }
422
423 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 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 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 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 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 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 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 .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 "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
869fn 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
881fn 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
908fn 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
943fn 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 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}