Files
Ely-GPUI-Components/src/media/strip.rs
T
ZacharyZhang-NY 81e73b79c1 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.
2026-09-28 13:07:22 -04:00

348 lines
12 KiB
Rust

use std::{rc::Rc, time::Duration};
use gpui::{
App, Bounds, CursorStyle, DragMoveEvent, ElementId, EmptyView, EntityId, ImageSource,
InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, Point,
RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div, img, prelude::*, relative,
transparent_black,
};
use super::scrubber::{OnTime, clock, time_at};
use crate::{
primitives::{FocusRing, tab_stop},
theme::ActiveTheme,
};
type OnTrim = Rc<dyn Fn((Duration, Duration), &mut Window, &mut App)>;
/// Least span a trim keeps.
const LEAST: Duration = Duration::from_secs(1);
/// Which end of a trim a press holds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum End {
In,
Out,
}
/// The trim after `end` moves to `to`, kept within `length` and at least `LEAST` long.
pub(crate) fn trimmed(
trim: (Duration, Duration),
end: End,
to: Duration,
length: Duration,
) -> (Duration, Duration) {
let (start, stop) = trim;
match end {
End::In => (to.min(stop.saturating_sub(LEAST)), stop),
End::Out => (start, to.max(start + LEAST).min(length)),
}
}
/// The end of `trim` within `reach`, both shares of `length`, from `at`; the nearer when both are.
pub(crate) fn end_at(
trim: (Duration, Duration),
at: f32,
reach: f32,
length: Duration,
) -> Option<End> {
let share = |time: Duration| time.as_secs_f32() / length.as_secs_f32();
let (start, stop) = ((share(trim.0) - at).abs(), (share(trim.1) - at).abs());
match (start <= reach, stop <= reach) {
(true, true) if stop < start => Some(End::Out),
(true, _) => Some(End::In),
(false, true) => Some(End::Out),
_ => None,
}
}
/// A drag on the strip, marked with its strip.
struct Hold(EntityId);
/// The strip's bounds and the end the last press took.
#[derive(Default)]
struct Strip {
bounds: Bounds<Pixels>,
held: Option<End>,
}
/// A video's frames laid along its length under a playhead, and with a trim, the part kept between two handles. A press seeks and a drag scrubs; a handle's drag moves its end. With focus, the arrows step the playhead a frame's span, and I and O set the trim's ends at the playhead.
#[derive(IntoElement)]
pub struct VideoThumbnailStrip {
id: ElementId,
frames: Vec<ImageSource>,
length: Duration,
at: Duration,
trim: Option<(Duration, Duration)>,
on_seek: Option<OnTime>,
on_trim: Option<OnTrim>,
}
impl VideoThumbnailStrip {
/// `frames` spread evenly along `length`.
pub fn new(
id: impl Into<ElementId>,
frames: impl IntoIterator<Item = impl Into<ImageSource>>,
length: Duration,
at: Duration,
) -> Self {
let frames: Vec<ImageSource> = frames.into_iter().map(Into::into).collect();
assert!(
!frames.is_empty() && !length.is_zero() && at <= length,
"{} frames, {at:?} of {length:?}",
frames.len()
);
Self {
id: id.into(),
frames,
length,
at,
trim: None,
on_seek: None,
on_trim: None,
}
}
/// The part kept, from its start to its end.
pub fn trim(mut self, start: Duration, end: Duration) -> Self {
assert!(
start + LEAST <= end && end <= self.length,
"a trim of {start:?} to {end:?}"
);
self.trim = Some((start, end));
self
}
pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
self.on_seek = Some(Rc::new(handler));
self
}
pub fn on_trim(
mut self,
handler: impl Fn((Duration, Duration), &mut Window, &mut App) + 'static,
) -> Self {
self.on_trim = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VideoThumbnailStrip {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let strip = window.use_keyed_state((self.id.clone(), "strip"), cx, |_, _| Strip::default());
let editable = self.on_seek.is_some() || self.on_trim.is_some();
let focus = tab_stop((self.id.clone(), "focus").into(), editable, window, cx);
let owner = strip.entity_id();
let (length, at, trim) = (self.length, self.at, self.trim);
let span = length / self.frames.len() as u32;
let share = move |time: Duration| time.as_secs_f32() / length.as_secs_f32();
let theme = cx.theme();
let colors = theme.colors.clone();
let sizes = theme.media();
let (tall, handle) = (sizes.strip, sizes.trim_handle);
let reach = handle.to_pixels(window.rem_size());
let frames = self.frames.into_iter().enumerate().map(|(ix, frame)| {
div().flex_1().min_w_0().h_full().overflow_hidden().child(
img(frame)
.id((self.id.clone(), format!("frame-{ix}")))
.size_full()
.object_fit(ObjectFit::Cover),
)
});
let veil = |from: f32, to: f32| {
div()
.absolute()
.top_0()
.bottom_0()
.left(relative(from))
.w(relative(to - from))
.bg(colors.media_backdrop.alpha(0.55))
};
let grip = |at: f32, cursor: CursorStyle| {
div()
.absolute()
.top_0()
.bottom_0()
.left(relative(at))
.w_0()
.flex()
.justify_center()
.child(
div()
.flex_none()
.w(handle)
.h_full()
.rounded(theme.radius(crate::theme::Radius::Sm))
.bg(colors.accent)
.cursor(cursor),
)
};
let kept = trim.map(|(start, stop)| {
let (from, to) = (share(start), share(stop));
div()
.absolute()
.inset_0()
.child(veil(0.0, from))
.child(veil(to, 1.0))
.child(
div()
.absolute()
.top_0()
.bottom_0()
.left(relative(from))
.w(relative(to - from))
.border_y_2()
.border_color(colors.accent),
)
.child(grip(from, CursorStyle::ResizeLeftRight))
.child(grip(to, CursorStyle::ResizeLeftRight))
});
let playhead = div()
.absolute()
.top_0()
.bottom_0()
.left(relative(share(at)))
.w_0()
.border_l_2()
.border_color(colors.on_media)
.debug_selector(|| "strip-playhead".into());
let measured = strip.clone();
let row = div()
.relative()
.h(tall)
.flex()
.debug_selector(|| "video-strip".into())
.children(frames)
.child(
canvas(
move |bounds, window, cx| {
if measured.read(cx).bounds != bounds {
measured.update(cx, |strip, cx| {
strip.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(kept)
.child(playhead);
let spot = move |bounds: Bounds<Pixels>, pointer: Point<Pixels>| {
f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)
};
let (on_seek, on_trim) = (self.on_seek, self.on_trim);
let act = move |held: Option<End>, time: Duration, window: &mut Window, cx: &mut App| match (
held, trim, &on_trim, &on_seek,
) {
(Some(end), Some(trim), Some(set), _) => {
set(trimmed(trim, end, time, length), window, cx)
}
(None, _, _, Some(seek)) => seek(time, window, cx),
_ => {}
};
let act = Rc::new(act);
let (pressed, moved, keys) = (act.clone(), act.clone(), act);
let (pressed_strip, moved_strip) = (strip.clone(), strip.clone());
div()
.id(self.id.clone())
.px(handle / 2.0)
.border_1()
.border_color(transparent_black())
.child(row)
.when(editable, |outer| {
outer
.track_focus(&focus)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
let bounds = pressed_strip.read(cx).bounds;
let at = spot(bounds, event.position);
let reach = f32::from(reach) / f32::from(bounds.size.width);
let held = trim.and_then(|trim| end_at(trim, at, reach, length));
pressed_strip.update(cx, |strip, _| strip.held = held);
if held.is_none() {
let time = time_at(at, length);
log::info!("video strip: seek to {}", clock(time));
pressed(None, time, window, cx);
}
})
.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 (bounds, held) = {
let strip = moved_strip.read(cx);
(strip.bounds, strip.held)
};
moved(
held,
time_at(spot(bounds, event.event.position), length),
window,
cx,
);
})
.on_key_down(move |event, window, cx| {
let command = &event.keystroke.modifiers;
if command.platform || command.control {
return;
}
let (held, time) = match event.keystroke.key.as_str() {
"left" => (None, at.saturating_sub(span)),
"right" => (None, (at + span).min(length)),
"i" if trim.is_some() => (Some(End::In), at),
"o" if trim.is_some() => (Some(End::Out), at),
_ => return,
};
cx.stop_propagation();
log::info!("video strip: {:?} at {}", held, clock(time));
keys(held, time, window, cx);
})
})
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{End, end_at, trimmed};
const TEN: Duration = Duration::from_secs(10);
#[test]
fn a_trim_end_moves_and_stops_short_of_the_other_and_the_length() {
let (trim, length) = ((TEN, TEN * 5), TEN * 10);
assert_eq!(trimmed(trim, End::In, TEN * 2, length), (TEN * 2, TEN * 5));
assert_eq!(
trimmed(trim, End::In, TEN * 6, length),
(TEN * 5 - Duration::from_secs(1), TEN * 5)
);
assert_eq!(
trimmed(trim, End::Out, TEN / 2, length),
(TEN, TEN + Duration::from_secs(1))
);
assert_eq!(trimmed(trim, End::Out, TEN * 20, length), (TEN, length));
}
#[test]
fn a_press_holds_the_nearer_end_within_reach() {
let (trim, length) = ((TEN, TEN * 5), TEN * 10);
assert_eq!(end_at(trim, 0.11, 0.02, length), Some(End::In));
assert_eq!(end_at(trim, 0.49, 0.02, length), Some(End::Out));
assert_eq!(end_at(trim, 0.3, 0.02, length), None);
let close = (TEN * 5, TEN * 5 + Duration::from_secs(1));
assert_eq!(
end_at(close, 0.509, 0.02, length),
Some(End::Out),
"the nearer of two"
);
}
}