1use 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#[derive(Debug, Clone, Copy, PartialEq)]
18pub enum ReplaySpeed {
19 X0_5,
21 X1,
23 X2,
25 X3,
27 X5,
29 X10,
31}
32
33impl ReplaySpeed {
34 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 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 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 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#[derive(Debug, Clone, Default)]
88pub struct ReplayControlState {
89 pub is_playing: bool,
91 pub speed: f32,
93 pub symbol: String,
95 pub progress_pct: f32,
97 pub ticks_processed: u64,
99 pub current_time: String,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105#[non_exhaustive]
106pub enum ReplayControlAction {
107 None,
109 Play,
111 Pause,
113 Stop,
115 StepForward,
117 StepBackward,
119 GoToStart,
121 PreviousBar,
123 NextBar,
125 GoToEnd,
127 SetSpeed(f32),
129 SetUpdateInterval(crate::model::Timeframe),
131 Close,
133}
134
135pub struct FloatingReplayControl {
137 id: Id,
139 pub state: ReplayControlState,
141 dock_rect: Option<Rect>,
143 visible: bool,
145 user_dismissed: bool,
147 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 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 pub fn set_dock_rect(&mut self, rect: Rect) {
180 self.dock_rect = Some(rect);
181 }
182
183 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 pub fn show_control(&mut self) {
198 self.visible = true;
199 self.user_dismissed = false;
200 }
201
202 pub fn hide_control(&mut self) {
204 self.visible = false;
205 }
206
207 pub fn dismiss(&mut self) {
209 self.visible = false;
210 self.user_dismissed = true;
211 }
212
213 pub fn is_visible(&self) -> bool {
215 self.visible
216 }
217
218 pub fn is_user_dismissed(&self) -> bool {
220 self.user_dismissed
221 }
222
223 pub fn update_state(&mut self, state: ReplayControlState) {
225 self.at_end = state.progress_pct >= 100.0;
226 self.state = state;
227 if self.state.is_playing || self.state.ticks_processed > 0 {
229 self.visible = true;
230 }
231 }
232
233 pub fn set_playing(&mut self, playing: bool) {
235 self.state.is_playing = playing;
236 }
237
238 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 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 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 pub fn set_speed(&mut self, speed: f32) {
271 self.state.speed = speed;
272 }
273
274 pub fn set_symbol(&mut self, symbol: &str) {
276 self.state.symbol = symbol.to_string();
277 }
278
279 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 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 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 [
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 fn render_control(&mut self, ui: &mut Ui) -> ReplayControlAction {
369 let mut action = ReplayControlAction::None;
370
371 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 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 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 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 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
569pub 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 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 #[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 #[test]
703 fn shortcuts_leave_keys_to_focused_widgets() {
704 let cases = [
706 (vec![], false, Key::Space, ReplayControlAction::Pause),
707 (vec![], false, Key::Escape, ReplayControlAction::None),
708 (vec![], true, Key::Space, ReplayControlAction::None),
709 (
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}