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:
1 parent
14f67f8429
commit
9ed801b67d
14 files changed
+1171
-7
No files matched your search
@@ -13,6 +13,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- Terminal: `alacritty_terminal` 0.26 (Apache-2.0, default features off) for the grid, its parser and the pseudo-terminal; `futures` carries its events.
|
||||
- Diffs: `similar` 3.2 (Apache-2.0) for line and word diffs and three-way merges; its `unicode` feature splits words at punctuation.
|
||||
- Markdown: `pulldown-cmark` 0.13 (MIT, default features off) for CommonMark with tables, tasks, strikethrough, footnotes and math.
|
||||
- Pictures: `image` 0.25 (MIT OR Apache-2.0), the crate gpui decodes with, default features off; it turns decoded frames.
|
||||
- Gallery: `examples/gallery`. Website: `frontend/` (Vite 8, pnpm), built after the components.
|
||||
|
||||
## Commands
|
||||
@@ -47,7 +48,8 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
||||
- gpui's `Window::dispatch_event` returns a private type, so the gallery scripts mouse input by posting `NSEvent`s to its own queue.
|
||||
- `hover()` needs an element id.
|
||||
- `occlude()` blocks the pointer for everything painted before it, its own ancestors too. Put it on the outermost box that should stop the pointer, as the toast stack does, or the ancestors' hover never fires.
|
||||
- gpui sends a drag's moves to every listener of its type. Each drag payload carries its owner's `EntityId`, and handlers check it.
|
||||
- A drag that pans, turns or slides goes through gpui's drag, whose moves reach the owner in or out of its box; `on_mouse_move` stops at the box's edge. gpui sends a drag's moves to every listener of its type, so each payload carries its owner's `EntityId` and handlers check it.
|
||||
- gpui 0.2.2 paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn.
|
||||
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags.
|
||||
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band.
|
||||
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
|
||||
|
||||
@@ -20,6 +20,7 @@ barcoders = { version = "2", default-features = false, features = ["std"] }
|
||||
emojis = "0.9"
|
||||
futures = "0.3"
|
||||
gpui = "0.2.2"
|
||||
image = { version = "0.25", default-features = false }
|
||||
iso_currency = { version = "0.7", features = ["iterator"] }
|
||||
isocountry = "0.3"
|
||||
isolang = { version = "2.4", features = ["local_names", "list_languages"] }
|
||||
|
||||
@@ -89,7 +89,11 @@ Per-component lines live in `tasks/`. Tags there:
|
||||
- [x] T25d Generative · Sound and motion
|
||||
- [x] T25e Generative · Models
|
||||
- [x] T25f Generative · Data and prompts
|
||||
- [ ] T26 Media
|
||||
- [ ] T26a Media · Images
|
||||
- [ ] T26b Media · Crop and annotate
|
||||
- [ ] T26c Media · Video
|
||||
- [ ] T26d Media · Audio and controls
|
||||
- [ ] T26e Media · Capture and devices
|
||||
- [ ] T27 Files
|
||||
- [ ] T28 Messaging
|
||||
- [ ] T29 Mail
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ely_gpui_component::{
|
||||
media::{ImageThumbnail, ImageViewer},
|
||||
overlays::{Lightbox, Slide},
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
|
||||
use crate::{
|
||||
probe::probe,
|
||||
ui::{keep, row, section, set},
|
||||
};
|
||||
|
||||
/// The gallery's pictures: a file, its width over height, a caption, and a corner label.
|
||||
const PICTURES: [(&str, f32, &str, &str); 5] = [
|
||||
(
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium.jpg"
|
||||
),
|
||||
1.5,
|
||||
"An atrium, noon",
|
||||
"",
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/dunes.jpg"
|
||||
),
|
||||
1.5,
|
||||
"Dunes at first light",
|
||||
"",
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/atrium-olive.jpg"
|
||||
),
|
||||
1.5,
|
||||
"An olive tree by the stair",
|
||||
"HDR",
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/dunes-grass.jpg"
|
||||
),
|
||||
1.5,
|
||||
"Grass on the ridge",
|
||||
"",
|
||||
),
|
||||
(
|
||||
concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/examples/gallery/assets/dunes-square.jpg"
|
||||
),
|
||||
1.0,
|
||||
"The long shadow",
|
||||
"+2",
|
||||
),
|
||||
];
|
||||
|
||||
pub fn viewer(cx: &mut App) -> impl IntoElement + use<> {
|
||||
section(
|
||||
"ImageViewer",
|
||||
"A picture to look at closely. It fits at first; the buttons, plus and minus, or Cmd-scroll zoom it, and 0 and 1 go back to fit and actual size, as a double press does. Drag it or use the arrows once it overhangs; R turns it.",
|
||||
cx,
|
||||
)
|
||||
.child(probe(
|
||||
"media-viewer",
|
||||
div().w(px(640.)).h(px(440.)).child(ImageViewer::new("media-viewer", PathBuf::from(PICTURES[0].0))),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn thumbnails(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let shown = keep("media-shown", || None::<usize>, window, cx);
|
||||
let opened = keep("media-last", || 0usize, window, cx);
|
||||
let last = *opened.read(cx);
|
||||
let thumbs = PICTURES
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, (path, ratio, _, label))| {
|
||||
let (open, mark) = (shown.clone(), opened.clone());
|
||||
let thumb = ImageThumbnail::new(
|
||||
SharedString::from(format!("media-thumb-{ix}")),
|
||||
Path::new(*path),
|
||||
*ratio,
|
||||
)
|
||||
.selected(ix == last)
|
||||
.on_click(move |_, cx| {
|
||||
set(&mark, ix, cx);
|
||||
set(&open, Some(ix), cx);
|
||||
});
|
||||
let thumb = if label.is_empty() {
|
||||
thumb
|
||||
} else {
|
||||
thumb.label(*label)
|
||||
};
|
||||
div().w(px(96. * ratio + 6.)).child(thumb)
|
||||
});
|
||||
let overlay = (*shown.read(cx)).map(|at| {
|
||||
let (step, close) = (shown.clone(), shown.clone());
|
||||
Lightbox::new(
|
||||
"media-lightbox",
|
||||
PICTURES.map(|(path, _, caption, _)| Slide::new(Path::new(path), caption)),
|
||||
at,
|
||||
move |_, cx| set(&close, None, cx),
|
||||
)
|
||||
.on_step(move |to, _, cx| set(&step, Some(to), cx))
|
||||
});
|
||||
section(
|
||||
"ImageThumbnail / Lightbox → overlays::Lightbox",
|
||||
"Pictures small, each in its own shape, with a label when one helps. The one last opened keeps a ring; a press or Enter opens the lightbox, which steps through them all.",
|
||||
cx,
|
||||
)
|
||||
.child(probe("media-thumbs", row().items_end().children(thumbs)))
|
||||
.children(overlay)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
mod images;
|
||||
|
||||
use gpui::{AnyElement, App, IntoElement, ParentElement, Window, div};
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 26,
|
||||
slug: "media",
|
||||
title: "Media",
|
||||
summary: "Pictures, video and sound: looking closely, cropping and marking up, playing, and the controls and devices around them.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[
|
||||
Step::DownAt("media-viewer", 320.0, 240.0),
|
||||
Step::UpAt("media-viewer", 320.0, 240.0),
|
||||
Step::Key("="),
|
||||
Step::Key("="),
|
||||
Step::Wait(300),
|
||||
Step::Shot("zoomed"),
|
||||
Step::Key("r"),
|
||||
Step::Wait(500),
|
||||
Step::Shot("turned"),
|
||||
Step::DownAt("media-thumbs", 237.0, 50.0),
|
||||
Step::UpAt("media-thumbs", 237.0, 50.0),
|
||||
Step::Wait(500),
|
||||
Step::Shot("lightbox"),
|
||||
Step::Key("escape"),
|
||||
Step::Wait(300),
|
||||
];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(images::viewer(cx))
|
||||
.child(images::thumbnails(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -14,6 +14,7 @@ mod generative;
|
||||
mod git;
|
||||
mod layout;
|
||||
mod lists;
|
||||
mod media;
|
||||
mod menus;
|
||||
mod motion;
|
||||
mod navigation;
|
||||
@@ -65,6 +66,7 @@ pub const ALL: &[Page] = &[
|
||||
chat::PAGE,
|
||||
agent::PAGE,
|
||||
generative::PAGE,
|
||||
media::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
+17
-5
@@ -251,16 +251,26 @@
|
||||
- [x] PromptPlayground
|
||||
- [x] PromptDiff / PromptVersionHistory
|
||||
|
||||
## T26 Media — `src/media`
|
||||
## T26a Media · Images — `src/media`
|
||||
|
||||
- [x] ImageViewer — zoom, pan, rotate, fit
|
||||
- [x] ImageThumbnail
|
||||
- [x] Lightbox / Gallery Viewer → `overlays::Lightbox`
|
||||
|
||||
## T26b Media · Crop and annotate
|
||||
|
||||
- [ ] ImageViewer — zoom, pan, rotate, fit
|
||||
- [ ] ImageCropper — also T06e ImageUpload
|
||||
- [ ] ImageAnnotator
|
||||
- [ ] ImageThumbnail
|
||||
- [ ] Lightbox / Gallery Viewer → `overlays::Lightbox`
|
||||
|
||||
## T26c Media · Video
|
||||
|
||||
- [ ] VideoPlayer — host frames
|
||||
- [ ] VideoTimeline / Scrubber
|
||||
- [ ] VideoThumbnailStrip
|
||||
- [ ] SubtitleEditor
|
||||
|
||||
## T26d Media · Audio and controls
|
||||
|
||||
- [ ] AudioPlayer — host playback
|
||||
- [ ] AudioWaveform
|
||||
- [ ] AudioSpectrum / Visualizer
|
||||
@@ -268,7 +278,9 @@
|
||||
- [ ] PlaybackSpeedControl
|
||||
- [ ] MediaControls
|
||||
- [ ] Playlist
|
||||
- [ ] SubtitleEditor
|
||||
|
||||
## T26e Media · Capture and devices
|
||||
|
||||
- [ ] ScreenRecorderControls
|
||||
- [ ] CameraPreview — host frames
|
||||
- [ ] MicLevelMeter
|
||||
|
||||
Reference in new issue
Block a user