From 078a2d19d86382d0bbef40e188cf6223b240a222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 02:50:33 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P26=20Media:=20live=20positio?= =?UTF-8?q?ns=20and=20trims=20peg=20to=20the=20media,=20gone=20choices=20c?= =?UTF-8?q?lear,=20rails=20with=20no=20width=20and=20huge=20waveforms=20ho?= =?UTF-8?q?ld,=20subtitle=20times=20refuse=20overflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/media/audio.rs | 34 ++++++------ src/media/camera.rs | 24 +++++++-- src/media/listen.rs | 34 ++++++++++-- src/media/mod.rs | 10 ++++ src/media/player.rs | 3 +- src/media/scrubber.rs | 52 ++++++++++-------- src/media/strip.rs | 59 +++++++++++++------- src/media/subtitles.rs | 26 ++++++--- src/media/tests/audio.rs | 6 --- src/media/tests/devices.rs | 6 --- src/media/tests/mod.rs | 1 + src/media/tests/stale.rs | 107 +++++++++++++++++++++++++++++++++++++ src/media/upload.rs | 2 +- tasks/panics.md | 3 +- 14 files changed, 279 insertions(+), 88 deletions(-) create mode 100644 src/media/tests/stale.rs diff --git a/src/media/audio.rs b/src/media/audio.rs index fabfef1..26d5e6b 100644 --- a/src/media/audio.rs +++ b/src/media/audio.rs @@ -2,12 +2,12 @@ use std::{rc::Rc, time::Duration}; use gpui::{ App, Bounds, Corners, DragMoveEvent, ElementId, EmptyView, EntityId, HoverListenerMode, - InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, + InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, StatefulInteractiveElement, Styled, Window, canvas, div, fill, point, prelude::*, relative, size, transparent_black, }; -use super::scrubber::{OnTime, clock, time_at, time_tip}; +use super::scrubber::{OnTime, along, clock, time_at, time_tip}; use crate::{ forms::keyed, layout::fit, @@ -25,10 +25,12 @@ pub(crate) fn resample(peaks: &[f32], count: usize) -> Vec { "{} peaks into {count}", peaks.len() ); + // In u64: bar by length outgrows a 32-bit usize on the web. + let edge = |bar: usize| (bar as u64 * peaks.len() as u64 / count as u64) as usize; (0..count) .map(|bar| { - let from = bar * peaks.len() / count; - let to = ((bar + 1) * peaks.len() / count).max(from + 1); + let from = edge(bar); + let to = edge(bar + 1).max(from + 1); peaks[from..to].iter().copied().fold(0.0, f32::max) }) .collect() @@ -66,7 +68,8 @@ impl AudioWaveform { !peaks.is_empty() && peaks.iter().all(|peak| (0.0..=1.0).contains(peak)), "peaks lie within 0 to 1" ); - assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}"); + assert!(!length.is_zero(), "{at:?} of no length"); + let at = super::played(at, length, "audio waveform"); Self { id: id.into(), peaks: Rc::new(peaks), @@ -138,9 +141,6 @@ impl RenderOnce for AudioWaveform { .top_0() .left_0() .size_full(); - let spot = move |bounds: Bounds, pointer: Point| { - (f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width)).clamp(0.0, 1.0) - }; let (moved, left) = (wave.clone(), wave.clone()); div() .id(self.id.clone()) @@ -154,7 +154,7 @@ impl RenderOnce for AudioWaveform { .children(tip) .on_mouse_move(move |event, _, cx| { moved.update(cx, |wave, cx| { - wave.hover = Some(spot(wave.bounds, event.position)); + wave.hover = along(wave.bounds, event.position); cx.notify(); }) }) @@ -175,8 +175,11 @@ impl RenderOnce for AudioWaveform { .focus_ring(cx) .cursor_pointer() .on_mouse_down(MouseButton::Left, move |event, window, cx| { - let time = - time_at(spot(pressed_wave.read(cx).bounds, event.position), length); + let Some(share) = along(pressed_wave.read(cx).bounds, event.position) + else { + return; + }; + let time = time_at(share, length); log::info!("audio waveform: seek to {}", clock(time)); pressed(time, window, cx); }) @@ -186,11 +189,10 @@ impl RenderOnce for AudioWaveform { return; } let bounds = dragged_wave.read(cx).bounds; - dragged( - time_at(spot(bounds, event.event.position), length), - window, - cx, - ); + let Some(share) = along(bounds, event.event.position) else { + return; + }; + dragged(time_at(share, length), window, cx); }) .on_key_down(move |event, window, cx| { let (now, end) = (at.as_secs_f64(), length.as_secs_f64()); diff --git a/src/media/camera.rs b/src/media/camera.rs index 91713ae..da8c86e 100644 --- a/src/media/camera.rs +++ b/src/media/camera.rs @@ -180,10 +180,11 @@ impl DeviceSelector { /// The device in use, by key. pub fn chosen(mut self, key: impl Into) -> Self { let key = key.into(); - assert!( - self.devices.iter().any(|device| device.key == key), - "no device {key}" - ); + if !self.devices.iter().any(|device| device.key == key) { + log::error!("device selector: no device {key}; none chosen"); + self.chosen = None; + return self; + } self.chosen = Some(key); self } @@ -225,3 +226,18 @@ impl RenderOnce for DeviceSelector { } } } + +#[cfg(test)] +mod tests { + use super::{Device, DeviceKind, DeviceSelector}; + + #[test] + fn a_device_gone_from_the_list_leaves_none_chosen() { + let device = Device { + key: "a".into(), + name: "Device a".into(), + }; + let selector = DeviceSelector::new("mics", DeviceKind::Microphone, [device]); + assert_eq!(selector.chosen("a").chosen("gone").chosen, None); + } +} diff --git a/src/media/listen.rs b/src/media/listen.rs index 3baf317..c1b9c5c 100644 --- a/src/media/listen.rs +++ b/src/media/listen.rs @@ -91,10 +91,12 @@ impl Playlist { /// The song under way, by key, and whether it plays. pub fn current(mut self, key: impl Into, playing: bool) -> Self { let key = key.into(); - assert!( - self.tracks.iter().any(|track| track.key == key), - "no track {key}" - ); + if !self.tracks.iter().any(|track| track.key == key) { + log::error!("playlist: no track {key}; none under way"); + self.current = None; + self.playing = false; + return self; + } self.current = Some(key); self.playing = playing; self @@ -176,7 +178,8 @@ impl AudioPlayer { length: Duration, at: Duration, ) -> Self { - assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}"); + assert!(!length.is_zero(), "{at:?} of no length"); + let at = super::played(at, length, "now playing"); Self { id: id.into(), title: title.into(), @@ -289,3 +292,24 @@ impl RenderOnce for AudioPlayer { .children(self.volume) } } + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{Playlist, Track}; + + #[test] + fn a_track_gone_from_the_list_leaves_none_under_way() { + let track = Track { + key: "a".into(), + title: "Tall Windows".into(), + artist: "Aster Quartet".into(), + length: Duration::from_secs(200), + }; + let list = Playlist::new("list", [track]) + .current("a", true) + .current("gone", true); + assert_eq!((list.current, list.playing), (None, false)); + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs index e11f599..1e11310 100644 --- a/src/media/mod.rs +++ b/src/media/mod.rs @@ -20,6 +20,8 @@ mod viewer; #[cfg(all(test, feature = "test-support"))] mod tests; +use std::time::Duration; + pub use annotate::ImageAnnotator; pub use audio::{AudioSpectrum, AudioWaveform}; pub(crate) use camera::name_chip; @@ -39,3 +41,11 @@ pub use subtitles::{Cue, SubtitleEditor}; pub use thumbnail::ImageThumbnail; pub use upload::ImageUpload; pub use viewer::ImageViewer; + +/// `at` within `length`: a live position past the end logs and sits at the end. +fn played(at: Duration, length: Duration, owner: &str) -> Duration { + if at > length { + log::error!("{owner}: {at:?} past {length:?}; at the end"); + } + at.min(length) +} diff --git a/src/media/player.rs b/src/media/player.rs index 74b89b7..0c8fe89 100644 --- a/src/media/player.rs +++ b/src/media/player.rs @@ -60,7 +60,8 @@ impl VideoPlayer { length: Duration, at: Duration, ) -> Self { - assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}"); + assert!(!length.is_zero(), "{at:?} of no length"); + let at = super::played(at, length, "video player"); Self { id: id.into(), frame, diff --git a/src/media/scrubber.rs b/src/media/scrubber.rs index 080cb77..c6e8e69 100644 --- a/src/media/scrubber.rs +++ b/src/media/scrubber.rs @@ -23,6 +23,12 @@ pub(crate) type OnTime = Rc; /// Seconds an arrow key moves the time; Page keys move ten times as far. const STEP: f64 = 5.0; +/// Where `pointer` falls along `bounds`, a share of its width held to it; none on a rail layout left no width. +pub(crate) fn along(bounds: Bounds, pointer: Point) -> Option { + let width = f32::from(bounds.size.width); + (width > 0.0).then(|| (f32::from(pointer.x - bounds.left()) / width).clamp(0.0, 1.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)) @@ -96,7 +102,8 @@ pub struct Scrubber { impl Scrubber { /// `at` is how far it has played, within `length`. pub fn new(id: impl Into, length: Duration, at: Duration) -> Self { - assert!(!length.is_zero() && at <= length, "{at:?} of {length:?}"); + assert!(!length.is_zero(), "{at:?} of no length"); + let at = super::played(at, length, "scrubber"); Self { id: id.into(), length, @@ -116,22 +123,24 @@ impl Scrubber { /// 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; + if loaded > self.length { + log::error!("scrubber: loaded {loaded:?} of {:?}; it fills", self.length); + } + self.loaded = loaded.min(self.length); self } /// A chapter from `start`, after the last one given. pub fn chapter(mut self, start: Duration, title: impl Into) -> Self { let after = self.chapters.last().is_none_or(|(last, _)| *last < start); - assert!( - after && start < self.length, - "a chapter at {start:?} out of order" - ); + assert!(after, "a chapter at {start:?} out of order"); + if start >= self.length { + log::error!( + "scrubber: a chapter at {start:?} past {:?}; left out", + self.length + ); + return self; + } self.chapters.push((start, title.into())); self } @@ -257,9 +266,6 @@ impl RenderOnce for Scrubber { ) .children(knob) .children(tip); - let spot = move |bounds: Bounds, pointer: Point| { - f32::from(pointer.x - bounds.left()) / f32::from(bounds.size.width) - }; let (moved, left) = (track.clone(), track.clone()); div() .id(self.id.clone()) @@ -271,7 +277,7 @@ impl RenderOnce for Scrubber { .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)); + track.hover = along(track.bounds, event.position); cx.notify(); }) }) @@ -292,8 +298,11 @@ impl RenderOnce for Scrubber { .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); + let Some(share) = along(pressed_track.read(cx).bounds, event.position) + else { + return; + }; + let time = time_at(share, length); log::info!("scrubber: seek to {}", clock(time)); pressed(time, window, cx); }) @@ -303,11 +312,10 @@ impl RenderOnce for Scrubber { return; } let bounds = dragged_track.read(cx).bounds; - dragged( - time_at(spot(bounds, event.event.position), length), - window, - cx, - ); + let Some(share) = along(bounds, event.event.position) else { + return; + }; + dragged(time_at(share, length), window, cx); }) .on_key_down(move |event, window, cx| { let (now, end) = (at.as_secs_f64(), length.as_secs_f64()); diff --git a/src/media/strip.rs b/src/media/strip.rs index af741ab..c6e33c3 100644 --- a/src/media/strip.rs +++ b/src/media/strip.rs @@ -2,12 +2,12 @@ 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, + InteractiveElement, IntoElement, MouseButton, ObjectFit, ParentElement, Pixels, RenderOnce, + StatefulInteractiveElement, Styled, Window, canvas, div, img, prelude::*, relative, transparent_black, }; -use super::scrubber::{OnTime, clock, time_at}; +use super::scrubber::{OnTime, along, clock, time_at}; use crate::{ primitives::{FocusRing, tab_stop}, theme::ActiveTheme, @@ -88,10 +88,11 @@ impl VideoThumbnailStrip { ) -> Self { let frames: Vec = frames.into_iter().map(Into::into).collect(); assert!( - !frames.is_empty() && !length.is_zero() && at <= length, + !frames.is_empty() && !length.is_zero(), "{} frames, {at:?} of {length:?}", frames.len() ); + let at = super::played(at, length, "thumbnail strip"); Self { id: id.into(), frames, @@ -105,10 +106,19 @@ impl VideoThumbnailStrip { /// 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:?}" - ); + assert!(start + LEAST <= end, "a trim of {start:?} to {end:?}"); + if end > self.length { + log::error!( + "thumbnail strip: a trim to {end:?} past {:?}; it ends there", + self.length + ); + } + let end = end.min(self.length); + if start + LEAST > end { + log::error!("thumbnail strip: a trim from {start:?} leaves too little; none kept"); + self.trim = None; + return self; + } self.trim = Some((start, end)); self } @@ -233,9 +243,6 @@ impl RenderOnce for VideoThumbnailStrip { ) .children(kept) .child(playhead); - let spot = move |bounds: Bounds, pointer: Point| { - 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, time: Duration, window: &mut Window, cx: &mut App| match ( held, trim, &on_trim, &on_seek, @@ -262,7 +269,9 @@ impl RenderOnce for VideoThumbnailStrip { .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 Some(at) = along(bounds, event.position) else { + return; + }; 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); @@ -281,12 +290,10 @@ impl RenderOnce for VideoThumbnailStrip { let strip = moved_strip.read(cx); (strip.bounds, strip.held) }; - moved( - held, - time_at(spot(bounds, event.event.position), length), - window, - cx, - ); + let Some(share) = along(bounds, event.event.position) else { + return; + }; + moved(held, time_at(share, length), window, cx); }) .on_key_down(move |event, window, cx| { let command = &event.keystroke.modifiers; @@ -312,10 +319,24 @@ impl RenderOnce for VideoThumbnailStrip { mod tests { use std::time::Duration; - use super::{End, end_at, trimmed}; + use super::{End, VideoThumbnailStrip, end_at, trimmed}; const TEN: Duration = Duration::from_secs(10); + #[test] + fn a_trim_past_the_video_ends_at_it_or_keeps_none() { + let strip = || VideoThumbnailStrip::new("strip", ["a.jpg"], TEN, Duration::ZERO); + let second = Duration::from_secs(1); + assert_eq!( + strip().trim(second * 2, second * 12).trim, + Some((second * 2, TEN)) + ); + let gone = strip() + .trim(second * 2, second * 6) + .trim(second * 11, second * 13); + assert_eq!(gone.trim, None); + } + #[test] fn a_trim_end_moves_and_stops_short_of_the_other_and_the_length() { let (trim, length) = ((TEN, TEN * 5), TEN * 10); diff --git a/src/media/subtitles.rs b/src/media/subtitles.rs index fbe1d29..b726929 100644 --- a/src/media/subtitles.rs +++ b/src/media/subtitles.rs @@ -27,7 +27,7 @@ pub struct Cue { pub text: SharedString, } -/// A time read from `mm:ss.mmm` or `h:mm:ss.mmm`, its fraction optional; none when it does not read. +/// A time read from `mm:ss.mmm` or `h:mm:ss.mmm`, its fraction optional; none when it does not read or no clock holds it. pub(crate) fn parse_time(text: &str) -> Option { let text = text.trim(); let (clock, fraction) = text.split_once('.').unwrap_or((text, "")); @@ -36,10 +36,10 @@ pub(crate) fn parse_time(text: &str) -> Option { .map(|part| part.parse().ok()) .collect::>()?; 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 - } + [minutes, seconds] if *seconds < 60 => minutes.checked_mul(60)?.checked_add(*seconds)?, + [hours, minutes, seconds] if *minutes < 60 && *seconds < 60 => hours + .checked_mul(3600)? + .checked_add(minutes * 60 + seconds)?, _ => return None, }; let millis = match fraction.len() { @@ -49,7 +49,9 @@ pub(crate) fn parse_time(text: &str) -> Option { } _ => return None, }; - Some(Duration::from_millis(seconds * 1000 + millis)) + Some(Duration::from_millis( + seconds.checked_mul(1000)?.checked_add(millis)?, + )) } /// A time as `mm:ss.mmm`, the hours ahead once it runs past one. @@ -331,7 +333,17 @@ mod tests { 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", ""] { + for bad in [ + "1:60", + "a:10", + "1:2:3:4", + "01:02.5000", + "01:02.x", + "", + "18446744073709551615:00", + "5124095576030431:00:00", + "307445734561825860:00", + ] { assert_eq!(parse_time(bad), None, "{bad:?}"); } assert_eq!(stamp(Duration::from_millis(62_500)), "01:02.500"); diff --git a/src/media/tests/audio.rs b/src/media/tests/audio.rs index c62ac16..f9b2704 100644 --- a/src/media/tests/audio.rs +++ b/src/media/tests/audio.rs @@ -329,12 +329,6 @@ fn the_keys_start_from_the_song_under_way(cx: &mut TestAppContext) { assert_eq!(view.read_with(cx, |view, _| view.played.clone()), ["d"]); } -#[test] -#[should_panic(expected = "no track c")] -fn the_song_under_way_is_one_of_the_list() { - let _ = Playlist::new("list", [track("a", "Tall Windows")]).current("c", false); -} - #[test] #[should_panic(expected = "track a twice")] fn a_song_is_listed_once() { diff --git a/src/media/tests/devices.rs b/src/media/tests/devices.rs index 773adf2..1d107fe 100644 --- a/src/media/tests/devices.rs +++ b/src/media/tests/devices.rs @@ -192,12 +192,6 @@ fn with_none_attached_the_selector_takes_no_focus(cx: &mut TestAppContext) { assert!(cx.update(|window, cx| window.focused(cx).is_none())); } -#[test] -#[should_panic(expected = "no device c")] -fn the_device_in_use_is_one_of_the_list() { - let _ = DeviceSelector::new("mics", DeviceKind::Microphone, [device("a")]).chosen("c"); -} - #[test] #[should_panic(expected = "device a twice")] fn a_device_is_listed_once() { diff --git a/src/media/tests/mod.rs b/src/media/tests/mod.rs index e3299da..1153439 100644 --- a/src/media/tests/mod.rs +++ b/src/media/tests/mod.rs @@ -11,6 +11,7 @@ mod devices; mod edit; mod images; mod player; +mod stale; mod video; pub(super) fn setup(cx: &mut TestAppContext) { diff --git a/src/media/tests/stale.rs b/src/media/tests/stale.rs new file mode 100644 index 0000000..7dc1bd1 --- /dev/null +++ b/src/media/tests/stale.rs @@ -0,0 +1,107 @@ +use std::time::Duration; + +use gpui::{ + Bounds, Context, IntoElement, Modifiers, MouseButton, ParentElement, Render, Styled, + TestAppContext, Window, div, point, px, size, +}; + +use super::{settle, setup}; +use crate::media::{ + AudioPlayer, AudioWaveform, Device, DeviceKind, DeviceSelector, Playlist, Scrubber, Track, + VideoPlayer, VideoThumbnailStrip, played, scrubber::along, +}; + +const SECOND: Duration = Duration::from_secs(1); + +#[test] +fn a_position_past_the_end_sits_at_it() { + assert_eq!(played(SECOND * 3, SECOND * 10, "test"), SECOND * 3); + assert_eq!(played(SECOND * 12, SECOND * 10, "test"), SECOND * 10); +} + +/// Live positions past their media, and choices gone from their lists. +struct Lagging; + +impl Render for Lagging { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let (length, past) = (SECOND * 10, SECOND * 12); + let track = Track { + key: "a".into(), + title: "Tall Windows".into(), + artist: "Aster Quartet".into(), + length, + }; + let device = Device { + key: "a".into(), + name: "Device a".into(), + }; + div() + .w(px(480.0)) + .child(AudioWaveform::new("wave", [0.5, 0.8], length, past)) + .child(AudioPlayer::new( + "player", + "Tall Windows", + "Aster Quartet", + length, + past, + )) + .child(VideoPlayer::new("video", None, 1.5, length, past)) + .child( + Scrubber::new("scrub", length, past) + .loaded(past) + .chapter(SECOND * 2, "Intro") + .chapter(past, "Gone"), + ) + .child( + VideoThumbnailStrip::new("strip", ["a.jpg"], length, past).trim(SECOND * 2, past), + ) + .child(Playlist::new("list", [track]).current("c", false)) + .child(DeviceSelector::new("mics", DeviceKind::Microphone, [device]).chosen("c")) + } +} + +#[gpui::test] +fn live_positions_and_gone_choices_draw(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Lagging); + settle(cx); +} + +#[test] +fn a_rail_with_no_width_has_no_place_along_it() { + let rail = |width: f32| Bounds::new(point(px(10.0), px(0.0)), size(px(width), px(8.0))); + assert_eq!(along(rail(0.0), point(px(10.0), px(4.0))), None); + assert_eq!(along(rail(100.0), point(px(60.0), px(4.0))), Some(0.5)); + assert_eq!(along(rail(100.0), point(px(500.0), px(4.0))), Some(1.0)); +} + +/// Rails that seek, laid out with no width. +struct Squeezed; + +impl Render for Squeezed { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + let length = SECOND * 10; + let seek = |_, _: &mut Window, _: &mut gpui::App| {}; + div() + .w(px(0.0)) + .child(Scrubber::new("scrub", length, SECOND).on_seek(seek)) + .child(AudioWaveform::new("wave", [0.5, 0.8], length, SECOND).on_seek(seek)) + .child(VideoThumbnailStrip::new("strip", ["a.jpg"], length, SECOND).on_seek(seek)) + } +} + +#[gpui::test] +fn the_pointer_over_rails_with_no_width_seeks_nothing(cx: &mut TestAppContext) { + setup(cx); + let (_, cx) = cx.add_window_view(|_, _| Squeezed); + settle(cx); + for rail in ["scrubber", "audio-waveform", "video-strip"] { + let bounds = cx.debug_bounds(rail).expect("a rail"); + let at = point(bounds.left(), bounds.center().y); + cx.simulate_mouse_move(at, None, Modifiers::none()); + settle(cx); + cx.simulate_mouse_down(at, MouseButton::Left, Modifiers::none()); + cx.simulate_mouse_up(at, MouseButton::Left, Modifiers::none()); + } + settle(cx); +} diff --git a/src/media/upload.rs b/src/media/upload.rs index bd76a45..6dbc7bf 100644 --- a/src/media/upload.rs +++ b/src/media/upload.rs @@ -94,7 +94,7 @@ impl RenderOnce for ImageUpload { ); let name = path .file_name() - .expect("a picked file has a name") + .unwrap_or(path.as_os_str()) .to_string_lossy() .into_owned(); let opened = match &decoded { diff --git a/tasks/panics.md b/tasks/panics.md index b6b023e..214988e 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -40,7 +40,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P23 AI Chat — `src/chat` - [x] P24 Agent — `src/agent` - [x] P25 Generative — `src/generative` -- [ ] P26 Media — `src/media` +- [x] P26 Media — `src/media` - [ ] P27 Files — `src/files` - [ ] P28 Messaging — `src/messaging` - [ ] P29 Mail — `src/mail` @@ -91,3 +91,4 @@ One line per item: what changed, what stayed and why, review rounds. - P23: a branch version past its total logs and shows a dash, its arrows off; a parameter outside its range logs and pegs; a passage's matches outside it log and stay out. Kept: one-value contracts (progress, scores, shapes, peaks, levels, limits, prices, capacity, format, hour), the composer's trigger rows and stream reveal by construction; `places` skips zero, so `decimals` meets no zero step. Review: 2 rounds. - P24: a checkpoint or browser frame past its list logs and marks none; progress past its total and memory past its limit log and fill their bars, the text keeping the real counts; three `should_panic` tests became render tests. Kept: the artifact's preview-or-source builder check, one-value contracts (cost, cpu, pointer, totals and limits above zero), registry lookups over a constant table. Review: 1 round. - P25: a verdict, ratio, style, shot, voice, variation or prompt version gone from its list logs and marks none (no picture, no Vary or Upscale, no diff); embedding points in a gone group stay out; an epoch and GPU memory past their whole log and peg. A seed field holds a seed: each field handed in is fitted, and text that is no seed clears; in `TextInput`, a fit starts a fresh undo history, undo keeps and `set_text` fits against the committed text, never an input method's unfinished one (`committed`), and a lifted mask stroke stays on its picture. Kept: one-value contracts, unique group names, one snapshot's loss lists, two to four sides, the legend's lookup. Review: 5 rounds, the cap; round 5's finding (`set_text` over a composition) is fixed and tested, and P26's review checks it. +- P26: a live position past its media logs and sits at the end (`media::played`); loaded time, a trim and chapters past the length peg or stay out; a gone device or track, or a refused trim, leaves none and clears the last; a dropped root path shows whole; subtitle times no clock holds read as none; a rail layout left no width gives no share (`scrubber::along`), so hover, press and drag skip it; waveform buckets count in `u64`, which a 32-bit web `usize` outgrows. Kept: non-zero lengths, one-value contracts, one list's uniqueness and order, lookups by construction. Review: 4 rounds; it also passed P25's last fix.