Skip to main content

egui_timeline_widget/
lib.rs

1use egui::{Rect, Vec2, Color32, Sense, Widget, Ui,
2    FontId, Response, Pos2, LayerId, Order, Align2,
3    Rounding, vec2
4};
5
6pub struct Timeline<'a> {
7    progress: f64,
8    total: f64,
9    accent_color: Color32,
10    height: f32,
11    radius_factor: f32,
12    font_size: f32,
13    seek_position: &'a mut f64,
14}
15
16impl<'a> Timeline<'a> {
17    pub fn new(progress: f64, total: f64, seek_position: &'a mut f64) -> Self {
18        Self {
19            progress,
20            total,
21            accent_color: Color32::from_rgb(0, 155, 255),
22            height: 8.0,
23            radius_factor: 0.3,
24            font_size: 12.0,
25            seek_position,
26        }
27    }
28
29    pub fn accent_color(mut self, color: Color32) -> Self {
30        self.accent_color = color;
31        self
32    }
33
34    pub fn height(mut self, height: f32) -> Self {
35        self.height = height;
36        self
37    }
38
39    pub fn radius_factor(mut self, factor: f32) -> Self {
40        self.radius_factor = factor;
41        self
42    }
43
44    pub fn font_size(mut self, font_size: f32) -> Self {
45        self.font_size = font_size;
46        self
47    }
48}
49
50impl<'a> Widget for Timeline<'a> {
51    fn ui(self, ui: &mut Ui) -> Response {
52        let font_id = FontId::new(self.font_size, egui::FontFamily::Proportional);
53        let time_font_width = ui.ctx().fonts(|fonts| {
54            fonts.layout_no_wrap(time_to_display(0.0), font_id, egui::Color32::WHITE).rect.width()
55        });
56
57        let desired_size_x = ui.available_size().x;
58        let desired_size_y = self.height;
59        let desired_size: Vec2 = vec2(desired_size_x, desired_size_y);
60
61        let (rect, mut response) = 
62            ui.allocate_exact_size(desired_size, Sense::click_and_drag());
63        let visuals = ui.style().interact(&response);
64
65        let mut outer_rect = rect.expand(visuals.expansion);
66        outer_rect.set_left(rect.left() + time_font_width + 5.0);
67        outer_rect.set_right(rect.right() - time_font_width - 5.0);
68
69        if ui.is_rect_visible(rect) {
70            let radius = self.radius_factor * rect.height();
71            ui.painter()
72                .rect(outer_rect, radius, visuals.bg_fill, visuals.bg_stroke);
73
74            if response.hovered() {
75                if let Some(mouse_position) = ui.input(|i| i.pointer.hover_pos()) {
76                    let seek_time;
77                    if mouse_position.x < outer_rect.min.x {
78                        seek_time = 0.0;
79                    } else if mouse_position.x > outer_rect.max.x {
80                        seek_time = self.total;
81                    } else {
82                        seek_time = self.total * (mouse_position.x - outer_rect.min.x) as f64 / outer_rect.width() as f64;
83                    }
84
85                    draw_tooltip(
86                        ui,
87                        Pos2::new(mouse_position.x, outer_rect.min.y - self.font_size - 10.0),
88                        time_to_display(seek_time),
89                        visuals.text_color(),
90                        visuals.bg_fill,
91                        self.font_size
92                    );
93
94                }
95            }
96
97            let mut fill_rect = outer_rect;
98            let mut seek_rect = outer_rect;
99
100            fill_rect.set_width(fill_rect.width() * self.progress as f32 / self.total as f32);
101            ui.painter().rect_filled(
102                fill_rect,
103                radius,
104                self.accent_color
105            );
106
107            if response.is_pointer_button_down_on() || response.dragged() {
108                if let Some(pt) = response.interact_pointer_pos() {
109                    seek_rect.max.x = pt.x;
110                    if seek_rect.width() > outer_rect.width() {
111                        seek_rect.set_width(outer_rect.width());
112                    }
113
114                    let seek_color = {
115                        let [r, g, b, _] = self.accent_color.to_array();
116                        Color32::from_rgba_unmultiplied(
117                            ((r as f32 * 1.1).min(255.0)) as u8,
118                            ((g as f32 * 1.1).min(255.0)) as u8,
119                            ((b as f32 * 1.1).min(255.0)) as u8,
120                            128,
121                        )
122                    };
123
124                    ui.painter().rect_filled(seek_rect, radius, seek_color);
125
126                    if pt.x < seek_rect.min.x {
127                        *self.seek_position = 0.0;
128                    } else if pt.x > rect.max.x {
129                        *self.seek_position = self.total;
130                    } else {
131                        *self.seek_position =
132                            self.total * seek_rect.width() as f64 / outer_rect.width() as f64;
133                    }
134
135                    response.mark_changed();
136                }
137            }
138        }
139
140        ui.painter().text(
141            rect.left_top() + Vec2::new(0.0 , self.height / 2.0 - self.font_size / 2.0),
142            Align2::LEFT_TOP,
143            time_to_display(self.progress),
144            FontId::proportional(self.font_size),
145            visuals.text_color(),
146        );
147
148        ui.painter().text(
149            rect.right_top() + Vec2::new(-time_font_width, self.height / 2.0 - self.font_size / 2.0),
150            Align2::LEFT_TOP,
151            time_to_display(self.total),
152            FontId::proportional(self.font_size),
153            visuals.text_color(),
154        );
155
156        response
157    }
158}
159
160fn time_to_display(seconds: f64) -> String {
161    let is: i64 = seconds.round() as i64;
162    let hours = is / (60 * 60);
163    let mins = (is % (60 * 60)) / 60;
164    let secs = seconds - 60.0 * mins as f64 - 60.0 * 60.0 * hours as f64; // is % 60;
165
166    format!("{}:{:0>2}:{:0>4.1}", hours, mins, secs)
167}
168
169fn draw_tooltip(
170    ui: &Ui,
171    pos: Pos2,
172    tooltip_text: impl ToString,
173    text_color: Color32,
174    tooltip_color: Color32,
175    font_size: f32
176) {
177    let layer_id = LayerId::new(Order::Foreground, ui.id().with("foreground_layer"));
178    let foreground_painter = ui.ctx().layer_painter(layer_id);
179
180    let font_id = FontId::new(font_size, egui::FontFamily::Proportional);
181    let tooltip_font_width = ui.ctx().fonts(|fonts| {
182        fonts.layout_no_wrap(tooltip_text.to_string(), font_id, egui::Color32::WHITE).rect.width()
183    });
184
185    let rect = Rect::from_min_size(pos, vec2(tooltip_font_width + 8.0, font_size + 6.0));
186    let rounding = Rounding::same(5.0);
187
188    foreground_painter.rect_filled(rect, rounding, tooltip_color);
189
190    foreground_painter.text(
191        rect.center(),
192        Align2::CENTER_CENTER,
193        tooltip_text,
194        FontId::proportional(font_size),
195        text_color,
196    );
197}