diff --git a/AGENTS.md b/AGENTS.md index 0423bc6..9b51144 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/TASKS.md b/TASKS.md index 08836a4..6d65a07 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 | diff --git a/src/media/player.rs b/src/media/player.rs index 661e4bd..1a76bc4 100644 --- a/src/media/player.rs +++ b/src/media/player.rs @@ -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| { diff --git a/src/media/tests/mod.rs b/src/media/tests/mod.rs index 9cd856d..3c7a917 100644 --- a/src/media/tests/mod.rs +++ b/src/media/tests/mod.rs @@ -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 { + let bounds = cx.debug_bounds(selector).expect("the box draws"); + point(bounds.left() + bounds.size.width * share, bounds.center().y) +} diff --git a/src/media/tests/player.rs b/src/media/tests/player.rs new file mode 100644 index 0000000..4421abf --- /dev/null +++ b/src/media/tests/player.rs @@ -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, + captions: bool, + fullscreens: usize, +} + +impl Render for Watching { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &'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, &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, view: &Entity, 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) -> 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()); +} diff --git a/src/media/tests/video.rs b/src/media/tests/video.rs index e5dc470..31e6fcd 100644 --- a/src/media/tests/video.rs +++ b/src/media/tests/video.rs @@ -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 { - 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, - captions: bool, - fullscreens: usize, -} - -impl Render for Watching { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &'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, &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, diff --git a/src/motion/effects.rs b/src/motion/effects.rs index 3f0b1bc..e513b76 100644 --- a/src/motion/effects.rs +++ b/src/motion/effects.rs @@ -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( +pub(crate) fn since_change( id: &ElementId, key: K, length: Duration, diff --git a/src/motion/mod.rs b/src/motion/mod.rs index c736fd3..089b499 100644 --- a/src/motion/mod.rs +++ b/src/motion/mod.rs @@ -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};