Skip to main content

egui_charts/ui/replay/
controls.rs

1//! Replay controls UI widget
2//!
3//! Playback controls for historical data replay.
4
5use super::{ReplayAction, ReplayColors, ReplayConfig, ReplaySpeed, ReplayState};
6use crate::icons::icons;
7use crate::localization as l10n;
8use crate::styles::stroke;
9use crate::theme::ThemeExt;
10use crate::tokens::DESIGN_TOKENS;
11use crate::ui_kit::buttons::{Button, ButtonSize, IconButton};
12use crate::ui_kit::ext::UiExt;
13use egui::{RichText, Ui, Vec2};
14
15/// The configured colours, or the active theme's while they are the defaults.
16fn colors(config: &ReplayConfig, ui: &Ui) -> ReplayColors {
17    if config.colors == ReplayColors::default() {
18        ReplayColors::from_theme(&ui.active_theme())
19    } else {
20        config.colors.clone()
21    }
22}
23
24/// The kit button size closest to a configured size in points.
25fn kit_size(points: f32) -> ButtonSize {
26    [
27        ButtonSize::SM,
28        ButtonSize::MD,
29        ButtonSize::LG,
30        ButtonSize::XL,
31    ]
32    .into_iter()
33    .min_by(|a, b| {
34        (a.pixels() - points)
35            .abs()
36            .total_cmp(&(b.pixels() - points).abs())
37    })
38    .unwrap_or_default()
39}
40
41/// Replay control bar widget
42///
43/// Provides play/pause, step, speed control, and progress bar for
44/// replaying historical market data.
45///
46/// # Example
47///
48/// ```rust
49/// use egui_charts::ui::replay::{ReplayConfig, ReplayControls, ReplayState};
50///
51/// let mut state = ReplayState::new();
52/// let config = ReplayConfig::default();
53/// let controls = ReplayControls::new(&config);
54///
55/// // In your UI loop:
56/// # egui::__run_test_ui(|ui| {
57/// let action = controls.show(ui, &mut state);
58/// # });
59/// ```
60pub struct ReplayControls<'a> {
61    config: &'a ReplayConfig,
62}
63
64impl<'a> ReplayControls<'a> {
65    /// Create new replay controls
66    pub fn new(config: &'a ReplayConfig) -> Self {
67        Self { config }
68    }
69
70    /// Show the replay control bar
71    pub fn show(&self, ui: &mut Ui, state: &mut ReplayState) -> ReplayAction {
72        // Update playback timing
73        let _bars_advanced = state.update();
74
75        let layout = self.config.layout.resolved(&ui.active_theme());
76        let mut result_action = ReplayAction::None;
77
78        ui.horizontal(|ui| {
79            ui.set_height(layout.control_bar_height);
80            ui.add_space(layout.padding);
81
82            // Replay mode toggle
83            if let action @ ReplayAction::ToggleReplayMode = self.render_mode_toggle(ui, state) {
84                result_action = action;
85            }
86
87            ui.add_space(layout.btn_spacing);
88            ui.separator();
89            ui.add_space(layout.btn_spacing);
90
91            // Only show full controls when replay is active
92            if state.is_active() {
93                // Playback controls
94                let playback_action = self.render_playback_controls(ui, state);
95                if !matches!(playback_action, ReplayAction::None) {
96                    result_action = playback_action;
97                }
98
99                ui.add_space(layout.btn_spacing);
100                ui.separator();
101                ui.add_space(layout.btn_spacing);
102
103                // Progress bar
104                let progress_action = self.render_progress_bar(ui, state);
105                if !matches!(progress_action, ReplayAction::None) {
106                    result_action = progress_action;
107                }
108
109                ui.add_space(layout.btn_spacing);
110                ui.separator();
111                ui.add_space(layout.btn_spacing);
112
113                // Speed control
114                let speed_action = self.render_speed_control(ui, state);
115                if !matches!(speed_action, ReplayAction::None) {
116                    result_action = speed_action;
117                }
118
119                ui.add_space(layout.btn_spacing);
120                ui.separator();
121                ui.add_space(layout.btn_spacing);
122
123                // Info display
124                self.render_info_display(ui, state);
125            } else {
126                // Show hint when not in replay mode
127                ui.label(
128                    RichText::new(l10n::text(ui.ctx(), "Click Replay to enter replay mode"))
129                        .color(colors(self.config, ui).disabled_color),
130                );
131            }
132
133            ui.add_space(layout.padding);
134        });
135
136        result_action
137    }
138
139    /// Render the replay mode toggle button
140    fn render_mode_toggle(&self, ui: &mut Ui, state: &ReplayState) -> ReplayAction {
141        let btn_text = if state.is_active() {
142            "Exit replay"
143        } else {
144            "Replay"
145        };
146        let button = Button::new(btn_text)
147            .secondary()
148            .size(kit_size(self.config.layout.btn_size));
149
150        if button.show(ui).clicked() {
151            ReplayAction::ToggleReplayMode
152        } else {
153            ReplayAction::None
154        }
155    }
156
157    /// Render playback control btns
158    fn render_playback_controls(&self, ui: &mut Ui, state: &ReplayState) -> ReplayAction {
159        let layout = self.config.layout.resolved(&ui.active_theme());
160        let small = kit_size(layout.small_button_size);
161        let mut action = ReplayAction::None;
162
163        if IconButton::new(&icons::UI_ARROW_BACK)
164            .size(small)
165            .tooltip("Step backward (Left arrow)")
166            .enabled(!state.at_start())
167            .show(ui)
168            .clicked()
169        {
170            action = ReplayAction::StepBackward;
171        }
172
173        ui.add_space(layout.btn_spacing);
174
175        let (play_pause_icon, play_pause_hint) = if state.is_playing() {
176            (&icons::MEDIA_PAUSE, "Pause (Space)")
177        } else {
178            (&icons::MEDIA_PLAY, "Play (Space)")
179        };
180        if IconButton::new(play_pause_icon)
181            .size(kit_size(layout.btn_size))
182            .tooltip(play_pause_hint)
183            .show(ui)
184            .clicked()
185        {
186            action = ReplayAction::TogglePlayPause;
187        }
188
189        ui.add_space(layout.btn_spacing);
190
191        if IconButton::new(&icons::UI_ARROW_FORWARD)
192            .size(small)
193            .tooltip("Step forward (Right arrow)")
194            .enabled(!state.at_end())
195            .show(ui)
196            .clicked()
197        {
198            action = ReplayAction::StepForward;
199        }
200
201        ui.add_space(layout.btn_spacing);
202
203        if IconButton::new(&icons::MEDIA_STOP)
204            .size(small)
205            .tooltip("Reset to start")
206            .show(ui)
207            .clicked()
208        {
209            action = ReplayAction::Reset;
210        }
211
212        action
213    }
214
215    /// Render the progress bar
216    fn render_progress_bar(&self, ui: &mut Ui, state: &mut ReplayState) -> ReplayAction {
217        let layout = self.config.layout.resolved(&ui.active_theme());
218        let colors = colors(self.config, ui);
219        let mut action = ReplayAction::None;
220
221        let progress = state.progress();
222        let width = layout
223            .progress_bar_width
224            .unwrap_or(DESIGN_TOKENS.sizing.replay.progress_bar_width);
225
226        let (rect, response) = ui.allocate_exact_size(
227            Vec2::new(width, layout.progress_bar_height),
228            egui::Sense::click_and_drag(),
229        );
230        crate::styles::focus::draw_focus_ring(ui, &response, ui.active_theme().radius.xs);
231        response.widget_info(|| {
232            egui::WidgetInfo::slider(
233                response.enabled(),
234                f64::from(progress) * 100.0,
235                "Replay position",
236            )
237        });
238
239        if ui.is_rect_visible(rect) {
240            // Background
241            ui.painter()
242                .rect_filled(rect, ui.active_theme().radius.xs, colors.progress_bg);
243
244            // Filled portion
245            let filled_width = rect.width() * progress;
246            let filled_rect =
247                egui::Rect::from_min_size(rect.min, Vec2::new(filled_width, rect.height()));
248            ui.painter().rect_filled(
249                filled_rect,
250                ui.active_theme().radius.xs,
251                colors.progress_fill,
252            );
253
254            // Cursor/handle
255            let cursor_x = rect.min.x + filled_width;
256            let cursor_center = egui::pos2(cursor_x, rect.center().y);
257            ui.painter().circle_filled(
258                cursor_center,
259                layout.progress_bar_height * 0.8,
260                colors.progress_cursor,
261            );
262
263            // Markers
264            for marker in &state.markers {
265                if state.total_bars > 1 {
266                    let marker_progress = marker.bar_idx as f32 / (state.total_bars - 1) as f32;
267                    let marker_x = rect.min.x + rect.width() * marker_progress;
268                    let marker_color = egui::Color32::from_rgba_unmultiplied(
269                        marker.color[0],
270                        marker.color[1],
271                        marker.color[2],
272                        marker.color[3],
273                    );
274                    ui.painter().line_segment(
275                        [
276                            egui::pos2(marker_x, rect.min.y),
277                            egui::pos2(marker_x, rect.max.y),
278                        ],
279                        egui::Stroke::new(stroke::MEDIUM, marker_color),
280                    );
281                }
282            }
283        }
284
285        // Handle interaction
286        if (response.clicked() || response.dragged())
287            && let Some(pos) = response.interact_pointer_pos()
288        {
289            let relative_x = (pos.x - rect.min.x) / rect.width();
290            let new_progress = relative_x.clamp(0.0, 1.0);
291            action = ReplayAction::JumpToPercent(new_progress);
292        }
293
294        // Show tooltip with position info
295        response.on_hover_text(l10n::format_text(
296            ui.ctx(),
297            "Bar {0} / {1} ({2}%)",
298            &[
299                ("0", format!("{}", state.curr_bar + 1)),
300                ("1", format!("{}", state.total_bars)),
301                ("2", format!("{:.1}", progress * 100.0)),
302            ],
303        ));
304
305        action
306    }
307
308    /// Render speed control
309    fn render_speed_control(&self, ui: &mut Ui, state: &mut ReplayState) -> ReplayAction {
310        let layout = self.config.layout.resolved(&ui.active_theme());
311        let colors = colors(self.config, ui);
312        let small = kit_size(layout.small_button_size);
313        let mut action = ReplayAction::None;
314
315        if IconButton::new(&icons::MINUS)
316            .size(small)
317            .tooltip("Slow down")
318            .show(ui)
319            .clicked()
320        {
321            action = ReplayAction::SlowDown;
322        }
323
324        // Speed dropdown using ComboBox
325        let speed_label = state.speed.label();
326        let combo_id = ui.scope_id().with("speed_combo");
327
328        egui::ComboBox::from_id_salt(combo_id)
329            .selected_text(
330                RichText::new(l10n::text(ui.ctx(), &speed_label)).color(colors.speed_color),
331            )
332            .width(layout.speed_dropdown_width)
333            .show_ui(ui, |ui| {
334                for preset in ReplaySpeed::presets() {
335                    let label = preset.label();
336                    if ui
337                        .selectable_label(*preset == state.speed, l10n::text(ui.ctx(), &label))
338                        .clicked()
339                    {
340                        action = ReplayAction::SetSpeed(preset.multiplier());
341                    }
342                }
343            });
344
345        if IconButton::new(&icons::PLUS)
346            .size(small)
347            .tooltip("Speed up")
348            .show(ui)
349            .clicked()
350        {
351            action = ReplayAction::SpeedUp;
352        }
353
354        action
355    }
356
357    /// Render info display (bar counter, time, etc.)
358    fn render_info_display(&self, ui: &mut Ui, state: &ReplayState) {
359        let behavior = &self.config.behavior;
360        let layout = self.config.layout.resolved(&ui.active_theme());
361
362        ui.right_aligned(|ui| {
363            // Bar counter
364            if behavior.show_bar_counter {
365                ui.label(
366                    RichText::new(format!("{}/{}", state.curr_bar + 1, state.total_bars)).small(),
367                );
368            }
369
370            // Current date/time
371            if let Some(date) = state.curr_date {
372                ui.add_space(layout.btn_spacing);
373                ui.label(
374                    RichText::new(l10n::text(
375                        ui.ctx(),
376                        date.format("%Y-%m-%d %H:%M").to_string(),
377                    ))
378                    .small(),
379                );
380            }
381
382            // Symbol
383            if let Some(ref symbol) = state.symbol {
384                ui.add_space(layout.btn_spacing);
385                ui.strong_label(symbol);
386            }
387        });
388    }
389}
390
391/// Compact replay controls (minimal UI)
392pub struct CompactReplayControls<'a> {
393    config: &'a ReplayConfig,
394}
395
396impl<'a> CompactReplayControls<'a> {
397    /// Create new compact replay controls
398    pub fn new(config: &'a ReplayConfig) -> Self {
399        Self { config }
400    }
401
402    /// Show compact controls (just play/pause and progress)
403    pub fn show(&self, ui: &mut Ui, state: &mut ReplayState) -> ReplayAction {
404        let mut action = ReplayAction::None;
405        let colors = colors(self.config, ui);
406
407        // Update playback
408        let _bars = state.update();
409
410        ui.horizontal(|ui| {
411            let (icon, hint) = if state.is_playing() {
412                (&icons::MEDIA_PAUSE, "Pause (Space)")
413            } else {
414                (&icons::MEDIA_PLAY, "Play (Space)")
415            };
416            if IconButton::new(icon)
417                .size(ButtonSize::SM)
418                .tooltip(hint)
419                .show(ui)
420                .clicked()
421            {
422                action = ReplayAction::TogglePlayPause;
423            }
424
425            // Simple progress bar, tinted with the configured fill color.
426            let progress = state.progress();
427            let progress_bar = egui::ProgressBar::new(progress)
428                .show_percentage()
429                .fill(colors.progress_fill);
430            let response = ui.add(progress_bar);
431
432            // Allow clicking on progress bar to seek
433            if response.clicked()
434                && let Some(pos) = response.interact_pointer_pos()
435            {
436                let rect = response.rect;
437                let relative_x = (pos.x - rect.min.x) / rect.width();
438                action = ReplayAction::JumpToPercent(relative_x.clamp(0.0, 1.0));
439            }
440
441            // Speed label
442            ui.label(
443                RichText::new(l10n::text(ui.ctx(), state.speed.label()))
444                    .small()
445                    .color(colors.speed_color),
446            );
447        });
448
449        action
450    }
451}
452
453/// Replay keyboard handler
454pub struct ReplayKeyboardHandler;
455
456impl ReplayKeyboardHandler {
457    /// Handle keyboard input for replay controls
458    pub fn handle(ui: &Ui, state: &ReplayState) -> ReplayAction {
459        if !state.is_active() {
460            return ReplayAction::None;
461        }
462
463        // Only handle when no widget has focus
464        if ui.ctx().memory(|mem| mem.focused().is_some()) {
465            return ReplayAction::None;
466        }
467
468        ui.input(|i| {
469            // Space - toggle play/pause
470            if i.key_pressed(egui::Key::Space) {
471                return ReplayAction::TogglePlayPause;
472            }
473
474            // Left arrow - step backward
475            if i.key_pressed(egui::Key::ArrowLeft) {
476                if i.modifiers.shift {
477                    return ReplayAction::StepBackwardN(10);
478                }
479                return ReplayAction::StepBackward;
480            }
481
482            // Right arrow - step forward
483            if i.key_pressed(egui::Key::ArrowRight) {
484                if i.modifiers.shift {
485                    return ReplayAction::StepForwardN(10);
486                }
487                return ReplayAction::StepForward;
488            }
489
490            // Up arrow - speed up
491            if i.key_pressed(egui::Key::ArrowUp) {
492                return ReplayAction::SpeedUp;
493            }
494
495            // Down arrow - slow down
496            if i.key_pressed(egui::Key::ArrowDown) {
497                return ReplayAction::SlowDown;
498            }
499
500            // Home - jump to start
501            if i.key_pressed(egui::Key::Home) {
502                return ReplayAction::JumpToStart;
503            }
504
505            // End - jump to end
506            if i.key_pressed(egui::Key::End) {
507                return ReplayAction::JumpToEnd;
508            }
509
510            // M - add marker
511            if i.key_pressed(egui::Key::M) {
512                return ReplayAction::AddMarker {
513                    label: None,
514                    color: None,
515                };
516            }
517
518            // R - reset
519            if i.key_pressed(egui::Key::R) && i.modifiers.ctrl {
520                return ReplayAction::Reset;
521            }
522
523            // Escape - exit replay mode
524            if i.key_pressed(egui::Key::Escape) {
525                return ReplayAction::ExitReplayMode;
526            }
527
528            ReplayAction::None
529        })
530    }
531}