T26c Media: video

Scrubber shows what has loaded, what has played and its chapters, names
the time under the pointer, seeks by press and gpui's drag, and steps by
the slider's keys; over_media lights it for a dark layer. VideoPlayer
shows the host's frame in its shape with a caption and a control bar over
it: the scrubber, play, the time, speed, captions, picture in picture and
full screen, each only with its handler; the bar rests after a still
pointer while it plays, on one wake from the executor's timer. Keys:
Space and K, J and L, the arrows, C and F. VideoThumbnailStrip lays frames
along the length under a playhead with a trim whose handles drag and whose
ends I and O set. SubtitleEditor edits cues in place: times as mm:ss.mmm,
kept in order, a time that does not read kept out with the reason shown.

InlineEdit is a Tab stop with the ring; it hands focus back after Enter or
Escape and keeps its text on Enter's release. overlays::media_button is
shared by the lightbox and the player's bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-09-26 17:07:26 -04:00
1 parent f7f20b5146
commit d0175edb7b
22 files changed
+2232 -25

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
mod bars;
mod button;
pub(crate) use button::label_size;
mod confirm;
mod copy;
mod fab;
+48 -12
View File
@@ -1,24 +1,25 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::*,
App, Context, ElementId, Entity, FocusHandle, InteractiveElement, IntoElement, MouseButton,
ParentElement, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription,
Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
primitives::{Icon, IconName},
primitives::{FocusRing, Icon, IconName, tab_stop},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
pub(crate) type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A text being edited in place: its field while open, and who hears the kept text.
/// A text being edited in place: its field while open, who hears the kept text, and where focus goes back after Enter or Escape. An edit that hands focus back keeps its text on Enter's release, so the release does not open it again.
#[derive(Default)]
pub(crate) struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
pub on_commit: Option<OnCommit>,
back: Option<FocusHandle>,
}
impl Editing {
@@ -33,10 +34,17 @@ impl Editing {
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone());
let owner = state.clone();
let events = window.subscribe(&field, cx, move |_, event, window, cx| {
if matches!(event, InputEvent::Submit | InputEvent::Blur) {
owner.update(cx, |editing, cx| editing.finish(false, window, cx));
}
let events = window.subscribe(&field, cx, move |_, event, window, cx| match event {
InputEvent::Submit => owner.update(cx, |editing, cx| {
if editing.back.is_none() {
editing.finish(false, window, cx);
}
}),
InputEvent::Blur => owner.update(cx, |editing, cx| {
editing.back = None;
editing.finish(false, window, cx);
}),
_ => {}
});
state.update(cx, |editing, cx| {
editing.field = Some((field, events));
@@ -44,6 +52,18 @@ impl Editing {
});
}
/// Focus goes back here after Enter or Escape.
fn returning(&mut self, back: FocusHandle) {
self.back = Some(back);
}
fn give_back(&mut self, window: &mut Window) {
if let Some(back) = self.back.take() {
log::info!("inline edit: focus handed back");
window.focus(&back);
}
}
pub(crate) fn field(&self) -> Option<Entity<TextInput>> {
self.field.as_ref().map(|(field, _)| field.clone())
}
@@ -102,16 +122,29 @@ impl InlineEdit {
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state.read(cx).field() {
let escape = state.clone();
let (escape, enter) = (state.clone(), state.clone());
return div()
.id(self.id)
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |editing, cx| editing.finish(true, window, cx));
escape.update(cx, |editing, cx| {
editing.finish(true, window, cx);
editing.give_back(window);
});
}
})
.on_key_up(move |event, window, cx| {
if event.keystroke.key == "enter" {
cx.stop_propagation();
enter.update(cx, |editing, cx| {
editing.finish(false, window, cx);
editing.give_back(window);
});
}
})
.child(Input::new(&field).size(ControlSize::Sm))
@@ -120,7 +153,7 @@ impl RenderOnce for InlineEdit {
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
let (value, start) = (self.value.clone(), state);
let (value, start, back) = (self.value.clone(), state, focus.clone());
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
div()
.id(self.id)
@@ -133,12 +166,15 @@ impl RenderOnce for InlineEdit {
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
start.update(cx, |editing, _| editing.returning(back.clone()));
Editing::begin(&start, value.to_string(), window, cx);
})
.child(if empty { self.placeholder } else { self.value })
+1
View File
@@ -97,6 +97,7 @@ pub use select::Select;
pub(crate) use select::{Listing, field_button, field_text, listing};
pub(crate) use signature::{Drawing, ink, pen};
pub use signature::{SignaturePad, Stroke};
pub(crate) use slider::keyed;
pub use slider::{RangeSlider, Slider};
pub use stepper::Stepper;
pub use structure::{
+55 -3
View File
@@ -1,11 +1,12 @@
use gpui::{
Context, IntoElement, Modifiers, MouseButton, Render, TestAppContext, Window, point, px,
Context, IntoElement, KeyBinding, KeyUpEvent, Keystroke, Modifiers, MouseButton, Render,
SharedString, TestAppContext, VisualTestContext, Window, point, px,
};
use super::setup;
use crate::{
forms::{IconPicker, SignaturePad, Stroke},
primitives::IconName,
forms::{IconPicker, InlineEdit, SignaturePad, Stroke},
primitives::{FocusNext, IconName},
};
struct Icons {
@@ -92,3 +93,54 @@ fn one_move_keeps_both_ends_of_the_stroke(cx: &mut TestAppContext) {
assert_eq!(stroke.len(), 2, "{stroke:?}");
assert_eq!(stroke[1] - stroke[0], point(px(60.0), px(-20.0)));
}
/// An inline edit and the texts it kept.
struct Titled(Vec<SharedString>);
impl Render for Titled {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
let value = self.0.last().cloned().unwrap_or_else(|| "Draft".into());
InlineEdit::new("title", value).on_commit(move |text, _, cx| {
view.update(cx, |view, cx| {
view.0.push(text.clone());
cx.notify();
})
})
}
}
/// Presses and releases `key`, a drawn frame between.
fn release(key: &str, cx: &mut VisualTestContext) {
let redraw = |cx: &mut VisualTestContext| {
cx.run_until_parked();
cx.update(|window, _| window.refresh());
cx.run_until_parked();
};
cx.simulate_keystrokes(key);
redraw(cx);
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse(key).expect("a key"),
});
redraw(cx);
}
#[gpui::test]
fn an_inline_edit_takes_tab_opens_on_enter_and_takes_focus_back(cx: &mut TestAppContext) {
setup(cx);
cx.update(|cx| cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]));
let (view, cx) = cx.add_window_view(|_, _| Titled(Vec::new()));
cx.update(|window, _| window.activate_window());
cx.update(|window, _| window.focus_next());
release("enter", cx);
cx.simulate_input("Stairs");
release("enter", cx);
release("enter", cx);
cx.simulate_input("Light");
release("enter", cx);
assert_eq!(
view.read_with(cx, |view, _| view.0.clone()),
["Stairs", "Light"],
"focus came back after the first"
);
}
+8
View File
@@ -2,6 +2,10 @@ mod annotate;
mod crop;
mod cropper;
mod marks;
mod player;
mod scrubber;
mod strip;
mod subtitles;
mod thumbnail;
mod turn;
mod upload;
@@ -14,6 +18,10 @@ pub use annotate::ImageAnnotator;
pub use crop::Crop;
pub use cropper::ImageCropper;
pub use marks::{Mark, Tool};
pub use player::{SPEEDS, VideoPlayer};
pub use scrubber::Scrubber;
pub use strip::VideoThumbnailStrip;
pub use subtitles::{Cue, SubtitleEditor};
pub use thumbnail::ImageThumbnail;
pub use upload::ImageUpload;
pub use viewer::ImageViewer;
+401
View File
@@ -0,0 +1,401 @@
use std::{rc::Rc, time::Duration};
use gpui::{
Animation, AnimationExt, App, ElementId, ImageSource, InteractiveElement, IntoElement,
ParentElement, RenderOnce, SharedString, Styled, Window, div, img, prelude::*,
transparent_black,
};
use super::scrubber::{OnTime, Scrubber, clock};
use crate::{
forms::{OnFlag, Run},
motion::duration as motion_duration,
overlays::media_button,
primitives::{FocusRing, IconName, framed, tab_stop},
theme::{ActiveTheme, ControlSize, Radius, TextSize},
typography::tabular,
};
type OnSpeed = Rc<dyn Fn(f32, &mut Window, &mut App)>;
/// Speeds a video plays at.
pub const SPEEDS: [f32; 6] = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
/// How long a still pointer leaves the controls up while a video plays.
const IDLE: Duration = Duration::from_millis(2500);
/// Seconds J and L jump.
const JUMP: f64 = 10.0;
/// When the pointer last moved over the player, and whether a wake is set to hide the controls.
struct Watch {
moved: std::time::Instant,
waking: bool,
}
/// A video the host plays, one frame at a time, in its own shape. Controls sit over it and fade once the pointer rests while it plays: play and pause, the time, the scrubber, the speed, captions, picture in picture and full screen, each only with its handler. With focus, Space and K play and pause, J and L jump ten seconds and the arrows five, C turns captions, F fills the screen.
#[derive(IntoElement)]
pub struct VideoPlayer {
id: ElementId,
frame: Option<ImageSource>,
ratio: f32,
length: Duration,
at: Duration,
loaded: Duration,
playing: bool,
speed: f32,
caption: Option<SharedString>,
captions: Option<bool>,
on_play: Option<OnFlag>,
on_seek: Option<OnTime>,
on_speed: Option<OnSpeed>,
on_captions: Option<OnFlag>,
on_picture: Option<Run>,
on_fullscreen: Option<Run>,
}
impl VideoPlayer {
/// `frame` is the frame to show, none before the first; `ratio` its width over its height.
pub fn new(
id: impl Into<ElementId>,
frame: Option<ImageSource>,
ratio: f32,
length: Duration,
at: Duration,
) -> Self {
assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
Self {
id: id.into(),
frame,
ratio: crate::primitives::checked_ratio(ratio),
length,
at,
loaded: Duration::ZERO,
playing: false,
speed: 1.0,
caption: None,
captions: None,
on_play: None,
on_seek: None,
on_speed: None,
on_captions: None,
on_picture: None,
on_fullscreen: None,
}
}
pub fn loaded(mut self, loaded: Duration) -> Self {
self.loaded = loaded;
self
}
pub fn playing(mut self, playing: bool) -> Self {
self.playing = playing;
self
}
/// One of `SPEEDS`.
pub fn speed(mut self, speed: f32) -> Self {
assert!(SPEEDS.contains(&speed), "a speed of {speed}");
self.speed = speed;
self
}
/// Captions on or off, and the words showing now; without this the video has none.
pub fn captions(mut self, on: bool, caption: Option<SharedString>) -> Self {
self.captions = Some(on);
self.caption = caption.filter(|_| on);
self
}
/// Gets whether to play.
pub fn on_play(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_play = Some(Rc::new(handler));
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_speed(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self {
self.on_speed = Some(Rc::new(handler));
self
}
pub fn on_captions(mut self, handler: impl Fn(bool, &mut Window, &mut App) + 'static) -> Self {
self.on_captions = Some(Rc::new(handler));
self
}
/// Gets a press on picture in picture.
pub fn on_picture(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_picture = Some(Rc::new(handler));
self
}
pub fn on_fullscreen(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
self.on_fullscreen = Some(Rc::new(handler));
self
}
}
impl RenderOnce for VideoPlayer {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let started = cx.background_executor().now();
let watch = window.use_keyed_state((self.id.clone(), "watch"), cx, move |_, _| Watch {
moved: started,
waking: false,
});
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
let now = cx.background_executor().now();
let resting = self.playing
&& now.duration_since(watch.read(cx).moved) >= IDLE
&& !focus.contains_focused(window, cx);
let changes = crate::motion::changes((self.id.clone(), "shown"), !resting, window, cx);
let theme = cx.theme();
let colors = theme.colors.clone();
let light = colors.on_media;
let (length, at, playing, speed) = (self.length, self.at, self.playing, self.speed);
let button = |key: &'static str, icon: IconName, run: Run| {
media_button(
(self.id.clone(), key),
icon,
ControlSize::Sm,
true,
move |window, cx| run(window, cx),
cx,
)
};
let play = self.on_play.clone().map(|play| {
let icon = if playing {
IconName::Pause
} else {
IconName::Play
};
button(
"play",
icon,
Rc::new(move |window, cx| {
log::info!("video player: {}", if playing { "pause" } else { "play" });
play(!playing, window, cx)
}),
)
});
let speed_button = self.on_speed.clone().map(|set| {
let next = SPEEDS[(SPEEDS
.iter()
.position(|each| *each == speed)
.expect("a listed speed")
+ 1)
% SPEEDS.len()];
div()
.flex()
.items_center()
.child(button(
"speed",
IconName::Gauge,
Rc::new(move |window, cx| {
log::info!("video player: speed {next}×");
set(next, window, cx)
}),
))
.child(
tabular(div())
.text_size(theme.text_size(TextSize::Xs))
.text_color(light)
.child(format!("{speed}×")),
)
});
let captions = self
.on_captions
.clone()
.zip(self.captions)
.map(|(set, on)| {
let icon = if on {
IconName::Captions
} else {
IconName::CaptionsOff
};
button(
"captions",
icon,
Rc::new(move |window, cx| set(!on, window, cx)),
)
});
let picture = self
.on_picture
.clone()
.map(|run| button("picture", IconName::PictureInPicture2, run));
let fullscreen = self
.on_fullscreen
.clone()
.map(|run| button("fullscreen", IconName::Fullscreen, run));
let scrubber = Scrubber::new((self.id.clone(), "scrubber"), length, at)
.loaded(self.loaded)
.over_media();
let scrubber = match self.on_seek.clone() {
Some(seek) => scrubber.on_seek(move |time, window, cx| seek(time, window, cx)),
None => scrubber,
};
let bar = div()
.flex()
.flex_col()
.px_2()
.pt_1()
.rounded(theme.radius(Radius::Md))
.bg(colors.media_backdrop.alpha(0.6))
.child(scrubber)
.child(
div()
.flex()
.flex_wrap()
.items_center()
.justify_between()
.child(
div().flex().items_center().children(play).child(
tabular(div())
.flex_none()
.px_1()
.text_size(theme.text_size(TextSize::Xs))
.text_color(light)
.child(format!("{} / {}", clock(at), clock(length))),
),
)
.child(
div()
.flex()
.items_center()
.children(speed_button)
.children(captions)
.children(picture)
.children(fullscreen),
),
);
let caption = self.caption.map(|words| {
div().flex().justify_center().child(
div()
.max_w(theme.prose_width())
.px_2()
.py_0p5()
.rounded(theme.radius(Radius::Sm))
.bg(colors.media_backdrop.alpha(0.7))
.text_color(light)
.text_size(theme.text_size(TextSize::Base))
.child(words),
)
});
let controls = div()
.when(resting, |controls| {
controls.debug_selector(|| "video-controls-resting".into())
})
.absolute()
.left_0()
.right_0()
.bottom_0()
.p_2()
.flex()
.flex_col()
.gap_2()
.children(caption)
.child(bar);
let (from, to) = if resting { (1.0, 0.0) } else { (0.0, 1.0) };
let controls = if changes == 0 {
controls.opacity(to).into_any_element()
} else {
let fade = motion_duration(crate::motion::BASE, cx);
controls
.with_animation(
("controls", changes),
Animation::new(fade),
move |controls, t| controls.opacity(crate::motion::lerp(from, to, t)),
)
.into_any_element()
};
let frame = self
.frame
.map(|frame| img(frame).id((self.id.clone(), "frame")).size_full());
let moved = watch.clone();
let keyed = (
self.on_play.clone(),
self.on_seek.clone(),
self.on_captions.clone(),
self.captions,
self.on_fullscreen.clone(),
);
framed(self.ratio, cx)
.id(self.id.clone())
.debug_selector(|| "video-player".into())
.bg(colors.media_backdrop)
.border_1()
.border_color(transparent_black())
.track_focus(&focus)
.focus_ring(cx)
.children(frame)
.child(controls)
.on_mouse_move(move |_, window, cx| {
let arm = moved.update(cx, |watch, cx| {
watch.moved = cx.background_executor().now();
cx.notify();
!std::mem::replace(&mut watch.waking, true)
});
if arm {
let watch = moved.clone();
window
.spawn(cx, async move |cx| {
loop {
let wait = watch.read_with(cx, |watch, cx| {
IDLE.saturating_sub(
cx.background_executor().now().duration_since(watch.moved),
)
})?;
if wait.is_zero() {
break;
}
cx.background_executor().timer(wait).await;
}
watch.update(cx, |watch, cx| {
watch.waking = false;
cx.notify();
})
})
.detach();
}
})
.on_key_down(move |event, window, cx| {
let (play, seek, captions, on, fullscreen) = &keyed;
let step = |by: f64| {
Duration::from_secs_f64(
(at.as_secs_f64() + by).clamp(0.0, length.as_secs_f64()),
)
};
match event.keystroke.key.as_str() {
"space" | "k" => match play {
Some(play) => play(!playing, window, cx),
None => return,
},
"j" | "l" | "left" | "right" => {
let Some(seek) = seek else {
return;
};
let by = match event.keystroke.key.as_str() {
"j" => -JUMP,
"l" => JUMP,
"left" => -5.0,
_ => 5.0,
};
seek(step(by), window, cx)
}
"c" => match (captions, on) {
(Some(set), Some(on)) => set(!on, window, cx),
_ => return,
},
"f" => match fullscreen {
Some(run) => run(window, cx),
None => return,
},
_ => return,
}
cx.stop_propagation();
})
}
}
+334
View File
@@ -0,0 +1,334 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, Bounds, DragMoveEvent, ElementId, EmptyView, EntityId, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Window, canvas, div, prelude::*, relative,
transparent_black,
};
use crate::{
forms::keyed,
primitives::{FocusRing, tab_stop},
theme::{ActiveTheme, Radius, TextSize},
typography::{
format::{DurationStyle, duration},
tabular,
},
};
pub(crate) type OnTime = Rc<dyn Fn(Duration, &mut Window, &mut App)>;
/// Seconds an arrow key moves the time; Page keys move ten times as far.
const STEP: f64 = 5.0;
/// The time at `share` of `length`, the share held to the track.
pub(crate) fn time_at(share: f32, length: Duration) -> Duration {
length.mul_f32(share.clamp(0.0, 1.0))
}
/// The chapter `at` falls in: the last to start at or before it.
pub(crate) fn chapter_at(starts: &[Duration], at: Duration) -> Option<usize> {
starts.iter().rposition(|start| *start <= at)
}
/// A time as the crate's clock reads it.
pub(crate) fn clock(at: Duration) -> String {
duration(at.as_secs(), DurationStyle::Clock)
}
/// A seeking drag, marked with its scrubber.
struct Scrub(EntityId);
/// The track's bounds and the share under the pointer.
#[derive(Default)]
struct Track {
bounds: Bounds<Pixels>,
hover: Option<f32>,
}
/// Where a video or a song stands along its length: what has loaded, how far it has played, its chapters, and the time under the pointer. A press or a drag seeks; with focus the arrows step five seconds, the Page keys fifty, Home and End go to the ends. The host plays it.
#[derive(IntoElement)]
pub struct Scrubber {
id: ElementId,
length: Duration,
at: Duration,
loaded: Duration,
chapters: Vec<(Duration, SharedString)>,
over_media: bool,
on_seek: Option<OnTime>,
}
impl Scrubber {
/// `at` is how far it has played, within `length`.
pub fn new(id: impl Into<ElementId>, length: Duration, at: Duration) -> Self {
assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}");
Self {
id: id.into(),
length,
at,
loaded: Duration::ZERO,
chapters: Vec::new(),
over_media: false,
on_seek: None,
}
}
/// Light on a dark layer over pictures, as a player's controls sit.
pub fn over_media(mut self) -> Self {
self.over_media = true;
self
}
/// How far it has loaded.
pub fn loaded(mut self, loaded: Duration) -> Self {
assert!(
loaded <= self.length,
"loaded {loaded:?} of {:?}",
self.length
);
self.loaded = loaded;
self
}
/// A chapter from `start`, after the last one given.
pub fn chapter(mut self, start: Duration, title: impl Into<SharedString>) -> Self {
let after = self.chapters.last().is_none_or(|(last, _)| *last < start);
assert!(
after && start < self.length,
"a chapter at {start:?} out of order"
);
self.chapters.push((start, title.into()));
self
}
/// Gets the time to seek to.
pub fn on_seek(mut self, handler: impl Fn(Duration, &mut Window, &mut App) + 'static) -> Self {
self.on_seek = Some(Rc::new(handler));
self
}
}
impl RenderOnce for Scrubber {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let track = window.use_keyed_state((self.id.clone(), "track"), cx, |_, _| Track::default());
let focus = tab_stop(
(self.id.clone(), "focus").into(),
self.on_seek.is_some(),
window,
cx,
);
let focused = focus.is_focused(window);
let owner = track.entity_id();
let (length, at) = (self.length, self.at);
let share = |time: Duration| time.as_secs_f32() / length.as_secs_f32();
let starts: Vec<Duration> = self.chapters.iter().map(|(start, _)| *start).collect();
let hover = track.read(cx).hover;
let theme = cx.theme();
let colors = theme.colors.clone();
let (played, lane) = match self.over_media {
true => (colors.on_media, colors.on_media.opacity(0.25)),
false => (colors.fg, colors.border),
};
let (line, thumb) = (theme.slider_track(), theme.slider_thumb() * 0.75);
let mut bounds: Vec<Duration> = if starts.first() == Some(&Duration::ZERO) {
starts.clone()
} else {
std::iter::once(Duration::ZERO)
.chain(starts.iter().copied())
.collect()
};
bounds.push(length);
let lanes = bounds.windows(2).map(|pair| {
let (start, end) = (pair[0], pair[1]);
let within = |time: Duration| {
((time.as_secs_f32() - start.as_secs_f32()) / (end - start).as_secs_f32())
.clamp(0.0, 1.0)
};
div()
.relative()
.h(line)
.flex_basis(relative(share(end - start)))
.flex_grow()
.rounded_full()
.bg(lane)
.child(
div()
.absolute()
.top_0()
.left_0()
.h_full()
.w(relative(within(self.loaded)))
.rounded_full()
.bg(played.opacity(0.35)),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.h_full()
.w(relative(within(at)))
.rounded_full()
.bg(played),
)
});
let tip = hover.map(|hovered| {
let time = time_at(hovered, length);
let name = chapter_at(&starts, time).map(|ix| self.chapters[ix].1.clone());
let text = match name {
Some(name) => format!("{} · {name}", clock(time)),
None => clock(time),
};
div()
.absolute()
.left(relative(hovered))
.bottom_full()
.size_0()
.flex()
.justify_center()
.items_end()
.child(
tabular(div())
.flex_none()
.mb_1()
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.bg(colors.tooltip_bg)
.text_color(colors.tooltip_fg)
.text_size(theme.text_size(TextSize::Xs))
.whitespace_nowrap()
.child(text),
)
.debug_selector(|| "scrubber-tip".into())
});
let knob = (hover.is_some() || focused).then(|| {
div()
.absolute()
.left(relative(share(at)))
.top(relative(0.5))
.size_0()
.flex()
.items_center()
.justify_center()
.child(div().flex_none().size(thumb).rounded_full().bg(played))
});
let measured = track.clone();
let rail = div()
.debug_selector(|| "scrubber-rail".into())
.relative()
.w_full()
.flex()
.gap_0p5()
.items_center()
.h(thumb)
.children(lanes)
.child(
canvas(
move |bounds, window, cx| {
if measured.read(cx).bounds != bounds {
measured.update(cx, |track, cx| {
track.bounds = bounds;
cx.notify();
});
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.children(knob)
.children(tip);
let spot = move |bounds: Bounds<Pixels>, pointer: Point<Pixels>| {
f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)
};
let (moved, left) = (track.clone(), track.clone());
div()
.id(self.id.clone())
.debug_selector(|| "scrubber".into())
.w_full()
.px(thumb / 2.0)
.rounded(theme.radius(Radius::Sm))
.border_1()
.border_color(transparent_black())
.child(rail)
.on_mouse_move(move |event, _, cx| {
moved.update(cx, |track, cx| {
track.hover = Some(spot(track.bounds, event.position).clamp(0.0, 1.0));
cx.notify();
})
})
.on_hover(move |inside, _, cx| {
if !inside {
left.update(cx, |track, cx| {
track.hover = None;
cx.notify();
})
}
})
.when_some(self.on_seek, |scrubber, seek| {
let (pressed, dragged, keys) = (seek.clone(), seek.clone(), seek);
let (pressed_track, dragged_track) = (track.clone(), track.clone());
scrubber
.track_focus(&focus)
.focus_ring(cx)
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
let time =
time_at(spot(pressed_track.read(cx).bounds, event.position), length);
log::info!("scrubber: seek to {}", clock(time));
pressed(time, window, cx);
})
.on_drag(Scrub(owner), |_, _, _, cx| cx.new(|_| EmptyView))
.on_drag_move(move |event: &DragMoveEvent<Scrub>, window, cx| {
if event.drag(cx).0 != owner {
return;
}
let bounds = dragged_track.read(cx).bounds;
dragged(
time_at(spot(bounds, event.event.position), length),
window,
cx,
);
})
.on_key_down(move |event, window, cx| {
let (now, end) = (at.as_secs_f64(), length.as_secs_f64());
let Some(next) = keyed(&event.keystroke.key, now, 0.0, end, STEP) else {
return;
};
cx.stop_propagation();
let time = Duration::from_secs_f64(next);
log::info!("scrubber: seek to {}", clock(time));
keys(time, window, cx);
})
})
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{chapter_at, clock, time_at};
#[test]
fn a_share_is_a_time_held_to_the_track() {
let length = Duration::from_secs(200);
assert_eq!(time_at(0.25, length), Duration::from_secs(50));
assert_eq!(time_at(1.4, length), length);
assert_eq!(time_at(-0.2, length), Duration::ZERO);
}
#[test]
fn a_time_falls_in_the_last_chapter_begun() {
let starts = [Duration::from_secs(10), Duration::from_secs(60)];
assert_eq!(chapter_at(&starts, Duration::from_secs(5)), None);
assert_eq!(chapter_at(&starts, Duration::from_secs(10)), Some(0));
assert_eq!(chapter_at(&starts, Duration::from_secs(90)), Some(1));
assert_eq!(clock(Duration::from_secs(83)), "01:23");
}
}
+344
View File
@@ -0,0 +1,344 @@
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())
.w_full()
.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 (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"
);
}
}
+362
View File
@@ -0,0 +1,362 @@
use std::{rc::Rc, time::Duration};
use gpui::{
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use super::scrubber::OnTime;
use crate::{
buttons::{Button, ButtonVariant, IconButton},
forms::InlineEdit,
primitives::IconName,
theme::{ActiveTheme, ControlSize, Radius, TextSize},
};
type OnCues = Rc<dyn Fn(&[Cue], &mut Window, &mut App)>;
/// How long a cue added at the playhead lasts.
const FRESH: Duration = Duration::from_secs(2);
/// A caption: its key, when it shows and when it goes, and its words.
#[derive(Clone, Debug, PartialEq)]
pub struct Cue {
pub key: SharedString,
pub start: Duration,
pub end: Duration,
pub text: SharedString,
}
/// A time read from `mm:ss.mmm` or `h:mm:ss.mmm`, its fraction optional; none when it does not read.
pub(crate) fn parse_time(text: &str) -> Option<Duration> {
let text = text.trim();
let (clock, fraction) = text.split_once('.').unwrap_or((text, ""));
let parts: Vec<u64> = clock
.split(':')
.map(|part| part.parse().ok())
.collect::<Option<_>>()?;
let seconds = match parts.as_slice() {
[minutes, seconds] if *seconds < 60 => minutes * 60 + seconds,
[hours, minutes, seconds] if *minutes < 60 && *seconds < 60 => {
hours * 3600 + minutes * 60 + seconds
}
_ => return None,
};
let millis = match fraction.len() {
0 => 0,
1..=3 if fraction.chars().all(|ch| ch.is_ascii_digit()) => {
format!("{fraction:0<3}").parse().ok()?
}
_ => return None,
};
Some(Duration::from_millis(seconds * 1000 + millis))
}
/// A time as `mm:ss.mmm`, the hours ahead once it runs past one.
pub(crate) fn stamp(time: Duration) -> String {
let (millis, seconds) = (time.subsec_millis(), time.as_secs());
match seconds / 3600 {
0 => format!("{:02}:{:02}.{millis:03}", seconds / 60, seconds % 60),
hours => format!(
"{hours}:{:02}:{:02}.{millis:03}",
seconds / 60 % 60,
seconds % 60
),
}
}
/// Which time of a cue an edit changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Edge {
Start,
End,
}
/// The cues after `key`'s `edge` reads `text`, in time order, or why they stay as they were.
pub(crate) fn retimed(cues: &[Cue], key: &str, edge: Edge, text: &str) -> Result<Vec<Cue>, String> {
let time = parse_time(text).ok_or_else(|| {
format!(
"\u{201c}{}\u{201d} is not a time; write mm:ss.mmm",
text.trim()
)
})?;
let mut next = cues.to_vec();
let cue = next
.iter_mut()
.find(|cue| cue.key == key)
.expect("a listed cue");
match edge {
Edge::Start => cue.start = time,
Edge::End => cue.end = time,
}
if cue.start >= cue.end {
return Err("A cue ends after it starts".to_string());
}
next.sort_by_key(|cue| cue.start);
Ok(next)
}
/// Captions to edit, a cue a block in time order: its start and end as mm:ss.mmm over its words, each edited in place, with seek and remove beside. The cue under the playhead is lit; Add puts a two-second cue there. A time that does not read, or ends before it starts, leaves the cue as it was and says why under it.
#[derive(IntoElement)]
pub struct SubtitleEditor {
id: ElementId,
cues: Vec<Cue>,
at: Duration,
on_change: Option<OnCues>,
on_seek: Option<OnTime>,
}
impl SubtitleEditor {
/// `cues` in time order, each ending after it starts; `at` is the playhead.
pub fn new(
id: impl Into<ElementId>,
cues: impl IntoIterator<Item = Cue>,
at: Duration,
) -> Self {
let cues: Vec<Cue> = cues.into_iter().collect();
for (ix, cue) in cues.iter().enumerate() {
assert!(cue.start < cue.end, "cue {} ends before it starts", cue.key);
assert!(
ix == 0 || cues[ix - 1].start <= cue.start,
"cue {} out of order",
cue.key
);
assert!(
!cues[..ix].iter().any(|other| other.key == cue.key),
"cue {} twice",
cue.key
);
}
Self {
id: id.into(),
cues,
at,
on_change: None,
on_seek: None,
}
}
pub fn on_change(mut self, handler: impl Fn(&[Cue], &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
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
}
}
impl RenderOnce for SubtitleEditor {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let wrong = window.use_keyed_state((self.id.clone(), "wrong"), cx, |_, _| {
None::<(SharedString, String)>
});
let complaint = wrong.read(cx).clone();
let theme = cx.theme();
let colors = theme.colors.clone();
let cues = Rc::new(self.cues);
let at = self.at;
let rows = cues.iter().map(|cue| {
let lit = cue.start <= at && at < cue.end;
let key = cue.key.clone();
let retime = |edge: Edge| {
let (cues, key, wrong, change) = (
cues.clone(),
key.clone(),
wrong.clone(),
self.on_change.clone(),
);
move |text: &SharedString, window: &mut Window, cx: &mut App| match retimed(
&cues, &key, edge, text,
) {
Ok(next) => {
wrong.update(cx, |wrong, cx| {
*wrong = None;
cx.notify();
});
log::info!("subtitle editor: {key} {edge:?} at {}", text.trim());
if let Some(change) = &change {
change(&next, window, cx);
}
}
Err(reason) => {
log::info!("subtitle editor: {key} kept: {reason}");
wrong.update(cx, |wrong, cx| {
*wrong = Some((key.clone(), reason));
cx.notify();
});
}
}
};
let words = {
let (cues, key, change) = (cues.clone(), key.clone(), self.on_change.clone());
move |text: &SharedString, window: &mut Window, cx: &mut App| {
let next: Vec<Cue> = cues
.iter()
.map(|cue| {
if cue.key == key {
Cue {
text: text.clone(),
..cue.clone()
}
} else {
cue.clone()
}
})
.collect();
if let Some(change) = &change {
change(&next, window, cx);
}
}
};
let editable = self.on_change.is_some();
let time = |edge: Edge, value: Duration| {
let field =
InlineEdit::new((self.id.clone(), format!("{key}-{edge:?}")), stamp(value));
if editable {
field.on_commit(retime(edge))
} else {
field
}
};
let seek = self.on_seek.clone().map(|seek| {
let start = cue.start;
IconButton::new((self.id.clone(), format!("{key}-seek")), IconName::Play)
.size(ControlSize::Sm)
.tooltip("Play from here")
.on_click(move |_, window, cx| seek(start, window, cx))
});
let remove = self.on_change.clone().map(|change| {
let (cues, key) = (cues.clone(), key.clone());
IconButton::new((self.id.clone(), format!("{key}-remove")), IconName::X)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
let next: Vec<Cue> =
cues.iter().filter(|cue| cue.key != key).cloned().collect();
log::info!("subtitle editor: removed {key}");
change(&next, window, cx)
})
});
let said = complaint
.as_ref()
.filter(|(which, _)| *which == key)
.map(|(_, reason)| {
div()
.debug_selector(|| "subtitle-complaint".into())
.px_2()
.text_size(theme.text_size(TextSize::Xs))
.text_color(colors.danger)
.child(reason.clone())
});
let text = InlineEdit::new((self.id.clone(), format!("{key}-words")), cue.text.clone())
.placeholder("Words");
div()
.w_full()
.flex()
.flex_col()
.gap_0p5()
.p_1()
.rounded(theme.radius(Radius::Md))
.when(lit, |row| row.bg(colors.active))
.child(
div()
.flex()
.items_center()
.gap_1()
.children(seek)
.child(time(Edge::Start, cue.start))
.child(div().text_color(colors.fg_muted).child("→"))
.child(time(Edge::End, cue.end)),
)
.child(
div()
.flex()
.items_center()
.gap_1()
.child(div().flex_1().min_w_0().child(if editable {
text.on_commit(words)
} else {
text
}))
.children(remove),
)
.children(said)
});
let add = self.on_change.clone().map(|change| {
let cues = cues.clone();
Button::new((self.id.clone(), "add"), "Add a cue")
.variant(ButtonVariant::Ghost)
.size(ControlSize::Sm)
.icon(IconName::Plus)
.on_click(move |_, window, cx| {
let taken = |key: &str| cues.iter().any(|cue| cue.key == key);
let key = (cues.len() + 1..)
.map(|n| format!("cue-{n}"))
.find(|key| !taken(key))
.expect("a free key");
let mut next = cues.to_vec();
next.push(Cue {
key: key.clone().into(),
start: at,
end: at + FRESH,
text: SharedString::default(),
});
next.sort_by_key(|cue| cue.start);
log::info!("subtitle editor: added {key} at {}", stamp(at));
change(&next, window, cx)
})
});
div()
.w_full()
.flex()
.flex_col()
.gap_1()
.children(rows)
.child(div().flex().children(add))
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::{Cue, Edge, parse_time, retimed, stamp};
#[test]
fn times_read_minutes_and_hours_with_or_without_a_fraction() {
assert_eq!(parse_time("01:02.5"), Some(Duration::from_millis(62_500)));
assert_eq!(
parse_time(" 1:02:03.004 "),
Some(Duration::from_millis(3_723_004))
);
assert_eq!(parse_time("75:00"), Some(Duration::from_secs(4500)));
for bad in ["1:60", "a:10", "1:2:3:4", "01:02.5000", "01:02.x", ""] {
assert_eq!(parse_time(bad), None, "{bad:?}");
}
assert_eq!(stamp(Duration::from_millis(62_500)), "01:02.500");
assert_eq!(stamp(Duration::from_millis(3_723_004)), "1:02:03.004");
}
#[test]
fn a_retimed_cue_keeps_order_and_refuses_a_bad_time_or_a_backward_one() {
let cue = |key: &str, start: u64, end: u64| Cue {
key: key.to_string().into(),
start: Duration::from_secs(start),
end: Duration::from_secs(end),
text: key.to_string().into(),
};
let cues = [cue("a", 1, 3), cue("b", 5, 8)];
let moved = retimed(&cues, "b", Edge::Start, "00:00.500").expect("a good time");
assert_eq!(
moved.iter().map(|cue| cue.key.as_ref()).collect::<Vec<_>>(),
["b", "a"]
);
assert!(retimed(&cues, "a", Edge::End, "1:x").is_err());
assert_eq!(
retimed(&cues, "a", Edge::End, "00:00.500"),
Err("A cue ends after it starts".to_string())
);
}
}
+1
View File
@@ -6,6 +6,7 @@ use crate::{primitives::FocusNext, theme::Theme};
mod edit;
mod images;
mod video;
pub(super) fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
+341
View File
@@ -0,0 +1,341 @@
use std::{path::PathBuf, time::Duration};
use gpui::{
Context, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Pixels, Point, Render,
Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
const SECOND: Duration = Duration::from_secs(1);
/// Where a share of a box sits in the window, halfway down.
fn across(selector: &'static str, share: f32, cx: &mut VisualTestContext) -> Point<Pixels> {
let bounds = cx.debug_bounds(selector).expect("the box draws");
point(bounds.left() + bounds.size.width * share, bounds.center().y)
}
/// A scrubber over a hundred seconds, 400 wide, and the times it was sent to.
struct Scrubbing {
at: Duration,
sought: Vec<Duration>,
}
impl Render for Scrubbing {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(400.0)).mt(px(40.0)).child(
Scrubber::new("scrub", SECOND * 100, self.at)
.chapter(Duration::ZERO, "Intro")
.chapter(SECOND * 40, "Stair")
.on_seek(move |time, _, cx| {
owner.update(cx, |view, cx| {
(view.at, view.sought) = (time, [view.sought.clone(), vec![time]].concat());
cx.notify();
})
}),
)
}
}
fn scrubbing(cx: &mut TestAppContext) -> (Entity<Scrubbing>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Scrubbing {
at: Duration::ZERO,
sought: Vec::new(),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_press_seeks_and_a_drag_past_the_end_stops_there(cx: &mut TestAppContext) {
let (view, cx) = scrubbing(cx);
let quarter = across("scrubber-rail", 0.25, cx);
let none = Modifiers::none();
cx.simulate_mouse_down(quarter, MouseButton::Left, none);
cx.simulate_mouse_move(quarter + point(px(20.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(quarter + point(px(900.0), px(0.0)), MouseButton::Left, none);
settle(cx);
let sought = view.read_with(cx, |view, _| view.sought.clone());
let first = sought.first().expect("a press seeks");
assert!(first.abs_diff(SECOND * 25) < SECOND, "{sought:?}");
assert_eq!(sought.last(), Some(&(SECOND * 100)), "{sought:?}");
}
#[gpui::test]
fn the_arrows_step_five_seconds_and_end_goes_to_the_end(cx: &mut TestAppContext) {
let (view, cx) = scrubbing(cx);
let tenth = across("scrubber-rail", 0.1, cx);
cx.simulate_click(tenth, Modifiers::none());
settle(cx);
let start = view.read_with(cx, |view, _| view.at);
press("right", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), start + SECOND * 5);
press("end", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), SECOND * 100);
}
#[gpui::test]
fn the_pointer_shows_its_time_over_the_track(cx: &mut TestAppContext) {
let (_, cx) = scrubbing(cx);
assert!(cx.debug_bounds("scrubber-tip").is_none());
let half = across("scrubber-rail", 0.5, cx);
cx.simulate_mouse_move(half, None, Modifiers::none());
settle(cx);
let tip = cx
.debug_bounds("scrubber-tip")
.expect("a tip over the track");
assert!((tip.left() - half.x).abs() < px(1.0), "{tip:?}");
}
/// A player of a hundred seconds, what it was told, and whether it plays.
struct Watching {
path: PathBuf,
at: Duration,
playing: bool,
captions: bool,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption) = (cx.entity(), cx.entity(), cx.entity());
div().w(px(480.0)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some("Light falls on the stair".into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
cx.notify();
})
})
.on_seek(move |time, _, cx| {
seek.update(cx, |view, cx| {
view.at = time;
cx.notify();
})
})
.on_captions(move |on, _, cx| {
caption.update(cx, |view, cx| {
view.captions = on;
cx.notify();
})
}),
)
}
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
at: SECOND * 30,
playing: false,
captions: false,
});
cx.update(|window, _| window.activate_window());
settle(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
cx.simulate_click(frame.origin + point(px(8.0), px(8.0)), Modifiers::none());
settle(cx);
(view, cx)
}
#[gpui::test]
fn space_plays_l_and_j_jump_ten_seconds_and_c_turns_captions(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
press("space", cx);
assert!(view.read_with(cx, |view, _| view.playing));
press("l", cx);
press("l", cx);
press("j", cx);
assert_eq!(view.read_with(cx, |view, _| view.at), SECOND * 40);
press("c", cx);
assert!(view.read_with(cx, |view, _| view.captions));
}
#[gpui::test]
fn controls_rest_once_the_pointer_is_still_while_it_plays(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| window.blur());
view.update(cx, |view, cx| {
view.playing = true;
cx.notify();
});
let frame = cx.debug_bounds("video-player").expect("the player draws");
cx.simulate_mouse_move(frame.center(), None, Modifiers::none());
settle(cx);
assert!(
cx.debug_bounds("video-controls-resting").is_none(),
"a moving pointer keeps them up"
);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
assert!(
cx.debug_bounds("video-controls-resting").is_some(),
"a still one lets them rest"
);
}
/// A strip of four frames over a hundred seconds with a trim, and what it was told.
struct Trimming {
path: PathBuf,
at: Duration,
trim: (Duration, Duration),
}
impl Render for Trimming {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (seek, cut) = (cx.entity(), cx.entity());
let frames: Vec<PathBuf> = (0..4).map(|_| self.path.clone()).collect();
div().w(px(400.0)).child(
VideoThumbnailStrip::new("strip", frames, SECOND * 100, self.at)
.trim(self.trim.0, self.trim.1)
.on_seek(move |time, _, cx| {
seek.update(cx, |view, cx| {
view.at = time;
cx.notify();
})
})
.on_trim(move |trim, _, cx| {
cut.update(cx, |view, cx| {
view.trim = trim;
cx.notify();
})
}),
)
}
}
fn trimming(cx: &mut TestAppContext) -> (Entity<Trimming>, &mut VisualTestContext) {
setup(cx);
let path = picture("strip", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Trimming {
path,
at: Duration::ZERO,
trim: (SECOND * 10, SECOND * 50),
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
#[gpui::test]
fn a_press_seeks_and_a_handle_drag_moves_its_end_to_the_length(cx: &mut TestAppContext) {
let (view, cx) = trimming(cx);
let press_at = across("video-strip", 0.3, cx);
cx.simulate_click(press_at, Modifiers::none());
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
assert!(at.abs_diff(SECOND * 30) < SECOND, "{at:?}");
let out = across("video-strip", 0.5, cx);
let none = Modifiers::none();
cx.simulate_mouse_down(out, MouseButton::Left, none);
cx.simulate_mouse_move(out + point(px(10.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_move(out + point(px(900.0), px(0.0)), MouseButton::Left, none);
cx.simulate_mouse_up(out + point(px(900.0), px(0.0)), MouseButton::Left, none);
settle(cx);
assert_eq!(
view.read_with(cx, |view, _| view.trim),
(SECOND * 10, SECOND * 100)
);
}
#[gpui::test]
fn i_and_o_set_the_trim_at_the_playhead(cx: &mut TestAppContext) {
let (view, cx) = trimming(cx);
let press_at = across("video-strip", 0.3, cx);
cx.simulate_click(press_at, Modifiers::none());
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
press("i", cx);
assert_eq!(view.read_with(cx, |view, _| view.trim), (at, SECOND * 50));
press("right", cx);
press("o", cx);
assert_eq!(
view.read_with(cx, |view, _| view.trim),
(at, at + SECOND * 25)
);
}
/// A subtitle editor over two cues, its playhead, and the cues it sent.
struct Captioning {
cues: Vec<Cue>,
at: Duration,
}
impl Render for Captioning {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let owner = cx.entity();
div().w(px(420.0)).child(
SubtitleEditor::new("subs", self.cues.clone(), self.at).on_change(
move |cues, _, cx| {
owner.update(cx, |view, cx| {
view.cues = cues.to_vec();
cx.notify();
})
},
),
)
}
}
fn captioning(cx: &mut TestAppContext) -> (Entity<Captioning>, &mut VisualTestContext) {
setup(cx);
let cue = |key: &str, start: u64, end: u64| Cue {
key: key.to_string().into(),
start: SECOND * start as u32,
end: SECOND * end as u32,
text: key.to_string().into(),
};
let (view, cx) = cx.add_window_view(|_, _| Captioning {
cues: vec![cue("a", 1, 3), cue("b", 5, 8)],
at: SECOND * 6,
});
cx.update(|window, _| window.activate_window());
settle(cx);
(view, cx)
}
/// Tabs to the `nth` stop and types over it.
fn retype(nth: usize, text: &str, cx: &mut VisualTestContext) {
cx.update(|window, _| {
window.blur();
for _ in 0..nth {
window.focus_next();
}
});
press("enter", cx);
cx.simulate_input(text);
press("enter", cx);
}
#[gpui::test]
fn a_start_typed_in_place_moves_its_cue_into_order(cx: &mut TestAppContext) {
let (view, cx) = captioning(cx);
retype(5, "00:00.500", cx);
let keys: Vec<String> = view.read_with(cx, |view, _| {
view.cues.iter().map(|cue| cue.key.to_string()).collect()
});
assert_eq!(keys, ["b", "a"]);
}
#[gpui::test]
fn a_time_that_does_not_read_keeps_the_cue_and_says_why(cx: &mut TestAppContext) {
let (view, cx) = captioning(cx);
let before = view.read_with(cx, |view, _| view.cues.clone());
retype(1, "1:x", cx);
assert_eq!(view.read_with(cx, |view, _| view.cues.clone()), before);
assert!(cx.debug_bounds("subtitle-complaint").is_some());
}
+9 -5
View File
@@ -7,10 +7,11 @@ use gpui::{
};
use crate::{
buttons::label_size,
forms::Run,
motion,
primitives::{FocusRing, FocusScope, Icon, IconName, Image, give_back, take_focus},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
theme::{ActiveTheme, ControlSize, TextSize},
};
/// One picture in a lightbox, and the line under it.
@@ -31,10 +32,11 @@ impl Slide {
type OnStep = Rc<dyn Fn(usize, &mut Window, &mut App)>;
/// A round button drawn for the dark layer: light icon, a faint fill on hover, dim when it has nowhere to go.
fn media_button(
/// A round button drawn for a dark layer over pictures: light icon, a faint fill on hover, dim when it has nowhere to go.
pub(crate) fn media_button(
id: (ElementId, &'static str),
icon: IconName,
size: ControlSize,
enabled: bool,
run: impl Fn(&mut Window, &mut App) + 'static,
cx: &App,
@@ -47,11 +49,11 @@ fn media_button(
.flex_none()
.items_center()
.justify_center()
.size(theme.control_height(ControlSize::Lg))
.size(theme.control_height(size))
.rounded_full()
.border_1()
.border_color(gpui::transparent_black())
.child(Icon::new(icon).size(IconSize::Md).color(if enabled {
.child(Icon::new(icon).size(label_size(size).1).color(if enabled {
light
} else {
light.opacity(0.3)
@@ -147,6 +149,7 @@ impl RenderOnce for Lightbox {
media_button(
(self.id.clone(), name),
icon,
ControlSize::Lg,
to.is_some(),
move |window, cx| {
if let Some(to) = to {
@@ -206,6 +209,7 @@ impl RenderOnce for Lightbox {
.child(div().flex().justify_end().p_3().child(media_button(
(self.id.clone(), "close"),
IconName::X,
ControlSize::Lg,
true,
move |window, cx| button(window, cx),
cx,
+1
View File
@@ -12,6 +12,7 @@ pub use dialog::{Close, Dialog};
pub use dialogs::{AlertDialog, ConfirmDialog, PromptDialog};
pub use floats::{FloatingToolbar, Peek};
pub use hover::HoverCard;
pub(crate) use lightbox::media_button;
pub use lightbox::{Lightbox, Slide};
pub use popover::Popover;
pub use spotlight::{Spotlight, Tour, TourStep};
+3
View File
@@ -72,6 +72,8 @@ icons! {
CalendarRange => "calendar-range",
Calendar => "calendar",
Camera => "camera",
Captions => "captions",
CaptionsOff => "captions-off",
CaseSensitive => "case-sensitive",
ChartArea => "chart-area",
ChartBar => "chart-bar",
@@ -138,6 +140,7 @@ icons! {
Folder => "folder",
Forward => "forward",
Frame => "frame",
Fullscreen => "fullscreen",
Gauge => "gauge",
GitBranch => "git-branch",
GitCommitHorizontal => "git-commit-horizontal",
+6
View File
@@ -13,6 +13,10 @@ pub struct MediaSizes {
pub arrow_head: Rems,
/// A numbered pin, round.
pub pin: Rems,
/// A strip of video frames, tall.
pub strip: Rems,
/// A trim's handle, wide.
pub trim_handle: Rems,
}
impl Theme {
@@ -22,6 +26,8 @@ impl Theme {
mark_stroke: px_to_rems(2.0),
arrow_head: px_to_rems(12.0),
pin: px_to_rems(20.0),
strip: px_to_rems(48.0),
trim_handle: px_to_rems(8.0),
}
}
}