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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4678773383
commit
f8a51a5352
14 files changed
+1171
-7
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user