Files
Ely-GPUI-Components/src/media/viewer.rs
T
ZacharyZhang-NY 0ccf4c1587 Media: the viewer's picture moves; a viewer with no picture takes no Tab
The stage's picture carries an id, so gpui keeps its frame and asks for
the next: an animated picture plays, turned or not. A viewer whose picture
has not loaded or cannot open is no Tab stop. AGENTS.md: img() moves an
animated picture's frames only with an id.
2026-09-26 15:51:03 -04:00

459 lines
18 KiB
Rust

use std::sync::Arc;
use gpui::{
App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, ImageAssetLoader, ImageSource,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderImage,
RenderOnce, Resource, StatefulInteractiveElement, Styled, Window, canvas, div, img, prelude::*,
transparent_black,
};
use super::turn::{Turn, Turned};
use crate::{
buttons::IconButton,
documents::zoom_step,
motion::Spinner,
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::tabular,
};
/// The zoom that shows the whole picture in the stage, never past actual size.
pub(crate) fn fit(picture: (f32, f32), stage: (f32, f32)) -> f32 {
(stage.0 / picture.0).min(stage.1 / picture.1).min(1.0)
}
/// An offset of the picture's middle from the stage's middle, kept so no edge comes inside the stage while the picture overhangs it, and centered where it fits.
pub(crate) fn held(offset: (f32, f32), shown: (f32, f32), stage: (f32, f32)) -> (f32, f32) {
let reach = |picture: f32, stage: f32| ((picture - stage) / 2.0).max(0.0);
let (x, y) = (reach(shown.0, stage.0), reach(shown.1, stage.1));
(offset.0.clamp(-x, x), offset.1.clamp(-y, y))
}
/// The offset after zooming from `from` to `to` about `at`, a place from the stage's middle, so the spot under it stays put.
pub(crate) fn zoom_about(offset: (f32, f32), at: (f32, f32), from: f32, to: f32) -> (f32, f32) {
let scale = to / from;
(
at.0 - (at.0 - offset.0) * scale,
at.1 - (at.1 - offset.1) * scale,
)
}
/// Most a picture grows, as the document zoom steps do.
const MOST: f32 = 4.0;
/// Share of the stage an arrow key pans.
const PAN: f32 = 0.1;
/// The view's own state: the picture it shows; its zoom, none while it fits; its offset in points; its quarter turns; the stage's bounds; and where a drag was last.
#[derive(Default)]
struct View {
source: Option<Resource>,
zoom: Option<f32>,
offset: (f32, f32),
turns: u8,
stage: Bounds<Pixels>,
from: Point<Pixels>,
failed: bool,
}
/// What a toolbar button does to the view, given the picture's size.
type Act = Box<dyn Fn(&mut View, (f32, f32))>;
/// A panning drag, marked with its viewer.
struct Pan(EntityId);
impl View {
fn size(&self) -> (f32, f32) {
(
f32::from(self.stage.size.width),
f32::from(self.stage.size.height),
)
}
/// Zooms to `to`, or to fit, about `at`, keeping the offset held.
fn zoom_to(&mut self, to: Option<f32>, at: (f32, f32), picture: (f32, f32)) {
let stage = self.size();
let fitted = fit(picture, stage);
let (from, next) = (
self.zoom.unwrap_or(fitted),
to.unwrap_or(fitted).clamp(fitted.min(0.25), MOST),
);
let offset = zoom_about(self.offset, at, from, next);
self.zoom = to.map(|_| next);
self.offset = held(offset, (picture.0 * next, picture.1 * next), stage);
log::info!("image viewer: zoom {:.0}%", next * 100.0);
}
fn pan(&mut self, by: (f32, f32), picture: (f32, f32)) {
let zoom = self.zoom.unwrap_or_else(|| fit(picture, self.size()));
let offset = (self.offset.0 + by.0, self.offset.1 + by.1);
self.offset = held(offset, (picture.0 * zoom, picture.1 * zoom), self.size());
}
}
/// A picture to look at closely. It fits its stage at first, and again when the host gives it another; the buttons and the plus and minus keys zoom by steps and Cmd-scroll smoothly, 0 fits and 1 shows actual size, a double press toggles the two; a drag, a scroll or the arrows pan once it overhangs; R turns it a quarter clockwise and Shift-R back. The host gives it a height.
#[derive(IntoElement)]
pub struct ImageViewer {
id: ElementId,
source: Resource,
}
impl ImageViewer {
/// `source` is a file or a web address.
pub fn new(id: impl Into<ElementId>, source: impl Into<Resource>) -> Self {
Self {
id: id.into(),
source: source.into(),
}
}
}
impl RenderOnce for ImageViewer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let view = window.use_keyed_state((self.id.clone(), "view"), cx, |_, _| View::default());
if view.read(cx).source.as_ref() != Some(&self.source) {
view.update(cx, |view, _| {
*view = View {
source: Some(self.source.clone()),
stage: view.stage,
..View::default()
}
});
}
let decoded = window.use_asset::<ImageAssetLoader>(&self.source, cx);
let turns = view.read(cx).turns;
let picture: Option<Arc<RenderImage>> = match decoded {
Some(Ok(image)) if turns.is_multiple_of(4) => Some(image),
Some(Ok(image)) => window.use_asset::<Turned>(&Turn(image, turns), cx),
Some(Err(error)) => {
if !view.read(cx).failed {
log::error!(
"image viewer {:?}: {:?} can't be opened: {error}",
self.id,
self.source
);
view.update(cx, |view, _| view.failed = true);
}
None
}
None => None,
};
let failed = view.read(cx).failed;
let pixels = picture.as_ref().map(|image| {
let size = image.size(0);
(size.width.0 as f32, size.height.0 as f32)
});
let focus = tab_stop(
(self.id.clone(), "focus").into(),
pixels.is_some(),
window,
cx,
);
let theme = cx.theme();
let colors = theme.colors.clone();
let (zoom, offset, stage) = {
let view = view.read(cx);
let stage = view.size();
let zoom = pixels.map(|pixels| view.zoom.unwrap_or_else(|| fit(pixels, stage)));
(zoom, view.offset, stage)
};
let button = |key: &'static str, icon: IconName, tip: &'static str, act: Act| {
let (acted, pixels) = (view.clone(), pixels);
IconButton::new((self.id.clone(), key), icon)
.size(ControlSize::Sm)
.tooltip(tip)
.disabled(pixels.is_none())
.when_some(pixels, |button, pixels| {
button.on_click(move |_, _, cx| {
acted.update(cx, |view, cx| {
act(view, pixels);
cx.notify();
})
})
})
};
let step = move |up: bool| {
move |view: &mut View, pixels: (f32, f32)| {
let now = view.zoom.unwrap_or_else(|| fit(pixels, view.size()));
if let Some(next) = zoom_step(now, up) {
view.zoom_to(Some(next), (0.0, 0.0), pixels);
}
}
};
let turn = move |by: u8| {
move |view: &mut View, _: (f32, f32)| {
view.turns = (view.turns + by) % 4;
(view.zoom, view.offset) = (None, (0.0, 0.0));
log::info!("image viewer: turned to {} quarters", view.turns);
}
};
let toolbar = div()
.flex_none()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.gap_2()
.child(
div()
.flex()
.items_center()
.gap_1()
.child(button(
"out",
IconName::ZoomOut,
"Zoom out",
Box::new(step(false)),
))
.child(
tabular(div())
.w(theme.label_width() * 0.3)
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.fg_muted)
.flex()
.justify_center()
.child(
zoom.map(|zoom| format!("{:.0}%", zoom * 100.0))
.unwrap_or_default(),
),
)
.child(button(
"in",
IconName::ZoomIn,
"Zoom in",
Box::new(step(true)),
))
.child(button(
"fit",
IconName::Maximize2,
"Fit",
Box::new(|view, pixels| view.zoom_to(None, (0.0, 0.0), pixels)),
)),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.child(button(
"left",
IconName::RotateCcw,
"Turn left",
Box::new(turn(3)),
))
.child(button(
"right",
IconName::RotateCw,
"Turn right",
Box::new(turn(1)),
)),
);
let shown = pixels.zip(zoom).map(|(pixels, zoom)| {
let size = (pixels.0 * zoom, pixels.1 * zoom);
let (left, top) = (
(stage.0 - size.0) / 2.0 + offset.0,
(stage.1 - size.1) / 2.0 + offset.1,
);
(size, left, top)
});
let body = match (picture, shown) {
(Some(image), Some((size, left, top))) => img(ImageSource::Render(image))
.id((self.id.clone(), "picture"))
.debug_selector(|| "image-viewer-picture".into())
.absolute()
.left(Pixels::from(left))
.top(Pixels::from(top))
.w(Pixels::from(size.0))
.h(Pixels::from(size.1))
.into_any_element(),
_ if failed => div()
.debug_selector(|| "image-viewer-failed".into())
.size_full()
.flex()
.flex_col()
.items_center()
.justify_center()
.gap_2()
.text_size(theme.text_size(TextSize::Sm))
.text_color(colors.fg_muted)
.child(
Icon::new(IconName::ImageOff)
.size(IconSize::Lg)
.color(colors.fg_subtle),
)
.child("This picture can't be opened")
.into_any_element(),
_ => div()
.size_full()
.flex()
.items_center()
.justify_center()
.child(Spinner::new((self.id.clone(), "loading")).size(IconSize::Md))
.into_any_element(),
};
let overhangs =
shown.is_some_and(|(size, _, _)| size.0 > stage.0 + 0.5 || size.1 > stage.1 + 0.5);
let owner = view.entity_id();
let (measure, wheel, pressed, moved, keys) = (
view.clone(),
view.clone(),
view.clone(),
view.clone(),
view.clone(),
);
let stage_box = div()
.id(self.id.clone())
.debug_selector(|| "image-viewer-stage".into())
.relative()
.flex_1()
.min_h_0()
.w_full()
.overflow_hidden()
.bg(colors.sunken)
.border_1()
.border_color(transparent_black())
.when(pixels.is_some(), |stage| {
stage.track_focus(&focus).focus_ring(cx)
})
.when(overhangs, |stage| stage.cursor_grab())
.child(
canvas(
move |bounds, window, cx| {
if measure.read(cx).stage != bounds {
measure.update(cx, |view, cx| {
view.stage = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(body)
.when_some(pixels, |stage, pixels| {
stage
.on_scroll_wheel(move |event, window, cx| {
let delta = event.delta.pixel_delta(window.line_height());
let zooming = event.modifiers.platform || event.modifiers.control;
wheel.update(cx, |view, cx| {
if zooming {
let now = view.zoom.unwrap_or_else(|| fit(pixels, view.size()));
let at = event.position - view.stage.center();
let next = now * (-f32::from(delta.y) * 0.01).exp();
view.zoom_to(
Some(next),
(f32::from(at.x), f32::from(at.y)),
pixels,
);
} else {
let before = view.offset;
view.pan((f32::from(delta.x), f32::from(delta.y)), pixels);
if view.offset == before {
return;
}
}
cx.stop_propagation();
cx.notify();
})
})
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
pressed.update(cx, |view, cx| {
view.from = event.position;
if event.click_count == 2 {
let at = event.position - view.stage.center();
let to = view.zoom.is_none().then_some(1.0);
view.zoom_to(to, (f32::from(at.x), f32::from(at.y)), pixels);
cx.notify();
}
})
})
.on_drag(Pan(owner), |_, _, _, cx| cx.new(|_| EmptyView))
.on_drag_move(move |event: &DragMoveEvent<Pan>, _, cx| {
if event.drag(cx).0 != owner {
return;
}
moved.update(cx, |view, cx| {
let by = event.event.position - view.from;
view.from = event.event.position;
view.pan((f32::from(by.x), f32::from(by.y)), pixels);
cx.notify();
})
})
.on_key_down(move |event, _, cx| {
let key = event.keystroke.key.as_str();
let shift = event.keystroke.modifiers.shift;
keys.update(cx, |view, cx| {
let (w, h) = view.size();
let now = view.zoom.unwrap_or_else(|| fit(pixels, (w, h)));
match key {
"+" | "=" => {
if let Some(next) = zoom_step(now, true) {
view.zoom_to(Some(next), (0.0, 0.0), pixels);
}
}
"-" => {
if let Some(next) = zoom_step(now, false) {
view.zoom_to(Some(next), (0.0, 0.0), pixels);
}
}
"0" => view.zoom_to(None, (0.0, 0.0), pixels),
"1" => view.zoom_to(Some(1.0), (0.0, 0.0), pixels),
"r" => turn(if shift { 3 } else { 1 })(view, pixels),
"left" => view.pan((w * PAN, 0.0), pixels),
"right" => view.pan((-w * PAN, 0.0), pixels),
"up" => view.pan((0.0, h * PAN), pixels),
"down" => view.pan((0.0, -h * PAN), pixels),
_ => return,
}
cx.stop_propagation();
cx.notify();
})
})
});
div()
.size_full()
.flex()
.flex_col()
.gap_2()
.child(toolbar)
.child(stage_box)
}
}
#[cfg(test)]
mod tests {
use super::{fit, held, zoom_about};
#[test]
fn a_big_picture_fits_by_its_tighter_side_and_a_small_one_keeps_its_size() {
assert_eq!(fit((800.0, 400.0), (400.0, 300.0)), 0.5);
assert_eq!(fit((300.0, 600.0), (400.0, 300.0)), 0.5);
assert_eq!(fit((100.0, 50.0), (400.0, 300.0)), 1.0);
}
#[test]
fn an_overhanging_picture_pans_to_its_edges_and_one_that_fits_stays_centered() {
assert_eq!(
held((500.0, 90.0), (800.0, 400.0), (400.0, 300.0)),
(200.0, 50.0)
);
assert_eq!(
held((-500.0, 90.0), (300.0, 200.0), (400.0, 300.0)),
(0.0, 0.0)
);
}
#[test]
fn zooming_about_a_place_keeps_the_spot_under_it() {
let (offset, at) = ((10.0, -20.0), (100.0, 50.0));
let after = zoom_about(offset, at, 1.0, 2.0);
let spot =
|offset: (f32, f32), zoom: f32| ((at.0 - offset.0) / zoom, (at.1 - offset.1) / zoom);
assert_eq!(spot(offset, 1.0), spot(after, 2.0));
}
}