diff --git a/assets/icons/maximize-2.svg b/assets/icons/maximize-2.svg
new file mode 100644
index 000000000..790d25383
--- /dev/null
+++ b/assets/icons/maximize-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/assets/icons/minimize-2.svg b/assets/icons/minimize-2.svg
new file mode 100644
index 000000000..f15d72cd4
--- /dev/null
+++ b/assets/icons/minimize-2.svg
@@ -0,0 +1,16 @@
+
diff --git a/assets/icons/zoom-in.svg b/assets/icons/zoom-in.svg
new file mode 100644
index 000000000..11173c0e1
--- /dev/null
+++ b/assets/icons/zoom-in.svg
@@ -0,0 +1,16 @@
+
diff --git a/assets/icons/zoom-out.svg b/assets/icons/zoom-out.svg
new file mode 100644
index 000000000..70abec44b
--- /dev/null
+++ b/assets/icons/zoom-out.svg
@@ -0,0 +1,15 @@
+
diff --git a/crates/ui/src/assets.rs b/crates/ui/src/assets.rs
index 4e1fc97c2..e4c571581 100644
--- a/crates/ui/src/assets.rs
+++ b/crates/ui/src/assets.rs
@@ -65,6 +65,22 @@ const EXTRA_ICONS: &[(&str, &[u8])] = &[
"icons/rotate-ccw.svg",
include_bytes!("../../../assets/icons/rotate-ccw.svg"),
),
+ (
+ "icons/zoom-in.svg",
+ include_bytes!("../../../assets/icons/zoom-in.svg"),
+ ),
+ (
+ "icons/zoom-out.svg",
+ include_bytes!("../../../assets/icons/zoom-out.svg"),
+ ),
+ (
+ "icons/maximize-2.svg",
+ include_bytes!("../../../assets/icons/maximize-2.svg"),
+ ),
+ (
+ "icons/minimize-2.svg",
+ include_bytes!("../../../assets/icons/minimize-2.svg"),
+ ),
(
"icons/openai.svg",
include_bytes!("../../../assets/icons/openai.svg"),
diff --git a/crates/ui/src/attachments.rs b/crates/ui/src/attachments.rs
index 444197718..83a3061a8 100644
--- a/crates/ui/src/attachments.rs
+++ b/crates/ui/src/attachments.rs
@@ -1,47 +1,7 @@
//! Attachment presentation with core-owned validation semantics and localized errors.
-use crate::overlay::OverlayExt as _;
-use crate::theme::ActiveTheme as _;
-use gpui::{App, ImageSource, ParentElement as _, Styled as _, Window, div, img, px};
use tcode_core::attachments::AttachError;
-/// Open an image as a window-level lightbox. The dialog lives on the Root
-/// layer, so its backdrop covers the whole window and it inherits
-/// backdrop-click / Escape / `x` dismissal. Shared by the composer's pending
-/// strip, sent-message thumbnails, Markdown images and image-link badges.
-pub(crate) fn open_image_lightbox(
- source: ImageSource,
- title: String,
- window: &mut Window,
- cx: &mut App,
-) {
- window.open_dialog(cx, move |builder, window, cx| {
- let viewport = window.viewport_size();
- // Leave room for the dialog header, padding and bottom margin in short windows.
- let max_h = (viewport.height * 0.75).min(viewport.height * 0.9 - px(96.));
- let source = source.clone();
- let image_radius = crate::material::radius_card(cx);
- builder
- .w(px(1200.))
- .rounded(crate::material::radius_overlay(cx))
- .bg(cx.theme().popover)
- .border_1()
- .border_color(cx.theme().border)
- .shadow_xl()
- .title(title.clone())
- .content(move |content_el, _, _| {
- content_el.child(
- div().w_full().flex().items_center().justify_center().child(
- img(source.clone())
- .max_w_full()
- .max_h(max_h)
- .rounded(image_radius),
- ),
- )
- })
- });
-}
-
/// How this client reaches the machine that stores the attachment.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransferLink {
diff --git a/crates/ui/src/chat/mod.rs b/crates/ui/src/chat/mod.rs
index 2a793b1ae..57c38eb37 100644
--- a/crates/ui/src/chat/mod.rs
+++ b/crates/ui/src/chat/mod.rs
@@ -1770,7 +1770,7 @@ impl ChatView {
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "image".to_string());
Box::new(cx.listener(move |_, _, window, cx| {
- crate::attachments::open_image_lightbox(
+ crate::image_viewer::open(
crate::store::host_image(path.clone()),
title.clone(),
window,
diff --git a/crates/ui/src/composer/components/images.rs b/crates/ui/src/composer/components/images.rs
index 08e2404df..d096ceb98 100644
--- a/crates/ui/src/composer/components/images.rs
+++ b/crates/ui/src/composer/components/images.rs
@@ -532,7 +532,7 @@ impl Composer {
}
/// Open the clicked thumbnail as a window-level lightbox (see
- /// [`crate::attachments::open_image_lightbox`]).
+ /// [`crate::image_viewer::open`]).
pub(in super::super) fn open_image_preview(
&self,
index: usize,
@@ -542,7 +542,7 @@ impl Composer {
let Some(image) = self.pending_images.get(index) else {
return;
};
- crate::attachments::open_image_lightbox(
+ crate::image_viewer::open(
crate::store::host_image(image.path.clone()),
image.name.clone(),
window,
diff --git a/crates/ui/src/image_viewer.rs b/crates/ui/src/image_viewer.rs
new file mode 100644
index 000000000..5e40174f0
--- /dev/null
+++ b/crates/ui/src/image_viewer.rs
@@ -0,0 +1,660 @@
+//! The window-level image viewer: one image on a host-filling dialog, zoomed
+//! and panned by wheel, trackpad, pinch, drag, touch, keyboard and a toolbar.
+//! Shared by the composer's pending strip, sent-message thumbnails, Markdown
+//! images and image-link badges.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use gpui::{
+ App, AppContext as _, Bounds, ClickEvent, Context, FocusHandle, ImageCacheError, ImageSource,
+ InteractiveElement as _, IntoElement, KeyBinding, MouseButton, MouseDownEvent, MouseMoveEvent,
+ ParentElement as _, PinchEvent, Pixels, Point, Render, RenderImage, ScrollDelta,
+ ScrollWheelEvent, SharedString, Size, StatefulInteractiveElement as _, Styled as _, Window,
+ actions, div, img, point, prelude::FluentBuilder as _, px, size,
+};
+use gpui_base::motion::{Interpolate, Transition, transition};
+
+use crate::icon::IconName;
+use crate::overlay::OverlayExt as _;
+use crate::theme::ActiveTheme as _;
+
+actions!(image_viewer, [ZoomIn, ZoomOut, ZoomToFit, ActualSize]);
+
+const CONTEXT: &str = "ImageViewer";
+
+/// Zoom gained by one wheel notch, one key press or one toolbar press.
+const ZOOM_STEP: f32 = 1.25;
+/// Zoom relative to the fitted size past which zooming stops. The image's
+/// own size stays reachable even when that lies further out.
+const MAX_ZOOM: f32 = 8.;
+/// What one wheel line is worth, so pixel-precise trackpad deltas zoom at
+/// the same rate as notched wheels.
+const WHEEL_LINE: Pixels = px(20.);
+/// Pointer travel before a press is a drag rather than the click it began as.
+const DRAG_SLOP: Pixels = px(4.);
+/// The toolbar is one of the shell's top strips, the height the window's
+/// own controls expect.
+const TOOLBAR_HEIGHT: Pixels = px(crate::window_caption::CAPTION_STRIP_HEIGHT);
+const STAGE_MARGIN: Pixels = px(16.);
+/// How long keys, buttons and double-clicks take to reach their placement.
+const GLIDE: Duration = Duration::from_millis(200);
+
+pub(crate) fn init(cx: &mut App) {
+ cx.bind_keys([
+ KeyBinding::new("=", ZoomIn, Some(CONTEXT)),
+ KeyBinding::new("shift-=", ZoomIn, Some(CONTEXT)),
+ KeyBinding::new("secondary-=", ZoomIn, Some(CONTEXT)),
+ KeyBinding::new("-", ZoomOut, Some(CONTEXT)),
+ KeyBinding::new("secondary--", ZoomOut, Some(CONTEXT)),
+ KeyBinding::new("0", ZoomToFit, Some(CONTEXT)),
+ KeyBinding::new("secondary-0", ZoomToFit, Some(CONTEXT)),
+ KeyBinding::new("1", ActualSize, Some(CONTEXT)),
+ KeyBinding::new("secondary-1", ActualSize, Some(CONTEXT)),
+ ]);
+}
+
+/// Open `source` in the viewer on the window's dialog stack, where it
+/// inherits Escape dismissal and focus restoration.
+pub(crate) fn open(
+ source: ImageSource,
+ title: impl Into,
+ window: &mut Window,
+ cx: &mut App,
+) {
+ let viewer = cx.new(|cx| ImageViewer::new(source, title.into(), cx));
+ let focus = viewer.read(cx).focus_handle.clone();
+ window.open_dialog(cx, move |dialog, _, _| {
+ dialog.fill().close_button(false).child(viewer.clone())
+ });
+ // The dialog focused its own host; the viewer's key bindings sit below it.
+ focus.focus(window, cx);
+}
+
+/// Where the image sits: its zoom relative to the fitted size, where 1 fits
+/// the stage, and the pan of its centre from the stage centre.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Placement {
+ zoom: f32,
+ offset: Point,
+}
+
+impl Placement {
+ const FIT: Self = Self {
+ zoom: 1.,
+ offset: Point {
+ x: Pixels::ZERO,
+ y: Pixels::ZERO,
+ },
+ };
+}
+
+impl Interpolate for Placement {
+ fn interpolate(&self, target: &Self, progress: f32) -> Self {
+ Self {
+ zoom: self.zoom + (target.zoom - self.zoom) * progress,
+ offset: self.offset + (target.offset - self.offset) * progress,
+ }
+ }
+}
+
+/// A primary press on the stage, kept until the click it completes reads it
+/// or a release elsewhere ends it.
+struct Drag {
+ origin: Point,
+ last: Point,
+ moved: bool,
+}
+
+pub(crate) struct ImageViewer {
+ source: ImageSource,
+ title: SharedString,
+ focus_handle: FocusHandle,
+ placement: Placement,
+ drag: Option,
+ /// Keys, buttons and double-clicks glide to their placement; wheel,
+ /// pinch and drag track the pointer.
+ animate: bool,
+ /// The last frame's geometry, so pointer input resolves against what was
+ /// drawn: the stage is the whole dialog host, the frame is the area a
+ /// fitted image fills, and `natural` is the decoded image's own size.
+ stage: Bounds,
+ frame: Size,
+ natural: Option>,
+ failed: bool,
+}
+
+impl ImageViewer {
+ fn new(source: ImageSource, title: SharedString, cx: &mut Context) -> Self {
+ Self {
+ source,
+ title,
+ focus_handle: cx.focus_handle(),
+ placement: Placement::FIT,
+ drag: None,
+ animate: false,
+ stage: Bounds::default(),
+ frame: Size::default(),
+ natural: None,
+ failed: false,
+ }
+ }
+
+ /// The scale at which the image fits the frame. A smaller image is shown
+ /// at its own size rather than enlarged.
+ fn fit_scale(&self) -> Option {
+ let natural = self.natural?;
+ if natural.width <= Pixels::ZERO
+ || natural.height <= Pixels::ZERO
+ || self.frame.width <= Pixels::ZERO
+ || self.frame.height <= Pixels::ZERO
+ {
+ return None;
+ }
+ Some(
+ (self.frame.width / natural.width)
+ .min(self.frame.height / natural.height)
+ .min(1.),
+ )
+ }
+
+ /// The zoom that shows the image at its own size.
+ fn actual_zoom(&self) -> f32 {
+ self.fit_scale().map_or(1., |fit| 1. / fit)
+ }
+
+ fn max_zoom(&self) -> f32 {
+ MAX_ZOOM.max(self.actual_zoom())
+ }
+
+ fn image_bounds(&self, placement: Placement) -> Option> {
+ let natural = self.natural?;
+ let scale = self.fit_scale()? * placement.zoom;
+ let size = size(natural.width * scale, natural.height * scale);
+ let centre = self.stage.center() + placement.offset;
+ Some(Bounds {
+ origin: point(centre.x - size.width / 2., centre.y - size.height / 2.),
+ size,
+ })
+ }
+
+ /// The zoom kept in range, and the image kept covering the stage on each
+ /// axis where it is larger and centred on each where it is not.
+ fn clamped(&self, placement: Placement) -> Placement {
+ let zoom = placement.zoom.clamp(1., self.max_zoom());
+ let centred = Placement {
+ zoom,
+ offset: Point::default(),
+ };
+ let Some(bounds) = self.image_bounds(centred) else {
+ return centred;
+ };
+ let slack = |image: Pixels, stage: Pixels| ((image - stage) / 2.).max(Pixels::ZERO);
+ let x = slack(bounds.size.width, self.stage.size.width);
+ let y = slack(bounds.size.height, self.stage.size.height);
+ Placement {
+ zoom,
+ offset: point(
+ placement.offset.x.max(-x).min(x),
+ placement.offset.y.max(-y).min(y),
+ ),
+ }
+ }
+
+ fn pannable(&self) -> bool {
+ self.image_bounds(self.placement).is_some_and(|bounds| {
+ bounds.size.width > self.stage.size.width || bounds.size.height > self.stage.size.height
+ })
+ }
+
+ fn set_placement(&mut self, placement: Placement, animate: bool, cx: &mut Context) {
+ self.placement = self.clamped(placement);
+ self.animate = animate;
+ cx.notify();
+ }
+
+ /// Zoom to `zoom` keeping the image point under `anchor` where it is,
+ /// as far as the clamp allows.
+ fn zoom_to(&mut self, zoom: f32, anchor: Point, animate: bool, cx: &mut Context) {
+ let zoom = zoom.clamp(1., self.max_zoom());
+ let ratio = zoom / self.placement.zoom;
+ let from_centre = anchor - self.stage.center();
+ let offset = from_centre * (1. - ratio) + self.placement.offset * ratio;
+ self.set_placement(Placement { zoom, offset }, animate, cx);
+ }
+
+ fn step(&mut self, factor: f32, cx: &mut Context) {
+ let zoom = self.placement.zoom * factor;
+ self.zoom_to(zoom, self.stage.center(), true, cx);
+ }
+
+ fn pan_by(&mut self, delta: Point, cx: &mut Context) {
+ let offset = self.placement.offset + delta;
+ self.set_placement(
+ Placement {
+ offset,
+ ..self.placement
+ },
+ false,
+ cx,
+ );
+ }
+
+ /// A double-click or double-tap: back to the fit when zoomed, otherwise
+ /// in to the image's own size, or twice the fit when that is nearer.
+ fn toggle_zoom(&mut self, anchor: Point, cx: &mut Context) {
+ let zoom = if self.placement.zoom > 1. {
+ 1.
+ } else {
+ self.actual_zoom().max(2.)
+ };
+ self.zoom_to(zoom, anchor, true, cx);
+ }
+
+ fn pressed(&mut self, event: &MouseDownEvent) {
+ self.drag = Some(Drag {
+ origin: event.position,
+ last: event.position,
+ moved: false,
+ });
+ }
+
+ fn pointer_moved(&mut self, event: &MouseMoveEvent, cx: &mut Context) {
+ let Some(drag) = &mut self.drag else {
+ return;
+ };
+ if event.pressed_button != Some(MouseButton::Left) {
+ self.drag = None;
+ return;
+ }
+ let delta = event.position - drag.last;
+ drag.last = event.position;
+ let travel = event.position - drag.origin;
+ if !drag.moved && travel.x.abs() < DRAG_SLOP && travel.y.abs() < DRAG_SLOP {
+ return;
+ }
+ drag.moved = true;
+ self.pan_by(delta, cx);
+ }
+
+ fn clicked(&mut self, event: &ClickEvent, window: &mut Window, cx: &mut Context) {
+ if self.drag.take().is_some_and(|drag| drag.moved) {
+ return;
+ }
+ let position = event.position();
+ if event.click_count() == 2 {
+ self.toggle_zoom(position, cx);
+ return;
+ }
+ let on_image = self
+ .image_bounds(self.placement)
+ .is_some_and(|bounds| bounds.contains(&position));
+ if !on_image {
+ window.close_dialog(cx);
+ }
+ }
+
+ /// A notched wheel zooms; a trackpad or touch pan moves the image, or
+ /// zooms when Control or the platform modifier is held, as browsers do.
+ fn scrolled(&mut self, event: &ScrollWheelEvent, cx: &mut Context) {
+ let delta = event.delta.pixel_delta(WHEEL_LINE);
+ let zooms = event.modifiers.control
+ || event.modifiers.platform
+ || matches!(event.delta, ScrollDelta::Lines(_));
+ if zooms {
+ let notches = delta.y / WHEEL_LINE;
+ if notches != 0. {
+ let zoom = self.placement.zoom * ZOOM_STEP.powf(notches);
+ self.zoom_to(zoom, event.position, false, cx);
+ }
+ } else {
+ self.pan_by(delta, cx);
+ }
+ }
+
+ fn pinched(&mut self, event: &PinchEvent, cx: &mut Context) {
+ if event.delta > -1. {
+ let zoom = self.placement.zoom * (1. + event.delta);
+ self.zoom_to(zoom, event.position, false, cx);
+ }
+ }
+
+ fn decode(&mut self, window: &mut Window, cx: &mut App) {
+ let decoded = match &self.source {
+ ImageSource::Resource(resource) => {
+ window.use_asset::(resource, cx)
+ }
+ ImageSource::Custom(load) => load(window, cx),
+ ImageSource::Render(image) => Some(Ok(image.clone())),
+ ImageSource::Image(image) => image.clone().use_render_image(window, cx).map(Ok),
+ };
+ let natural = decoded.map(|result: Result, ImageCacheError>| {
+ result
+ .ok()
+ .map(|image| image.size(0))
+ .filter(|size| size.width.0 > 0 && size.height.0 > 0)
+ });
+ self.failed = matches!(natural, Some(None));
+ self.natural = natural
+ .flatten()
+ .map(|size| gpui::size(px(size.width.0 as f32), px(size.height.0 as f32)));
+ }
+}
+
+impl Render for ImageViewer {
+ fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ let viewport = window.viewport_size();
+ let insets = crate::window_seam::content_insets(window);
+ let compact = crate::window_seam::window_is_compact(window, cx);
+ self.stage = Bounds {
+ origin: Point::default(),
+ size: viewport,
+ };
+ // The frame is centred on the stage, so the band the toolbar takes
+ // above it is mirrored below.
+ let band = insets.top.max(insets.bottom) + TOOLBAR_HEIGHT + STAGE_MARGIN;
+ self.frame = size(
+ (viewport.width - insets.left - insets.right - STAGE_MARGIN * 2.).max(px(1.)),
+ (viewport.height - band * 2.).max(px(1.)),
+ );
+ self.decode(window, cx);
+ self.placement = self.clamped(self.placement);
+ let policy = Transition::new(if self.animate { GLIDE } else { Duration::ZERO });
+ let shown = transition("image-viewer-placement", self.placement, policy, window, cx);
+ let image = self.image_bounds(shown);
+ let zoomed = self.placement.zoom > 1.;
+ let percent = self
+ .fit_scale()
+ .map(|fit| format!("{}%", (fit * shown.zoom * 100.).round() as i32));
+ let pannable = self.pannable();
+ let muted = cx.theme().muted_foreground;
+
+ let stage = div()
+ .id("image-viewer-stage")
+ .absolute()
+ .inset_0()
+ .occlude()
+ .when(pannable, |el| el.cursor_grab())
+ .on_mouse_down(
+ MouseButton::Left,
+ cx.listener(|this, event, _, _| this.pressed(event)),
+ )
+ .on_mouse_move(cx.listener(|this, event, _, cx| this.pointer_moved(event, cx)))
+ .on_click(cx.listener(|this, event, window, cx| this.clicked(event, window, cx)))
+ .on_scroll_wheel(cx.listener(|this, event, _, cx| this.scrolled(event, cx)))
+ .on_pinch(cx.listener(|this, event, _, cx| this.pinched(event, cx)))
+ .when_some(image, |el, bounds| {
+ el.child(
+ img(self.source.clone())
+ .debug_selector(|| "image-viewer-image".into())
+ .absolute()
+ .left(bounds.origin.x)
+ .top(bounds.origin.y)
+ .w(bounds.size.width)
+ .h(bounds.size.height),
+ )
+ })
+ .when(image.is_none(), |el| {
+ el.flex().items_center().justify_center().map(|el| {
+ if self.failed {
+ el.child(
+ div()
+ .text_sm()
+ .text_color(muted)
+ .child(crate::tr!("image_viewer.load_failed").into_owned()),
+ )
+ } else {
+ el.child(crate::widgets::Spinner::new())
+ }
+ })
+ });
+
+ let fit_or_actual = if zoomed {
+ crate::material::toolbar_icon_button(
+ "image-viewer-fit",
+ IconName::Minimize2,
+ crate::tr!("image_viewer.fit").into_owned(),
+ compact,
+ )
+ .on_click(cx.listener(|this, _, _, cx| this.set_placement(Placement::FIT, true, cx)))
+ } else {
+ crate::material::toolbar_icon_button(
+ "image-viewer-actual-size",
+ IconName::Maximize2,
+ crate::tr!("image_viewer.actual_size").into_owned(),
+ compact,
+ )
+ .disabled(self.actual_zoom() <= 1.)
+ .on_click(cx.listener(|this, _, _, cx| {
+ let zoom = this.actual_zoom();
+ this.zoom_to(zoom, this.stage.center(), true, cx);
+ }))
+ };
+ let toolbar = div()
+ .absolute()
+ .top(insets.top)
+ .left(insets.left)
+ .right(insets.right)
+ .h(TOOLBAR_HEIGHT)
+ .flex()
+ .items_center()
+ .gap_1()
+ .px_2()
+ .pl(crate::window_caption::traffic_light_inset(window).max(px(8.)))
+ .occlude()
+ .bg(cx.theme().popover)
+ .border_b_1()
+ .border_color(cx.theme().border)
+ .child(
+ div()
+ .flex_1()
+ .min_w_0()
+ .truncate()
+ .text_sm()
+ .child(self.title.clone()),
+ )
+ .child(
+ crate::material::toolbar_icon_button(
+ "image-viewer-zoom-out",
+ IconName::ZoomOut,
+ crate::tr!("image_viewer.zoom_out").into_owned(),
+ compact,
+ )
+ .disabled(!zoomed)
+ .on_click(cx.listener(|this, _, _, cx| this.step(1. / ZOOM_STEP, cx))),
+ )
+ .child(
+ div()
+ .w(px(48.))
+ .text_center()
+ .text_xs()
+ .text_color(muted)
+ .children(percent),
+ )
+ .child(
+ crate::material::toolbar_icon_button(
+ "image-viewer-zoom-in",
+ IconName::ZoomIn,
+ crate::tr!("image_viewer.zoom_in").into_owned(),
+ compact,
+ )
+ .disabled(self.placement.zoom >= self.max_zoom())
+ .on_click(cx.listener(|this, _, _, cx| this.step(ZOOM_STEP, cx))),
+ )
+ .child(fit_or_actual)
+ .child(
+ crate::material::toolbar_icon_button(
+ "image-viewer-close",
+ IconName::Close,
+ crate::tr!("image_viewer.close").into_owned(),
+ compact,
+ )
+ .on_click(|_, window, cx| window.close_dialog(cx)),
+ );
+
+ div()
+ .id("image-viewer")
+ .track_focus(&self.focus_handle)
+ .key_context(CONTEXT)
+ .size_full()
+ .relative()
+ .overflow_hidden()
+ .on_action(cx.listener(|this, _: &ZoomIn, _, cx| this.step(ZOOM_STEP, cx)))
+ .on_action(cx.listener(|this, _: &ZoomOut, _, cx| this.step(1. / ZOOM_STEP, cx)))
+ .on_action(cx.listener(|this, _: &ZoomToFit, _, cx| {
+ this.set_placement(Placement::FIT, true, cx)
+ }))
+ .on_action(cx.listener(|this, _: &ActualSize, _, cx| {
+ let zoom = this.actual_zoom();
+ this.zoom_to(zoom, this.stage.center(), true, cx);
+ }))
+ .child(stage)
+ .child(toolbar)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use gpui::{Modifiers, TestAppContext, TouchPhase, VisualTestContext};
+ use image::{Frame, ImageBuffer, Rgba};
+
+ struct Body;
+
+ impl Render for Body {
+ fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement {
+ div().size_full()
+ }
+ }
+
+ fn open_viewer(cx: &mut TestAppContext, width: u32, height: u32) -> &mut VisualTestContext {
+ cx.update(crate::theme::init);
+ cx.update(init);
+ let (_, cx) = cx.add_window_view(|window, cx| {
+ let body = cx.new(|_| Body);
+ gpui_base::Root::new(body, window, cx)
+ });
+ cx.simulate_resize(size(px(800.), px(600.)));
+ let frame = Frame::new(ImageBuffer::from_pixel(width, height, Rgba([0, 0, 0, 255])));
+ let image = Arc::new(RenderImage::new(vec![frame]));
+ cx.update(|window, cx| open(ImageSource::Render(image), "shot.png", window, cx));
+ draw(cx);
+ cx
+ }
+
+ fn draw(cx: &mut VisualTestContext) {
+ cx.run_until_parked();
+ cx.update(|window, cx| {
+ window.refresh();
+ _ = window.draw(cx);
+ });
+ }
+
+ fn image(cx: &mut VisualTestContext) -> Bounds {
+ cx.debug_bounds("image-viewer-image")
+ .expect("the viewer shows its image")
+ }
+
+ #[gpui::test]
+ fn pointer_gestures_zoom_about_the_pointer_pan_within_the_stage_and_close(
+ cx: &mut TestAppContext,
+ ) {
+ // 400×200 fits the 800×600 window untouched, centred on (400, 300).
+ let cx = open_viewer(cx, 400, 200);
+ assert_eq!(
+ image(cx),
+ Bounds::new(point(px(200.), px(200.)), size(px(400.), px(200.)))
+ );
+
+ // One wheel notch at the centre grows the image a step, still centred.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(400.), px(300.)),
+ delta: ScrollDelta::Lines(point(0., 1.)),
+ modifiers: Modifiers::default(),
+ touch_phase: TouchPhase::Moved,
+ });
+ draw(cx);
+ assert_eq!(
+ image(cx),
+ Bounds::new(point(px(150.), px(175.)), size(px(500.), px(250.)))
+ );
+
+ // A pinch at the image's corner doubles it about that corner; now
+ // wider than the stage, it is held flush to the left edge instead of
+ // leaving a gap, and still centred vertically.
+ cx.simulate_event(PinchEvent {
+ position: point(px(150.), px(175.)),
+ delta: 1.,
+ modifiers: Modifiers::default(),
+ phase: TouchPhase::Moved,
+ });
+ draw(cx);
+ assert_eq!(
+ image(cx),
+ Bounds::new(point(px(0.), px(50.)), size(px(1000.), px(500.)))
+ );
+
+ // Dragging pans, and the release of a drag is not a click.
+ cx.simulate_mouse_down(
+ point(px(400.), px(300.)),
+ MouseButton::Left,
+ Modifiers::default(),
+ );
+ cx.simulate_mouse_move(
+ point(px(300.), px(300.)),
+ Some(MouseButton::Left),
+ Modifiers::default(),
+ );
+ cx.simulate_mouse_up(
+ point(px(300.), px(300.)),
+ MouseButton::Left,
+ Modifiers::default(),
+ );
+ draw(cx);
+ assert_eq!(image(cx).origin, point(px(-100.), px(50.)));
+
+ // A trackpad or touch pan moves the image too.
+ cx.simulate_event(ScrollWheelEvent {
+ position: point(px(400.), px(300.)),
+ delta: ScrollDelta::Pixels(point(px(-50.), px(0.))),
+ modifiers: Modifiers::default(),
+ touch_phase: TouchPhase::Moved,
+ });
+ draw(cx);
+ assert_eq!(image(cx).origin, point(px(-150.), px(50.)));
+
+ // A click beside the image closes the viewer.
+ cx.simulate_click(point(px(400.), px(580.)), Modifiers::default());
+ draw(cx);
+ assert!(cx.debug_bounds("image-viewer-image").is_none());
+ }
+
+ #[gpui::test]
+ fn keys_glide_between_fit_and_actual_size_and_escape_closes(cx: &mut TestAppContext) {
+ // 1600×800 fits the 768×480 frame at 0.48: 768×384, centred.
+ let cx = open_viewer(cx, 1600, 800);
+ let fitted = Bounds::new(point(px(16.), px(108.)), size(px(768.), px(384.)));
+ assert_eq!(image(cx), fitted);
+
+ cx.simulate_keystrokes("1");
+ draw(cx);
+ assert_eq!(image(cx), fitted, "the glide starts from the fit");
+ // The frame after the key press began the glide; the next ends it.
+ cx.executor().advance_clock(GLIDE);
+ draw(cx);
+ assert_eq!(
+ image(cx),
+ Bounds::new(point(px(-400.), px(-100.)), size(px(1600.), px(800.)))
+ );
+
+ cx.simulate_keystrokes("0");
+ draw(cx);
+ cx.executor().advance_clock(GLIDE);
+ draw(cx);
+ assert_eq!(image(cx), fitted);
+
+ cx.simulate_keystrokes("escape");
+ draw(cx);
+ assert!(cx.debug_bounds("image-viewer-image").is_none());
+ }
+}
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index a3255293b..bba9f6d0c 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -18,6 +18,7 @@ mod highlight;
mod host_permissions;
pub mod i18n;
pub mod icon;
+mod image_viewer;
/// macOS TCC permission status and grant flow. Compiled only where the platform
/// actually has one; other attachments query the host for read-only status.
#[cfg(all(feature = "local-permissions", target_os = "macos"))]
diff --git a/crates/ui/src/markdown/image_link.rs b/crates/ui/src/markdown/image_link.rs
index 1ac100a71..65d1b19d4 100644
--- a/crates/ui/src/markdown/image_link.rs
+++ b/crates/ui/src/markdown/image_link.rs
@@ -75,7 +75,7 @@ pub(super) fn badge(
.on_click(move |_, window, cx| {
crate::widgets::stop_click_propagation(window, cx);
let source = view.read(cx).image_source(&url.to_string().into());
- crate::attachments::open_image_lightbox(source, label.to_string(), window, cx);
+ crate::image_viewer::open(source, label.to_string(), window, cx);
});
// Preserve the line's spacing while shortening only the visible badge.
div()
diff --git a/crates/ui/src/markdown/inline_flow.rs b/crates/ui/src/markdown/inline_flow.rs
index 3ada998ce..5e532e48b 100644
--- a/crates/ui/src/markdown/inline_flow.rs
+++ b/crates/ui/src/markdown/inline_flow.rs
@@ -199,12 +199,7 @@ impl InlineFlow {
LinkTarget::Local(path) => cx.open_with_system(&path),
}
} else {
- crate::attachments::open_image_lightbox(
- source.clone(),
- title.clone(),
- window,
- cx,
- );
+ crate::image_viewer::open(source.clone(), title.clone(), window, cx);
}
})
.into_any_element()
diff --git a/crates/ui/src/markdown/render.rs b/crates/ui/src/markdown/render.rs
index 3e161edde..ea7afab09 100644
--- a/crates/ui/src/markdown/render.rs
+++ b/crates/ui/src/markdown/render.rs
@@ -651,12 +651,7 @@ fn render_paragraph(
LinkTarget::Local(path) => cx.open_with_system(&path),
}
} else {
- crate::attachments::open_image_lightbox(
- source.clone(),
- title.clone(),
- window,
- cx,
- );
+ crate::image_viewer::open(source.clone(), title.clone(), window, cx);
}
})
}))
@@ -1178,7 +1173,7 @@ fn render_diagram(
.on_click(move |_, window, cx| {
gpui_base::TextSelection::end(window, cx);
cx.stop_propagation();
- crate::attachments::open_image_lightbox(
+ crate::image_viewer::open(
lightbox_source.clone(),
lightbox_label.to_string(),
window,
diff --git a/crates/ui/src/markdown/view.rs b/crates/ui/src/markdown/view.rs
index f51e878f8..a5ec74bec 100644
--- a/crates/ui/src/markdown/view.rs
+++ b/crates/ui/src/markdown/view.rs
@@ -175,12 +175,7 @@ impl Element for MarkdownView {
let state = state.clone();
move |action: &OpenImage, window, cx| {
let source = state.read(cx).image_source(&action.url.clone().into());
- crate::attachments::open_image_lightbox(
- source,
- action.title.clone(),
- window,
- cx,
- );
+ crate::image_viewer::open(source, action.title.clone(), window, cx);
}
})
.child(state.clone())
diff --git a/crates/ui/src/overlay/dialog.rs b/crates/ui/src/overlay/dialog.rs
index 3ddd881f9..de3d44a68 100644
--- a/crates/ui/src/overlay/dialog.rs
+++ b/crates/ui/src/overlay/dialog.rs
@@ -229,6 +229,7 @@ pub struct Dialog {
width: Pixels,
max_width: Option,
close_button: bool,
+ fill: bool,
overlay: bool,
overlay_closable: bool,
keyboard: bool,
@@ -250,6 +251,7 @@ impl Dialog {
width: px(448.),
max_width: None,
close_button: true,
+ fill: false,
overlay: true,
overlay_closable: true,
keyboard: true,
@@ -289,6 +291,13 @@ impl Dialog {
self.close_button = value;
self
}
+
+ /// Span the whole window instead of floating a card: the surface brings
+ /// its own chrome, as the image viewer does.
+ pub fn fill(mut self) -> Self {
+ self.fill = true;
+ self
+ }
pub fn w(mut self, width: impl Into) -> Self {
self.width = width.into();
self
@@ -378,21 +387,26 @@ impl RenderOnce for Dialog {
let popup = div()
.id(("tcode-dialog", self.layer))
.absolute()
- .left(x)
- .top(y)
- .w(width)
- .when_some(self.max_width, |el, width| el.max_w(width))
- .max_h(viewport.height - y - px(16.))
- .min_h_24()
- .flex()
- .flex_col()
- .gap_4()
- .p(padding.top)
- .bg(cx.theme().popover)
- .border_1()
- .border_color(cx.theme().border)
- .rounded(crate::material::radius_overlay(cx))
- .shadow_xl()
+ .map(|el| {
+ if self.fill {
+ return el.inset_0().overflow_hidden();
+ }
+ el.left(x)
+ .top(y)
+ .w(width)
+ .when_some(self.max_width, |el, width| el.max_w(width))
+ .max_h(viewport.height - y - px(16.))
+ .min_h_24()
+ .flex()
+ .flex_col()
+ .gap_4()
+ .p(padding.top)
+ .bg(cx.theme().popover)
+ .border_1()
+ .border_color(cx.theme().border)
+ .rounded(crate::material::radius_overlay(cx))
+ .shadow_xl()
+ })
.occlude()
.refine_style(&self.style)
.when_some(self.title, |el, title| {
diff --git a/crates/ui/src/run.rs b/crates/ui/src/run.rs
index 15e6bfc7c..225441159 100644
--- a/crates/ui/src/run.rs
+++ b/crates/ui/src/run.rs
@@ -131,6 +131,7 @@ pub fn run_shell(
.expect("failed to register bundled application fonts");
theme::init_with_json(&options.theme_json, cx);
crate::markdown::init(cx);
+ crate::image_viewer::init(cx);
crate::shortcut::init(cx);
// Global ⌘K / Ctrl-K opens/closes the command palette (handled by
// AppShell). `secondary` is gpui's platform modifier: command on macOS,
diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs
index 699df4f9c..6b2592107 100644
--- a/crates/ui/src/shell.rs
+++ b/crates/ui/src/shell.rs
@@ -1227,10 +1227,6 @@ impl AppShell {
// Compact chrome
// ---------------------------------------------------------------------------
-/// Left inset so the nav bar's back control clears the native macOS traffic
-/// lights, which a compact *desktop* window still draws over this strip.
-const TRAFFIC_LIGHT_INSET: f32 = 80.;
-
/// Room a nav bar reserves on each side for its controls, so the centered title
/// keeps the same box on every page: enough for the widest fixed back label
/// ([`Destination::back_label`]) — which never truncates — and for two 44pt
@@ -1252,13 +1248,13 @@ fn nav_bar(
);
// A compact window on the desktop is still a window: it keeps the platform's
// own controls out of the content and stays draggable by its top strip.
- let clears_traffic_lights = cfg!(target_os = "macos") && !window.is_fullscreen();
+ let traffic_lights = window_caption::traffic_light_inset(window);
let hosts_caption = window_caption::compact_hosts_caption();
let leading = h_flex()
.absolute()
.inset_0()
.px(px(4.))
- .when(clears_traffic_lights, |row| row.pl(px(TRAFFIC_LIGHT_INSET)))
+ .when(traffic_lights > Pixels::ZERO, |row| row.pl(traffic_lights))
.items_center()
.children(back)
.child(window_caption::drag_region(div().flex_1().h_full()))
@@ -1281,11 +1277,7 @@ fn nav_bar(
v_flex()
.absolute()
.inset_0()
- .pl(px(if clears_traffic_lights {
- TRAFFIC_LIGHT_INSET + NAV_CONTROL_WIDTH
- } else {
- NAV_CONTROL_WIDTH
- }))
+ .pl(traffic_lights + px(NAV_CONTROL_WIDTH))
.pr(px(NAV_CONTROL_WIDTH))
.items_center()
.justify_center()
diff --git a/crates/ui/src/window_caption.rs b/crates/ui/src/window_caption.rs
index 26c789c40..a6f878185 100644
--- a/crates/ui/src/window_caption.rs
+++ b/crates/ui/src/window_caption.rs
@@ -24,8 +24,8 @@ use crate::{
sizing::Sizable as _,
};
use gpui::{
- App, InteractiveElement, IntoElement, ParentElement as _, StatefulInteractiveElement as _,
- Styled as _, Window, WindowControlArea, div, px,
+ App, InteractiveElement, IntoElement, ParentElement as _, Pixels,
+ StatefulInteractiveElement as _, Styled as _, Window, WindowControlArea, div, px,
};
use tcode_core::ui::RightTab;
@@ -43,6 +43,16 @@ pub(crate) const CAPTION_CLUSTER_WIDTH: f32 = CAPTION_BUTTON_WIDTH * 3.;
/// Whether this build owns its window chrome and must draw caption buttons.
const CLIENT_DECORATED: bool = cfg!(target_os = "windows");
+/// Left inset a top strip keeps clear of the native macOS traffic lights,
+/// which the system draws over the window's top-left outside full screen.
+pub(crate) fn traffic_light_inset(window: &Window) -> Pixels {
+ if cfg!(target_os = "macos") && !window.is_fullscreen() {
+ px(80.)
+ } else {
+ Pixels::ZERO
+ }
+}
+
/// A top strip that can host the caption cluster.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CaptionSurface {
diff --git a/locales/en.yml b/locales/en.yml
index 788ee4b12..4c9d40918 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -934,6 +934,13 @@ attach:
tunnel_title: "Large attachment"
tunnel_body: "'%{name}' is %{size} and this device reaches the machine across the internet, so sending it may take a while."
tunnel_confirm: "Send anyway"
+image_viewer:
+ zoom_in: "Zoom in"
+ zoom_out: "Zoom out"
+ fit: "Fit to window"
+ actual_size: "Actual size"
+ close: "Close"
+ load_failed: "This image could not be loaded."
preview:
external_unavailable: "Open the remote page in Preview first. Its forwarded browser link works only while this preview stays open."
remote_forwarding: "Remote loopback URLs are forwarded. Other addresses open from this Mac."
diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml
index 35cda3304..219e11bc3 100644
--- a/locales/zh-CN.yml
+++ b/locales/zh-CN.yml
@@ -928,6 +928,13 @@ attach:
tunnel_title: "附件较大"
tunnel_body: "'%{name}' 大小为 %{size},当前设备通过互联网直连主机,发送可能需要一些时间。"
tunnel_confirm: "仍然发送"
+image_viewer:
+ zoom_in: "放大"
+ zoom_out: "缩小"
+ fit: "适应窗口"
+ actual_size: "实际大小"
+ close: "关闭"
+ load_failed: "无法加载此图片。"
preview:
external_unavailable: "请先在预览中打开远程页面。转发后的浏览器链接仅在此预览保持打开时有效。"
remote_forwarding: "远程回环地址通过转发访问,其他地址从此 Mac 直接访问。"