use std::rc::Rc;
use gpui::{
App, InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
SharedString, Styled, Window, div, prelude::FluentBuilder, px, relative,
};
use gpui_kit_assets::Icon;
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
use crate::controls::button::{Button, IconButton};
use crate::controls::segmented::{Segment, SegmentedControl};
use crate::controls::slider::Slider;
use crate::display::badge::Tone;
use crate::display::status::StatusLine;
use crate::foundation::{Disableable, FocusRing, Ident, Selectable, Sizable, StyledExt};
use crate::layout::measure;
use crate::motion::{self, keyed};
use crate::strings::{ActiveStrings, StringKey};
const TRACK_HEIGHT: f32 = 4.0;
const KNOB: f32 = 11.0;
const VOLUME_WIDTH: f32 = 96.0;
const DEFAULT_STEP: f32 = 5.0;
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TransportDuration {
Known(f32),
Unknown,
}
impl TransportDuration {
pub fn is_known(self) -> bool {
matches!(self, Self::Known(_))
}
pub fn seconds(self) -> Option<f32> {
match self {
Self::Known(seconds) if seconds > 0.0 => Some(seconds),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TransportState {
Playing,
#[default]
Paused,
Buffering,
}
impl TransportState {
pub fn name(self) -> &'static str {
match self {
Self::Playing => "playing",
Self::Paused => "paused",
Self::Buffering => "buffering",
}
}
fn is_playing(self) -> bool {
matches!(self, Self::Playing | Self::Buffering)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct BufferedRange {
pub start: f32,
pub end: f32,
}
impl BufferedRange {
pub fn new(start: f32, end: f32) -> Self {
let (start, end) = if start <= end {
(start, end)
} else {
(end, start)
};
Self { start, end }
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackStep {
Previous,
Next,
}
impl TrackStep {
pub fn name(self) -> &'static str {
match self {
Self::Previous => "previous",
Self::Next => "next",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TransportEvent {
PlayRequested,
PauseRequested,
SeekPreview(f32),
SeekRequested(f32),
VolumeRequested(f32),
MuteToggled,
SpeedRequested(f32),
Stepped(TrackStep),
}
type EventHandler = Rc<dyn Fn(&TransportEvent, &mut Window, &mut App)>;
#[derive(Debug, Default)]
struct Scrubbing {
held: bool,
}
#[derive(IntoElement)]
pub struct TransportBar {
ident: Ident,
label: Option<SharedString>,
state: TransportState,
position: f32,
duration: TransportDuration,
elapsed: Option<SharedString>,
remaining: Option<SharedString>,
buffered: Vec<BufferedRange>,
volume: f32,
muted: bool,
speed: f32,
speeds: Vec<f32>,
step: f32,
has_previous: bool,
has_next: bool,
disabled: bool,
on_event: Option<EventHandler>,
}
impl std::fmt::Debug for TransportBar {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("TransportBar")
.field("ident", &self.ident)
.field("state", &self.state)
.field("position", &self.position)
.field("duration", &self.duration)
.field("buffered", &self.buffered.len())
.field("disabled", &self.disabled)
.field("has_handler", &self.on_event.is_some())
.finish()
}
}
impl TransportBar {
pub fn new(ident: impl Into<Ident>) -> Self {
Self {
ident: ident.into(),
label: None,
state: TransportState::default(),
position: 0.0,
duration: TransportDuration::Unknown,
elapsed: None,
remaining: None,
buffered: Vec::new(),
volume: 1.0,
muted: false,
speed: 1.0,
speeds: Vec::new(),
step: DEFAULT_STEP,
has_previous: false,
has_next: false,
disabled: false,
on_event: None,
}
}
pub fn label(mut self, label: impl Into<SharedString>) -> Self {
self.label = Some(label.into());
self
}
pub fn state(mut self, state: TransportState) -> Self {
self.state = state;
self
}
pub fn position(mut self, seconds: f32) -> Self {
self.position = seconds.max(0.0);
self
}
pub fn duration(mut self, seconds: f32) -> Self {
self.duration = TransportDuration::Known(seconds.max(0.0));
self
}
pub fn unknown_duration(mut self) -> Self {
self.duration = TransportDuration::Unknown;
self
}
pub fn elapsed(mut self, elapsed: impl Into<SharedString>) -> Self {
self.elapsed = Some(elapsed.into());
self
}
pub fn remaining(mut self, remaining: impl Into<SharedString>) -> Self {
self.remaining = Some(remaining.into());
self
}
pub fn buffered(mut self, ranges: impl IntoIterator<Item = BufferedRange>) -> Self {
self.buffered = ranges.into_iter().collect();
self
}
pub fn volume(mut self, volume: f32) -> Self {
self.volume = volume.clamp(0.0, 1.0);
self
}
pub fn muted(mut self, muted: bool) -> Self {
self.muted = muted;
self
}
pub fn speeds(mut self, speeds: impl IntoIterator<Item = f32>, current: f32) -> Self {
self.speeds = speeds.into_iter().filter(|speed| *speed > 0.0).collect();
self.speed = current;
self
}
pub fn step_seconds(mut self, seconds: f32) -> Self {
self.step = seconds.max(0.0);
self
}
pub fn has_previous(mut self, has_previous: bool) -> Self {
self.has_previous = has_previous;
self
}
pub fn has_next(mut self, has_next: bool) -> Self {
self.has_next = has_next;
self
}
pub fn on_event(
mut self,
handler: impl Fn(&TransportEvent, &mut Window, &mut App) + 'static,
) -> Self {
self.on_event = Some(Rc::new(handler));
self
}
fn fraction(&self) -> Option<f32> {
self.duration
.seconds()
.map(|total| (self.position / total).clamp(0.0, 1.0))
}
}
impl Disableable for TransportBar {
fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
}
fn buffered_span(range: BufferedRange, total: f32) -> (f32, f32) {
let start = (range.start / total).clamp(0.0, 1.0);
let end = (range.end / total).clamp(0.0, 1.0);
(start, (end - start).max(0.0))
}
fn seek_at(x: f32, left: f32, width: f32, total: f32) -> f32 {
if width <= 0.0 {
return 0.0;
}
(((x - left) / width).clamp(0.0, 1.0) * total).clamp(0.0, total)
}
fn speed_id(speed: f32) -> String {
format!("speed-{}", format!("{speed}").replace('.', "-"))
}
fn speed_label(speed: f32) -> String {
format!("{speed}×")
}
impl RenderOnce for TransportBar {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme().clone();
let ident = self.ident.clone();
let actionable = !self.disabled && self.on_event.is_some();
let handler = self.on_event.clone().filter(|_| actionable);
let report = {
let handler = handler.clone();
Rc::new(
move |event: TransportEvent, window: &mut Window, cx: &mut App| {
if let Some(handler) = &handler {
handler(&event, window, cx);
}
},
)
};
let total = self.duration.seconds();
let fraction = self.fraction();
let scrubbing = keyed::slot::<Scrubbing>(&ident.child("scrubber").semantic_id(), cx);
let held = scrubbing.borrow().held;
let drawn = fraction.map(|fraction| {
motion::tracked_or_snap(
&ident.child("scrubber").semantic_id(),
fraction,
motion::tracking(&theme),
held,
window,
cx,
)
});
let measured = measure::cell(&ident.child("scrubber").semantic_id(), cx);
let scrubbable = actionable && total.is_some();
let mut track = div()
.id(ident.child("scrubber").element_id())
.relative()
.w_full()
.h(px(KNOB))
.flex()
.items_center()
.when(scrubbable, |element| element.cursor_pointer())
.child(
div()
.absolute()
.left_0()
.right_0()
.h(px(TRACK_HEIGHT))
.rounded_full()
.bg(theme.colors.hairline_strong),
);
if let (Some(total), false) = (total, self.buffered.is_empty()) {
let mut band = div().absolute().left_0().right_0().h(px(TRACK_HEIGHT));
for range in &self.buffered {
let (start, width) = buffered_span(*range, total);
if width <= 0.0 {
continue;
}
band = band.child(
div()
.absolute()
.left(relative(start))
.w(relative(width))
.h(px(TRACK_HEIGHT))
.rounded_full()
.bg(theme.colors.text_faint),
);
}
let furthest = self
.buffered
.iter()
.map(|range| buffered_span(*range, total))
.map(|(start, width)| start + width)
.fold(0.0_f32, f32::max);
track = track.child(
band.child(
div()
.absolute()
.left_0()
.w(relative(furthest.max(f32::EPSILON)))
.h(px(TRACK_HEIGHT))
.semantic_in(
cx,
NodeSpec::new(ident.child("buffered").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(cx.strings().text(StringKey::TransportBuffered))
.range(0.0, 1.0, furthest)
.value(format!("{} ranges", self.buffered.len())),
),
),
);
}
if let Some(drawn) = drawn {
track = track
.child(
div()
.absolute()
.left_0()
.w(relative(drawn))
.h(px(TRACK_HEIGHT))
.rounded_full()
.bg(theme.colors.accent),
)
.child(
div()
.absolute()
.left(relative(drawn))
.ml(px(-KNOB / 2.0))
.size(px(KNOB))
.rounded_full()
.bg(theme.colors.text)
.border(px(theme.borders.hairline))
.border_color(theme.colors.hairline_strong),
);
}
if let (true, Some(total)) = (scrubbable, total) {
let press = Rc::clone(&report);
let press_bounds = Rc::clone(&measured);
let press_held = Rc::clone(&scrubbing);
track = track.on_mouse_down(MouseButton::Left, move |event, window, cx| {
let bounds = press_bounds.get();
press_held.borrow_mut().held = true;
press(
TransportEvent::SeekPreview(seek_from(event.position, bounds, total)),
window,
cx,
);
});
}
let readout = |text: SharedString, tone: gpui::Hsla| {
div()
.flex_none()
.type_scale(&theme, TypeScale::Caption)
.text_color(tone)
.child(text)
};
let elapsed = self
.elapsed
.clone()
.unwrap_or_else(|| cx.strings().text(StringKey::TransportTimeUnknown));
let trailing = match (self.duration, self.remaining.clone()) {
(TransportDuration::Unknown, _) => {
Some(cx.strings().text(StringKey::TransportDurationUnknown))
}
(TransportDuration::Known(_), remaining) => remaining,
};
let scrubber_spec = match total {
Some(total) => NodeSpec::new(ident.child("scrubber").semantic_id(), Role::Slider)
.parent(ident.semantic_id())
.text(cx.strings().text(StringKey::TransportPosition))
.disabled(!scrubbable)
.range(0.0, total, self.position.clamp(0.0, total))
.value(elapsed.clone()),
None => NodeSpec::new(ident.child("scrubber").semantic_id(), Role::Status)
.parent(ident.semantic_id())
.text(cx.strings().text(StringKey::TransportDurationUnknown))
.value(elapsed.clone()),
};
let scrubber_row = div()
.row()
.w_full()
.gap_token(&theme, Space::Sm)
.child(readout(elapsed.clone(), theme.colors.text_muted))
.child(
div()
.flex_1()
.min_w_0()
.on_children_prepainted({
let measured = Rc::clone(&measured);
move |bounds, window, _| {
if let Some(first) = bounds.first() {
measure::record(&measured, *first, window);
}
}
})
.child(track)
.semantic_in(cx, scrubber_spec),
)
.children(trailing.map(|text| readout(text, theme.colors.text_faint)));
let playing = self.state.is_playing();
let play = {
let report = Rc::clone(&report);
let mut control = Button::new(ident.child(if playing { "pause" } else { "play" }))
.label(cx.strings().text(if playing {
StringKey::TransportPause
} else {
StringKey::TransportPlay
}))
.secondary()
.control_size(ControlSize::Sm)
.semantic_parent(ident.semantic_id())
.disabled(!actionable);
if actionable {
control = control.on_click(move |window, cx| {
let event = if playing {
TransportEvent::PauseRequested
} else {
TransportEvent::PlayRequested
};
report(event, window, cx);
});
}
control
};
let strings = cx.strings().clone();
let step_control =
|name: &'static str, glyph: Icon, key: StringKey, step: TrackStep, enabled: bool| {
let mut control = IconButton::new(ident.child(name), glyph, strings.text(key))
.ghost()
.control_size(ControlSize::Sm)
.semantic_parent(ident.semantic_id())
.disabled(!enabled || !actionable);
if enabled && actionable {
let report = Rc::clone(&report);
control = control.on_click(move |window, cx| {
report(TransportEvent::Stepped(step), window, cx)
});
}
control
};
let status = match self.state {
TransportState::Playing => (strings.text(StringKey::TransportPlaying), Tone::Success),
TransportState::Paused => (strings.text(StringKey::TransportPaused), Tone::Neutral),
TransportState::Buffering => {
(strings.text(StringKey::TransportBuffering), Tone::Warning)
}
};
let mute = {
let report = Rc::clone(&report);
let mut control = Button::new(ident.child("mute"))
.label(strings.text(if self.muted {
StringKey::TransportUnmute
} else {
StringKey::TransportMute
}))
.ghost()
.control_size(ControlSize::Sm)
.semantic_parent(ident.semantic_id())
.selected(self.muted)
.disabled(!actionable);
if actionable {
control = control
.on_click(move |window, cx| report(TransportEvent::MuteToggled, window, cx));
}
control
};
let volume = {
let report = Rc::clone(&report);
let mut control = Slider::new(ident.child("volume"))
.label(strings.text(StringKey::TransportVolume))
.range(0.0, 1.0)
.value(self.volume)
.control_size(ControlSize::Sm)
.display(format!("{}%", (self.volume * 100.0).round() as i64))
.disabled(!actionable);
if actionable {
control = control.on_change(move |value, window, cx| {
report(TransportEvent::VolumeRequested(value), window, cx)
});
}
div().flex_none().w(px(VOLUME_WIDTH)).child(control)
};
let speeds = (!self.speeds.is_empty()).then(|| {
let offered = self.speeds.clone();
let report = Rc::clone(&report);
let mut control = SegmentedControl::new(ident.child("speed"))
.control_size(ControlSize::Xs)
.segments(
offered
.iter()
.map(|speed| Segment::new(speed_id(*speed), speed_label(*speed))),
)
.selected(speed_id(self.speed))
.disabled(!actionable);
if actionable {
control = control.on_select(move |id, window, cx| {
let Some(speed) = offered
.iter()
.find(|speed| speed_id(**speed) == id.as_ref())
else {
return;
};
report(TransportEvent::SpeedRequested(*speed), window, cx);
});
}
control
});
let controls = div()
.row()
.w_full()
.flex_wrap()
.gap_token(&theme, Space::Sm)
.justify_between()
.child(
div()
.row()
.gap_token(&theme, Space::Xs)
.child(step_control(
"previous",
Icon::AltArrowLeft,
StringKey::TransportPreviousTrack,
TrackStep::Previous,
self.has_previous,
))
.child(play)
.child(step_control(
"next",
Icon::AltArrowRight,
StringKey::TransportNextTrack,
TrackStep::Next,
self.has_next,
))
.children(self.label.clone().map(|label| {
div()
.type_scale(&theme, TypeScale::Label)
.text_color(theme.colors.text)
.child(label)
}))
.child(StatusLine::new(status.0, status.1).id(ident.child("status"))),
)
.child(
div()
.row()
.gap_token(&theme, Space::Sm)
.child(mute)
.child(volume)
.children(speeds),
);
let mut root = div()
.id(ident.element_id())
.column()
.w_full()
.gap_token(&theme, Space::Sm)
.when(self.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.when(actionable, |element| {
element.tab_index(0).focus_ring(&theme)
})
.child(controls)
.child(scrubber_row);
if actionable {
if let Some(total) = total {
let drag = Rc::clone(&report);
let drag_bounds = Rc::clone(&measured);
let drag_held = Rc::clone(&scrubbing);
root = root.on_mouse_move(move |event, window, cx| {
if !drag_held.borrow().held {
return;
}
if event.pressed_button != Some(MouseButton::Left) {
drag_held.borrow_mut().held = false;
return;
}
drag(
TransportEvent::SeekPreview(seek_from(
event.position,
drag_bounds.get(),
total,
)),
window,
cx,
);
});
let release = Rc::clone(&report);
let release_bounds = Rc::clone(&measured);
let release_held = Rc::clone(&scrubbing);
root = root.on_mouse_up(MouseButton::Left, move |event, window, cx| {
if !std::mem::take(&mut release_held.borrow_mut().held) {
return;
}
release(
TransportEvent::SeekRequested(seek_from(
event.position,
release_bounds.get(),
total,
)),
window,
cx,
);
});
}
let keys = Rc::clone(&report);
let (position, step, state) = (self.position, self.step, self.state);
root.interactivity().on_key_down(move |event, window, cx| {
let event = match event.keystroke.key.as_str() {
"space" => {
if state.is_playing() {
TransportEvent::PauseRequested
} else {
TransportEvent::PlayRequested
}
}
"left" => TransportEvent::SeekRequested(clamp_to(position - step, total)),
"right" => TransportEvent::SeekRequested(clamp_to(position + step, total)),
_ => return,
};
cx.stop_propagation();
keys(event, window, cx);
});
}
let mut spec = NodeSpec::new(ident.semantic_id(), Role::Group)
.disabled(self.disabled)
.busy(matches!(self.state, TransportState::Buffering))
.value(self.state.name());
if let Some(label) = self.label.clone() {
spec = spec.text(label);
}
root.semantic_in(cx, spec)
}
}
fn seek_from(at: Point<Pixels>, bounds: gpui::Bounds<Pixels>, total: f32) -> f32 {
seek_at(
f32::from(at.x),
f32::from(bounds.left()),
f32::from(bounds.size.width),
total,
)
}
fn clamp_to(seconds: f32, total: Option<f32>) -> f32 {
match total {
Some(total) => seconds.clamp(0.0, total),
None => seconds.max(0.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_live_stream_has_a_position_and_no_fraction() {
let live = TransportBar::new("live").position(90.0).unknown_duration();
assert!(!live.duration.is_known());
assert_eq!(live.fraction(), None, "there is no total to be a part of");
}
#[test]
fn a_zero_duration_is_no_more_a_total_than_an_unknown_one() {
let empty = TransportBar::new("empty").duration(0.0).position(0.0);
assert_eq!(empty.duration.seconds(), None);
assert_eq!(empty.fraction(), None);
}
#[test]
fn a_known_duration_places_the_head_and_stops_at_the_end() {
let bar = TransportBar::new("clip").duration(200.0).position(50.0);
assert_eq!(bar.fraction(), Some(0.25));
let past = TransportBar::new("clip").duration(200.0).position(400.0);
assert_eq!(past.fraction(), Some(1.0));
}
#[test]
fn a_buffered_range_covers_its_own_share_and_no_more() {
assert_eq!(
buffered_span(BufferedRange::new(0.0, 50.0), 200.0),
(0.0, 0.25)
);
assert_eq!(
buffered_span(BufferedRange::new(100.0, 400.0), 200.0),
(0.5, 0.5)
);
assert_eq!(
BufferedRange::new(80.0, 20.0),
BufferedRange::new(20.0, 80.0),
"a reversed range is corrected rather than drawn backwards"
);
}
#[test]
fn a_pointer_asks_for_the_position_it_is_standing_over() {
assert_eq!(seek_at(100.0, 100.0, 400.0, 200.0), 0.0);
assert_eq!(seek_at(300.0, 100.0, 400.0, 200.0), 100.0);
assert_eq!(seek_at(900.0, 100.0, 400.0, 200.0), 200.0);
}
#[test]
fn an_unmeasured_track_asks_for_nothing() {
assert_eq!(seek_at(300.0, 100.0, 0.0, 200.0), 0.0);
}
#[test]
fn a_step_stops_at_a_known_end_and_only_at_zero_without_one() {
assert_eq!(clamp_to(-5.0, Some(200.0)), 0.0);
assert_eq!(clamp_to(400.0, Some(200.0)), 200.0);
assert_eq!(clamp_to(-5.0, None), 0.0);
assert_eq!(clamp_to(400.0, None), 400.0);
}
#[test]
fn buffering_is_playing_rather_than_paused() {
assert!(TransportState::Buffering.is_playing());
assert!(TransportState::Playing.is_playing());
assert!(!TransportState::Paused.is_playing());
assert_eq!(TransportState::Buffering.name(), "buffering");
}
#[test]
fn a_speed_addresses_by_its_value_rather_than_its_place() {
assert_eq!(speed_id(1.5), "speed-1-5");
assert_eq!(speed_id(2.0), "speed-2");
assert_eq!(speed_label(1.5), "1.5×");
}
}