From 109c253c031ebfbda14ed61b9427717b8d7c7d0e Mon Sep 17 00:00:00 2001 From: Tryanks Date: Tue, 6 Oct 2026 15:48:11 +0800 Subject: [PATCH] feat(ui): image viewer with zoom, pan and gesture support Replace the fixed-card image lightbox with an ImageViewer entity on a host-filling dialog. Wheel and pinch zoom about the pointer, drag and touch pan, double-click toggles fit and actual size, keys and toolbar glide between placements, and a click beside the image, Escape or the close button dismiss it. Placement is clamped to the stage and small images are not enlarged at fit. Dialog gains an opt-in fill() mode; the macOS traffic-light inset moves to window_caption as its owner and the shell nav bar uses it; the four Lucide icons the toolbar needs are bundled. --- assets/icons/maximize-2.svg | 16 + assets/icons/minimize-2.svg | 16 + assets/icons/zoom-in.svg | 16 + assets/icons/zoom-out.svg | 15 + crates/ui/src/assets.rs | 16 + crates/ui/src/attachments.rs | 40 -- crates/ui/src/chat/mod.rs | 2 +- crates/ui/src/composer/components/images.rs | 4 +- crates/ui/src/image_viewer.rs | 660 ++++++++++++++++++++ crates/ui/src/lib.rs | 1 + crates/ui/src/markdown/image_link.rs | 2 +- crates/ui/src/markdown/inline_flow.rs | 7 +- crates/ui/src/markdown/render.rs | 9 +- crates/ui/src/markdown/view.rs | 7 +- crates/ui/src/overlay/dialog.rs | 44 +- crates/ui/src/run.rs | 1 + crates/ui/src/shell.rs | 14 +- crates/ui/src/window_caption.rs | 14 +- locales/en.yml | 7 + locales/zh-CN.yml | 7 + 20 files changed, 807 insertions(+), 91 deletions(-) create mode 100644 assets/icons/maximize-2.svg create mode 100644 assets/icons/minimize-2.svg create mode 100644 assets/icons/zoom-in.svg create mode 100644 assets/icons/zoom-out.svg create mode 100644 crates/ui/src/image_viewer.rs 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 直接访问。"