Skip to main content

egui_charts/ui/replay/
floating.rs

1//! Floating Replay Control
2//!
3//! A floating toolbar for controlling bar replay.
4//! Displays play/pause, step controls, and speed selector.
5
6use crate::icons::icons;
7use crate::localization as l10n;
8use crate::styles::icons as icon_sizes;
9use crate::theme::ThemeExt;
10use crate::ui_kit::buttons::{Button, ButtonSize, IconButton};
11use crate::ui_kit::ext::UiExt;
12use egui::{
13    Align2, Context, CursorIcon, FontId, Id, Key, Modifiers, Rect, RichText, Ui, Vec2, pos2,
14};
15
16/// Replay speed presets for playback control
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum ReplaySpeed {
19    /// Play at 0.5 times the original speed.
20    X0_5,
21    /// Play at 1 times the original speed.
22    X1,
23    /// Play at 2 times the original speed.
24    X2,
25    /// Play at 3 times the original speed.
26    X3,
27    /// Play at 5 times the original speed.
28    X5,
29    /// Play at 10 times the original speed.
30    X10,
31}
32
33impl ReplaySpeed {
34    /// Get the multiplier value
35    pub fn multiplier(&self) -> f32 {
36        match self {
37            Self::X0_5 => 0.5,
38            Self::X1 => 1.0,
39            Self::X2 => 2.0,
40            Self::X3 => 3.0,
41            Self::X5 => 5.0,
42            Self::X10 => 10.0,
43        }
44    }
45
46    /// Get display label
47    pub fn label(&self) -> &'static str {
48        match self {
49            Self::X0_5 => "0.5x",
50            Self::X1 => "1x",
51            Self::X2 => "2x",
52            Self::X3 => "3x",
53            Self::X5 => "5x",
54            Self::X10 => "10x",
55        }
56    }
57
58    /// All speeds in order (slowest to fastest)
59    pub fn all() -> &'static [Self] {
60        &[
61            Self::X0_5,
62            Self::X1,
63            Self::X2,
64            Self::X3,
65            Self::X5,
66            Self::X10,
67        ]
68    }
69
70    /// From multiplier (find closest)
71    pub fn from_multiplier(mult: f32) -> Self {
72        Self::all()
73            .iter()
74            .min_by(|a, b| {
75                let diff_a = (a.multiplier() - mult).abs();
76                let diff_b = (b.multiplier() - mult).abs();
77                diff_a
78                    .partial_cmp(&diff_b)
79                    .unwrap_or(std::cmp::Ordering::Equal)
80            })
81            .copied()
82            .unwrap_or(Self::X1)
83    }
84}
85
86/// Current replay state
87#[derive(Debug, Clone, Default)]
88pub struct ReplayControlState {
89    /// Is replay currently playing
90    pub is_playing: bool,
91    /// Current speed
92    pub speed: f32,
93    /// Current symbol being replayed
94    pub symbol: String,
95    /// Progress percentage (0.0 - 100.0)
96    pub progress_pct: f32,
97    /// Ticks processed
98    pub ticks_processed: u64,
99    /// Current time being replayed
100    pub current_time: String,
101}
102
103/// Actions emitted by the replay control
104#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub enum ReplayControlAction {
107    /// No action
108    None,
109    /// Play/resume replay
110    Play,
111    /// Pause replay
112    Pause,
113    /// Stop and close replay
114    Stop,
115    /// Step forward one bar
116    StepForward,
117    /// Step backward one bar
118    StepBackward,
119    /// Go to first bar
120    GoToStart,
121    /// Go to previous bar
122    PreviousBar,
123    /// Go to next bar
124    NextBar,
125    /// Go to current/end bar
126    GoToEnd,
127    /// Change replay speed
128    SetSpeed(f32),
129    /// Change the source interval used to advance the replay cursor.
130    SetUpdateInterval(crate::model::Timeframe),
131    /// Close the control (but don't stop replay)
132    Close,
133}
134
135/// Floating replay control widget
136pub struct FloatingReplayControl {
137    /// Widget ID for state persistence
138    id: Id,
139    /// Current state (public for external access)
140    pub state: ReplayControlState,
141    /// Area the bar docks in, set by the host each frame
142    dock_rect: Option<Rect>,
143    /// Whether control is visible
144    visible: bool,
145    /// User explicitly dismissed the control (prevents auto-show until replay restarts)
146    user_dismissed: bool,
147    /// Replay is on its last bar, where Next and Go to end do nothing
148    at_end: bool,
149    update_intervals: Vec<crate::model::Timeframe>,
150    update_interval: Option<crate::model::Timeframe>,
151}
152
153impl Default for FloatingReplayControl {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl FloatingReplayControl {
160    /// Create a new floating replay control
161    pub fn new() -> Self {
162        Self {
163            id: Id::unique("floating_replay_control"),
164            state: ReplayControlState::default(),
165            dock_rect: None,
166            visible: false,
167            user_dismissed: false,
168            at_end: false,
169            update_intervals: Vec::new(),
170            update_interval: None,
171        }
172    }
173
174    /// Dock the bar in `rect`, usually the chart's rect.
175    ///
176    /// The bar opens centred at the bottom of this rect and can be dragged only within it. Call
177    /// it before [`Self::show`] in each frame; without it the bar docks in the window's content
178    /// rect.
179    pub fn set_dock_rect(&mut self, rect: Rect) {
180        self.dock_rect = Some(rect);
181    }
182
183    /// Supplies source intervals the host can serve. An empty list hides the selector.
184    /// Selecting one emits an intent; the host reloads that source and retains the cursor time.
185    /// Offer `Timeframe::Ticks` of one for trade-by-trade updates when the host holds the trades
186    /// (see [`Chart::set_replay_source`](crate::Chart::set_replay_source)).
187    pub fn set_update_intervals(
188        &mut self,
189        current: crate::model::Timeframe,
190        available: Vec<crate::model::Timeframe>,
191    ) {
192        self.update_interval = Some(current);
193        self.update_intervals = available;
194    }
195
196    /// Show the control (clears user_dismissed flag)
197    pub fn show_control(&mut self) {
198        self.visible = true;
199        self.user_dismissed = false;
200    }
201
202    /// Hide the control
203    pub fn hide_control(&mut self) {
204        self.visible = false;
205    }
206
207    /// Hide control via user action (sets user_dismissed flag to prevent auto-show)
208    pub fn dismiss(&mut self) {
209        self.visible = false;
210        self.user_dismissed = true;
211    }
212
213    /// Check if visible
214    pub fn is_visible(&self) -> bool {
215        self.visible
216    }
217
218    /// Check if user explicitly dismissed (prevents auto-show)
219    pub fn is_user_dismissed(&self) -> bool {
220        self.user_dismissed
221    }
222
223    /// Update the state
224    pub fn update_state(&mut self, state: ReplayControlState) {
225        self.at_end = state.progress_pct >= 100.0;
226        self.state = state;
227        // Auto-show when replay is active
228        if self.state.is_playing || self.state.ticks_processed > 0 {
229            self.visible = true;
230        }
231    }
232
233    /// Set playing state
234    pub fn set_playing(&mut self, playing: bool) {
235        self.state.is_playing = playing;
236    }
237
238    /// Sync replay control state from external values.
239    ///
240    /// Drives visibility and playback display from central replay state.
241    pub fn sync_from(
242        &mut self,
243        active: bool,
244        playing: bool,
245        speed: f32,
246        current_bar: usize,
247        total_bars: usize,
248    ) {
249        self.state.is_playing = playing;
250        self.state.speed = speed;
251
252        // `current_bar` is zero-based, so the last bar is 100%.
253        self.state.progress_pct =
254            current_bar as f32 / total_bars.saturating_sub(1).max(1) as f32 * 100.0;
255
256        self.state.ticks_processed = current_bar as u64;
257        // A one-bar replay is at its start and its end.
258        self.at_end = current_bar + 1 >= total_bars;
259
260        if active {
261            if !self.user_dismissed {
262                self.visible = true;
263            }
264        } else {
265            self.user_dismissed = false;
266        }
267    }
268
269    /// Set speed
270    pub fn set_speed(&mut self, speed: f32) {
271        self.state.speed = speed;
272    }
273
274    /// Set symbol
275    pub fn set_symbol(&mut self, symbol: &str) {
276        self.state.symbol = symbol.to_string();
277    }
278
279    /// Show the floating control and return any action
280    pub fn show(&mut self, ctx: &Context) -> ReplayControlAction {
281        if !self.visible {
282            return ReplayControlAction::None;
283        }
284
285        let mut action = self.shortcut(ctx);
286
287        let dock = self
288            .dock_rect
289            .unwrap_or_else(|| ctx.content_rect())
290            .intersect(ctx.content_rect());
291        let inset = ctx.active_theme().spacing.lg.max(8.0);
292        egui::Area::new(self.id)
293            .order(egui::Order::Foreground)
294            .movable(true)
295            .pivot(Align2::CENTER_BOTTOM)
296            .default_pos(pos2(dock.center().x, dock.bottom() - inset))
297            .constrain_to(dock.shrink(inset))
298            .show(ctx, |ui| {
299                ui.set_max_width((dock.width() - 2.0 * inset).max(0.0));
300                // Keep the shadow outside the inset while constraining the toolbar itself.
301                ui.set_clip_rect(dock);
302                let clicked = self.render_control(ui);
303                if clicked != ReplayControlAction::None {
304                    action = clicked;
305                }
306            });
307
308        action
309    }
310
311    /// The action of a shortcut pressed this frame, which takes its key.
312    ///
313    /// Shortcuts act only while no text field has focus, no popup is open and no control
314    /// outside the background layer has focus, so keys typed into a field, meant for an open
315    /// popup or pressing a focused button in the bar or a dialog never reach the replay. A
316    /// focused chart in a panel keeps them.
317    pub fn shortcut(&self, ctx: &Context) -> ReplayControlAction {
318        let focus_off_background = ctx
319            .memory(|m| m.focused())
320            .and_then(|id| ctx.read_response(id))
321            .is_some_and(|r| r.layer_id.order != egui::Order::Background);
322        if !self.visible
323            || crate::widget::keyboard::shortcuts_suppressed(ctx)
324            || ctx.text_edit_focused()
325            || focus_off_background
326            || egui::Popup::is_any_open(ctx)
327        {
328            return ReplayControlAction::None;
329        }
330        let play_or_pause = if self.state.is_playing {
331            ReplayControlAction::Pause
332        } else {
333            ReplayControlAction::Play
334        };
335        // Shift+arrow first: a pattern without Shift also matches the key with Shift held.
336        [
337            (Modifiers::NONE, Key::Space, play_or_pause),
338            (Modifiers::NONE, Key::Home, ReplayControlAction::GoToStart),
339            (Modifiers::NONE, Key::End, ReplayControlAction::GoToEnd),
340            (
341                Modifiers::SHIFT,
342                Key::ArrowLeft,
343                ReplayControlAction::StepBackward,
344            ),
345            (
346                Modifiers::SHIFT,
347                Key::ArrowRight,
348                ReplayControlAction::StepForward,
349            ),
350            (
351                Modifiers::NONE,
352                Key::ArrowLeft,
353                ReplayControlAction::PreviousBar,
354            ),
355            (
356                Modifiers::NONE,
357                Key::ArrowRight,
358                ReplayControlAction::NextBar,
359            ),
360        ]
361        .into_iter()
362        .find(|(modifiers, key, _)| ctx.input_mut(|i| i.consume_key(*modifiers, *key)))
363        .map_or(ReplayControlAction::None, |(_, _, action)| action)
364    }
365
366    /// Render the control contents
367    /// Layout: grip | start, previous, play or pause, next, end | speed | symbol, progress | exit, hide
368    fn render_control(&mut self, ui: &mut Ui) -> ReplayControlAction {
369        let mut action = ReplayControlAction::None;
370
371        // Floating surface colours follow the active theme.
372        let theme = ui.active_theme();
373        let visuals = ui.visuals();
374        let row = ButtonSize::MD.pixels();
375
376        egui::Frame::new()
377            .fill(visuals.window_fill)
378            .stroke(visuals.window_stroke)
379            .corner_radius(visuals.window_corner_radius)
380            .shadow(visuals.popup_shadow)
381            .inner_margin(egui::Margin::symmetric(
382                theme.spacing.sm as i8,
383                (theme.spacing.xs - visuals.window_stroke.width).max(0.0) as i8,
384            ))
385            .show(ui, |ui| {
386                egui::ScrollArea::horizontal()
387                    .auto_shrink([true, true])
388                    .show(ui, |ui| {
389                        ui.horizontal(|ui| {
390                            // The grip and first separator are laid out before any button sets the height.
391                            ui.set_min_height(row);
392                            ui.spacing_mut().item_spacing.x = theme.spacing.sm;
393                            ui.spacing_mut().interact_size.y = row;
394
395                            // The whole frame drags the bar; the grip says so.
396                            ui.add(icons::UI_DRAG_HANDLE.as_image_tinted(
397                                Vec2::splat(icon_sizes::COMPACT),
398                                theme.ui.text_muted,
399                            ))
400                            .on_hover_cursor(CursorIcon::Grab)
401                            .on_hover_text(l10n::text(ui.ctx(), "Move"));
402
403                            ui.separator();
404
405                            let at_start = self.state.progress_pct <= 0.0;
406                            let at_end = self.at_end;
407                            if IconButton::new(&icons::MEDIA_SKIP_BACK)
408                                .tooltip("Go to start (Home)")
409                                .enabled(!at_start)
410                                .show(ui)
411                                .clicked()
412                            {
413                                action = ReplayControlAction::GoToStart;
414                            }
415
416                            if IconButton::new(&icons::UI_ARROW_BACK)
417                                .tooltip("Previous bar (Left arrow)")
418                                .enabled(!at_start)
419                                .show(ui)
420                                .clicked()
421                            {
422                                action = ReplayControlAction::PreviousBar;
423                            }
424
425                            let (play_icon, play_tooltip, play_action) = if self.state.is_playing {
426                                (
427                                    &icons::MEDIA_PAUSE,
428                                    "Pause (Space)",
429                                    ReplayControlAction::Pause,
430                                )
431                            } else {
432                                (
433                                    &icons::MEDIA_PLAY,
434                                    "Play (Space)",
435                                    ReplayControlAction::Play,
436                                )
437                            };
438                            if IconButton::new(play_icon)
439                                .tooltip(play_tooltip)
440                                .show(ui)
441                                .clicked()
442                            {
443                                action = play_action;
444                            }
445
446                            if IconButton::new(&icons::UI_ARROW_FORWARD)
447                                .tooltip("Next bar (Right arrow)")
448                                .enabled(!at_end)
449                                .show(ui)
450                                .clicked()
451                            {
452                                action = ReplayControlAction::NextBar;
453                            }
454
455                            if IconButton::new(&icons::MEDIA_SKIP_FORWARD)
456                                .tooltip("Go to end (End)")
457                                .enabled(!at_end)
458                                .show(ui)
459                                .clicked()
460                            {
461                                action = ReplayControlAction::GoToEnd;
462                            }
463
464                            ui.separator();
465
466                            // As wide as the widest label, so the bar keeps its width for every speed.
467                            let font = FontId::proportional(theme.text.label);
468                            let text_width = |ui: &Ui, text: &str| {
469                                ui.painter()
470                                    .layout_no_wrap(
471                                        l10n::text(ui.ctx(), text),
472                                        font.clone(),
473                                        theme.ui.text,
474                                    )
475                                    .size()
476                                    .x
477                            };
478                            let spacing = ui.spacing();
479                            let combo_width = text_width(ui, ReplaySpeed::X0_5.label())
480                                + spacing.icon_spacing
481                                + spacing.icon_width
482                                + 2.0 * spacing.button_padding.x;
483                            let mut speed = ReplaySpeed::from_multiplier(self.state.speed);
484                            let speed_response = ui.scope(|ui| {
485                                ui.combo_select_width(
486                                    "replay_speed_selector",
487                                    &mut speed,
488                                    ReplaySpeed::all().iter().copied(),
489                                    |speed| speed.label().to_owned(),
490                                    combo_width,
491                                )
492                            });
493                            speed_response
494                                .response
495                                .on_hover_text(l10n::text(ui.ctx(), "Playback speed"));
496                            if speed_response.inner {
497                                action = ReplayControlAction::SetSpeed(speed.multiplier());
498                            }
499
500                            if !self.update_intervals.is_empty()
501                                && let Some(mut interval) = self.update_interval
502                                && ui.combo_select_width(
503                                    "replay_update_interval",
504                                    &mut interval,
505                                    self.update_intervals.iter().copied(),
506                                    |interval| format!("{} updates", interval),
507                                    110.0,
508                                )
509                            {
510                                action = ReplayControlAction::SetUpdateInterval(interval);
511                            }
512
513                            ui.separator();
514
515                            if !self.state.symbol.is_empty() {
516                                ui.label(
517                                    RichText::new(l10n::text(ui.ctx(), &self.state.symbol))
518                                        .font(font.clone())
519                                        .color(theme.ui.text),
520                                );
521                            }
522
523                            // A slot as wide as "100.0%", so the bar keeps its width as progress changes.
524                            ui.add_sized(
525                                [text_width(ui, "100.0%"), row],
526                                egui::Label::new(
527                                    RichText::new(format!("{:.1}%", self.state.progress_pct))
528                                        .font(font.clone())
529                                        .color(theme.ui.text_secondary),
530                                ),
531                            );
532
533                            ui.separator();
534
535                            if Button::new("Exit replay")
536                                .secondary()
537                                .text_color(theme.ui.error)
538                                .size(ButtonSize::MD)
539                                .show(ui)
540                                .clicked()
541                            {
542                                action = ReplayControlAction::Stop;
543                            }
544
545                            if IconButton::new(&icons::CLOSE)
546                                .size(ButtonSize::MD)
547                                .tooltip("Hide replay controls")
548                                .show(ui)
549                                .clicked()
550                            {
551                                action = ReplayControlAction::Close;
552                            }
553                        });
554                        if let Some(response) = ui
555                            .ctx()
556                            .memory(|m| m.focused())
557                            .and_then(|id| ui.ctx().read_response(id))
558                            && response.layer_id == ui.layer_id()
559                        {
560                            ui.scroll_to_rect(response.rect, None);
561                        }
562                    });
563            });
564
565        action
566    }
567}
568
569/// Show a simple replay control (function style)
570pub fn show_floating_replay_control(
571    ctx: &Context,
572    control: &mut FloatingReplayControl,
573) -> ReplayControlAction {
574    control.show(ctx)
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use egui::{Event, Key, Modifiers, RawInput};
581
582    fn key(key: Key) -> Event {
583        Event::Key {
584            key,
585            physical_key: None,
586            pressed: true,
587            repeat: false,
588            modifiers: Modifiers::NONE,
589        }
590    }
591
592    /// Runs one frame: the bar, then a text field, as the demo draws dialogs after the bar.
593    fn frame(
594        ctx: &Context,
595        bar: &mut FloatingReplayControl,
596        events: Vec<Event>,
597        focus_field: bool,
598    ) -> ReplayControlAction {
599        let mut action = ReplayControlAction::None;
600        let input = RawInput {
601            events,
602            ..Default::default()
603        };
604        ctx.run_ui(input, |ui| {
605            action = bar.show(ui.ctx());
606            let field = ui.text_edit_singleline(&mut String::new());
607            if focus_field {
608                field.request_focus();
609            }
610        })
611        .drop_without_applying_deltas();
612        action
613    }
614
615    #[test]
616    fn replay_surface_fits_the_dock_and_keeps_the_exit_label_readable() {
617        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
618            let ctx = Context::default();
619            crate::theme::apply_to_egui(&ctx, &theme);
620            let mut bar = FloatingReplayControl::new();
621            bar.sync_from(true, false, 1.0, 150, 300);
622            bar.set_symbol("SAMPLE");
623            bar.set_update_intervals(
624                crate::model::Timeframe::Hour1,
625                vec![
626                    crate::model::Timeframe::Hour1,
627                    crate::model::Timeframe::Min1,
628                ],
629            );
630            for width in [900.0, 320.0, 600.0] {
631                let dock = Rect::from_min_max(pos2(0.0, 80.0), pos2(width, 560.0));
632                bar.set_dock_rect(dock);
633                for pass in 0..8 {
634                    let mut neutral = egui::Color32::TRANSPARENT;
635                    let mut output = ctx.run_ui(
636                        RawInput {
637                            screen_rect: Some(Rect::from_min_size(
638                                egui::Pos2::ZERO,
639                                Vec2::new(width, 600.0),
640                            )),
641                            events: if width == 320.0 && pass == 4 {
642                                vec![
643                                    Event::PointerMoved(pos2(width / 2.0, dock.bottom() - 24.0)),
644                                    Event::MouseWheel {
645                                        unit: egui::MouseWheelUnit::Point,
646                                        delta: egui::vec2(-1000.0, 0.0),
647                                        phase: egui::TouchPhase::Move,
648                                        source: egui::MouseWheelSource::Wheel,
649                                        modifiers: Modifiers::NONE,
650                                    },
651                                ]
652                            } else {
653                                vec![]
654                            },
655                            time: Some(ctx.input(|i| i.time) + 1.0),
656                            ..Default::default()
657                        },
658                        |ui| {
659                            neutral = crate::ui_kit::buttons::ButtonVariant::Secondary
660                                .bg_color(ui, false, false);
661                            bar.show(ui.ctx());
662                        },
663                    );
664                    output.textures_delta.clear();
665                    if pass == 7 {
666                        let rect = ctx.memory(|m| m.area_rect(bar.id)).unwrap();
667                        assert!(dock.shrink(8.0).contains_rect(rect), "{width}: {rect:?}");
668                        assert_eq!(rect.height(), 32.0);
669                        let text = output
670                            .shapes
671                            .iter()
672                            .find_map(|shape| match &shape.shape {
673                                egui::Shape::Text(text) if text.galley.text() == "Exit replay" => {
674                                    Some(text)
675                                }
676                                _ => None,
677                            })
678                            .unwrap();
679                        assert_eq!(text.fallback_color, theme.ui.error);
680                        assert!(rect.contains(text.pos + text.galley.size() / 2.0));
681                        let center = text.pos + text.galley.size() / 2.0;
682                        assert!(output.shapes.iter().any(|shape| matches!(&shape.shape,
683                                egui::Shape::Rect(rect) if rect.rect.contains(center) && rect.rect.height() == ButtonSize::MD.pixels() && rect.fill == neutral)));
684                    }
685                    output.drop_without_applying_deltas();
686                }
687            }
688        }
689    }
690
691    /// `current_bar` is zero-based: the first of 300 bars is 0% and the last 100%.
692    #[test]
693    fn progress_spans_first_to_last_bar() {
694        let mut bar = FloatingReplayControl::new();
695        for (current, expected) in [(0, 0.0), (299, 100.0)] {
696            bar.sync_from(true, false, 1.0, current, 300);
697            assert_eq!(bar.state.progress_pct, expected, "bar {current} of 300");
698        }
699    }
700
701    /// The key a shortcut reads goes to the replay only when nothing else should take it.
702    #[test]
703    fn shortcuts_leave_keys_to_focused_widgets() {
704        // (keys before the checked frame, focus the text field, checked key, expected action)
705        let cases = [
706            (vec![], false, Key::Space, ReplayControlAction::Pause),
707            (vec![], false, Key::Escape, ReplayControlAction::None),
708            (vec![], true, Key::Space, ReplayControlAction::None),
709            // Tab focuses the bar's first button, which Space then presses.
710            (
711                vec![key(Key::Tab)],
712                false,
713                Key::Space,
714                ReplayControlAction::GoToStart,
715            ),
716        ];
717        for (before, focus_field, pressed, expected) in cases {
718            let ctx = Context::default();
719            let mut bar = FloatingReplayControl::new();
720            bar.sync_from(true, true, 1.0, 150, 300);
721            for _ in 0..2 {
722                frame(&ctx, &mut bar, vec![], focus_field);
723            }
724            frame(&ctx, &mut bar, before, focus_field);
725            frame(&ctx, &mut bar, vec![], focus_field);
726            let action = frame(&ctx, &mut bar, vec![key(pressed)], focus_field);
727            assert_eq!(
728                action, expected,
729                "{pressed:?}, text field focused: {focus_field}"
730            );
731        }
732    }
733}