Files
Ely-GPUI-Components/src/media/cropper.rs
T
ZacharyZhang-NYandClaude Opus 5.5 5779daef82 Capture · Roots: roots drop w_full; hosts give the width
145 component roots drop w_full, the early returns included. Three
field rows keep it, Input, InputGroup and TagInput: stretched without
a width, taffy 0.9 laid the field's flex_1 share at no width in the
template picker at 280px, and the placeholder drew nothing. Container
keeps it, since mx_auto needs a width to fill.

Hosts that relied on a root's width now give it: MessageList boxes
each row at the list's width, the splash gives its bar the column's,
and test stages that mount a component as the window's root sit in a
size_full box. Tests hold a narrow row across the list and the splash
bar across its column; each goes red without its fix.

The gallery gains --narrow <px>, which lays each page in a padded
card at that width and shoots it without its script. The page
header wraps: the title takes the room and the theme switcher drops
below, where a long summary used to push it past the edge. A file
panel step waits for the panel to become key instead of a fixed
pause, which lost a race in a capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:07:22 -04:00

267 lines
10 KiB
Rust

use std::rc::Rc;
use gpui::{
App, Bounds, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, ImageSource,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
transparent_black,
};
use super::crop::{Crop, Grip, dragged, grip_at};
use crate::{
layout::seeded::use_seeded,
primitives::{FocusRing, Image, checked_ratio, framed, tab_stop},
theme::ActiveTheme,
};
pub(super) type OnCrop = Rc<dyn Fn(Crop, &mut Window, &mut App)>;
/// Least side of a crop, as a share of its picture.
const LEAST: f32 = 0.05;
/// What an arrow moves the crop, and Shift with it.
const NUDGE: (f32, f32) = (0.01, 0.1);
/// A crop's drag, marked with its cropper.
struct Hold(EntityId);
/// The stage's bounds, what the press holds, and where the pointer was last.
#[derive(Default)]
struct Stage {
bounds: Bounds<Pixels>,
held: Option<Grip>,
from: Point<Pixels>,
}
/// A picture and a box to crop it by, the picture's shape from the host, inset by half a handle so handles on its edge stay whole. Drag the box to move it and its handles to size it; with an aspect, only its corners size it and it keeps that shape. The arrows move it a hundredth of the picture, a tenth with Shift. The host keeps the crop; `Crop::fitted` gives a box of a new aspect.
#[derive(IntoElement)]
pub struct ImageCropper {
id: ElementId,
source: ImageSource,
ratio: f32,
crop: Crop,
aspect: Option<f32>,
on_change: Option<OnCrop>,
}
impl ImageCropper {
/// `ratio` is the picture's width over its height.
pub fn new(
id: impl Into<ElementId>,
source: impl Into<ImageSource>,
ratio: f32,
crop: Crop,
) -> Self {
Self {
id: id.into(),
source: source.into(),
ratio: checked_ratio(ratio),
crop: crop.checked(),
aspect: None,
on_change: None,
}
}
/// The crop's width over its height, in pixels; none leaves it free.
pub fn aspect(mut self, aspect: Option<f32>) -> Self {
self.aspect = aspect.map(checked_ratio);
self
}
pub fn on_change(mut self, handler: impl Fn(Crop, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for ImageCropper {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let crop = use_seeded((self.id.clone(), "crop"), self.crop, window, cx);
let stage = window.use_keyed_state((self.id.clone(), "stage"), cx, |_, _| Stage::default());
let editable = self.on_change.is_some();
let focus = tab_stop((self.id.clone(), "focus").into(), editable, window, cx);
let now = crop.read(cx).value;
let shape = self.aspect.map(|aspect| aspect / self.ratio);
let owner = crop.entity_id();
let theme = cx.theme();
let (veil, line) = (
theme.colors.media_backdrop.alpha(0.55),
theme.colors.on_media,
);
let side = theme.media().crop_handle;
let hit = side.to_pixels(window.rem_size());
let change = {
let (crop, on_change) = (crop.clone(), self.on_change.clone());
move |next: Crop, window: &mut Window, cx: &mut App| {
crop.update(cx, |crop, cx| {
crop.value = next;
cx.notify();
});
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
}
};
let pane = |left: f32, top: f32, w: f32, h: f32| {
div()
.absolute()
.left(relative(left))
.top(relative(top))
.w(relative(w))
.h(relative(h))
.bg(veil)
};
let (x2, y2) = (now.x + now.w, now.y + now.h);
let rule = |across: bool, at: f32| {
let rule = div().absolute().border_color(line.alpha(0.35));
if across {
rule.left_0().right_0().top(relative(at)).border_t_1()
} else {
rule.top_0().bottom_0().left(relative(at)).border_l_1()
}
};
let (fall, rise) = (
CursorStyle::ResizeUpLeftDownRight,
CursorStyle::ResizeUpRightDownLeft,
);
let corners = [
(0.0, 0.0, fall),
(1.0, 0.0, rise),
(0.0, 1.0, rise),
(1.0, 1.0, fall),
];
let (tall, wide) = (CursorStyle::ResizeUpDown, CursorStyle::ResizeLeftRight);
let edges = [
(0.5, 0.0, tall),
(0.5, 1.0, tall),
(0.0, 0.5, wide),
(1.0, 0.5, wide),
];
let marks = corners
.into_iter()
.chain(edges.into_iter().filter(|_| shape.is_none()))
.map(|(x, y, cursor)| {
div()
.absolute()
.left(relative(x))
.top(relative(y))
.size_0()
.flex()
.items_center()
.justify_center()
.child(div().flex_none().size(side).bg(line).cursor(cursor))
});
let body = div()
.absolute()
.left(relative(now.x))
.top(relative(now.y))
.w(relative(now.w))
.h(relative(now.h))
.border_1()
.border_color(line)
.children([1.0 / 3.0, 2.0 / 3.0].map(|at| rule(true, at)))
.children([1.0 / 3.0, 2.0 / 3.0].map(|at| rule(false, at)))
.when(editable, |body| body.children(marks));
let (measure, pressed, moving, keyed) =
(stage.clone(), stage.clone(), stage.clone(), crop.clone());
let (drag_change, key_change) = (change.clone(), change);
let pressed_crop = crop.clone();
let picture = framed(self.ratio, cx)
.debug_selector(|| "image-cropper".into())
.child(Image::new((self.id.clone(), "picture"), self.source).size_full())
.child(
canvas(
move |bounds, window, cx| {
if measure.read(cx).bounds != bounds {
measure.update(cx, |stage, cx| {
stage.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.child(pane(0.0, 0.0, 1.0, now.y))
.child(pane(0.0, y2, 1.0, 1.0 - y2))
.child(pane(0.0, now.y, now.x, now.h))
.child(pane(x2, now.y, 1.0 - x2, now.h));
let lid = div()
.absolute()
.top(side / 2.0)
.left(side / 2.0)
.right(side / 2.0)
.bottom(side / 2.0)
.child(body);
div()
.id(self.id.clone())
.relative()
.p(side / 2.0)
.border_1()
.border_color(transparent_black())
.child(picture)
.child(lid)
.when(editable, |area| {
area.track_focus(&focus)
.focus_ring(cx)
.on_mouse_down(MouseButton::Left, move |event, _, cx| {
let now = pressed_crop.read(cx).value;
pressed.update(cx, |stage, _| {
let (w, h) = (
f32::from(stage.bounds.size.width),
f32::from(stage.bounds.size.height),
);
let at = event.position - stage.bounds.origin;
let spot = (f32::from(at.x) / w, f32::from(at.y) / h);
let reach = (f32::from(hit) / w, f32::from(hit) / h);
stage.held = grip_at(now, spot, reach, shape.is_some());
stage.from = event.position;
})
})
.on_drag(Hold(owner), |_, _, _, cx| cx.new(|_| EmptyView))
.on_drag_move(move |event: &DragMoveEvent<Hold>, window, cx| {
if event.drag(cx).0 != owner {
return;
}
let at = event.event.position;
let (bounds, held, from) = moving.update(cx, |stage, _| {
let from = std::mem::replace(&mut stage.from, at);
(stage.bounds, stage.held, from)
});
let Some(grip) = held else {
return;
};
let (w, h) = (f32::from(bounds.size.width), f32::from(bounds.size.height));
let by = (f32::from(at.x - from.x) / w, f32::from(at.y - from.y) / h);
let before = keyed.read(cx).value;
let next = dragged(before, grip, by, LEAST, shape);
if next != before {
drag_change(next, window, cx);
}
})
.on_key_down(move |event, window, cx| {
let step = if event.keystroke.modifiers.shift {
NUDGE.1
} else {
NUDGE.0
};
let by = match event.keystroke.key.as_str() {
"left" => (-step, 0.0),
"right" => (step, 0.0),
"up" => (0.0, -step),
"down" => (0.0, step),
_ => return,
};
cx.stop_propagation();
let next = dragged(now, Grip::default(), by, LEAST, shape);
log::info!("image cropper: moved to {:.2}, {:.2}", next.x, next.y);
key_change(next, window, cx);
})
})
}
}