Skip to main content

egui_cha_ds/atoms/visual/
timeline.rs

1//! Timeline - Seek bar with markers and playhead
2//!
3//! A timeline component for video/audio with seek, markers, and regions.
4//!
5//! # Example
6//! ```ignore
7//! Timeline::new(model.duration)
8//!     .position(model.position)
9//!     .markers(&model.markers)
10//!     .show_with(ctx, |event| match event {
11//!         TimelineEvent::Seek(pos) => Msg::Seek(pos),
12//!         TimelineEvent::MarkerClick(idx) => Msg::JumpToMarker(idx),
13//!     });
14//! ```
15
16use crate::Theme;
17use egui::{Color32, Rect, Sense, Stroke, Ui, Vec2};
18use egui_cha::ViewCtx;
19
20/// Timeline events
21#[derive(Clone, Debug, PartialEq)]
22pub enum TimelineEvent {
23    /// Seek to position (0.0 - 1.0 normalized)
24    Seek(f64),
25    /// Seek to absolute time in seconds
26    SeekAbsolute(f64),
27    /// Marker clicked
28    MarkerClick(usize),
29    /// Region selected (start, end in normalized 0.0-1.0)
30    RegionSelect(f64, f64),
31}
32
33/// A marker on the timeline
34#[derive(Debug, Clone)]
35pub struct TimelineMarker {
36    /// Position (0.0 - 1.0 normalized, or absolute time if using duration)
37    pub position: f64,
38    /// Label
39    pub label: String,
40    /// Color
41    pub color: Option<Color32>,
42}
43
44impl TimelineMarker {
45    /// Create a marker at normalized position
46    pub fn new(position: f64, label: impl Into<String>) -> Self {
47        Self {
48            position,
49            label: label.into(),
50            color: None,
51        }
52    }
53
54    /// Create a marker at absolute time (seconds)
55    pub fn at_time(time: f64, duration: f64, label: impl Into<String>) -> Self {
56        Self {
57            position: time / duration,
58            label: label.into(),
59            color: None,
60        }
61    }
62
63    /// Set marker color
64    pub fn with_color(mut self, color: Color32) -> Self {
65        self.color = Some(color);
66        self
67    }
68}
69
70/// A region on the timeline (loop region, selection, etc.)
71#[derive(Debug, Clone)]
72pub struct TimelineRegion {
73    /// Start position (0.0 - 1.0 normalized)
74    pub start: f64,
75    /// End position (0.0 - 1.0 normalized)
76    pub end: f64,
77    /// Color
78    pub color: Color32,
79}
80
81impl TimelineRegion {
82    /// Create a region
83    pub fn new(start: f64, end: f64, color: Color32) -> Self {
84        Self { start, end, color }
85    }
86}
87
88/// Time display format
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum TimeFormat {
91    /// Seconds only (e.g., "42.5")
92    Seconds,
93    /// Minutes:Seconds (e.g., "1:30")
94    #[default]
95    MinutesSeconds,
96    /// Hours:Minutes:Seconds (e.g., "1:30:00")
97    HoursMinutesSeconds,
98    /// Bars:Beats (for music, requires BPM)
99    BarsBeat,
100}
101
102/// Timeline component
103pub struct Timeline<'a> {
104    duration: f64,
105    position: f64,
106    markers: &'a [TimelineMarker],
107    regions: &'a [TimelineRegion],
108    height: f32,
109    show_time: bool,
110    time_format: TimeFormat,
111    bpm: Option<f32>,
112    show_ticks: bool,
113    tick_interval: Option<f64>,
114    loop_region: Option<(f64, f64)>,
115}
116
117impl<'a> Timeline<'a> {
118    /// Create a new timeline with duration in seconds
119    pub fn new(duration: f64) -> Self {
120        Self {
121            duration: duration.max(0.001),
122            position: 0.0,
123            markers: &[],
124            regions: &[],
125            height: 32.0,
126            show_time: true,
127            time_format: TimeFormat::default(),
128            bpm: None,
129            show_ticks: true,
130            tick_interval: None,
131            loop_region: None,
132        }
133    }
134
135    /// Set current position (0.0 - 1.0 normalized)
136    pub fn position(mut self, pos: f64) -> Self {
137        self.position = pos.clamp(0.0, 1.0);
138        self
139    }
140
141    /// Set current position in seconds
142    pub fn position_seconds(mut self, seconds: f64) -> Self {
143        self.position = (seconds / self.duration).clamp(0.0, 1.0);
144        self
145    }
146
147    /// Set markers
148    pub fn markers(mut self, markers: &'a [TimelineMarker]) -> Self {
149        self.markers = markers;
150        self
151    }
152
153    /// Set regions
154    pub fn regions(mut self, regions: &'a [TimelineRegion]) -> Self {
155        self.regions = regions;
156        self
157    }
158
159    /// Set height
160    pub fn height(mut self, height: f32) -> Self {
161        self.height = height;
162        self
163    }
164
165    /// Show/hide time display
166    pub fn show_time(mut self, show: bool) -> Self {
167        self.show_time = show;
168        self
169    }
170
171    /// Set time format
172    pub fn time_format(mut self, format: TimeFormat) -> Self {
173        self.time_format = format;
174        self
175    }
176
177    /// Set BPM for musical time display
178    pub fn bpm(mut self, bpm: f32) -> Self {
179        self.bpm = Some(bpm);
180        self
181    }
182
183    /// Show/hide tick marks
184    pub fn show_ticks(mut self, show: bool) -> Self {
185        self.show_ticks = show;
186        self
187    }
188
189    /// Set tick interval in seconds (auto-calculated if None)
190    pub fn tick_interval(mut self, interval: f64) -> Self {
191        self.tick_interval = Some(interval);
192        self
193    }
194
195    /// Set loop region (start, end in normalized 0.0-1.0)
196    pub fn loop_region(mut self, start: f64, end: f64) -> Self {
197        self.loop_region = Some((start.clamp(0.0, 1.0), end.clamp(0.0, 1.0)));
198        self
199    }
200
201    /// TEA-style: Show timeline and emit events
202    pub fn show_with<Msg>(
203        self,
204        ctx: &mut ViewCtx<'_, Msg>,
205        on_event: impl Fn(TimelineEvent) -> Msg,
206    ) {
207        if let Some(event) = self.render(ctx.ui) {
208            ctx.emit(on_event(event));
209        }
210    }
211
212    /// Show timeline, returns event if any
213    pub fn show(self, ui: &mut Ui) -> Option<TimelineEvent> {
214        self.render(ui)
215    }
216
217    fn render(self, ui: &mut Ui) -> Option<TimelineEvent> {
218        let theme = Theme::current(ui.ctx());
219        let mut event = None;
220
221        // Calculate dimensions
222        let time_width = if self.show_time { 60.0 } else { 0.0 };
223        let available_width = ui.available_width();
224        let track_width = available_width - time_width - theme.spacing_sm;
225
226        let (rect, response) = ui.allocate_exact_size(
227            Vec2::new(available_width, self.height),
228            Sense::click_and_drag(),
229        );
230
231        if !ui.is_rect_visible(rect) {
232            return None;
233        }
234
235        let track_rect = Rect::from_min_size(
236            rect.min + Vec2::new(time_width + theme.spacing_sm, 0.0),
237            Vec2::new(track_width, self.height),
238        );
239
240        // Handle seek interaction
241        if response.clicked() || response.dragged() {
242            if let Some(pos) = response.interact_pointer_pos() {
243                if track_rect.contains(pos) {
244                    let normalized = ((pos.x - track_rect.min.x) / track_rect.width()) as f64;
245                    let normalized = normalized.clamp(0.0, 1.0);
246                    event = Some(TimelineEvent::Seek(normalized));
247                }
248            }
249        }
250
251        // Check marker clicks
252        for (idx, marker) in self.markers.iter().enumerate() {
253            let marker_x = track_rect.min.x + (marker.position as f32) * track_rect.width();
254            let marker_rect = Rect::from_center_size(
255                egui::pos2(marker_x, track_rect.center().y),
256                Vec2::new(12.0, self.height),
257            );
258
259            if response.clicked() {
260                if let Some(pos) = response.interact_pointer_pos() {
261                    if marker_rect.contains(pos) {
262                        event = Some(TimelineEvent::MarkerClick(idx));
263                    }
264                }
265            }
266        }
267
268        let painter = ui.painter();
269
270        // Draw time display
271        if self.show_time {
272            let time_rect = Rect::from_min_size(rect.min, Vec2::new(time_width, self.height));
273            let current_time = self.position * self.duration;
274            let time_str = self.format_time(current_time);
275
276            painter.text(
277                time_rect.center(),
278                egui::Align2::CENTER_CENTER,
279                time_str,
280                egui::FontId::monospace(theme.font_size_sm),
281                theme.text_primary,
282            );
283        }
284
285        // Draw track background
286        painter.rect_filled(track_rect, theme.radius_sm, theme.bg_secondary);
287
288        // Draw regions
289        for region in self.regions {
290            let start_x = track_rect.min.x + (region.start as f32) * track_rect.width();
291            let end_x = track_rect.min.x + (region.end as f32) * track_rect.width();
292            let region_rect = Rect::from_min_max(
293                egui::pos2(start_x, track_rect.min.y),
294                egui::pos2(end_x, track_rect.max.y),
295            );
296            painter.rect_filled(region_rect, theme.radius_sm * 0.5, region.color);
297        }
298
299        // Draw loop region
300        if let Some((start, end)) = self.loop_region {
301            let start_x = track_rect.min.x + (start as f32) * track_rect.width();
302            let end_x = track_rect.min.x + (end as f32) * track_rect.width();
303            let loop_rect = Rect::from_min_max(
304                egui::pos2(start_x, track_rect.min.y),
305                egui::pos2(end_x, track_rect.max.y),
306            );
307            let loop_color = Color32::from_rgba_unmultiplied(
308                theme.primary.r(),
309                theme.primary.g(),
310                theme.primary.b(),
311                40,
312            );
313            painter.rect_filled(loop_rect, theme.radius_sm * 0.5, loop_color);
314
315            // Loop boundaries
316            painter.line_segment(
317                [
318                    egui::pos2(start_x, track_rect.min.y),
319                    egui::pos2(start_x, track_rect.max.y),
320                ],
321                Stroke::new(2.0, theme.primary),
322            );
323            painter.line_segment(
324                [
325                    egui::pos2(end_x, track_rect.min.y),
326                    egui::pos2(end_x, track_rect.max.y),
327                ],
328                Stroke::new(2.0, theme.primary),
329            );
330        }
331
332        // Draw tick marks
333        if self.show_ticks {
334            let interval = self
335                .tick_interval
336                .unwrap_or_else(|| self.auto_tick_interval());
337            let num_ticks = (self.duration / interval).ceil() as usize;
338
339            for i in 0..=num_ticks {
340                let time = i as f64 * interval;
341                if time > self.duration {
342                    break;
343                }
344                let x = track_rect.min.x + (time / self.duration) as f32 * track_rect.width();
345                let is_major = i % 4 == 0;
346                let tick_height = if is_major { 8.0 } else { 4.0 };
347                let tick_color = if is_major {
348                    theme.text_muted
349                } else {
350                    Color32::from_rgba_unmultiplied(
351                        theme.text_muted.r(),
352                        theme.text_muted.g(),
353                        theme.text_muted.b(),
354                        100,
355                    )
356                };
357
358                painter.line_segment(
359                    [
360                        egui::pos2(x, track_rect.max.y - tick_height),
361                        egui::pos2(x, track_rect.max.y),
362                    ],
363                    Stroke::new(1.0, tick_color),
364                );
365            }
366        }
367
368        // Draw markers
369        for marker in self.markers {
370            let marker_x = track_rect.min.x + (marker.position as f32) * track_rect.width();
371            let marker_color = marker.color.unwrap_or(theme.state_warning);
372
373            // Marker line
374            painter.line_segment(
375                [
376                    egui::pos2(marker_x, track_rect.min.y),
377                    egui::pos2(marker_x, track_rect.max.y),
378                ],
379                Stroke::new(2.0, marker_color),
380            );
381
382            // Marker triangle at top
383            let tri_size = 6.0;
384            let points = vec![
385                egui::pos2(marker_x - tri_size, track_rect.min.y),
386                egui::pos2(marker_x + tri_size, track_rect.min.y),
387                egui::pos2(marker_x, track_rect.min.y + tri_size),
388            ];
389            painter.add(egui::Shape::convex_polygon(
390                points,
391                marker_color,
392                Stroke::NONE,
393            ));
394        }
395
396        // Draw playhead
397        let playhead_x = track_rect.min.x + (self.position as f32) * track_rect.width();
398
399        // Playhead line
400        painter.line_segment(
401            [
402                egui::pos2(playhead_x, track_rect.min.y),
403                egui::pos2(playhead_x, track_rect.max.y),
404            ],
405            Stroke::new(2.0, theme.state_success),
406        );
407
408        // Playhead triangle at top
409        let head_size = 8.0;
410        let head_points = vec![
411            egui::pos2(playhead_x - head_size, track_rect.min.y),
412            egui::pos2(playhead_x + head_size, track_rect.min.y),
413            egui::pos2(playhead_x, track_rect.min.y + head_size),
414        ];
415        painter.add(egui::Shape::convex_polygon(
416            head_points,
417            theme.state_success,
418            Stroke::NONE,
419        ));
420
421        // Draw border
422        painter.rect_stroke(
423            track_rect,
424            theme.radius_sm,
425            Stroke::new(theme.border_width, theme.border),
426            egui::StrokeKind::Inside,
427        );
428
429        event
430    }
431
432    fn format_time(&self, seconds: f64) -> String {
433        match self.time_format {
434            TimeFormat::Seconds => format!("{:.1}", seconds),
435            TimeFormat::MinutesSeconds => {
436                let mins = (seconds / 60.0).floor() as u32;
437                let secs = seconds % 60.0;
438                format!("{}:{:05.2}", mins, secs)
439            }
440            TimeFormat::HoursMinutesSeconds => {
441                let hours = (seconds / 3600.0).floor() as u32;
442                let mins = ((seconds % 3600.0) / 60.0).floor() as u32;
443                let secs = seconds % 60.0;
444                format!("{}:{:02}:{:05.2}", hours, mins, secs)
445            }
446            TimeFormat::BarsBeat => {
447                if let Some(bpm) = self.bpm {
448                    let beats_per_second = bpm as f64 / 60.0;
449                    let total_beats = seconds * beats_per_second;
450                    let bars = (total_beats / 4.0).floor() as u32 + 1;
451                    let beat = (total_beats % 4.0).floor() as u32 + 1;
452                    format!("{}:{}", bars, beat)
453                } else {
454                    format!("{:.1}", seconds)
455                }
456            }
457        }
458    }
459
460    fn auto_tick_interval(&self) -> f64 {
461        // Auto-calculate a nice tick interval based on duration
462        let target_ticks = 16.0;
463        let raw_interval = self.duration / target_ticks;
464
465        // Round to nice values
466        let nice_intervals = [0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 15.0, 30.0, 60.0];
467        nice_intervals
468            .iter()
469            .copied()
470            .find(|&i| i >= raw_interval)
471            .unwrap_or(60.0)
472    }
473}