1use crate::Theme;
17use egui::{Color32, Rect, Sense, Stroke, Ui, Vec2};
18use egui_cha::ViewCtx;
19
20#[derive(Clone, Debug, PartialEq)]
22pub enum TimelineEvent {
23 Seek(f64),
25 SeekAbsolute(f64),
27 MarkerClick(usize),
29 RegionSelect(f64, f64),
31}
32
33#[derive(Debug, Clone)]
35pub struct TimelineMarker {
36 pub position: f64,
38 pub label: String,
40 pub color: Option<Color32>,
42}
43
44impl TimelineMarker {
45 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 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 pub fn with_color(mut self, color: Color32) -> Self {
65 self.color = Some(color);
66 self
67 }
68}
69
70#[derive(Debug, Clone)]
72pub struct TimelineRegion {
73 pub start: f64,
75 pub end: f64,
77 pub color: Color32,
79}
80
81impl TimelineRegion {
82 pub fn new(start: f64, end: f64, color: Color32) -> Self {
84 Self { start, end, color }
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
90pub enum TimeFormat {
91 Seconds,
93 #[default]
95 MinutesSeconds,
96 HoursMinutesSeconds,
98 BarsBeat,
100}
101
102pub 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 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 pub fn position(mut self, pos: f64) -> Self {
137 self.position = pos.clamp(0.0, 1.0);
138 self
139 }
140
141 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 pub fn markers(mut self, markers: &'a [TimelineMarker]) -> Self {
149 self.markers = markers;
150 self
151 }
152
153 pub fn regions(mut self, regions: &'a [TimelineRegion]) -> Self {
155 self.regions = regions;
156 self
157 }
158
159 pub fn height(mut self, height: f32) -> Self {
161 self.height = height;
162 self
163 }
164
165 pub fn show_time(mut self, show: bool) -> Self {
167 self.show_time = show;
168 self
169 }
170
171 pub fn time_format(mut self, format: TimeFormat) -> Self {
173 self.time_format = format;
174 self
175 }
176
177 pub fn bpm(mut self, bpm: f32) -> Self {
179 self.bpm = Some(bpm);
180 self
181 }
182
183 pub fn show_ticks(mut self, show: bool) -> Self {
185 self.show_ticks = show;
186 self
187 }
188
189 pub fn tick_interval(mut self, interval: f64) -> Self {
191 self.tick_interval = Some(interval);
192 self
193 }
194
195 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 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 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 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 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 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 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 painter.rect_filled(track_rect, theme.radius_sm, theme.bg_secondary);
287
288 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 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 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 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 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 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 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 let playhead_x = track_rect.min.x + (self.position as f32) * track_rect.width();
398
399 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 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 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 let target_ticks = 16.0;
463 let raw_interval = self.duration / target_ticks;
464
465 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}