1mod custom;
5
6use crate::icons::icons as embedded_icons;
7use crate::localization as l10n;
8use crate::model::Timeframe;
9use crate::styles::focus::focus_ring_color;
10use crate::styles::icons as icon_sizes;
11use crate::theme::{Theme, ThemeExt};
12use crate::tokens::DESIGN_TOKENS;
13use crate::ui::top_toolbar::buttons::Trigger;
14use crate::ui_kit::list_item::{ContentContext, LabelContent, ListItem, ListItemContent};
15use egui::{Color32, Rect, RichText, Role, Ui, WidgetInfo, pos2, vec2};
16use std::collections::HashSet;
17use std::hash::Hash;
18use std::num::{NonZeroU32, NonZeroU64};
19
20pub use custom::CustomTimeframeDialog;
21
22const ROW_H: f32 = 32.0;
24
25const LABEL_SIZE: f32 = 16.0;
27
28const CUSTOM_FORM_W: f32 = 232.0;
30
31pub use crate::model::TimeframeUnit;
32
33fn interval(value: u32, unit: TimeframeUnit) -> Timeframe {
34 let count = std::num::NonZeroU64::new(u64::from(value)).expect("validated interval");
35 match unit {
36 TimeframeUnit::Tick => Timeframe::Ticks(count),
37 TimeframeUnit::Volume => Timeframe::Volume(count),
38 TimeframeUnit::Range => Timeframe::Range(count),
39 TimeframeUnit::Delta => Timeframe::Delta(count),
40 _ => format!(
41 "{value}{}",
42 match unit {
43 TimeframeUnit::Minute => "min",
44 TimeframeUnit::Month => "month",
45 _ => unit.short_name(),
46 }
47 )
48 .parse()
49 .expect("valid interval unit"),
50 }
51}
52
53fn parts(tf: Timeframe) -> (u64, TimeframeUnit) {
54 use TimeframeUnit as Unit;
55 let seconds = match tf {
56 Timeframe::Custom(seconds) => seconds.get(),
57 _ => tf.duration_ms() as u64 / 1000,
58 };
59 match tf {
60 Timeframe::Ms100 => (100, Unit::Millisecond),
61 Timeframe::Ms250 => (250, Unit::Millisecond),
62 Timeframe::Ms500 => (500, Unit::Millisecond),
63 Timeframe::Ticks(n) => (n.get(), Unit::Tick),
64 Timeframe::Volume(n) => (n.get(), Unit::Volume),
65 Timeframe::Range(n) => (n.get(), Unit::Range),
66 Timeframe::Delta(n) => (n.get(), Unit::Delta),
67 Timeframe::Months(n) => (u64::from(n.get()), Unit::Month),
68 Timeframe::Weeks(n) => (u64::from(n.get()), Unit::Week),
69 Timeframe::Month1 => (1, Unit::Month),
70 Timeframe::Week1 => (1, Unit::Week),
71 _ if seconds >= 86_400 && seconds % 86_400 == 0 => (seconds / 86_400, Unit::Day),
72 _ if seconds >= 3_600 && seconds % 3_600 == 0 => (seconds / 3_600, Unit::Hour),
73 _ if seconds >= 60 && seconds % 60 == 0 => (seconds / 60, Unit::Minute),
74 _ => (seconds, Unit::Second),
75 }
76}
77
78fn display(tf: Timeframe) -> String {
79 let (value, unit) = parts(tf);
80 if value == 1
81 && matches!(
82 unit,
83 TimeframeUnit::Day | TimeframeUnit::Week | TimeframeUnit::Month
84 )
85 {
86 unit.short_name().to_owned()
87 } else {
88 format!("{value}{}", unit.short_name())
89 }
90}
91
92pub(crate) fn display_long(tf: Timeframe) -> String {
93 let (value, unit) = parts(tf);
94 let name = if value == 1 {
95 match unit {
96 TimeframeUnit::Tick => "tick",
97 TimeframeUnit::Volume => "volume unit",
98 TimeframeUnit::Range => "range tick",
99 TimeframeUnit::Delta => "delta unit",
100 TimeframeUnit::Millisecond => "millisecond",
101 TimeframeUnit::Second => "second",
102 TimeframeUnit::Minute => "minute",
103 TimeframeUnit::Hour => "hour",
104 TimeframeUnit::Day => "day",
105 TimeframeUnit::Week => "week",
106 TimeframeUnit::Month => "month",
107 }
108 } else {
109 unit.name()
110 };
111 format!("{value} {name}")
112}
113
114const PRESETS: &[Timeframe] = &[
115 Timeframe::Ticks(NonZeroU64::new(1).unwrap()),
117 Timeframe::Ticks(NonZeroU64::new(10).unwrap()),
118 Timeframe::Ticks(NonZeroU64::new(100).unwrap()),
119 Timeframe::Ticks(NonZeroU64::new(1000).unwrap()),
120 Timeframe::Volume(NonZeroU64::new(100).unwrap()),
121 Timeframe::Volume(NonZeroU64::new(1000).unwrap()),
122 Timeframe::Range(NonZeroU64::new(10).unwrap()),
123 Timeframe::Delta(NonZeroU64::new(100).unwrap()),
124 Timeframe::Ms100,
126 Timeframe::Ms250,
127 Timeframe::Ms500,
128 Timeframe::Sec1,
130 Timeframe::Sec2,
131 Timeframe::Sec5,
132 Timeframe::Sec10,
133 Timeframe::Custom(NonZeroU64::new(15).unwrap()),
134 Timeframe::Sec30,
135 Timeframe::Custom(NonZeroU64::new(45).unwrap()),
136 Timeframe::Min1,
138 Timeframe::Custom(NonZeroU64::new(120).unwrap()),
139 Timeframe::Custom(NonZeroU64::new(180).unwrap()),
140 Timeframe::Min5,
141 Timeframe::Custom(NonZeroU64::new(600).unwrap()),
142 Timeframe::Min15,
143 Timeframe::Min30,
144 Timeframe::Custom(NonZeroU64::new(2700).unwrap()),
145 Timeframe::Hour1,
147 Timeframe::Custom(NonZeroU64::new(7200).unwrap()),
148 Timeframe::Custom(NonZeroU64::new(10800).unwrap()),
149 Timeframe::Hour4,
150 Timeframe::Day1,
152 Timeframe::Week1,
153 Timeframe::Month1,
154 Timeframe::Months(NonZeroU32::new(3).unwrap()),
155 Timeframe::Months(NonZeroU32::new(6).unwrap()),
156 Timeframe::Months(NonZeroU32::new(12).unwrap()),
157];
158
159#[derive(Debug, Clone)]
161pub struct TimeframeSelectorConfig {
162 pub btn_width: f32,
164 pub dropdown_width: f32,
166}
167
168impl Default for TimeframeSelectorConfig {
169 fn default() -> Self {
170 Self {
171 btn_width: 0.0,
172 dropdown_width: 184.0,
173 }
174 }
175}
176
177#[derive(Debug, Clone, PartialEq)]
179#[non_exhaustive]
180pub enum TimeframeAction {
181 None,
183 Select(Timeframe),
185 ToggleFavorite(Timeframe),
187 Hide(Timeframe),
189 RestoreHidden,
191}
192
193#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
196pub struct TimeframePreferences {
197 #[serde(default)]
199 pub favorites: Vec<Timeframe>,
200 #[serde(default)]
202 pub custom_timeframes: Vec<Timeframe>,
203 #[serde(default)]
205 pub hidden_timeframes: Vec<Timeframe>,
206}
207
208pub struct TimeframeSelector {
210 pub current: Timeframe,
212 is_open: bool,
214 pub favorites: HashSet<Timeframe>,
216 pub custom_timeframes: Vec<Timeframe>,
218 pub config: TimeframeSelectorConfig,
220 show_custom_input: bool,
222 custom_dialog: CustomTimeframeDialog,
224 supported_timeframes: Option<HashSet<crate::model::Timeframe>>,
225 feed_timeframes: Option<HashSet<crate::model::Timeframe>>,
226 hidden_timeframes: HashSet<Timeframe>,
227 last_non_favorite: Option<Timeframe>,
228}
229
230impl Default for TimeframeSelector {
231 fn default() -> Self {
232 Self::new(Timeframe::Day1)
233 }
234}
235
236impl TimeframeSelector {
237 pub fn new(initial: Timeframe) -> Self {
239 Self {
240 current: initial,
241 is_open: false,
242 favorites: HashSet::new(),
243 custom_timeframes: Vec::new(),
244 config: TimeframeSelectorConfig::default(),
245 show_custom_input: false,
246 custom_dialog: CustomTimeframeDialog::default(),
247 supported_timeframes: None,
248 feed_timeframes: None,
249 hidden_timeframes: HashSet::new(),
250 last_non_favorite: None,
251 }
252 }
253
254 pub fn set_supported_timeframes(&mut self, timeframes: Option<Vec<crate::model::Timeframe>>) {
259 self.supported_timeframes = timeframes.map(|values| values.into_iter().collect());
260 }
261
262 pub fn set_feed_capabilities(&mut self, capabilities: &crate::data::FeedCapabilities) {
267 self.feed_timeframes = capabilities
268 .timeframes
269 .as_ref()
270 .map(|values| values.iter().copied().collect());
271 }
272
273 pub(crate) fn supports(&self, timeframe: Timeframe) -> bool {
274 [&self.supported_timeframes, &self.feed_timeframes]
275 .into_iter()
276 .all(|supported| {
277 supported
278 .as_ref()
279 .is_none_or(|supported| supported.contains(&timeframe))
280 })
281 }
282
283 pub fn preferences(&self) -> TimeframePreferences {
285 let mut favorites: Vec<_> = self.favorites.iter().copied().collect();
286 favorites.sort_by_key(|tf| (parts(*tf).1 as u8, parts(*tf).0));
287 let mut hidden_timeframes: Vec<_> = self.hidden_timeframes.iter().copied().collect();
288 hidden_timeframes.sort_by_key(|tf| (parts(*tf).1 as u8, parts(*tf).0));
289 TimeframePreferences {
290 favorites,
291 hidden_timeframes,
292 custom_timeframes: self.custom_timeframes.clone(),
293 }
294 }
295
296 pub fn set_preferences(&mut self, preferences: TimeframePreferences) {
298 self.hidden_timeframes = preferences.hidden_timeframes.into_iter().collect();
299 self.favorites = preferences
300 .favorites
301 .into_iter()
302 .filter(|tf| !self.hidden_timeframes.contains(tf))
303 .collect();
304 self.custom_timeframes.clear();
305 for tf in preferences.custom_timeframes {
306 if !self.custom_timeframes.contains(&tf) && !PRESETS.contains(&tf) {
307 self.custom_timeframes.push(tf);
308 }
309 }
310 }
311
312 pub fn with_config(mut self, config: TimeframeSelectorConfig) -> Self {
314 self.config = config;
315 self
316 }
317
318 pub fn show(&mut self, ui: &mut Ui) -> TimeframeAction {
320 let extra = retain_non_favorite(&mut self.last_non_favorite, self.current, &self.favorites)
321 .filter(|tf| self.supports(*tf));
322 let favorites: Vec<_> = PRESETS
323 .iter()
324 .chain(&self.custom_timeframes)
325 .copied()
326 .filter(|tf| {
327 self.favorites.contains(tf)
328 && self.supports(*tf)
329 && !self.hidden_timeframes.contains(tf)
330 })
331 .collect();
332 let (btn_res, clicked) = favorite_strip(
333 ui,
334 self.current,
335 favorites,
336 extra,
337 self.is_open,
338 "Chart interval",
339 |ui, tf| self.interval_button(ui, tf),
340 );
341 let mut selected = TimeframeAction::None;
342 if let Some(tf) = clicked {
343 self.current = tf;
344 selected = TimeframeAction::Select(tf);
345 }
346
347 let theme = ui.active_theme();
348 let id = ui.make_persistent_id("timeframe_menu");
349 let max_height = menu_list_height(ui, btn_res.rect, ROW_H);
350 let action = egui::Popup::menu(&btn_res)
351 .id(id)
352 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
353 .align_alternatives(&[])
354 .frame(menu_frame(ui, &theme))
355 .gap(theme.spacing.xs)
356 .show(|ui| self.menu_ui(ui, &theme, max_height))
357 .map_or(TimeframeAction::None, |menu| menu.inner);
358 self.is_open = egui::Popup::is_id_open(ui.ctx(), id);
359 if action == TimeframeAction::None {
360 selected
361 } else {
362 action
363 }
364 }
365
366 fn interval_button(&self, ui: &Ui, tf: Timeframe) -> Trigger<'static> {
368 let theme = ui.active_theme();
369 let label = display(tf);
370 let text = ui.painter().layout_no_wrap(
371 l10n::text(ui.ctx(), &label),
372 egui::FontId::proportional(theme.text.title),
373 Color32::PLACEHOLDER,
374 );
375 Trigger {
376 icon: None,
377 name: display_long(tf),
378 label,
379 min_width: (text.size().x + 2.0 * theme.spacing.lg)
381 .ceil()
382 .max(self.config.btn_width),
383 filled: false,
384 }
385 }
386
387 fn label_width(&self, ui: &Ui) -> f32 {
389 let font = egui::FontId::proportional(LABEL_SIZE);
390 let width = |text: String| {
391 ui.painter()
392 .layout_no_wrap(
393 l10n::text(ui.ctx(), text),
394 font.clone(),
395 Color32::PLACEHOLDER,
396 )
397 .size()
398 .x
399 };
400 PRESETS
401 .iter()
402 .chain(&self.custom_timeframes)
403 .filter(|tf| self.supports(**tf) && !self.hidden_timeframes.contains(tf))
404 .map(|tf| width(display_long(*tf)))
405 .fold(0.0, f32::max)
406 }
407
408 fn menu_ui(&mut self, ui: &mut Ui, theme: &Theme, max_height: f32) -> TimeframeAction {
409 let mut action = TimeframeAction::None;
410 ui.spacing_mut().item_spacing.y = 0.0;
412 crate::styles::menu_scroll_bar(ui);
413 let bar = ui.spacing().scroll.allocated_width();
414
415 let desc_w = self.label_width(ui);
417 let reserved = desc_w + 2.0 * theme.spacing.lg + STAR_SLOT_W + bar;
418 ui.set_width(
419 reserved
420 .max(self.config.dropdown_width)
421 .max(if self.show_custom_input {
422 CUSTOM_FORM_W
423 } else {
424 0.0
425 }),
426 );
427 ui.visuals_mut().selection.bg_fill = theme.ui.text;
428 ui.visuals_mut().selection.stroke.color = theme.ui.panel_bg;
429 ui.set_max_height(max_height);
431
432 let row = |ui: &mut Ui, tf: Timeframe, favorite: bool, current: Timeframe| {
433 let mut toggle_favorite = false;
434 let mut hide = false;
435 let content = TimeframeRow {
436 tf,
437 favorite,
438 theme,
439 toggle_favorite: &mut toggle_favorite,
440 hide: &mut hide,
441 };
442 let response = ListItem::new()
443 .selected(current == tf)
444 .height(ROW_H)
445 .show(ui, content);
446 if hide {
447 TimeframeAction::Hide(tf)
448 } else if toggle_favorite {
449 TimeframeAction::ToggleFavorite(tf)
450 } else if response.clicked() {
451 TimeframeAction::Select(tf)
452 } else {
453 TimeframeAction::None
454 }
455 };
456
457 let current = self.current;
458 egui::ScrollArea::vertical()
459 .max_height(max_height)
460 .auto_shrink([false, true])
461 .show(ui, |ui| {
462 let custom = ListItem::new().height(ROW_H).show(
463 ui,
464 LabelContent::new("Custom interval\u{2026}").with_icon(&embedded_icons::PLUS),
465 );
466 if custom.clicked() {
467 self.show_custom_input = !self.show_custom_input;
468 }
469 if self.show_custom_input
470 && let Some(tf) = self.custom_dialog.show(ui)
471 {
472 if self.supports(tf) {
473 self.hidden_timeframes.remove(&tf);
474 if !PRESETS.contains(&tf) && !self.custom_timeframes.contains(&tf) {
475 self.custom_timeframes.push(tf);
476 }
477 action = TimeframeAction::Select(tf);
478 } else {
479 self.custom_dialog.error =
480 Some("Interval unavailable from this source".to_owned());
481 }
482 }
483 let (rule, _) = ui.allocate_exact_size(
484 vec2(ui.available_width(), DESIGN_TOKENS.stroke.hairline),
485 egui::Sense::hover(),
486 );
487 ui.painter().rect_filled(rule, 0.0, theme.ui.border);
488
489 let mut first = true;
490 for (unit, header) in [
491 (TimeframeUnit::Tick, "Ticks"),
492 (TimeframeUnit::Volume, "Volume"),
493 (TimeframeUnit::Range, "Range"),
494 (TimeframeUnit::Delta, "Delta"),
495 (TimeframeUnit::Millisecond, "Milliseconds"),
496 (TimeframeUnit::Second, "Seconds"),
497 (TimeframeUnit::Minute, "Minutes"),
498 (TimeframeUnit::Hour, "Hours"),
499 (TimeframeUnit::Day, "Days"),
500 (TimeframeUnit::Week, "Weeks"),
501 (TimeframeUnit::Month, "Months"),
502 ] {
503 let available: Vec<_> = PRESETS
504 .iter()
505 .copied()
506 .filter(|tf| {
507 parts(*tf).1 == unit
508 && self.supports(*tf)
509 && !self.hidden_timeframes.contains(tf)
510 })
511 .collect();
512 if available.is_empty() {
513 continue;
514 }
515 let separated = !first;
516 first = false;
517 if !section_header(ui, theme, header, separated) {
518 continue;
519 }
520 for tf in available {
521 let picked = row(ui, tf, self.favorites.contains(&tf), current);
522 if picked != TimeframeAction::None {
523 action = picked;
524 }
525 }
526 }
527 let custom: Vec<_> = self
528 .custom_timeframes
529 .iter()
530 .copied()
531 .filter(|tf| self.supports(*tf) && !self.hidden_timeframes.contains(tf))
532 .collect();
533 if !custom.is_empty() && section_header(ui, theme, "Custom", !first) {
534 for tf in &custom {
535 let picked = row(ui, *tf, self.favorites.contains(tf), current);
536 if picked != TimeframeAction::None {
537 action = picked;
538 }
539 }
540 }
541
542 if !self.hidden_timeframes.is_empty()
543 && ListItem::new()
544 .height(ROW_H)
545 .show(ui, LabelContent::new("Restore hidden intervals"))
546 .clicked()
547 {
548 action = TimeframeAction::RestoreHidden;
549 }
550 });
551
552 match action {
553 TimeframeAction::Hide(tf) => {
554 self.hidden_timeframes.insert(tf);
555 self.favorites.remove(&tf);
556 }
557 TimeframeAction::RestoreHidden => self.hidden_timeframes.clear(),
558 _ => {}
559 }
560 if let TimeframeAction::ToggleFavorite(tf) = action
561 && !self.favorites.remove(&tf)
562 {
563 self.favorites.insert(tf);
564 }
565 if let TimeframeAction::Select(timeframe) = action {
566 self.current = timeframe;
567 self.show_custom_input = false;
568 ui.close();
569 }
570 action
571 }
572}
573
574pub(super) fn menu_frame(ui: &Ui, theme: &Theme) -> egui::Frame {
576 egui::Frame::popup(ui.style())
577 .fill(theme.ui.panel_bg)
578 .inner_margin(egui::Margin::symmetric(
579 (theme.spacing.md - DESIGN_TOKENS.stroke.hairline) as i8,
580 theme.spacing.sm as i8,
581 ))
582}
583
584pub(super) fn menu_list_height(ui: &Ui, trigger: Rect, row_h: f32) -> f32 {
587 let theme = ui.active_theme();
588 let available = ui.ctx().content_rect().bottom()
589 - trigger.bottom()
590 - theme.spacing.xs
591 - theme.spacing.lg
592 - ui.spacing().menu_margin.sum().y;
593 (available / row_h).floor().max(3.0) * row_h
594}
595
596pub(super) fn menu_row_focus(ui: &Ui, response: &egui::Response) {
599 if response.has_focus() {
600 ui.painter().rect_stroke(
601 response.rect,
602 ui.visuals().menu_corner_radius,
603 egui::Stroke::new(DESIGN_TOKENS.stroke.thick, focus_ring_color(ui)),
604 egui::StrokeKind::Inside,
605 );
606 }
607 if response.gained_focus() {
608 response.scroll_to_me(None);
609 }
610}
611
612pub(super) fn retain_non_favorite<T: Copy + Eq + Hash>(
615 last: &mut Option<T>,
616 current: T,
617 favorites: &HashSet<T>,
618) -> Option<T> {
619 if !favorites.contains(¤t) {
620 *last = Some(current);
621 }
622 last.filter(|choice| !favorites.contains(choice))
623}
624
625pub(super) fn favorite_strip<T: Copy + PartialEq>(
631 ui: &mut Ui,
632 current: T,
633 favorites: Vec<T>,
634 extra: Option<T>,
635 open: bool,
636 menu_name: &str,
637 button: impl Fn(&Ui, T) -> Trigger<'static>,
638) -> (egui::Response, Option<T>) {
639 if favorites.is_empty() {
640 return (button(ui, current).show(ui, open), None);
641 }
642 let mut clicked = None;
643 for choice in favorites.into_iter().chain(extra) {
644 if button(ui, choice).show(ui, choice == current).clicked() {
645 clicked = Some(choice);
646 }
647 }
648 let menu = Trigger {
649 icon: Some(&embedded_icons::CHEVRON_DOWN),
650 name: menu_name.to_owned(),
651 label: String::new(),
652 min_width: 0.0,
653 filled: false,
654 }
655 .show(ui, open);
656 (menu, clicked)
657}
658
659pub(super) const STAR_SLOT_W: f32 = 28.0;
661
662pub(super) fn favorite_star(
666 ui: &Ui,
667 context: &ContentContext<'_>,
668 favorite: bool,
669 on_hover: bool,
670 color: Color32,
671) -> bool {
672 let rect = context.rect;
673 let star_rect = Rect::from_center_size(
674 pos2(rect.right() - icon_sizes::SM / 2.0, rect.center().y),
675 vec2(STAR_SLOT_W, rect.height()),
676 );
677 let star = ui.interact(
678 star_rect,
679 context.response.id.with("favorite"),
680 egui::Sense::click(),
681 );
682 let tooltip = if favorite {
683 "Remove from favorites"
684 } else {
685 "Add to favorites"
686 };
687 let translated_label = l10n::text(ui.ctx(), tooltip);
688 star.widget_info(|| {
689 WidgetInfo::selected(Role::Button, ui.is_enabled(), favorite, &translated_label)
690 });
691 let shown = !on_hover
692 || favorite
693 || ui.rect_contains_pointer(context.response.rect)
694 || context.response.has_focus()
695 || star.has_focus();
696 if shown {
697 let icon = if favorite {
698 &embedded_icons::STAR_FILLED
699 } else {
700 &embedded_icons::STAR_EMPTY
701 };
702 icon.as_image_tinted(vec2(icon_sizes::SM, icon_sizes::SM), color)
703 .paint_at(
704 ui,
705 Rect::from_center_size(star_rect.center(), vec2(icon_sizes::SM, icon_sizes::SM)),
706 );
707 }
708 menu_row_focus(ui, &star);
709 let clicked = star.clicked();
710 star.on_hover_text(translated_label);
711 clicked
712}
713
714fn section_header(ui: &mut Ui, theme: &Theme, text: &str, separated: bool) -> bool {
718 if separated {
719 ui.add_space(12.0);
720 ui.separator();
721 }
722 let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 40.0), egui::Sense::hover());
723 let response = ui.interact(rect, ui.make_persistent_id(text), egui::Sense::click());
725 let folded_id = response.id.with("folded");
726 let mut folded = ui.data(|d| d.get_temp::<bool>(folded_id).unwrap_or(false));
727 if response.clicked() {
728 folded = !folded;
729 ui.data_mut(|d| d.insert_temp(folded_id, folded));
730 }
731 let translated_label = l10n::text(ui.ctx(), text);
732 response.widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &translated_label));
733 ui.ctx()
734 .accesskit_node_builder(response.id, |node| node.set_expanded(!folded));
735 let inner = rect.shrink2(vec2(theme.spacing.lg, theme.spacing.sm));
736 let mut title = ui.new_child(
738 egui::UiBuilder::new()
739 .max_rect(inner)
740 .layout(egui::Layout::bottom_up(egui::Align::Min)),
741 );
742 let label = title.add(
743 egui::Label::new(
744 RichText::new(l10n::text(ui.ctx(), text.to_uppercase()))
745 .size(theme.text.small)
746 .color(theme.ui.text_muted),
747 )
748 .selectable(false)
749 .sense(egui::Sense::hover()),
750 );
751 let tip = if folded { 2.5 } else { -2.5 };
753 let center = pos2(inner.right() - 4.0, label.rect.center().y);
754 ui.painter().line(
755 vec![
756 center + vec2(-4.0, -tip),
757 center + vec2(0.0, tip),
758 center + vec2(4.0, -tip),
759 ],
760 egui::Stroke::new(DESIGN_TOKENS.stroke.medium, theme.ui.text_muted),
761 );
762 menu_row_focus(ui, &response);
763 !folded
764}
765
766struct TimeframeRow<'a> {
768 tf: Timeframe,
769 favorite: bool,
770 theme: &'a Theme,
771 toggle_favorite: &'a mut bool,
772 hide: &'a mut bool,
773}
774
775impl ListItemContent for TimeframeRow<'_> {
776 fn ui(self, ui: &mut Ui, context: &ContentContext<'_>) -> egui::Response {
777 let theme = self.theme;
778 let rect = context.rect;
779 let text = if context.selected {
780 theme.ui.panel_bg
781 } else {
782 theme.ui.text
783 };
784 let translated_label = l10n::text(ui.ctx(), display_long(self.tf));
785 context.response.widget_info(|| {
786 WidgetInfo::selected(
787 Role::MenuItemRadio,
788 context.response.enabled(),
789 context.selected,
790 &translated_label,
791 )
792 });
793
794 let hide_rect = Rect::from_center_size(
795 pos2(rect.right() - icon_sizes::SM / 2.0 - 28.0, rect.center().y),
796 vec2(28.0, ROW_H),
797 );
798 let hide = ui.interact(
799 hide_rect,
800 context.response.id.with("hide"),
801 egui::Sense::click(),
802 );
803 let label = l10n::text(ui.ctx(), "Hide interval");
804 hide.widget_info(|| WidgetInfo::labeled(Role::Button, ui.is_enabled(), &label));
805 *self.hide = hide.clicked();
806 let shown = ui.rect_contains_pointer(context.response.rect)
808 || context.response.has_focus()
809 || hide.has_focus()
810 || ui.memory(|m| m.has_focus(context.response.id.with("favorite")));
811 if shown {
812 embedded_icons::CLOSE
813 .as_image_tinted(vec2(icon_sizes::SM, icon_sizes::SM), text)
814 .paint_at(
815 ui,
816 Rect::from_center_size(
817 hide_rect.center(),
818 vec2(icon_sizes::SM, icon_sizes::SM),
819 ),
820 );
821 }
822 menu_row_focus(ui, &hide);
823 hide.on_hover_text(label);
824
825 *self.toggle_favorite = favorite_star(ui, context, self.favorite, true, text);
826 let label_rect = if shown {
828 rect.with_max_x(hide_rect.left())
829 } else {
830 rect
831 };
832 ui.painter().with_clip_rect(label_rect).text(
833 pos2(rect.min.x, rect.center().y),
834 egui::Align2::LEFT_CENTER,
835 l10n::text(ui.ctx(), display_long(self.tf)),
836 egui::FontId::proportional(LABEL_SIZE),
837 text,
838 );
839 menu_row_focus(ui, context.response);
840 context.response.clone()
841 }
842}
843
844#[cfg(test)]
845mod tests {
846 use super::*;
847 use crate::model::Timeframe as Model;
848
849 #[test]
850 fn interval_buttons_retain_last_non_favorite_without_duplicates() {
851 for theme in [Theme::light(), Theme::dark()] {
852 let ctx = egui::Context::default();
853 egui_extras::install_image_loaders(&ctx);
854 crate::theme::apply_to_egui(&ctx, &theme);
855 ctx.enable_accesskit();
856 let mut selector = TimeframeSelector::new(Model::Min1);
857 assert!(selector.preferences().favorites.is_empty());
858 let frame = |selector: &mut TimeframeSelector, events| {
859 let mut action = TimeframeAction::None;
860 let output = ctx.run_ui(
861 egui::RawInput {
862 screen_rect: Some(Rect::from_min_size(
863 egui::Pos2::ZERO,
864 vec2(1440.0, 2400.0),
865 )),
866 events,
867 ..Default::default()
868 },
869 |ui| {
870 ui.horizontal(|ui| action = selector.show(ui));
871 },
872 );
873 let labels: Vec<_> = output
874 .shapes
875 .iter()
876 .filter_map(|shape| match &shape.shape {
877 egui::Shape::Text(text) if !text.galley.text().is_empty() => Some((
878 text.galley.text().to_owned(),
879 text.pos + text.galley.size() / 2.0,
880 )),
881 _ => None,
882 })
883 .collect();
884 let buttons: Vec<_> = output
885 .platform_output
886 .accesskit_update
887 .iter()
888 .flat_map(|update| &update.nodes)
889 .filter_map(|(_, node)| {
890 let bounds = node.bounds()?;
891 Some((
892 node.label()?.to_owned(),
893 Rect::from_min_max(
894 pos2(bounds.x0 as f32, bounds.y0 as f32),
895 pos2(bounds.x1 as f32, bounds.y1 as f32),
896 ),
897 ))
898 })
899 .collect();
900 let active = ctx.global_style().visuals.widgets.active.bg_fill;
901 let pressed: Vec<_> = labels
902 .iter()
903 .filter(|(_, pos)| {
904 output.shapes.iter().any(|shape| match &shape.shape {
905 egui::Shape::Rect(rect) => {
906 rect.rect.contains(*pos) && rect.fill == active
907 }
908 _ => false,
909 })
910 })
911 .map(|(label, _)| label.clone())
912 .collect();
913 output.drop_without_applying_deltas();
914 (action, labels, buttons, pressed)
915 };
916 let click = |selector: &mut TimeframeSelector, pos| {
917 frame(selector, vec![egui::Event::PointerMoved(pos)]);
918 let event = |pressed| egui::Event::PointerButton {
919 pos,
920 button: egui::PointerButton::Primary,
921 pressed,
922 modifiers: Default::default(),
923 };
924 frame(selector, vec![event(true)]);
925 frame(selector, vec![event(false)]).0
926 };
927 let strip = |selector: &mut TimeframeSelector, expected: &[&str], selected: &str| {
928 frame(selector, vec![]);
929 let (_, labels, _, pressed) = frame(selector, vec![egui::Event::PointerGone]);
930 assert_eq!(
931 labels
932 .iter()
933 .map(|(text, _)| text.as_str())
934 .collect::<Vec<_>>(),
935 expected
936 );
937 assert_eq!(
938 pressed,
939 if selected.is_empty() {
940 vec![]
941 } else {
942 vec![selected.to_owned()]
943 }
944 );
945 };
946 strip(&mut selector, &["1m"], "");
947 for tf in [Model::Min5, Model::Hour1, Model::Day1] {
948 let (_, _, buttons, _) = frame(&mut selector, vec![]);
949 let trigger = buttons
950 .iter()
951 .find(|(name, _)| name == "Chart interval")
952 .or_else(|| buttons.iter().find(|(name, _)| name == "1 minute"))
953 .unwrap()
954 .1;
955 click(&mut selector, trigger.center());
956 frame(&mut selector, vec![]);
957 let (_, _, buttons, _) = frame(&mut selector, vec![]);
958 let row = buttons
959 .iter()
960 .find(|(name, _)| *name == display_long(tf))
961 .unwrap_or_else(|| panic!("{tf:?}: {buttons:?}"))
962 .1;
963 assert_eq!(
964 click(
965 &mut selector,
966 pos2(row.right() - icon_sizes::SM / 2.0, row.center().y)
967 ),
968 TimeframeAction::ToggleFavorite(tf)
969 );
970 assert!(selector.preferences().favorites.contains(&tf));
971 click(&mut selector, pos2(1000.0, 2200.0));
973 }
974 strip(&mut selector, &["5m", "1h", "D", "1m"], "1m");
975 let (_, labels, _, _) = frame(&mut selector, vec![]);
976 let favorite = labels.iter().find(|(name, _)| name == "5m").unwrap().1;
977 assert_eq!(
978 click(&mut selector, favorite),
979 TimeframeAction::Select(Model::Min5)
980 );
981 strip(&mut selector, &["5m", "1h", "D", "1m"], "5m");
982 let (_, _, buttons, _) = frame(&mut selector, vec![]);
983 let trigger = buttons
984 .iter()
985 .find(|(name, _)| name == "Chart interval")
986 .unwrap()
987 .1;
988 click(&mut selector, trigger.center());
989 frame(&mut selector, vec![]);
990 let (_, labels, _, _) = frame(&mut selector, vec![]);
991 let quarter = labels
992 .iter()
993 .find(|(name, _)| name == "3 months")
994 .unwrap()
995 .1;
996 assert_eq!(
997 click(&mut selector, quarter),
998 TimeframeAction::Select("3month".parse().unwrap())
999 );
1000 strip(&mut selector, &["5m", "1h", "D", "3M"], "3M");
1001 let saved = selector.preferences();
1002 let mut restored = TimeframeSelector::new(Model::Min5);
1003 restored.set_preferences(saved.clone());
1004 strip(&mut restored, &["5m", "1h", "D"], "5m");
1005 assert_eq!(restored.preferences(), saved);
1006 restored.current = Model::Week1;
1007 strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1008 restored.favorites.insert(Model::Week1);
1009 strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1010 restored.favorites.remove(&Model::Week1);
1011 strip(&mut restored, &["5m", "1h", "D", "W"], "W");
1012 let custom = [
1013 interval(7, TimeframeUnit::Minute),
1014 interval(8, TimeframeUnit::Minute),
1015 ];
1016 restored.custom_timeframes.extend(custom);
1017 restored.favorites.extend(custom);
1018 strip(&mut restored, &["5m", "1h", "D", "7m", "8m", "W"], "W");
1019 restored.set_supported_timeframes(Some(vec![Model::Min5, Model::Week1]));
1020 strip(&mut restored, &["5m", "W"], "W");
1021 }
1022 }
1023
1024 #[test]
1025 fn custom_intervals_and_stars_survive_saved_preferences() {
1026 for theme in [Theme::light(), Theme::dark()] {
1027 for width in [320.0, 400.0] {
1028 let ctx = egui::Context::default();
1029 crate::theme::apply_to_egui(&ctx, &theme);
1030 let custom = interval(7, TimeframeUnit::Minute);
1031 let mut selector = TimeframeSelector {
1032 show_custom_input: true,
1033 custom_dialog: CustomTimeframeDialog {
1034 value_input: "7".to_owned(),
1035 ..Default::default()
1036 },
1037 ..Default::default()
1038 };
1039 let frame = |selector: &mut TimeframeSelector, events| {
1040 let mut action = TimeframeAction::None;
1041 let mut right = 0.0;
1042 let mut output = ctx.run_ui(
1043 egui::RawInput {
1044 screen_rect: Some(Rect::from_min_size(
1045 egui::Pos2::ZERO,
1046 vec2(width, 2600.0),
1047 )),
1048 events,
1049 ..Default::default()
1050 },
1051 |ui| {
1052 action = selector.menu_ui(ui, &theme, 2500.0);
1053 right = ui.min_rect().right();
1054 },
1055 );
1056 assert!(right <= width, "{right} exceeds {width}");
1057 let labels: Vec<_> = output
1058 .shapes
1059 .iter()
1060 .filter_map(|shape| match &shape.shape {
1061 egui::Shape::Text(text) => Some((
1062 text.galley.text().to_owned(),
1063 text.pos + text.galley.size() / 2.0,
1064 )),
1065 _ => None,
1066 })
1067 .collect();
1068 output.textures_delta.clear();
1069 output.drop_without_applying_deltas();
1070 (action, labels, right)
1071 };
1072 let click = |selector: &mut TimeframeSelector, pos| {
1073 frame(selector, vec![egui::Event::PointerMoved(pos)]);
1074 let event = |pressed| egui::Event::PointerButton {
1075 pos,
1076 button: egui::PointerButton::Primary,
1077 pressed,
1078 modifiers: Default::default(),
1079 };
1080 frame(selector, vec![event(true)]);
1081 frame(selector, vec![event(false)]).0
1082 };
1083 frame(&mut selector, vec![]);
1084 for unfolded in [false, true] {
1086 let (_, labels, _) = frame(&mut selector, vec![]);
1087 let header = labels.iter().find(|(label, _)| label == "TICKS").unwrap().1;
1088 assert_eq!(click(&mut selector, header), TimeframeAction::None);
1089 let (_, labels, _) = frame(&mut selector, vec![]);
1090 assert_eq!(labels.iter().any(|(label, _)| label == "1 tick"), unfolded);
1091 }
1092 let (_, labels, _) = frame(&mut selector, vec![]);
1093 let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1094 assert_eq!(click(&mut selector, add), TimeframeAction::Select(custom));
1095 assert_eq!(selector.custom_timeframes, vec![custom]);
1096 selector.show_custom_input = true;
1098 frame(&mut selector, vec![]);
1099 let (_, labels, _) = frame(&mut selector, vec![]);
1100 let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1101 click(&mut selector, add);
1102 assert_eq!(selector.custom_timeframes, vec![custom]);
1103 let selected = selector.current;
1104 for favorite in [true, false, true] {
1105 let (_, labels, right) = frame(&mut selector, vec![]);
1106 let y = labels
1107 .iter()
1108 .find(|(label, _)| *label == display_long(custom))
1109 .unwrap()
1110 .1
1111 .y;
1112 let action = click(
1113 &mut selector,
1114 pos2(right - theme.spacing.lg - icon_sizes::SM / 2.0, y),
1115 );
1116 assert_eq!(action, TimeframeAction::ToggleFavorite(custom));
1117 assert_eq!(selector.favorites.contains(&custom), favorite);
1118 assert_eq!(selector.current, selected);
1119 }
1120 for tf in [Timeframe::Min1, custom] {
1122 let (_, labels, right) = frame(&mut selector, vec![]);
1123 let y = labels
1124 .iter()
1125 .find(|(label, _)| *label == display_long(tf))
1126 .unwrap()
1127 .1
1128 .y;
1129 assert_eq!(
1130 click(
1131 &mut selector,
1132 pos2(right - theme.spacing.lg - icon_sizes::SM / 2.0 - 28.0, y)
1133 ),
1134 TimeframeAction::Hide(tf)
1135 );
1136 let (_, labels, _) = frame(&mut selector, vec![]);
1137 assert!(!labels.iter().any(|(label, _)| *label == display_long(tf)));
1138 assert_eq!(selector.current, selected);
1139 assert!(!selector.favorites.contains(&tf));
1140 }
1141 let saved = serde_json::to_string(&selector.preferences()).unwrap();
1142 let mut restored = TimeframeSelector::default();
1143 restored.set_preferences(serde_json::from_str(&saved).unwrap());
1144 assert_eq!(restored.preferences(), selector.preferences());
1145 restored.set_supported_timeframes(Some(vec![]));
1146 frame(&mut restored, vec![]);
1147 assert_eq!(restored.preferences(), selector.preferences());
1148 restored.set_supported_timeframes(None);
1149 let legacy: TimeframePreferences =
1150 serde_json::from_str(r#"{"favorites":[],"custom_timeframes":[]}"#).unwrap();
1151 assert!(legacy.hidden_timeframes.is_empty());
1152 let (_, labels, _) = frame(&mut restored, vec![]);
1153 assert!(!labels.iter().any(|(label, _)| label == "1 minute"));
1154 let restore = labels
1155 .iter()
1156 .find(|(label, _)| label == "Restore hidden intervals")
1157 .unwrap()
1158 .1;
1159 click(&mut restored, restore);
1160 let (_, labels, _) = frame(&mut restored, vec![]);
1161 assert!(labels.iter().any(|(label, _)| label == "1 minute"));
1162 assert!(
1163 labels
1164 .iter()
1165 .any(|(label, _)| *label == display_long(custom))
1166 );
1167 assert!(!restored.favorites.contains(&Timeframe::Min1));
1168 let saved = restored.preferences();
1169 restored.set_supported_timeframes(Some(vec![Model::Min1]));
1170 restored.show_custom_input = true;
1171 restored.custom_dialog.value_input = "8".to_owned();
1172 let (_, labels, _) = frame(&mut restored, vec![]);
1173 assert!(
1174 !labels
1175 .iter()
1176 .any(|(label, _)| *label == display_long(custom))
1177 );
1178 let add = labels.iter().find(|(label, _)| label == "Add").unwrap().1;
1179 assert_eq!(click(&mut restored, add), TimeframeAction::None);
1180 assert!(restored.custom_dialog.error.is_some());
1181 assert_eq!(restored.preferences(), saved);
1182 restored.custom_dialog.unit = TimeframeUnit::Millisecond;
1183 restored.custom_dialog.value_input = "1".to_owned();
1184 assert_eq!(click(&mut restored, add), TimeframeAction::None);
1185 assert!(restored.custom_dialog.error.is_some());
1186 assert_eq!(restored.preferences(), saved);
1187 restored.set_supported_timeframes(None);
1188 let (_, labels, _) = frame(&mut restored, vec![]);
1189 assert!(
1190 labels
1191 .iter()
1192 .any(|(label, _)| *label == display_long(custom))
1193 );
1194 }
1195 }
1196 }
1197
1198 #[test]
1200 fn feed_and_symbol_intervals_both_restrict_choices() {
1201 use crate::data::FeedCapabilities;
1202 let feed = |timeframes: Option<Vec<Model>>| {
1203 let mut capabilities = FeedCapabilities::new();
1204 capabilities.timeframes = timeframes;
1205 capabilities
1206 };
1207 for (feed_list, symbol_list, min1, day1) in [
1208 (None, None, true, true),
1209 (Some(vec![]), None, false, false),
1210 (Some(vec![Model::Min1]), None, true, false),
1211 (
1212 Some(vec![Model::Min1, Model::Day1]),
1213 Some(vec![Model::Day1]),
1214 false,
1215 true,
1216 ),
1217 ] {
1218 let mut selector = TimeframeSelector::default();
1219 selector.set_feed_capabilities(&feed(feed_list));
1220 selector.set_supported_timeframes(symbol_list);
1221 assert_eq!(selector.supports(Model::Min1), min1);
1222 assert_eq!(selector.supports(Model::Day1), day1);
1223 }
1224 }
1225
1226 #[test]
1227 fn interval_pick_updates_the_trigger_and_selection() {
1228 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1229 for (selected, short, long) in [
1230 (
1231 Model::Ticks(std::num::NonZeroU64::new(100).unwrap()),
1232 "100T",
1233 "100 ticks",
1234 ),
1235 (Model::Ms100, "100ms", "100 milliseconds"),
1236 (Model::Ms250, "250ms", "250 milliseconds"),
1237 (Model::Ms500, "500ms", "500 milliseconds"),
1238 (Model::Sec2, "2S", "2 seconds"),
1239 ("45s".parse().unwrap(), "45S", "45 seconds"),
1240 (Model::Min5, "5m", "5 minutes"),
1241 (Model::Hour1, "1h", "1 hour"),
1242 (Model::Day1, "D", "1 day"),
1243 (Model::Week1, "W", "1 week"),
1244 (Model::Month1, "M", "1 month"),
1245 ("2D".parse().unwrap(), "2D", "2 days"),
1246 ("2W".parse().unwrap(), "2W", "2 weeks"),
1247 ("3month".parse().unwrap(), "3M", "3 months"),
1248 ("6month".parse().unwrap(), "6M", "6 months"),
1249 ("12month".parse().unwrap(), "12M", "12 months"),
1250 (
1251 Model::Custom(std::num::NonZeroU64::MAX),
1252 "18446744073709551615S",
1253 "18446744073709551615 seconds",
1254 ),
1255 ] {
1256 assert_eq!(display(selected), short);
1257 assert_eq!(display_long(selected), long);
1258 for supported in [None, Some(vec![selected]), Some(vec![])] {
1259 let ctx = egui::Context::default();
1260 crate::theme::apply_to_egui(&ctx, &theme);
1261 let mut selector = TimeframeSelector::new(Model::Min1);
1262 if !PRESETS.contains(&selected) {
1263 selector.custom_timeframes.push(selected);
1264 }
1265 let available = supported.as_ref().is_none_or(|values| !values.is_empty());
1266 selector.set_supported_timeframes(supported.clone());
1267 let mut frame = |events| {
1268 let mut action = TimeframeAction::None;
1269 let mut output = ctx.run_ui(
1270 egui::RawInput {
1271 screen_rect: Some(Rect::from_min_size(
1272 egui::Pos2::ZERO,
1273 vec2(320.0, 2400.0),
1274 )),
1275 events,
1276 ..Default::default()
1277 },
1278 |ui| {
1279 action = selector.menu_ui(ui, &theme, 2400.0);
1280 assert!(
1281 ui.min_rect().right() <= 320.0,
1282 "{selected:?}: {:?}",
1283 ui.min_rect()
1284 );
1285 },
1286 );
1287 output.textures_delta.clear();
1288 if supported.is_some() {
1289 let labels: Vec<_> = output
1290 .shapes
1291 .iter()
1292 .filter_map(|shape| match &shape.shape {
1293 egui::Shape::Text(text) => Some(text.galley.text()),
1294 _ => None,
1295 })
1296 .collect();
1297 for &tf in PRESETS {
1298 assert_eq!(
1299 labels.contains(&display_long(tf).as_str()),
1300 available && tf == selected,
1301 "{tf:?}, {supported:?}"
1302 );
1303 }
1304 }
1305 let pos = output.shapes.iter().find_map(|shape| match &shape.shape {
1306 egui::Shape::Text(text)
1307 if text.galley.text() == display_long(selected) =>
1308 {
1309 Some(text.pos + text.galley.size() / 2.0)
1310 }
1311 _ => None,
1312 });
1313 output.textures_delta.clear();
1314 output.drop_without_applying_deltas();
1315 (pos, action)
1316 };
1317 let (pos, _) = frame(vec![]);
1318 let Some(pos) = pos else {
1319 assert!(!available, "{selected:?}, {supported:?}");
1320 continue;
1321 };
1322 frame(vec![egui::Event::PointerMoved(pos)]);
1323 let click = |pressed| egui::Event::PointerButton {
1324 pos,
1325 button: egui::PointerButton::Primary,
1326 pressed,
1327 modifiers: Default::default(),
1328 };
1329 frame(vec![click(true)]);
1330 let (_, action) = frame(vec![click(false)]);
1331 assert!(
1332 matches!(action, TimeframeAction::Select(timeframe) if timeframe == selected)
1333 );
1334 assert_eq!(selector.current, selected);
1335 }
1336 }
1337 }
1338 }
1339}