Media: the player's bar fades by elapsed time, so its scrubber and buttons keep their state

The fade restarted an animation id at each rest and wake, and gpui hands
that id to every descendant: the scrubber's drag, hover and focus handle
and the buttons' focus were rebuilt under it. The bar's opacity now reads
the time since the last change through motion::since_change. The stage
tracks focus even without key handlers, so a focused bar button keeps
the bar up. The player's tests move to their own file.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-26 18:14:06 -04:00
1 parent f8fcfa2870
commit 1ac50e4002
8 files changed
+330 -220

No files matched your search

+1 -1
View File
@@ -39,7 +39,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- One component, one home. Duplicates point to it.
- Fail fast. No silent fallbacks. Log state changes with `log`.
- Files stay at or under 500 lines. Comments are one short line, and rare.
- A repeating animation (spinner, breath, sweep) holds still under reduced motion instead of shortening, or it would spin at 1 ms a turn. An effect set off by a change (flash, shake) reads elapsed time rather than restarting an animation id, so what it holds keeps its state.
- A repeating animation (spinner, breath, sweep) holds still under reduced motion instead of shortening, or it would spin at 1 ms a turn. An effect set off by a change (flash, shake, a bar that fades at rest) reads elapsed time through `motion::since_change` rather than restarting an animation id, so what it holds keeps its state: an animation's id passes to every descendant, and a new one rebuilds their focus, drags and hover.
- `motion::duration()` honors reduced motion. Springs overshoot: use them inside animators, never as gpui easings, because gpui asserts eased values stay in `0..=1`.
- `svg()` paints only with its own `text_color`. `Icon` always sets one.
- Buttons stay out of focus on click (`prevent_default` on mouse down). Tab still reaches them.
+1
View File
@@ -312,3 +312,4 @@ Per-component lines live in `tasks/`. Tags there:
| T26b | 1 | FAIL | a shaped crop's corner ignored the pointer's move up or down |
| T26b | 2 | PASS | |
| T26c | 1 | FAIL | the caption faded with the controls; Space on a focused bar button acted twice; Command letters fired the player and the strip; a long caption, cue words and the scrubber's tip ran past their boxes at 280px |
| T26c | 2 | FAIL | the bar's fade restarted an animation id per rest and wake, which rebuilt the scrubber's drag, hover and focus under it |
+13 -17
View File
@@ -1,9 +1,8 @@
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,
App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, img, prelude::*, transparent_black,
};
use super::scrubber::{OnTime, Scrubber, clock};
@@ -155,7 +154,13 @@ impl RenderOnce for VideoPlayer {
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 fading = crate::motion::since_change(
&(self.id.clone(), "fade").into(),
resting,
motion_duration(crate::motion::BASE, cx),
window,
cx,
);
let theme = cx.theme();
let colors = theme.colors.clone();
let light = colors.on_media;
@@ -294,18 +299,8 @@ impl RenderOnce for VideoPlayer {
let bar = bar.when(resting, |bar| {
bar.debug_selector(|| "video-controls-resting".into())
});
let (from, to) = if resting { (1.0, 0.0) } else { (0.0, 1.0) };
let bar = if changes == 0 {
bar.opacity(to).into_any_element()
} else {
let fade = motion_duration(crate::motion::BASE, cx);
bar.with_animation(
(self.id.clone(), format!("controls-{changes}")),
Animation::new(fade),
move |bar, t| bar.opacity(crate::motion::lerp(from, to, t)),
)
.into_any_element()
};
let shown = if resting { 0.0 } else { 1.0 };
let bar = bar.opacity(fading.map_or(shown, |t| crate::motion::lerp(1.0 - shown, shown, t)));
let controls = div()
.absolute()
.left_0()
@@ -333,7 +328,8 @@ impl RenderOnce for VideoPlayer {
.bg(colors.media_backdrop)
.border_1()
.border_color(transparent_black())
.when(keys, |stage| stage.track_focus(&focus).focus_ring(cx))
.track_focus(&focus)
.when(keys, |stage| stage.focus_ring(cx))
.children(frame)
.child(controls)
.on_mouse_move(move |_, window, cx| {
+14 -1
View File
@@ -1,11 +1,14 @@
use std::path::PathBuf;
use gpui::{KeyBinding, KeyUpEvent, Keystroke, TestAppContext, VisualTestContext};
use gpui::{
KeyBinding, KeyUpEvent, Keystroke, Pixels, Point, TestAppContext, VisualTestContext, point,
};
use crate::{primitives::FocusNext, theme::Theme};
mod edit;
mod images;
mod player;
mod video;
pub(super) fn setup(cx: &mut TestAppContext) {
@@ -41,3 +44,13 @@ pub(super) fn picture(name: &str, w: u32, h: u32) -> PathBuf {
.expect("the picture writes");
path
}
/// Where a share of a box sits in the window, halfway down.
pub(super) 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)
}
+296
View File
@@ -0,0 +1,296 @@
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::{across, picture, press, settle, setup};
use crate::media::VideoPlayer;
const SECOND: Duration = Duration::from_secs(1);
/// A player of a hundred seconds, this wide, with this caption, and what it was told.
struct Watching {
path: PathBuf,
width: f32,
caption: &'static str,
at: Duration,
playing: bool,
plays: Vec<bool>,
captions: bool,
fullscreens: usize,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption, full) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
div().w(px(self.width)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some(self.caption.into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
view.plays.push(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();
})
})
.on_fullscreen(move |_, cx| {
full.update(cx, |view, cx| {
view.fullscreens += 1;
cx.notify();
})
}),
)
}
}
fn watching_at<'a>(
width: f32,
caption: &'static str,
captions: bool,
cx: &'a mut TestAppContext,
) -> (Entity<Watching>, &'a mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
width,
caption,
at: SECOND * 30,
playing: false,
plays: Vec::new(),
captions,
fullscreens: 0,
});
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)
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
watching_at(480.0, "Light falls on the stair", false, cx)
}
#[gpui::test]
fn space_on_a_focused_bar_button_presses_it_once(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| {
window.blur();
for _ in 0..3 {
window.focus_next();
}
});
press("space", cx);
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [true]);
}
#[gpui::test]
fn command_letters_leave_the_player_to_the_app(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
for key in ["cmd-f", "cmd-c", "cmd-k", "ctrl-f"] {
press(key, cx);
}
let heard = view.read_with(cx, |view, _| {
(view.fullscreens, view.captions, view.plays.clone())
});
assert_eq!(heard, (0, false, vec![]));
}
#[gpui::test]
fn a_long_caption_wraps_inside_a_narrow_player(cx: &mut TestAppContext) {
let (view, cx) = watching_at(280.0, "Light falls", true, cx);
let line = cx.debug_bounds("video-caption").expect("the caption draws");
view.update(cx, |view, cx| {
view.caption = "The shadow walks the length of the hall while the morning comes in.";
cx.notify();
});
settle(cx);
let (chip, frame) = (
cx.debug_bounds("video-caption").expect("the caption draws"),
cx.debug_bounds("video-player").expect("the player draws"),
);
assert!(
chip.left() >= frame.left() && chip.right() <= frame.right(),
"{chip:?} in {frame:?}"
);
assert!(
chip.size.height > line.size.height * 1.5,
"{chip:?} wraps past {line:?}"
);
}
#[gpui::test]
fn a_caption_stays_while_the_controls_rest(cx: &mut TestAppContext) {
let (view, cx) = watching_at(480.0, "Light falls on the stair", true, 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);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
let faded = cx
.debug_bounds("video-controls-resting")
.expect("the bar rests");
let caption = cx.debug_bounds("video-caption").expect("the caption draws");
assert!(
caption.bottom() <= faded.top(),
"{caption:?} outside {faded:?}"
);
}
#[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"
);
}
/// Rests a playing player's bar, focus outside it and the pointer still at `spot`.
fn rest(spot: Point<Pixels>, view: &Entity<Watching>, cx: &mut VisualTestContext) {
cx.update(|window, _| window.blur());
view.update(cx, |view, cx| {
view.playing = true;
cx.notify();
});
cx.simulate_mouse_move(spot, None, Modifiers::none());
settle(cx);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
settle(cx);
assert!(
cx.debug_bounds("video-controls-resting").is_some(),
"the bar rests"
);
}
#[gpui::test]
fn a_drag_begun_on_a_resting_bar_follows_the_pointer(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
rest(across("scrubber-rail", 0.1, cx), &view, cx);
let none = Modifiers::none();
let start = across("scrubber-rail", 0.1, cx);
cx.simulate_mouse_down(start, MouseButton::Left, none);
settle(cx);
for step in 1..=5 {
let to = across("scrubber-rail", 0.1 + 0.1 * step as f32, cx);
cx.simulate_mouse_move(to, MouseButton::Left, none);
settle(cx);
}
let end = across("scrubber-rail", 0.6, cx);
cx.simulate_mouse_up(end, MouseButton::Left, none);
settle(cx);
let at = view.read_with(cx, |view, _| view.at);
assert!(at.abs_diff(SECOND * 60) < SECOND * 2, "{at:?}");
}
#[gpui::test]
fn shift_tab_into_a_resting_bar_keeps_focus_on_its_last_button(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
rest(frame.center(), &view, cx);
cx.update(|window, _| window.focus_prev());
settle(cx);
press("enter", cx);
assert_eq!(view.read_with(cx, |view, _| view.fullscreens), 1);
}
#[gpui::test]
fn the_first_move_onto_a_resting_bar_shows_the_tip(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
let frame = cx.debug_bounds("video-player").expect("the player draws");
rest(frame.origin + point(px(8.0), px(8.0)), &view, cx);
let half = across("scrubber-rail", 0.5, cx);
cx.simulate_mouse_move(half, None, Modifiers::none());
settle(cx);
assert!(cx.debug_bounds("scrubber-tip").is_some());
}
/// A playing player whose only control is its speed.
struct Paced {
path: PathBuf,
}
impl Render for Paced {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let player = VideoPlayer::new(
"paced",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
SECOND * 30,
);
div()
.w(px(480.0))
.child(player.playing(true).on_speed(|_, _, _| {}))
}
}
#[gpui::test]
fn the_bar_stays_up_while_a_button_in_it_has_focus(cx: &mut TestAppContext) {
setup(cx);
let path = picture("paced", 160, 100);
let (_, cx) = cx.add_window_view(|_, _| Paced { path });
cx.update(|window, _| window.activate_window());
settle(cx);
cx.update(|window, _| window.focus_next());
settle(cx);
cx.executor().advance_clock(Duration::from_millis(2600));
settle(cx);
assert!(cx.debug_bounds("video-controls-resting").is_none());
}
+3 -200
View File
@@ -2,20 +2,14 @@ use std::{path::PathBuf, time::Duration};
use gpui::{
Context, Entity, InteractiveElement, IntoElement, Modifiers, MouseButton, ParentElement,
Pixels, Point, Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, VideoPlayer, VideoThumbnailStrip};
use super::{across, picture, press, settle, setup};
use crate::media::{Cue, Scrubber, SubtitleEditor, 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,
@@ -112,197 +106,6 @@ fn the_tip_stays_over_the_track_at_its_ends(cx: &mut TestAppContext) {
}
}
/// A player of a hundred seconds, this wide, with this caption, and what it was told.
struct Watching {
path: PathBuf,
width: f32,
caption: &'static str,
at: Duration,
playing: bool,
plays: Vec<bool>,
captions: bool,
fullscreens: usize,
}
impl Render for Watching {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let (play, seek, caption, full) = (cx.entity(), cx.entity(), cx.entity(), cx.entity());
div().w(px(self.width)).child(
VideoPlayer::new(
"player",
Some(self.path.clone().into()),
1.6,
SECOND * 100,
self.at,
)
.playing(self.playing)
.captions(self.captions, Some(self.caption.into()))
.on_play(move |on, _, cx| {
play.update(cx, |view, cx| {
view.playing = on;
view.plays.push(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();
})
})
.on_fullscreen(move |_, cx| {
full.update(cx, |view, cx| {
view.fullscreens += 1;
cx.notify();
})
}),
)
}
}
fn watching_at<'a>(
width: f32,
caption: &'static str,
captions: bool,
cx: &'a mut TestAppContext,
) -> (Entity<Watching>, &'a mut VisualTestContext) {
setup(cx);
let path = picture("frame", 160, 100);
let (view, cx) = cx.add_window_view(|_, _| Watching {
path,
width,
caption,
at: SECOND * 30,
playing: false,
plays: Vec::new(),
captions,
fullscreens: 0,
});
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)
}
fn watching(cx: &mut TestAppContext) -> (Entity<Watching>, &mut VisualTestContext) {
watching_at(480.0, "Light falls on the stair", false, cx)
}
#[gpui::test]
fn space_on_a_focused_bar_button_presses_it_once(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
cx.update(|window, _| {
window.blur();
for _ in 0..3 {
window.focus_next();
}
});
press("space", cx);
assert_eq!(view.read_with(cx, |view, _| view.plays.clone()), [true]);
}
#[gpui::test]
fn command_letters_leave_the_player_to_the_app(cx: &mut TestAppContext) {
let (view, cx) = watching(cx);
for key in ["cmd-f", "cmd-c", "cmd-k", "ctrl-f"] {
press(key, cx);
}
let heard = view.read_with(cx, |view, _| {
(view.fullscreens, view.captions, view.plays.clone())
});
assert_eq!(heard, (0, false, vec![]));
}
#[gpui::test]
fn a_long_caption_wraps_inside_a_narrow_player(cx: &mut TestAppContext) {
let (view, cx) = watching_at(280.0, "Light falls", true, cx);
let line = cx.debug_bounds("video-caption").expect("the caption draws");
view.update(cx, |view, cx| {
view.caption = "The shadow walks the length of the hall while the morning comes in.";
cx.notify();
});
settle(cx);
let (chip, frame) = (
cx.debug_bounds("video-caption").expect("the caption draws"),
cx.debug_bounds("video-player").expect("the player draws"),
);
assert!(
chip.left() >= frame.left() && chip.right() <= frame.right(),
"{chip:?} in {frame:?}"
);
assert!(
chip.size.height > line.size.height * 1.5,
"{chip:?} wraps past {line:?}"
);
}
#[gpui::test]
fn a_caption_stays_while_the_controls_rest(cx: &mut TestAppContext) {
let (view, cx) = watching_at(480.0, "Light falls on the stair", true, 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);
cx.executor().advance_clock(Duration::from_millis(2600));
cx.run_until_parked();
let faded = cx
.debug_bounds("video-controls-resting")
.expect("the bar rests");
let caption = cx.debug_bounds("video-caption").expect("the caption draws");
assert!(
caption.bottom() <= faded.top(),
"{caption:?} outside {faded:?}"
);
}
#[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,
+1 -1
View File
@@ -26,7 +26,7 @@ const RING: Duration = Duration::from_millis(1_600);
const PACE: f32 = 40.0;
/// How far into its effect a change is, 0 to 1, and whether it still moves; it asks for the next frame while it does.
fn since_change<K: Clone + PartialEq + 'static>(
pub(crate) fn since_change<K: Clone + PartialEq + 'static>(
id: &ElementId,
key: K,
length: Duration,
+1
View File
@@ -16,6 +16,7 @@ mod tests;
pub(crate) use changes::changes;
pub use curve::*;
pub(crate) use effects::since_change;
pub use effects::{Blink, Flash, Glow, Marquee, Pulse, Shake};
pub use list::{AnimatePresence, Flip};
pub use overlay::{LazyLoad, LoadingOverlay, Refresh, RefreshIndicator, TypingIndicator};