Skip to main content

egui_charts/ui/
symbol_header.rs

1//! Symbol Header Widget
2//!
3//! Displays symbol info, the last close and its change, bid/ask prices, and volume
4//! in a horizontal bar at the top of the chart area.
5
6use crate::localization as l10n;
7use crate::model::{Instrument, Quote, percent_change};
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10
11use crate::widget::QuoteSide;
12use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
13
14/// OHLC price data for the current bar
15#[derive(Clone, Debug, Default)]
16pub struct OhlcData {
17    /// Opening price
18    pub open: f64,
19    /// Highest price
20    pub high: f64,
21    /// Lowest price
22    pub low: f64,
23    /// Closing price
24    pub close: f64,
25    /// Absolute price change (close - open)
26    pub change: f64,
27    /// Percentage price change
28    pub change_percent: f64,
29    /// Trading volume
30    pub volume: f64,
31}
32
33impl OhlcData {
34    /// Create OHLC data and compute change values automatically
35    pub fn new(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Self {
36        let change = close - open;
37        let change_percent = percent_change(open, close);
38        Self {
39            open,
40            high,
41            low,
42            close,
43            change,
44            change_percent,
45            volume,
46        }
47    }
48
49    /// Return true if the bar closed at or above its open
50    pub fn is_bullish(&self) -> bool {
51        self.close >= self.open
52    }
53}
54
55/// Configuration for the symbol header
56#[derive(Clone, Debug)]
57pub struct SymbolHeaderConfig {
58    /// Height, in egui logical points.
59    pub height: f32,
60    /// Retained color value; the header lets its parent paint the background.
61    pub bg_color: Color32,
62    /// Text color.
63    pub text_color: Color32,
64    /// Label color.
65    pub label_color: Color32,
66    /// Bullish color.
67    pub bullish_color: Color32,
68    /// Bearish color.
69    pub bearish_color: Color32,
70    /// Separator color.
71    pub separator_color: Color32,
72    /// Whether to show the instrument description, exchange and interval label.
73    pub show_company_name: bool,
74    /// Whether to show bid ask.
75    pub show_bid_ask: bool,
76    /// Whether to show volume.
77    pub show_volume: bool,
78}
79
80impl Default for SymbolHeaderConfig {
81    fn default() -> Self {
82        // `Color32::PLACEHOLDER` colours resolve from the active egui style while painting.
83        Self {
84            height: DESIGN_TOKENS.sizing.symbol_header.height,
85            bg_color: Color32::TRANSPARENT, // Let parent Frame show through
86            text_color: Color32::PLACEHOLDER,
87            label_color: Color32::PLACEHOLDER,
88            bullish_color: Color32::PLACEHOLDER,
89            bearish_color: Color32::PLACEHOLDER,
90            separator_color: Color32::PLACEHOLDER,
91            show_company_name: true,
92            show_bid_ask: true,
93            show_volume: true,
94        }
95    }
96}
97
98impl SymbolHeaderConfig {
99    /// Create config with colours fixed to `theme`.
100    pub fn from_theme(theme: &crate::theme::Theme) -> Self {
101        let ui = &theme.ui;
102        Self {
103            text_color: ui.text,
104            label_color: ui.text_secondary,
105            bullish_color: ui.success,
106            bearish_color: ui.error,
107            separator_color: ui.border,
108            ..Self::default()
109        }
110    }
111
112    /// This config with `Color32::PLACEHOLDER` colours taken from the style and theme of `ui`.
113    fn resolved(&self, ui: &Ui) -> Self {
114        let visuals = ui.visuals();
115        // Text-grade status colours: the candle colours are too light for text on white.
116        let colors = ui.active_theme().ui;
117        let or = |c: Color32, style: Color32| if c == Color32::PLACEHOLDER { style } else { c };
118        Self {
119            bullish_color: or(self.bullish_color, colors.success),
120            bearish_color: or(self.bearish_color, colors.error),
121            text_color: or(self.text_color, visuals.strong_text_color()),
122            label_color: or(
123                self.label_color,
124                visuals.widgets.noninteractive.fg_stroke.color,
125            ),
126            separator_color: or(
127                self.separator_color,
128                visuals.widgets.noninteractive.bg_stroke.color,
129            ),
130            ..self.clone()
131        }
132    }
133}
134
135/// Symbol Header Widget
136pub struct SymbolHeader {
137    /// Instrument whose quote is shown.
138    pub instrument: Instrument,
139    /// Chart interval label.
140    pub timeframe: String,
141    /// Price and volume readout displayed in the header.
142    pub ohlc: OhlcData,
143    /// The symbol's quote, such as the one `WatchlistPanel::quote` holds. Its bid and ask show
144    /// when both are known.
145    pub quote: Option<Quote>,
146    /// Display and layout options for this component.
147    pub config: SymbolHeaderConfig,
148}
149
150impl SymbolHeader {
151    /// Create a new symbol header for the given symbol
152    pub fn new(instrument: Instrument, timeframe: impl Into<String>) -> Self {
153        Self {
154            instrument,
155            timeframe: timeframe.into(),
156            ohlc: OhlcData::default(),
157            quote: None,
158            config: SymbolHeaderConfig::default(),
159        }
160    }
161
162    /// Set OHLC price data for the current bar
163    pub fn with_ohlc(mut self, ohlc: OhlcData) -> Self {
164        self.ohlc = ohlc;
165        self
166    }
167
168    /// Set the quote whose bid and ask are shown
169    pub fn with_quote(mut self, quote: Quote) -> Self {
170        self.quote = Some(quote);
171        self
172    }
173
174    /// Set display configuration
175    pub fn with_config(mut self, config: SymbolHeaderConfig) -> Self {
176        self.config = config;
177        self
178    }
179
180    /// Show the symbol header
181    pub fn show(&self, ui: &mut Ui) -> Response {
182        self.show_with_quote_action(ui).0
183    }
184
185    /// Show the symbol header and return the Sell or Buy box the user clicked, for
186    /// [`Trading::trade`](crate::ui::trading::Trading::trade).
187    pub fn show_with_quote_action(&self, ui: &mut Ui) -> (Response, Option<QuoteSide>) {
188        let width = ui.available_width();
189        egui::ScrollArea::horizontal()
190            .auto_shrink([false, true])
191            .show(ui, |ui| {
192                let config = &self.config.resolved(ui);
193                let response =
194                    ui.allocate_response(Vec2::new(width, config.height), Sense::hover());
195                let painter = ui.painter().clone();
196                let rect = response.rect;
197
198                let mut x = rect.min.x + ui.active_theme().spacing.sm;
199                let center_y = rect.center().y;
200
201                // Symbol name (bold, larger)
202                let symbol_galley = painter.layout_no_wrap(
203                    self.instrument.symbol.clone(),
204                    egui::FontId::proportional(ui.active_theme().text.title),
205                    config.text_color,
206                );
207                painter.galley(
208                    Pos2::new(x, center_y - symbol_galley.size().y / 2.0),
209                    symbol_galley.clone(),
210                    Color32::TRANSPARENT,
211                );
212                x += symbol_galley.size().x + ui.active_theme().spacing.sm;
213
214                // Company name • Exchange • Timeframe, skipping empty fields
215                let info = &self.instrument;
216                let info_text = [
217                    info.description.as_deref().unwrap_or_default(),
218                    info.exchange.as_deref().unwrap_or_default(),
219                    &self.timeframe,
220                ]
221                .into_iter()
222                .filter(|field| !field.is_empty())
223                .collect::<Vec<_>>()
224                .join(" • ");
225                if config.show_company_name && !info_text.is_empty() {
226                    let info_galley = painter.layout_no_wrap(
227                        info_text,
228                        egui::FontId::proportional(ui.active_theme().text.body),
229                        config.label_color,
230                    );
231                    painter.galley(
232                        Pos2::new(x, center_y - info_galley.size().y / 2.0),
233                        info_galley.clone(),
234                        Color32::TRANSPARENT,
235                    );
236                    x += info_galley.size().x + ui.active_theme().spacing.md;
237                }
238
239                // Separator
240                self.draw_separator(
241                    &painter,
242                    config,
243                    x,
244                    rect.min.y + ui.active_theme().spacing.xs,
245                    rect.max.y - ui.active_theme().spacing.xs,
246                );
247                x += ui.active_theme().spacing.md;
248
249                // Last close and change; the chart legend shows the bar's OHLC.
250                let price_color = if self.ohlc.is_bullish() {
251                    config.bullish_color
252                } else {
253                    config.bearish_color
254                };
255
256                let change_text = format!(
257                    "{:.2} {}{:.2} ({}{:.2}%)",
258                    self.ohlc.close,
259                    if self.ohlc.change >= 0.0 { "+" } else { "" },
260                    self.ohlc.change,
261                    if self.ohlc.change_percent >= 0.0 {
262                        "+"
263                    } else {
264                        ""
265                    },
266                    self.ohlc.change_percent
267                );
268                let change_galley = painter.layout_no_wrap(
269                    change_text,
270                    egui::FontId::proportional(ui.active_theme().text.body),
271                    price_color,
272                );
273                painter.galley(
274                    Pos2::new(x, center_y - change_galley.size().y / 2.0),
275                    change_galley.clone(),
276                    Color32::TRANSPARENT,
277                );
278                x += change_galley.size().x + ui.active_theme().spacing.md;
279
280                // Separator
281                self.draw_separator(
282                    &painter,
283                    config,
284                    x,
285                    rect.min.y + ui.active_theme().spacing.xs,
286                    rect.max.y - ui.active_theme().spacing.xs,
287                );
288                x += ui.active_theme().spacing.md;
289
290                // Bid/Ask boxes
291                let mut quote_action = None;
292                let bid_ask = (self.quote.as_ref())
293                    .and_then(|quote| quote.bid.zip(quote.ask))
294                    .filter(|(bid, ask)| bid.is_finite() && ask.is_finite());
295                if config.show_bid_ask
296                    && let Some((bid, ask)) = bid_ask
297                {
298                    // SELL box (red background)
299                    let sell = self.draw_quote_box(
300                        &painter,
301                        x,
302                        center_y,
303                        bid,
304                        "SELL",
305                        config.bearish_color,
306                    );
307                    x = sell.right() + ui.active_theme().spacing.xs;
308                    // BUY box (green background)
309                    let buy = self.draw_quote_box(
310                        &painter,
311                        x,
312                        center_y,
313                        ask,
314                        "BUY",
315                        config.bullish_color,
316                    );
317                    x = buy.right() + ui.active_theme().spacing.md;
318                    for (rect, side) in [(sell, QuoteSide::Sell), (buy, QuoteSide::Buy)] {
319                        let id = response.id.with(side == QuoteSide::Buy);
320                        let quote_response = ui.interact(rect, id, Sense::click());
321                        let translated_label = l10n::text(
322                            ui.ctx(),
323                            if side == QuoteSide::Buy {
324                                "Buy"
325                            } else {
326                                "Sell"
327                            },
328                        );
329                        quote_response.widget_info(|| {
330                            egui::WidgetInfo::labeled(
331                                egui::Role::Button,
332                                quote_response.enabled(),
333                                &translated_label,
334                            )
335                        });
336                        if quote_response.clicked() {
337                            quote_action = Some(side);
338                        }
339                    }
340
341                    // Separator
342                    self.draw_separator(
343                        &painter,
344                        config,
345                        x,
346                        rect.min.y + ui.active_theme().spacing.xs,
347                        rect.max.y - ui.active_theme().spacing.xs,
348                    );
349                    x += ui.active_theme().spacing.md;
350                }
351
352                // Volume
353                if config.show_volume {
354                    let vol_text = l10n::format_text(
355                        ui.ctx(),
356                        "Vol {volume}",
357                        &[("volume", self.format_volume(self.ohlc.volume))],
358                    );
359                    let vol_galley = painter.layout_no_wrap(
360                        vol_text,
361                        egui::FontId::proportional(ui.active_theme().text.body),
362                        config.label_color,
363                    );
364                    let width = vol_galley.size().x;
365                    painter.galley(
366                        Pos2::new(x, center_y - vol_galley.size().y / 2.0),
367                        vol_galley,
368                        Color32::TRANSPARENT,
369                    );
370                    x += width;
371                }
372                ui.set_min_width(x - rect.left() + ui.active_theme().spacing.sm);
373                (response, quote_action)
374            })
375            .inner
376    }
377
378    fn draw_separator(
379        &self,
380        painter: &egui::Painter,
381        config: &SymbolHeaderConfig,
382        x: f32,
383        y1: f32,
384        y2: f32,
385    ) {
386        painter.line_segment(
387            [Pos2::new(x, y1), Pos2::new(x, y2)],
388            Stroke::new(DESIGN_TOKENS.stroke.hairline, config.separator_color),
389        );
390    }
391
392    fn draw_quote_box(
393        &self,
394        painter: &egui::Painter,
395        x: f32,
396        center_y: f32,
397        price: f64,
398        label: &str,
399        bg_color: Color32,
400    ) -> Rect {
401        let text = format!("{price:.2} {}", l10n::text(painter.ctx(), label));
402        let galley = painter.layout_no_wrap(
403            text,
404            egui::FontId::proportional(painter.ctx().active_theme().text.small),
405            if crate::theme::contrast(Color32::WHITE, bg_color)
406                >= crate::theme::contrast(Color32::BLACK, bg_color)
407            {
408                Color32::WHITE
409            } else {
410                Color32::BLACK
411            },
412        );
413        let padding = painter.ctx().active_theme().spacing.xs;
414        let box_width = galley.size().x + padding * 2.0;
415        let box_height = DESIGN_TOKENS.sizing.symbol_header.quote_box_height;
416
417        let box_rect = Rect::from_min_size(
418            Pos2::new(x, center_y - box_height / 2.0),
419            Vec2::new(box_width, box_height),
420        );
421
422        painter.rect_filled(box_rect, painter.ctx().active_theme().radius.sm, bg_color);
423        painter.galley(
424            Pos2::new(x + padding, center_y - galley.size().y / 2.0),
425            galley.clone(),
426            Color32::TRANSPARENT,
427        );
428
429        box_rect
430    }
431
432    fn format_volume(&self, vol: f64) -> String {
433        if vol >= 1_000_000_000.0 {
434            format!("{:.2}B", vol / 1_000_000_000.0)
435        } else if vol >= 1_000_000.0 {
436            format!("{:.2}M", vol / 1_000_000.0)
437        } else if vol >= 1_000.0 {
438            format!("{:.2}K", vol / 1_000.0)
439        } else {
440            format!("{vol:.0}")
441        }
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::OhlcData;
448
449    #[test]
450    fn quote_buttons_remain_clickable_in_narrow_headers() {
451        use super::*;
452        for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
453            for width in [800.0, 320.0] {
454                for (translated, label, expected) in [
455                    (false, "SELL", QuoteSide::Sell),
456                    (false, "BUY", QuoteSide::Buy),
457                    (true, "VERKAUFEN", QuoteSide::Sell),
458                    (true, "KAUFEN", QuoteSide::Buy),
459                ] {
460                    let ctx = egui::Context::default();
461                    crate::theme::apply_to_egui(&ctx, &theme);
462                    if translated {
463                        l10n::set_translator(&ctx, |english| match english {
464                            "SELL" => Some("VERKAUFEN".into()),
465                            "BUY" => Some("KAUFEN".into()),
466                            _ => None,
467                        });
468                    }
469                    let mut header = SymbolHeader::new(
470                        Instrument::new("SYM").with_description("A long company name"),
471                        "",
472                    );
473                    let quote = Quote::new(100.5, 100.0, Default::default());
474                    header.quote = Some(quote.with_bid_ask(100.0, 101.0));
475                    let mut selected = None;
476                    let mut target = None;
477                    let mut pressed = false;
478                    for pass in 0..30 {
479                        let mut events = vec![];
480                        if let Some(pos) = target {
481                            events.push(egui::Event::PointerMoved(pos));
482                            events.push(egui::Event::PointerButton {
483                                pos,
484                                button: egui::PointerButton::Primary,
485                                pressed: !pressed,
486                                modifiers: Default::default(),
487                            });
488                            pressed = !pressed;
489                        } else if pass > 2 {
490                            events.push(egui::Event::PointerMoved(egui::pos2(width / 2.0, 20.0)));
491                            events.push(egui::Event::MouseWheel {
492                                unit: egui::MouseWheelUnit::Point,
493                                source: egui::MouseWheelSource::Wheel,
494                                phase: egui::TouchPhase::Move,
495                                delta: egui::vec2(-60.0, 0.0),
496                                modifiers: Default::default(),
497                            });
498                        }
499                        let output = ctx.run_ui(
500                            egui::RawInput {
501                                screen_rect: Some(Rect::from_min_size(
502                                    Pos2::ZERO,
503                                    Vec2::new(width, 200.0),
504                                )),
505                                events,
506                                ..Default::default()
507                            },
508                            |ui| {
509                                selected = header.show_with_quote_action(ui).1;
510                            },
511                        );
512                        if pass > 14 && target.is_none() {
513                            target = output.shapes.iter().find_map(|shape| match &shape.shape {
514                                egui::Shape::Text(text)
515                                    if text.galley.text().ends_with(&format!(" {label}")) =>
516                                {
517                                    let rect = text.galley.rect.translate(text.pos.to_vec2());
518                                    shape.clip_rect.contains_rect(rect).then_some(rect.center())
519                                }
520                                _ => None,
521                            });
522                        }
523                        output.drop_without_applying_deltas();
524                        if selected.is_some() {
525                            break;
526                        }
527                    }
528                    assert_eq!(selected, Some(expected), "{label} at {width}");
529                }
530            }
531        }
532    }
533
534    #[test]
535    fn ohlc_change_percent_follows_the_sign_of_the_change() {
536        // (open, close, expected percent)
537        for (open, close, expected) in [(100.0, 105.0, 5.0), (-10.0, -5.0, 50.0), (0.0, 5.0, 0.0)] {
538            let data = OhlcData::new(open, close, open, close, 0.0);
539            assert_eq!(data.change_percent, expected, "{open} -> {close}");
540        }
541    }
542}