T26a Media: images

A new chapter, src/media. ImageViewer fits a picture to its stage and
zooms by the document steps, by keys, buttons and Cmd-scroll about the
pointer; a drag through gpui's drag, a scroll or the arrows pan it once it
overhangs; R turns it a quarter at a time. gpui paints pictures without a
transform, so media::turn turns the decoded frames off the main thread,
an asset keyed by the picture and the turn; `image` 0.25, which gpui
already decodes with, is now a dependency. ImageThumbnail shows a picture
in its own shape with a corner label and a ring when chosen, and is a Tab
stop only with a handler. Lightbox / Gallery Viewer points to
overlays::Lightbox, which the gallery opens from the thumbnails.

T26 splits into T26a–e. AGENTS.md: the drag rule says drags that pan,
turn or slide go through gpui's drag; pictures turn by their pixels.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 15:34:45 -04:00
1 parent 14f67f8429
commit 9ed801b67d
14 files changed
+1171 -7

No files matched your search

+1
View File
@@ -45,6 +45,7 @@ pub use templates::{Template, TemplatePicker};
pub use thumbs::PageThumbnailList;
pub use todo::{Checklist, TodoItem};
pub use toolbar::FixedFormatToolbar;
pub(crate) use viewer::step as zoom_step;
pub use viewer::{DocPage, DocumentViewer, PageHit, PageNote};
/// Binds the documents keys. `init` calls it.
+1
View File
@@ -16,6 +16,7 @@ pub mod generative;
pub mod git;
pub mod layout;
pub mod lists;
pub mod media;
pub mod menus;
pub mod motion;
pub mod navigation;
+9
View File
@@ -0,0 +1,9 @@
mod thumbnail;
mod turn;
mod viewer;
#[cfg(all(test, feature = "test-support"))]
mod tests;
pub use thumbnail::ImageThumbnail;
pub use viewer::ImageViewer;
+329
View File
@@ -0,0 +1,329 @@
use std::{cell::Cell, path::PathBuf, rc::Rc};
use gpui::{
Bounds, Context, InteractiveElement, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers,
MouseButton, MouseDownEvent, MouseUpEvent, ParentElement, Pixels, Render, ScrollDelta,
ScrollWheelEvent, Styled, TestAppContext, TouchPhase, VisualTestContext, Window, div, point,
px,
};
use super::{ImageThumbnail, ImageViewer};
use crate::{primitives::FocusNext, theme::Theme};
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
Theme::update(cx, |theme| theme.reduced_motion = true);
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
});
}
fn settle(cx: &mut VisualTestContext) {
for _ in 0..4 {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
}
cx.run_until_parked();
}
fn press(key: &str, cx: &mut VisualTestContext) {
cx.simulate_keystrokes(key);
settle(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
settle(cx);
}
/// A picture `w` by `h` written to a file named for its test, the same file each run.
fn picture(name: &str, w: u32, h: u32) -> PathBuf {
let path = std::env::temp_dir().join(format!("ely-media-{name}.png"));
image::RgbaImage::from_pixel(w, h, image::Rgba([90, 120, 150, 255]))
.save(&path)
.expect("the picture writes");
path
}
/// A viewer 400 by 300 over a picture the test may change.
struct Viewing(PathBuf);
impl Render for Viewing {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div()
.w(px(400.0))
.h(px(300.0))
.child(ImageViewer::new("viewer", self.0.clone()))
}
}
fn viewing(path: PathBuf, cx: &mut TestAppContext) -> &mut VisualTestContext {
setup(cx);
let (_, cx) = cx.add_window_view(|_, _| Viewing(path));
cx.update(|window, _| window.activate_window());
settle(cx);
cx
}
/// A press on the stage, which takes focus as a user's would.
fn focus_stage(cx: &mut VisualTestContext) {
let stage = cx
.debug_bounds("image-viewer-stage")
.expect("the stage draws");
cx.simulate_click(stage.center(), Modifiers::none());
settle(cx);
}
/// A double press at `position`.
fn press_twice(position: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
let (button, modifiers) = (MouseButton::Left, Modifiers::none());
cx.simulate_event(MouseDownEvent {
button,
position,
modifiers,
click_count: 2,
first_mouse: false,
});
cx.simulate_event(MouseUpEvent {
button,
position,
modifiers,
click_count: 2,
});
settle(cx);
}
fn shown(cx: &mut VisualTestContext) -> Bounds<Pixels> {
cx.debug_bounds("image-viewer-picture")
.expect("the picture draws")
}
#[gpui::test]
fn keys_zoom_by_steps_fit_and_turn_the_picture(cx: &mut TestAppContext) {
let cx = viewing(picture("keys", 200, 100), cx);
assert_eq!(
shown(cx).size,
gpui::size(px(200.0), px(100.0)),
"a small picture keeps its size"
);
let stage = cx
.debug_bounds("image-viewer-stage")
.expect("the stage draws");
assert_eq!(shown(cx).center(), stage.center(), "and sits in the middle");
focus_stage(cx);
press("=", cx);
assert_eq!(
shown(cx).size,
gpui::size(px(250.0), px(125.0)),
"a step up to 125%"
);
press("r", cx);
assert_eq!(
shown(cx).size,
gpui::size(px(100.0), px(200.0)),
"a quarter turn stands it up, fitted again"
);
press("-", cx);
assert_eq!(
shown(cx).size,
gpui::size(px(75.0), px(150.0)),
"a step down to 75%"
);
press("1", cx);
assert_eq!(
shown(cx).size,
gpui::size(px(100.0), px(200.0)),
"1 shows actual size"
);
}
#[gpui::test]
fn a_drag_pans_to_the_edge_and_keeps_going_past_the_stage(cx: &mut TestAppContext) {
let cx = viewing(picture("pan", 400, 300), cx);
focus_stage(cx);
for _ in 0..3 {
press("=", cx);
}
let stage = cx
.debug_bounds("image-viewer-stage")
.expect("the stage draws");
let middle = stage.center();
let none = Modifiers::none();
cx.simulate_mouse_down(middle, MouseButton::Left, none);
cx.simulate_mouse_move(middle - point(px(40.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(middle - point(px(900.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(middle - point(px(900.0), px(0.0)), MouseButton::Left, none);
settle(cx);
let (picture, inner) = (shown(cx), stage.size.width - px(2.0));
assert!(
picture.size.width > inner,
"zoomed past the stage: {picture:?}"
);
assert!(
(picture.right() - (stage.right() - px(1.0))).abs() < px(0.5),
"the right edge stops at the stage's: {picture:?} in {stage:?}"
);
}
#[gpui::test]
fn a_double_press_toggles_actual_size_and_cmd_scroll_zooms_about_the_pointer(
cx: &mut TestAppContext,
) {
let cx = viewing(picture("wheel", 800, 400), cx);
let (stage, fitted) = (
cx.debug_bounds("image-viewer-stage")
.expect("the stage draws"),
shown(cx),
);
press_twice(stage.center(), cx);
assert_eq!(
shown(cx).size,
gpui::size(px(800.0), px(400.0)),
"a double press shows actual size"
);
press_twice(stage.center(), cx);
assert_eq!(shown(cx), fitted, "and another fits it again");
let pointer = point(stage.left() + px(100.0), stage.center().y);
cx.simulate_event(ScrollWheelEvent {
position: pointer,
delta: ScrollDelta::Pixels(point(px(0.0), px(-100.0))),
modifiers: Modifiers::command(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
let grown = shown(cx);
let expected = fitted.size.width * std::f32::consts::E;
assert!(
(grown.size.width - expected).abs() < px(1.0),
"{grown:?}, not {expected:?} wide"
);
let under = |picture: Bounds<Pixels>| (pointer.x - picture.left()) / picture.size.width;
assert!(
(under(grown) - under(fitted)).abs() < 0.005,
"the spot under the pointer stays"
);
}
#[gpui::test]
fn a_new_picture_starts_fitted_and_unturned(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Viewing(picture("first", 200, 100)));
cx.update(|window, _| window.activate_window());
settle(cx);
focus_stage(cx);
press("=", cx);
press("r", cx);
assert_eq!(shown(cx).size, gpui::size(px(100.0), px(200.0)), "turned");
view.update(cx, |view, cx| {
view.0 = picture("second", 120, 60);
cx.notify();
});
settle(cx);
assert_eq!(
shown(cx).size,
gpui::size(px(120.0), px(60.0)),
"the new one fits, as it lies"
);
}
/// A viewer in a page that counts the scrolls reaching it.
struct Scrolled(PathBuf, Rc<Cell<usize>>);
impl Render for Scrolled {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let heard = self.1.clone();
div()
.id("page")
.w(px(400.0))
.h(px(300.0))
.on_scroll_wheel(move |_, _, _| heard.set(heard.get() + 1))
.child(ImageViewer::new("viewer", self.0.clone()))
}
}
#[gpui::test]
fn a_scroll_pans_an_overhanging_picture_and_passes_on_otherwise(cx: &mut TestAppContext) {
setup(cx);
let heard = Rc::new(Cell::new(0));
let (seen, path) = (heard.clone(), picture("scroll", 800, 400));
let (_, cx) = cx.add_window_view(|_, _| Scrolled(path, seen));
settle(cx);
let stage = cx
.debug_bounds("image-viewer-stage")
.expect("the stage draws");
let scroll = |cx: &mut VisualTestContext| {
cx.simulate_event(ScrollWheelEvent {
position: stage.center(),
delta: ScrollDelta::Pixels(point(px(-50.0), px(0.0))),
modifiers: Modifiers::none(),
touch_phase: TouchPhase::Moved,
});
settle(cx);
};
let fitted = shown(cx);
scroll(cx);
assert_eq!(
(shown(cx), heard.get()),
(fitted, 1),
"a fitted picture lets the page scroll"
);
press_twice(stage.center(), cx);
let actual = shown(cx);
scroll(cx);
assert_eq!(
shown(cx).origin.x,
actual.origin.x - px(50.0),
"an overhanging one moves"
);
assert_eq!(heard.get(), 1, "and keeps the scroll");
}
#[gpui::test]
fn a_picture_that_cannot_open_says_so(cx: &mut TestAppContext) {
let cx = viewing(std::env::temp_dir().join("ely-media-missing.png"), cx);
assert!(cx.debug_bounds("image-viewer-failed").is_some());
assert!(cx.debug_bounds("image-viewer-picture").is_none());
}
/// Two thumbnails, the second with no handler; the opens the first heard.
struct Strip(Rc<Cell<usize>>, PathBuf);
impl Render for Strip {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let opened = self.0.clone();
div()
.flex()
.gap_2()
.child(
div().w(px(120.0)).child(
ImageThumbnail::new("open", self.1.clone(), 1.5)
.label("0:42")
.on_click(move |_, _| opened.set(opened.get() + 1)),
),
)
.child(
div()
.w(px(120.0))
.child(ImageThumbnail::new("still", self.1.clone(), 1.5)),
)
}
}
#[gpui::test]
fn only_a_thumbnail_with_a_handler_takes_tab_and_opens_on_enter(cx: &mut TestAppContext) {
setup(cx);
let opened = Rc::new(Cell::new(0));
let (seen, path) = (opened.clone(), picture("thumb", 60, 40));
let (_, cx) = cx.add_window_view(|_, _| Strip(seen, path));
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| window.focus_next());
let first = cx.update(|window, cx| window.focused(cx));
cx.update(|window, _| window.focus_next());
let wrapped = cx.update(|window, cx| window.focused(cx));
assert!(
first.is_some() && first == wrapped,
"the still one takes no Tab"
);
press("enter", cx);
assert_eq!(opened.get(), 1);
}
+106
View File
@@ -0,0 +1,106 @@
use std::rc::Rc;
use gpui::{
App, ElementId, ImageSource, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::*,
transparent_black,
};
use crate::{
forms::Run,
primitives::{FocusRing, Image, checked_ratio, framed},
theme::{ActiveTheme, Radius, TextSize},
typography::tabular,
};
/// A picture in its own shape, taken from the host, as wide as its box: a shimmer while it loads, a mark if it fails, and a short label in a corner when given, such as a length or a count. Chosen, an accent ring holds it; with `on_click` it is a Tab stop.
#[derive(IntoElement)]
pub struct ImageThumbnail {
id: ElementId,
source: ImageSource,
ratio: f32,
label: Option<SharedString>,
selected: bool,
on_click: Option<Run>,
}
impl ImageThumbnail {
/// `ratio` is the picture's width over its height.
pub fn new(id: impl Into<ElementId>, source: impl Into<ImageSource>, ratio: f32) -> Self {
Self {
id: id.into(),
source: source.into(),
ratio: checked_ratio(ratio),
label: None,
selected: false,
on_click: None,
}
}
/// A few words in the corner, such as "0:42" or "+3".
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn selected(mut self, selected: bool) -> Self {
self.selected = selected;
self
}
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_click = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageThumbnail {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = &theme.colors;
let (outer, inner) = (theme.radius(Radius::Md), theme.radius(Radius::Sm));
let label = self.label.map(|label| {
tabular(div())
.absolute()
.right_1()
.bottom_1()
.px_1p5()
.rounded(inner)
.bg(colors.media_backdrop.alpha(0.7))
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.on_media)
.child(label)
});
let picture = framed(self.ratio, cx)
.rounded(inner)
.child(
Image::new((self.id.clone(), "picture"), self.source)
.size_full()
.rounded(inner),
)
.children(label);
div()
.id(self.id)
.w_full()
.rounded(outer)
.border_1()
.border_color(if self.selected {
colors.accent
} else {
transparent_black()
})
.p_0p5()
.when_some(self.on_click, |thumb, click| {
thumb
.tab_index(0)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("image thumbnail: opened");
click(window, cx)
})
})
.child(picture)
}
}
+88
View File
@@ -0,0 +1,88 @@
use std::{
hash::{Hash, Hasher},
sync::Arc,
};
use gpui::{App, Asset, RenderImage};
use image::{
Frame, RgbaImage,
imageops::{rotate90, rotate180, rotate270},
};
/// A decoded picture and how many quarter turns clockwise to give it; two are alike when they turn the same picture as far.
#[derive(Clone)]
pub(crate) struct Turn(pub Arc<RenderImage>, pub u8);
impl Hash for Turn {
fn hash<H: Hasher>(&self, state: &mut H) {
self.0.id.hash(state);
(self.1 % 4).hash(state);
}
}
/// Turns a decoded picture off the main thread; gpui keeps each turn once made.
pub(crate) enum Turned {}
impl Asset for Turned {
type Source = Turn;
type Output = Arc<RenderImage>;
fn load(
source: Self::Source,
_: &mut App,
) -> impl Future<Output = Self::Output> + Send + 'static {
let Turn(image, quarters) = source;
async move { Arc::new(turn(&image, quarters)) }
}
}
/// Every frame of `image` turned `quarters` quarter turns clockwise, each keeping its delay.
pub(crate) fn turn(image: &RenderImage, quarters: u8) -> RenderImage {
let frames: Vec<Frame> = (0..image.frame_count())
.map(|ix| {
let size = image.size(ix);
let bytes = image.as_bytes(ix).expect("a frame in range").to_vec();
let buffer = RgbaImage::from_raw(size.width.0 as u32, size.height.0 as u32, bytes)
.expect("a frame's bytes fill its size");
let turned = match quarters % 4 {
1 => rotate90(&buffer),
2 => rotate180(&buffer),
3 => rotate270(&buffer),
_ => buffer,
};
Frame::from_parts(turned, 0, 0, image.delay(ix))
})
.collect();
RenderImage::new(frames)
}
#[cfg(test)]
mod tests {
use gpui::RenderImage;
use image::{Frame, RgbaImage};
use super::turn;
#[test]
fn a_quarter_turn_swaps_the_sides_and_carries_each_pixel_round() {
let mut wide = RgbaImage::new(2, 1);
wide.get_pixel_mut(0, 0).0 = [1, 0, 0, 255];
wide.get_pixel_mut(1, 0).0 = [2, 0, 0, 255];
let image = RenderImage::new(vec![Frame::new(wide)]);
let turned = turn(&image, 1);
let size = turned.size(0);
assert_eq!((size.width.0, size.height.0), (1, 2));
let bytes = turned.as_bytes(0).expect("a frame");
assert_eq!(
(bytes[0], bytes[4]),
(1, 2),
"the left pixel turns to the top"
);
let back = turn(&turned, 3);
assert_eq!(
back.as_bytes(0),
image.as_bytes(0),
"three more quarters bring it home"
);
}
}
+451
View File
@@ -0,0 +1,451 @@
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 focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
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 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))
.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())
.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));
}
}