Support zooming and panning in the image viewer (#43944)
Implemented Pan and Zoom on the Image Viewer. Demo: https://github.com/user-attachments/assets/855bafe8-fdc2-4945-9bfb-e48382264806 Closes #9584 Release Notes: - Add zoom in/out, reset, fit-to-view and zoom-to-actual actions - Support scroll-wheel zoom with modifier, click-and-drag panning - Renders a zoom percentage overlay. - ImageView Toolbar Added to control zoom in/out and fit view. --------- Co-authored-by: MrSubidubi <finn@zed.dev>
This commit is contained in:
1 parent
20bc0f6f67
commit
b162fb4019
5 files changed
+569
-81
No files matched your search
@@ -1342,6 +1342,17 @@
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "ImageViewer",
|
||||
"bindings": {
|
||||
"ctrl-=": "image_viewer::ZoomIn",
|
||||
"ctrl-+": "image_viewer::ZoomIn",
|
||||
"ctrl--": "image_viewer::ZoomOut",
|
||||
"ctrl-0": "image_viewer::ResetZoom",
|
||||
"ctrl-1": "image_viewer::ZoomToActualSize",
|
||||
"ctrl-shift-0": "image_viewer::FitToView",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "RunModal",
|
||||
"bindings": {
|
||||
|
||||
@@ -1444,6 +1444,18 @@
|
||||
"cmd-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "ImageViewer",
|
||||
"use_key_equivalents": true,
|
||||
"bindings": {
|
||||
"cmd-=": "image_viewer::ZoomIn",
|
||||
"cmd-+": "image_viewer::ZoomIn",
|
||||
"cmd--": "image_viewer::ZoomOut",
|
||||
"cmd-0": "image_viewer::ResetZoom",
|
||||
"cmd-1": "image_viewer::ZoomToActualSize",
|
||||
"cmd-shift-0": "image_viewer::FitToView",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "RunModal",
|
||||
"bindings": {
|
||||
|
||||
@@ -1363,6 +1363,17 @@
|
||||
"ctrl-shift-i": "branch_picker::FilterRemotes",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "ImageViewer",
|
||||
"bindings": {
|
||||
"ctrl-=": "image_viewer::ZoomIn",
|
||||
"ctrl-+": "image_viewer::ZoomIn",
|
||||
"ctrl--": "image_viewer::ZoomOut",
|
||||
"ctrl-0": "image_viewer::ResetZoom",
|
||||
"ctrl-1": "image_viewer::ZoomToActualSize",
|
||||
"ctrl-shift-0": "image_viewer::FitToView",
|
||||
},
|
||||
},
|
||||
{
|
||||
"context": "RunModal",
|
||||
"bindings": {
|
||||
|
||||
@@ -7,39 +7,79 @@ use anyhow::Context as _;
|
||||
use editor::{EditorSettings, items::entry_git_aware_label_color};
|
||||
use file_icons::FileIcons;
|
||||
use gpui::{
|
||||
AnyElement, App, Bounds, Context, Entity, EventEmitter, FocusHandle, Focusable,
|
||||
InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, Task, WeakEntity,
|
||||
Window, canvas, div, fill, img, opaque_grey, point, size,
|
||||
AnyElement, App, Bounds, Context, DispatchPhase, Element, ElementId, Entity, EventEmitter,
|
||||
FocusHandle, Focusable, GlobalElementId, InspectorElementId, InteractiveElement, IntoElement,
|
||||
LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels,
|
||||
Point, Render, ScrollDelta, ScrollWheelEvent, Style, Styled, Task, WeakEntity, Window, actions,
|
||||
canvas, div, img, opaque_grey, point, px, size,
|
||||
};
|
||||
use language::File as _;
|
||||
use persistence::IMAGE_VIEWER;
|
||||
use project::{ImageItem, Project, ProjectPath, image_store::ImageItemEvent};
|
||||
use settings::Settings;
|
||||
use theme::{Theme, ThemeSettings};
|
||||
use ui::prelude::*;
|
||||
use ui::{Tooltip, prelude::*};
|
||||
use util::paths::PathExt;
|
||||
use workspace::{
|
||||
ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, delete_unloaded_items,
|
||||
ItemId, ItemSettings, Pane, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
|
||||
WorkspaceId, delete_unloaded_items,
|
||||
invalid_item_view::InvalidItemView,
|
||||
item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams},
|
||||
item::{BreadcrumbText, Item, ItemHandle, ProjectItem, SerializableItem, TabContentParams},
|
||||
};
|
||||
|
||||
pub use crate::image_info::*;
|
||||
pub use crate::image_viewer_settings::*;
|
||||
|
||||
actions!(
|
||||
image_viewer,
|
||||
[
|
||||
/// Zoom in the image.
|
||||
ZoomIn,
|
||||
/// Zoom out the image.
|
||||
ZoomOut,
|
||||
/// Reset zoom to 100%.
|
||||
ResetZoom,
|
||||
/// Fit the image to view.
|
||||
FitToView,
|
||||
/// Zoom to actual size (100%).
|
||||
ZoomToActualSize
|
||||
]
|
||||
);
|
||||
|
||||
const MIN_ZOOM: f32 = 0.1;
|
||||
const MAX_ZOOM: f32 = 20.0;
|
||||
const ZOOM_STEP: f32 = 1.1;
|
||||
const SCROLL_LINE_MULTIPLIER: f32 = 20.0;
|
||||
const BASE_SQUARE_SIZE: f32 = 48.0;
|
||||
|
||||
pub struct ImageView {
|
||||
image_item: Entity<ImageItem>,
|
||||
project: Entity<Project>,
|
||||
focus_handle: FocusHandle,
|
||||
zoom_level: f32,
|
||||
pan_offset: Point<Pixels>,
|
||||
last_mouse_position: Option<Point<Pixels>>,
|
||||
container_bounds: Option<Bounds<Pixels>>,
|
||||
image_size: Option<(u32, u32)>,
|
||||
}
|
||||
|
||||
impl ImageView {
|
||||
fn is_dragging(&self) -> bool {
|
||||
self.last_mouse_position.is_some()
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
image_item: Entity<ImageItem>,
|
||||
project: Entity<Project>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
// Start loading the image to render in the background to prevent the view
|
||||
// from flickering in most cases.
|
||||
let _ = image_item.update(cx, |image, cx| {
|
||||
image.image.clone().get_render_image(window, cx)
|
||||
});
|
||||
|
||||
cx.subscribe(&image_item, Self::on_image_event).detach();
|
||||
cx.on_release_in(window, |this, window, cx| {
|
||||
let image_data = this.image_item.read(cx).image.clone();
|
||||
@@ -50,10 +90,20 @@ impl ImageView {
|
||||
})
|
||||
.detach();
|
||||
|
||||
let image_size = image_item
|
||||
.read(cx)
|
||||
.image_metadata
|
||||
.map(|m| (m.width, m.height));
|
||||
|
||||
Self {
|
||||
image_item,
|
||||
project,
|
||||
focus_handle: cx.focus_handle(),
|
||||
zoom_level: 1.0,
|
||||
pan_offset: Point::default(),
|
||||
last_mouse_position: None,
|
||||
container_bounds: None,
|
||||
image_size,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,12 +117,339 @@ impl ImageView {
|
||||
ImageItemEvent::MetadataUpdated
|
||||
| ImageItemEvent::FileHandleChanged
|
||||
| ImageItemEvent::Reloaded => {
|
||||
self.image_size = self
|
||||
.image_item
|
||||
.read(cx)
|
||||
.image_metadata
|
||||
.map(|m| (m.width, m.height));
|
||||
cx.emit(ImageViewEvent::TitleChanged);
|
||||
cx.notify();
|
||||
}
|
||||
ImageItemEvent::ReloadNeeded => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_in(&mut self, _: &ZoomIn, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_zoom(self.zoom_level * ZOOM_STEP, None, cx);
|
||||
}
|
||||
|
||||
fn zoom_out(&mut self, _: &ZoomOut, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.set_zoom(self.zoom_level / ZOOM_STEP, None, cx);
|
||||
}
|
||||
|
||||
fn reset_zoom(&mut self, _: &ResetZoom, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
self.zoom_level = 1.0;
|
||||
self.pan_offset = Point::default();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn fit_to_view(&mut self, _: &FitToView, _window: &mut Window, cx: &mut Context<Self>) {
|
||||
if let Some((bounds, (img_width, img_height))) = self.container_bounds.zip(self.image_size)
|
||||
{
|
||||
let container_width: f32 = bounds.size.width.into();
|
||||
let container_height: f32 = bounds.size.height.into();
|
||||
let scale_x = container_width / img_width as f32;
|
||||
let scale_y = container_height / img_height as f32;
|
||||
self.zoom_level = scale_x.min(scale_y).min(1.0);
|
||||
self.pan_offset = Point::default();
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_to_actual_size(
|
||||
&mut self,
|
||||
_: &ZoomToActualSize,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.zoom_level = 1.0;
|
||||
self.pan_offset = Point::default();
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn set_zoom(
|
||||
&mut self,
|
||||
new_zoom: f32,
|
||||
zoom_center: Option<Point<Pixels>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let old_zoom = self.zoom_level;
|
||||
self.zoom_level = new_zoom.clamp(MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
if let Some((center, bounds)) = zoom_center.zip(self.container_bounds) {
|
||||
let relative_center = point(
|
||||
center.x - bounds.origin.x - bounds.size.width / 2.0,
|
||||
center.y - bounds.origin.y - bounds.size.height / 2.0,
|
||||
);
|
||||
|
||||
let mouse_offset_from_image = relative_center - self.pan_offset;
|
||||
|
||||
let zoom_ratio = self.zoom_level / old_zoom;
|
||||
|
||||
self.pan_offset += mouse_offset_from_image * (1.0 - zoom_ratio);
|
||||
}
|
||||
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_scroll_wheel(
|
||||
&mut self,
|
||||
event: &ScrollWheelEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if event.modifiers.control || event.modifiers.platform {
|
||||
let delta: f32 = match event.delta {
|
||||
ScrollDelta::Pixels(pixels) => pixels.y.into(),
|
||||
ScrollDelta::Lines(lines) => lines.y * SCROLL_LINE_MULTIPLIER,
|
||||
};
|
||||
let zoom_factor = if delta > 0.0 {
|
||||
1.0 + delta.abs() * 0.01
|
||||
} else {
|
||||
1.0 / (1.0 + delta.abs() * 0.01)
|
||||
};
|
||||
self.set_zoom(self.zoom_level * zoom_factor, Some(event.position), cx);
|
||||
} else {
|
||||
let delta = match event.delta {
|
||||
ScrollDelta::Pixels(pixels) => pixels,
|
||||
ScrollDelta::Lines(lines) => lines.map(|d| px(d * SCROLL_LINE_MULTIPLIER)),
|
||||
};
|
||||
self.pan_offset += delta;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse_down(
|
||||
&mut self,
|
||||
event: &MouseDownEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if event.button == MouseButton::Left || event.button == MouseButton::Middle {
|
||||
self.last_mouse_position = Some(event.position);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_mouse_up(
|
||||
&mut self,
|
||||
_event: &MouseUpEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
self.last_mouse_position = None;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
fn handle_mouse_move(
|
||||
&mut self,
|
||||
event: &MouseMoveEvent,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
if self.is_dragging() {
|
||||
if let Some(last_pos) = self.last_mouse_position {
|
||||
let delta = event.position - last_pos;
|
||||
self.pan_offset += delta;
|
||||
}
|
||||
self.last_mouse_position = Some(event.position);
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ImageContentElement {
|
||||
image_view: Entity<ImageView>,
|
||||
}
|
||||
|
||||
impl ImageContentElement {
|
||||
fn new(image_view: Entity<ImageView>) -> Self {
|
||||
Self { image_view }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoElement for ImageContentElement {
|
||||
type Element = Self;
|
||||
|
||||
fn into_element(self) -> Self::Element {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for ImageContentElement {
|
||||
type RequestLayoutState = ();
|
||||
type PrepaintState = Option<(AnyElement, bool)>;
|
||||
|
||||
fn id(&self) -> Option<ElementId> {
|
||||
None
|
||||
}
|
||||
|
||||
fn source_location(&self) -> Option<&'static core::panic::Location<'static>> {
|
||||
None
|
||||
}
|
||||
|
||||
fn request_layout(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> (LayoutId, Self::RequestLayoutState) {
|
||||
(
|
||||
window.request_layout(
|
||||
Style {
|
||||
size: size(relative(1.).into(), relative(1.).into()),
|
||||
..Default::default()
|
||||
},
|
||||
[],
|
||||
cx,
|
||||
),
|
||||
(),
|
||||
)
|
||||
}
|
||||
|
||||
fn prepaint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Self::PrepaintState {
|
||||
let image_view = self.image_view.read(cx);
|
||||
let image = image_view.image_item.read(cx).image.clone();
|
||||
|
||||
let zoom_level = image_view.zoom_level;
|
||||
let pan_offset = image_view.pan_offset;
|
||||
let border_color = cx.theme().colors().border;
|
||||
|
||||
let is_dragging = image_view.is_dragging();
|
||||
|
||||
let scaled_size = image_view
|
||||
.image_size
|
||||
.map(|(w, h)| (px(w as f32 * zoom_level), px(h as f32 * zoom_level)));
|
||||
|
||||
let (mut left, mut top) = (px(0.0), px(0.0));
|
||||
let mut scaled_width = px(0.0);
|
||||
let mut scaled_height = px(0.0);
|
||||
|
||||
if let Some((width, height)) = scaled_size {
|
||||
scaled_width = width;
|
||||
scaled_height = height;
|
||||
|
||||
let center_x = bounds.size.width / 2.0;
|
||||
let center_y = bounds.size.height / 2.0;
|
||||
|
||||
left = center_x - (scaled_width / 2.0) + pan_offset.x;
|
||||
top = center_y - (scaled_height / 2.0) + pan_offset.y;
|
||||
}
|
||||
|
||||
self.image_view.update(cx, |this, _| {
|
||||
this.container_bounds = Some(bounds);
|
||||
});
|
||||
|
||||
let mut image_content = div()
|
||||
.relative()
|
||||
.size_full()
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left(left)
|
||||
.top(top)
|
||||
.w(scaled_width)
|
||||
.h(scaled_height)
|
||||
.child(
|
||||
canvas(
|
||||
|_, _, _| {},
|
||||
move |bounds, _, window, _cx| {
|
||||
let bounds_x: f32 = bounds.origin.x.into();
|
||||
let bounds_y: f32 = bounds.origin.y.into();
|
||||
let bounds_width: f32 = bounds.size.width.into();
|
||||
let bounds_height: f32 = bounds.size.height.into();
|
||||
let square_size = BASE_SQUARE_SIZE * zoom_level;
|
||||
let cols = (bounds_width / square_size).ceil() as i32 + 1;
|
||||
let rows = (bounds_height / square_size).ceil() as i32 + 1;
|
||||
for row in 0..rows {
|
||||
for col in 0..cols {
|
||||
if (row + col) % 2 == 0 {
|
||||
continue;
|
||||
}
|
||||
let x = bounds_x + col as f32 * square_size;
|
||||
let y = bounds_y + row as f32 * square_size;
|
||||
let w = square_size.min(bounds_x + bounds_width - x);
|
||||
let h = square_size.min(bounds_y + bounds_height - y);
|
||||
if w > 0.0 && h > 0.0 {
|
||||
let rect = Bounds::new(
|
||||
point(px(x), px(y)),
|
||||
size(px(w), px(h)),
|
||||
);
|
||||
window.paint_quad(gpui::fill(
|
||||
rect,
|
||||
opaque_grey(0.6, 1.0),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let border_rect = Bounds::new(
|
||||
point(px(bounds_x), px(bounds_y)),
|
||||
size(px(bounds_width), px(bounds_height)),
|
||||
);
|
||||
window.paint_quad(gpui::outline(
|
||||
border_rect,
|
||||
border_color,
|
||||
gpui::BorderStyle::default(),
|
||||
));
|
||||
},
|
||||
)
|
||||
.size_full()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.bg(gpui::rgb(0xCCCCCD)),
|
||||
)
|
||||
.child({
|
||||
img(image)
|
||||
.id(("image-viewer-image", self.image_view.entity_id()))
|
||||
.size_full()
|
||||
}),
|
||||
)
|
||||
.into_any_element();
|
||||
|
||||
image_content.prepaint_as_root(bounds.origin, bounds.size.into(), window, cx);
|
||||
Some((image_content, is_dragging))
|
||||
}
|
||||
|
||||
fn paint(
|
||||
&mut self,
|
||||
_id: Option<&GlobalElementId>,
|
||||
_inspector_id: Option<&InspectorElementId>,
|
||||
_bounds: Bounds<Pixels>,
|
||||
_request_layout: &mut Self::RequestLayoutState,
|
||||
prepaint: &mut Self::PrepaintState,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let Some((mut element, is_dragging)) = prepaint.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if is_dragging {
|
||||
let image_view = self.image_view.downgrade();
|
||||
window.on_mouse_event(move |_event: &MouseUpEvent, phase, _window, cx| {
|
||||
if phase == DispatchPhase::Bubble
|
||||
&& let Some(entity) = image_view.upgrade()
|
||||
{
|
||||
entity.update(cx, |this, cx| {
|
||||
this.last_mouse_position = None;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
element.paint(window, cx);
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ImageViewEvent {
|
||||
@@ -191,6 +568,11 @@ impl Item for ImageView {
|
||||
image_item: self.image_item.clone(),
|
||||
project: self.project.clone(),
|
||||
focus_handle: cx.focus_handle(),
|
||||
zoom_level: self.zoom_level,
|
||||
pan_offset: self.pan_offset,
|
||||
last_mouse_position: None,
|
||||
container_bounds: None,
|
||||
image_size: self.image_size,
|
||||
})))
|
||||
}
|
||||
|
||||
@@ -303,81 +685,36 @@ impl Focusable for ImageView {
|
||||
}
|
||||
|
||||
impl Render for ImageView {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let image = self.image_item.read(cx).image.clone();
|
||||
let checkered_background =
|
||||
|bounds: Bounds<Pixels>, _, window: &mut Window, _cx: &mut App| {
|
||||
let square_size: f32 = 32.0;
|
||||
|
||||
let start_y = bounds.origin.y.into();
|
||||
let height: f32 = bounds.size.height.into();
|
||||
let start_x = bounds.origin.x.into();
|
||||
let width: f32 = bounds.size.width.into();
|
||||
|
||||
let mut y = start_y;
|
||||
let mut x = start_x;
|
||||
let mut color_swapper = true;
|
||||
// draw checkerboard pattern
|
||||
while y < start_y + height {
|
||||
// Keeping track of the grid in order to be resilient to resizing
|
||||
let start_swap = color_swapper;
|
||||
while x < start_x + width {
|
||||
// Clamp square dimensions to not exceed bounds
|
||||
let square_width = square_size.min(start_x + width - x);
|
||||
let square_height = square_size.min(start_y + height - y);
|
||||
|
||||
let rect = Bounds::new(
|
||||
point(px(x), px(y)),
|
||||
size(px(square_width), px(square_height)),
|
||||
);
|
||||
|
||||
let color = if color_swapper {
|
||||
opaque_grey(0.6, 0.4)
|
||||
} else {
|
||||
opaque_grey(0.7, 0.4)
|
||||
};
|
||||
|
||||
window.paint_quad(fill(rect, color));
|
||||
color_swapper = !color_swapper;
|
||||
x += square_size;
|
||||
}
|
||||
x = start_x;
|
||||
color_swapper = !start_swap;
|
||||
y += square_size;
|
||||
}
|
||||
};
|
||||
|
||||
div().track_focus(&self.focus_handle(cx)).size_full().child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_center()
|
||||
.items_center()
|
||||
.w_full()
|
||||
// TODO: In browser based Tailwind & Flex this would be h-screen and we'd use w-full
|
||||
.h_full()
|
||||
.child(
|
||||
div()
|
||||
.relative()
|
||||
.max_w_full()
|
||||
.max_h_full()
|
||||
.child(
|
||||
canvas(|_, _, _| (), checkered_background)
|
||||
.border_2()
|
||||
.border_color(cx.theme().styles.colors.border)
|
||||
.size_full()
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0(),
|
||||
)
|
||||
.child(
|
||||
img(image)
|
||||
.object_fit(ObjectFit::ScaleDown)
|
||||
.max_w_full()
|
||||
.max_h_full()
|
||||
.id("img"),
|
||||
),
|
||||
),
|
||||
)
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div()
|
||||
.track_focus(&self.focus_handle(cx))
|
||||
.key_context("ImageViewer")
|
||||
.on_action(cx.listener(Self::zoom_in))
|
||||
.on_action(cx.listener(Self::zoom_out))
|
||||
.on_action(cx.listener(Self::reset_zoom))
|
||||
.on_action(cx.listener(Self::fit_to_view))
|
||||
.on_action(cx.listener(Self::zoom_to_actual_size))
|
||||
.size_full()
|
||||
.relative()
|
||||
.bg(cx.theme().colors().editor_background)
|
||||
.child(
|
||||
div()
|
||||
.id("image-container")
|
||||
.size_full()
|
||||
.overflow_hidden()
|
||||
.cursor(if self.is_dragging() {
|
||||
gpui::CursorStyle::ClosedHand
|
||||
} else {
|
||||
gpui::CursorStyle::OpenHand
|
||||
})
|
||||
.on_scroll_wheel(cx.listener(Self::handle_scroll_wheel))
|
||||
.on_mouse_down(MouseButton::Left, cx.listener(Self::handle_mouse_down))
|
||||
.on_mouse_down(MouseButton::Middle, cx.listener(Self::handle_mouse_down))
|
||||
.on_mouse_up(MouseButton::Left, cx.listener(Self::handle_mouse_up))
|
||||
.on_mouse_up(MouseButton::Middle, cx.listener(Self::handle_mouse_up))
|
||||
.on_mouse_move(cx.listener(Self::handle_mouse_move))
|
||||
.child(ImageContentElement::new(cx.entity())),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -411,6 +748,120 @@ impl ProjectItem for ImageView {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ImageViewToolbarControls {
|
||||
image_view: Option<WeakEntity<ImageView>>,
|
||||
_subscription: Option<gpui::Subscription>,
|
||||
}
|
||||
|
||||
impl ImageViewToolbarControls {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
image_view: None,
|
||||
_subscription: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for ImageViewToolbarControls {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let Some(image_view) = self.image_view.as_ref().and_then(|v| v.upgrade()) else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
|
||||
let zoom_level = image_view.read(cx).zoom_level;
|
||||
let zoom_percentage = format!("{}%", (zoom_level * 100.0).round() as i32);
|
||||
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
IconButton::new("zoom-out", IconName::Dash)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|_window, cx| Tooltip::for_action("Zoom Out", &ZoomOut, cx))
|
||||
.on_click({
|
||||
let image_view = image_view.downgrade();
|
||||
move |_, window, cx| {
|
||||
if let Some(view) = image_view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.zoom_out(&ZoomOut, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("zoom-level", zoom_percentage)
|
||||
.label_size(LabelSize::Small)
|
||||
.tooltip(|_window, cx| Tooltip::for_action("Reset Zoom", &ResetZoom, cx))
|
||||
.on_click({
|
||||
let image_view = image_view.downgrade();
|
||||
move |_, window, cx| {
|
||||
if let Some(view) = image_view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.reset_zoom(&ResetZoom, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("zoom-in", IconName::Plus)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|_window, cx| Tooltip::for_action("Zoom In", &ZoomIn, cx))
|
||||
.on_click({
|
||||
let image_view = image_view.downgrade();
|
||||
move |_, window, cx| {
|
||||
if let Some(view) = image_view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.zoom_in(&ZoomIn, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
IconButton::new("fit-to-view", IconName::Maximize)
|
||||
.icon_size(IconSize::Small)
|
||||
.tooltip(|_window, cx| Tooltip::for_action("Fit to View", &FitToView, cx))
|
||||
.on_click({
|
||||
let image_view = image_view.downgrade();
|
||||
move |_, window, cx| {
|
||||
if let Some(view) = image_view.upgrade() {
|
||||
view.update(cx, |this, cx| {
|
||||
this.fit_to_view(&FitToView, window, cx);
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<ToolbarItemEvent> for ImageViewToolbarControls {}
|
||||
|
||||
impl ToolbarItemView for ImageViewToolbarControls {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> ToolbarItemLocation {
|
||||
self.image_view = None;
|
||||
self._subscription = None;
|
||||
|
||||
if let Some(item) = active_pane_item.and_then(|i| i.downcast::<ImageView>()) {
|
||||
self._subscription = Some(cx.observe(&item, |_, _, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
self.image_view = Some(item.downgrade());
|
||||
cx.notify();
|
||||
return ToolbarItemLocation::PrimaryRight;
|
||||
}
|
||||
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init(cx: &mut App) {
|
||||
workspace::register_project_item::<ImageView>(cx);
|
||||
workspace::register_serializable_item::<ImageView>(cx);
|
||||
|
||||
@@ -1241,6 +1241,8 @@ fn initialize_pane(
|
||||
toolbar.add_item(agent_diff_toolbar, window, cx);
|
||||
let basedpyright_banner = cx.new(|cx| BasedPyrightBanner::new(workspace, cx));
|
||||
toolbar.add_item(basedpyright_banner, window, cx);
|
||||
let image_view_toolbar = cx.new(|_| image_viewer::ImageViewToolbarControls::new());
|
||||
toolbar.add_item(image_view_toolbar, window, cx);
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -4794,6 +4796,7 @@ mod tests {
|
||||
"git_picker",
|
||||
"go_to_line",
|
||||
"icon_theme_selector",
|
||||
"image_viewer",
|
||||
"inline_assistant",
|
||||
"journal",
|
||||
"keymap_editor",
|
||||
|
||||
Reference in new issue
Block a user