Skip to main content

egui_charts/ui/widget_bar/panels/
mod.rs

1//! Right toolbar panels
2//!
3//! Individual panels for alerts, object tree, watchlists, symbol details, time and sales, news,
4//! the event calendars, the screener, hotlists, notifications, chats and the products launcher.
5
6use crate::scales::{CurrencyFormatter, PriceFormatter};
7use egui::text::TextFormat;
8use egui::{Color32, FontId};
9
10pub mod alerts_panel;
11pub mod calendar;
12pub mod chats;
13mod controls;
14pub mod hotlists;
15pub mod market_details;
16pub mod market_heatmap;
17pub mod news;
18pub mod notifications;
19pub mod object_tree;
20#[cfg(feature = "detectors")]
21pub mod order_flow;
22pub mod products;
23pub mod screener;
24pub mod symbol_details;
25pub mod time_and_sales;
26pub mod watchlist;
27
28pub use alerts_panel::*;
29pub use calendar::*;
30pub use chats::*;
31pub use hotlists::*;
32pub use market_details::*;
33pub use market_heatmap::*;
34pub use news::*;
35pub use notifications::*;
36pub use object_tree::*;
37#[cfg(feature = "detectors")]
38pub use order_flow::*;
39pub use products::*;
40pub use screener::*;
41pub use symbol_details::*;
42pub use time_and_sales::*;
43pub use watchlist::*;
44
45/// Height shared by market data rows.
46const ROW_HEIGHT: f32 = 26.0;
47
48/// Height of a panel header row, which fits its title and action buttons.
49const HEADER_HEIGHT: f32 = 40.0;
50
51/// Text in the bundled face at `weight`, such as 700 for bold.
52fn weighted(size: f32, weight: f32, color: Color32) -> TextFormat {
53    let mut format = TextFormat::simple(FontId::proportional(size), color);
54    format.coords.push("wght", weight);
55    format
56}
57
58/// `value` with an explicit sign, unsigned when it rounds to zero, or a dash when not finite.
59fn signed(value: f64, decimals: usize) -> String {
60    let text = grouped(value, decimals);
61    if value > 0.0 && text.bytes().any(|b| matches!(b, b'1'..=b'9')) {
62        format!("+{text}")
63    } else {
64        text
65    }
66}
67
68/// A price with thousands separators and `decimals` decimals, or a dash when not finite.
69fn grouped(value: f64, decimals: usize) -> String {
70    if !value.is_finite() {
71        return "-".to_owned();
72    }
73    CurrencyFormatter::new("", decimals).format(value)
74}
75
76/// Format price with decimals chosen by its magnitude, so a negative price
77/// keeps the decimals of the positive price of the same size
78fn format_price(price: f64, decimals: Option<u8>) -> String {
79    if !price.is_finite() {
80        return "\u{2014}".to_owned();
81    }
82    let decimals = decimals.unwrap_or_else(|| price_decimals(price));
83    format!("{:.*}", usize::from(decimals), price)
84}
85
86/// Decimals of a price label without instrument precision, chosen by the price's magnitude
87fn price_decimals(price: f64) -> u8 {
88    match price.abs() {
89        m if m >= 10000.0 => 0,
90        m if m >= 1000.0 => 1,
91        m if m >= 10.0 => 2,
92        m if m >= 1.0 => 4,
93        m if m >= 0.001 => 6,
94        _ => 8,
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use crate::data::{
102        Datafeed, FeedError, HistoryPage, HistoryRequest, HotlistEntry, HotlistQuery, Reply,
103        ScreenerColumn, ScreenerColumnSet, ScreenerQuery, ScreenerRow, SymbolDetails,
104    };
105    use crate::model::{Instrument, OrderSide, Quote, Tick};
106    use crate::theme::{Theme, apply_to_egui};
107
108    struct Feed;
109    impl Datafeed for Feed {
110        fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
111            reply.send(Err(FeedError::NotSupported));
112        }
113        fn hotlist(&self, _: HotlistQuery, reply: Reply<Vec<HotlistEntry>>) {
114            reply.send(Ok(vec![
115                HotlistEntry::new("AAA", 123.45, 12.34),
116                HotlistEntry::new("B", 10.0, 0.0),
117            ]));
118        }
119        fn screen(&self, _: ScreenerQuery, reply: Reply<Vec<ScreenerRow>>) {
120            reply.send(Ok(vec![
121                ScreenerRow::new("AAA", vec!["123.45".into()]),
122                ScreenerRow::new("B", vec!["10.00".into()]),
123            ]));
124        }
125        fn symbol_details(&self, _: &str, reply: Reply<SymbolDetails>) {
126            reply.send(Ok(SymbolDetails::new(
127                vec![("Market capitalization".into(), "2.95T USD".into())],
128                vec![("Sector".into(), "Technology".into())],
129            )
130            .with_category("Software")));
131        }
132    }
133
134    #[test]
135    fn market_panel_cells_stay_separate_in_narrow_windows() {
136        for theme in [Theme::light(), Theme::dark()] {
137            for width in [220.0, 280.0] {
138                for panel in [
139                    "watchlist",
140                    "details",
141                    "tape",
142                    "tape_combined",
143                    "hotlist",
144                    "screener",
145                    "news",
146                ] {
147                    let tape = matches!(panel, "tape" | "tape_combined");
148                    let ctx = egui::Context::default();
149                    apply_to_egui(&ctx, &theme);
150                    let symbol = "EXCHANGE:LONG_SYMBOL_NAME";
151                    let mut watchlist = WatchlistPanel::new(vec![Watchlist::from_text(
152                        "A long watchlist name that must leave room for its menu",
153                        &format!("{symbol},A"),
154                    )]);
155                    let time = Default::default();
156                    watchlist.set_quote(symbol, Quote::new(123456.789, 100000.0, time));
157                    watchlist.set_price_decimals(symbol, 3);
158                    watchlist.set_quote("A", Quote::new(10.0, 10.0, time));
159                    watchlist.set_price_decimals("A", 2);
160                    watchlist.set_mark(
161                        "A",
162                        egui::load::SizedTexture::new(
163                            egui::TextureId::User(1),
164                            egui::Vec2::splat(18.0),
165                        )
166                        .into(),
167                    );
168                    let mut details = SymbolDetailsPanel::new();
169                    details.set_instrument(
170                        &Instrument::new(symbol)
171                            .with_description("A long description supplied by the feed")
172                            .with_price_decimals(3),
173                    );
174                    details.set_quote(Quote::new(123456.789, 100000.0, time));
175                    details.set_feed(std::sync::Arc::new(Feed));
176                    details.poll(&ctx);
177                    let mut trades = TimeAndSalesPanel::default();
178                    trades.combine_fills = panel == "tape_combined";
179                    trades.config.price_decimals = Some(2);
180                    trades.push(Tick::new(
181                        "2026-10-23T12:34:55Z".parse().unwrap(),
182                        2.0,
183                        3.0,
184                        None,
185                    ));
186                    trades.push(Tick::new(
187                        "2026-10-23T12:34:56Z".parse().unwrap(),
188                        123456789.12,
189                        123456.0,
190                        Some(OrderSide::Buy),
191                    ));
192                    if trades.combine_fills {
193                        trades.push(Tick::new(
194                            "2026-10-23T12:34:56Z".parse().unwrap(),
195                            123456789.12,
196                            0.0,
197                            Some(OrderSide::Buy),
198                        ));
199                    }
200                    trades.filters = vec![
201                        TradeSizeFilter {
202                            min_size: 1.0,
203                            sound: false
204                        };
205                        20
206                    ];
207                    let mut hotlist = HotlistsPanel::new(vec!["Gainers".into()]);
208                    hotlist.set_feed(std::sync::Arc::new(Feed));
209                    hotlist.poll(&ctx);
210                    let mut screener = ScreenerPanel::new(vec![ScreenerColumnSet::new(
211                        "Overview",
212                        vec![ScreenerColumn::new("last", "Last")],
213                    )]);
214                    screener.set_feed(std::sync::Arc::new(Feed));
215                    screener.poll(&ctx);
216                    let mut news = NewsPanel::new();
217                    news.set_symbol(symbol);
218                    let mut used = egui::Rect::NOTHING;
219                    let output = ctx.run_ui(
220                        egui::RawInput {
221                            screen_rect: Some(egui::Rect::from_min_size(
222                                egui::Pos2::ZERO,
223                                egui::vec2(width, 300.0),
224                            )),
225                            ..Default::default()
226                        },
227                        |ui| {
228                            if panel == "details" {
229                                details.show(ui);
230                            } else if panel == "news" {
231                                news.show(ui);
232                            } else if panel == "hotlist" {
233                                hotlist.show(ui);
234                            } else if panel == "screener" {
235                                screener.show(ui);
236                            } else if tape {
237                                trades.show(ui);
238                            } else {
239                                watchlist.show(ui);
240                            }
241                            used = ui.min_rect();
242                        },
243                    );
244                    let texts: Vec<_> = output
245                        .shapes
246                        .iter()
247                        .filter_map(|shape| {
248                            let egui::Shape::Text(text) = &shape.shape else {
249                                return None;
250                            };
251                            let rect = text.visual_bounding_rect().intersect(shape.clip_rect);
252                            (rect.is_positive()).then(|| (text.galley.text().to_owned(), rect))
253                        })
254                        .collect();
255                    output.drop_without_applying_deltas();
256                    assert!(
257                        used.right() <= width,
258                        "{panel} expanded beyond {width}: {used:?}"
259                    );
260                    if panel == "details" {
261                        for label in ["Software", "Key statistics", "2.95T USD", "Profile"] {
262                            assert!(texts.iter().any(|(text, _)| text == label), "{texts:?}");
263                        }
264                        continue;
265                    }
266                    if panel == "news" {
267                        assert!(texts.iter().any(|(text, _)| text == "Market"), "{texts:?}");
268                        continue;
269                    }
270                    if matches!(panel, "hotlist" | "screener") {
271                        let bounds = |label: &str| {
272                            texts
273                                .iter()
274                                .rev()
275                                .find(|(text, _)| text == label)
276                                .unwrap_or_else(|| panic!("{panel}: missing {label}: {texts:?}"))
277                                .1
278                        };
279                        for label in ["123.45", "10.00"] {
280                            assert!(
281                                (bounds(label).right() - bounds("Last").right()).abs() < 2.0,
282                                "{panel}: {texts:?}"
283                            );
284                        }
285                        assert!(
286                            (bounds("B").center().y - bounds("AAA").center().y - 26.0).abs() < 1.0,
287                            "{panel}: rows must have watchlist height: {texts:?}"
288                        );
289                        assert!(!bounds("AAA").intersects(bounds("123.45")), "{texts:?}");
290                        continue;
291                    }
292                    let row: Vec<_> = texts
293                        .iter()
294                        .filter(|(text, _)| {
295                            if tape {
296                                matches!(text.as_str(), "12:34:56" | "123456789.12" | "123.46K")
297                                    || (panel == "tape_combined" && text == "2")
298                            } else {
299                                [symbol, "123,456.789", "+23,456.789", "+23.46%"]
300                                    .contains(&text.as_str())
301                            }
302                        })
303                        .collect();
304                    assert_eq!(row.len(), if panel == "tape" { 3 } else { 4 }, "{texts:?}");
305                    // The last match, so a symbol's name wins over its mark's initial.
306                    let bounds = |label: &str| {
307                        texts
308                            .iter()
309                            .rev()
310                            .find(|(text, _)| text == label)
311                            .unwrap()
312                            .1
313                    };
314                    if !tape {
315                        // The name follows the 18 point mark at the symbol column's edge.
316                        assert!((bounds("A").left() - 34.0).abs() < 2.0, "{texts:?}");
317                        assert!(
318                            (bounds("A").center().y - bounds(symbol).center().y - 30.0).abs() < 1.0,
319                            "{texts:?}"
320                        );
321                    }
322                    let (value, heading) = if tape {
323                        ("2.00", "Price")
324                    } else {
325                        ("10.00", "Last")
326                    };
327                    assert!(
328                        (bounds(value).right() - bounds(heading).right()).abs() < 2.0,
329                        "{texts:?}"
330                    );
331                    for (i, (_, rect)) in row.iter().enumerate() {
332                        assert!(rect.left() >= 0.0 && rect.right() <= width, "{row:?}");
333                        for (_, other) in &row[i + 1..] {
334                            assert!(!rect.intersects(*other), "{row:?}");
335                        }
336                    }
337                }
338            }
339        }
340    }
341
342    #[test]
343    fn panel_numbers_group_digits_and_omit_signs_at_rounded_zero() {
344        for (value, decimals, price, change) in [
345            (43237.45, 2, "43,237.45", "+43,237.45"),
346            (-1234567.0, 0, "-1,234,567", "-1,234,567"),
347            (863.83, 2, "863.83", "+863.83"),
348            (100000.5, 1, "100,000.5", "+100,000.5"),
349            (0.000123, 6, "0.000123", "+0.000123"),
350            (-0.0, 2, "0.00", "0.00"),
351            (-0.001, 2, "0.00", "0.00"),
352            (0.001, 2, "0.00", "0.00"),
353            (f64::INFINITY, 2, "-", "-"),
354            (f64::NAN, 2, "-", "-"),
355        ] {
356            assert_eq!(grouped(value, decimals), price);
357            assert_eq!(signed(value, decimals), change);
358        }
359    }
360
361    #[test]
362    fn test_format_price() {
363        assert_eq!(format_price(0.0, None), "0.00000000");
364        assert_eq!(format_price(f64::NAN, Some(2)), "\u{2014}");
365        assert_eq!(format_price(50000.0, None), "50000");
366        assert_eq!(format_price(1234.5, None), "1234.5");
367        assert_eq!(format_price(99.99, None), "99.99");
368        assert_eq!(format_price(1.2345, None), "1.2345");
369        assert_eq!(format_price(0.001234, None), "0.001234");
370        assert_eq!(format_price(-37.63, None), "-37.63");
371        for (price, decimals, expected) in [
372            (50000.25, 2, "50000.25"),
373            (-37.625, 3, "-37.625"),
374            (0.0, 2, "0.00"),
375            (1.2345, 4, "1.2345"),
376        ] {
377            assert_eq!(format_price(price, Some(decimals)), expected);
378        }
379    }
380}