Skip to main content

egui_charts/ui/
financials.rs

1//! A company's financials in place of the price chart: a chart of one row over the periods and
2//! the table of every row, page by page, from figures the host supplies.
3
4use crate::icons::icons;
5use crate::localization as l10n;
6use crate::scales::StudyFormat;
7use crate::theme::ThemeExt;
8use crate::ui_kit::EmptyState;
9use crate::ui_kit::tab_bar::{TabBar, TabLabel};
10use egui::{Align, Align2, FontId, Layout, Rect, RichText, Sense, Ui, pos2, vec2};
11
12/// Height of the chart above the table.
13const CHART_HEIGHT: f32 = 200.0;
14/// Width of the column of row names.
15const NAME_WIDTH: f32 = 240.0;
16/// Width of a period's column.
17const PERIOD_WIDTH: f32 = 96.0;
18/// Height of a table row.
19const ROW_HEIGHT: f32 = 32.0;
20
21/// A company's reported figures, as the host supplies them.
22#[derive(Clone, Debug, Default, PartialEq)]
23#[non_exhaustive]
24pub struct CompanyFinancials {
25    /// The currency of the amounts, shown above the tables.
26    pub currency: Option<String>,
27    /// The pages, such as the income statement, the balance sheet and the statistics, each
28    /// chosen with its own pill.
29    pub pages: Vec<FinancialPage>,
30}
31
32/// One page of [`CompanyFinancials`]: rows of figures by period.
33#[derive(Clone, Debug, Default, PartialEq)]
34#[non_exhaustive]
35pub struct FinancialPage {
36    /// The page's name, such as "Income statement".
37    pub name: String,
38    /// The periods, oldest first, such as "FY 2024" or "Q3 '25": the table's columns.
39    pub periods: Vec<String>,
40    /// The rows of the table.
41    pub rows: Vec<FinancialRow>,
42}
43
44/// One row of a [`FinancialPage`].
45#[derive(Clone, Debug, Default, PartialEq)]
46#[non_exhaustive]
47pub struct FinancialRow {
48    /// The row's name, such as "Total revenue".
49    pub name: String,
50    /// One value per period of the page; `None` where the company reported none. Non-finite
51    /// values are shown as unavailable.
52    pub values: Vec<Option<f64>>,
53}
54
55impl FinancialPage {
56    /// A page named `name` with columns `periods` and no rows.
57    pub fn new(name: impl Into<String>, periods: Vec<String>) -> Self {
58        Self {
59            name: name.into(),
60            periods,
61            rows: Vec::new(),
62        }
63    }
64
65    /// Adds a row named `name` with one value per period.
66    pub fn with_row(mut self, name: impl Into<String>, values: Vec<Option<f64>>) -> Self {
67        self.rows.push(FinancialRow {
68            name: name.into(),
69            values,
70        });
71        self
72    }
73}
74
75impl CompanyFinancials {
76    /// Financials made of `pages`, with amounts in `currency`.
77    pub fn new(currency: Option<String>, pages: Vec<FinancialPage>) -> Self {
78        Self { currency, pages }
79    }
80}
81
82/// A page's pill.
83#[derive(Clone, Copy, PartialEq)]
84struct Page<'a>(&'a str);
85
86impl TabLabel for Page<'_> {
87    fn tab_label(&self) -> &str {
88        self.0
89    }
90}
91
92/// The financials of a company, shown in place of the price chart.
93///
94/// Open it with the host's figures, such as when
95/// [`SymbolDetailsPanel::take_financials_request`](super::widget_bar::SymbolDetailsPanel::take_financials_request)
96/// reports the user's request: a pill per page, a column chart of the chosen row over the page's
97/// periods, and the table of all its rows. Clicking a row's name charts it. Without figures it
98/// says that none are available. Its Back to chart button ends it.
99pub struct FinancialsPanel {
100    title: String,
101    financials: Option<CompanyFinancials>,
102    page: usize,
103    charted: usize,
104}
105
106impl FinancialsPanel {
107    /// The financials of `title`, such as the company's name, or the unavailable state when the
108    /// host has none.
109    pub fn new(title: impl Into<String>, financials: Option<CompanyFinancials>) -> Self {
110        Self {
111            title: title.into(),
112            financials: financials.filter(|financials| !financials.pages.is_empty()),
113            page: 0,
114            charted: 0,
115        }
116    }
117
118    /// Shows the financials; returns whether the user asked to go back to the chart.
119    pub fn show(&mut self, ui: &mut Ui) -> bool {
120        let theme = ui.active_theme();
121        let mut back = false;
122        ui.horizontal(|ui| {
123            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
124                back = ui
125                    .add(egui::Button::image_and_text(
126                        icons::UI_ARROW_BACK.as_image_tinted(vec2(16.0, 16.0), theme.ui.text),
127                        l10n::text(ui.ctx(), "Back to chart"),
128                    ))
129                    .clicked();
130                ui.label(
131                    RichText::new(l10n::text(ui.ctx(), "Financials"))
132                        .size(theme.text.title + 2.0)
133                        .color(theme.ui.text_secondary),
134                );
135                ui.allocate_ui_with_layout(
136                    vec2(ui.available_width(), ui.spacing().interact_size.y),
137                    Layout::left_to_right(Align::Center),
138                    |ui| {
139                        ui.add(
140                            egui::Label::new(
141                                RichText::new(&self.title)
142                                    .size(theme.text.title + 2.0)
143                                    .strong()
144                                    .color(theme.ui.text),
145                            )
146                            .truncate(),
147                        )
148                        .on_hover_text(&self.title);
149                    },
150                );
151            });
152        });
153        ui.add_space(theme.spacing.md);
154        let Some(financials) = &self.financials else {
155            back |= EmptyState::new("No financial data is available for this symbol")
156                .icon(&icons::CHART_COLUMNS)
157                .action("Back to chart")
158                .fill_height()
159                .show(ui);
160            return back;
161        };
162        let names: Vec<_> = financials
163            .pages
164            .iter()
165            .map(|page| Page(&page.name))
166            .collect();
167        if names.len() > 1 {
168            let mut active = names[self.page.min(names.len() - 1)];
169            let changed = egui::ScrollArea::horizontal()
170                .id_salt("financial_pages")
171                .show(ui, |ui| TabBar::new(&names, &mut active).pills().show(ui))
172                .inner;
173            if changed {
174                self.page = names.iter().position(|page| *page == active).unwrap_or(0);
175                self.charted = 0;
176            }
177            ui.add_space(theme.spacing.md);
178        }
179        let page = &financials.pages[self.page.min(financials.pages.len() - 1)];
180        let currency = financials.currency.as_deref();
181        egui::ScrollArea::both()
182            .auto_shrink([false, false])
183            .show(ui, |ui| {
184                ui.set_min_width(NAME_WIDTH + PERIOD_WIDTH * page.periods.len() as f32);
185                if let Some(row) = page.rows.get(self.charted) {
186                    column_chart(ui, page, row);
187                }
188                ui.add_space(theme.spacing.md);
189                if let Some(charted) = table(ui, page, currency, self.charted) {
190                    self.charted = charted;
191                }
192            });
193        back
194    }
195}
196
197/// A value as the table and chart show it: thousands, millions and billions with a suffix.
198fn figure(value: Option<f64>) -> String {
199    value.filter(|value| value.is_finite()).map_or_else(
200        || "-".to_owned(),
201        |value| StudyFormat::Volume.format(value, Some(2)),
202    )
203}
204
205/// Columns of `row` over the periods of `page`, from a zero line, with their values above them
206/// and the periods below.
207fn column_chart(ui: &mut Ui, page: &FinancialPage, row: &FinancialRow) {
208    let theme = ui.active_theme();
209    let width = (NAME_WIDTH + PERIOD_WIDTH * page.periods.len() as f32).min(ui.available_width());
210    let (rect, response) = ui.allocate_exact_size(vec2(width, CHART_HEIGHT), Sense::hover());
211    let name = l10n::text(ui.ctx(), &row.name);
212    response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Image, true, &name));
213    let painter = ui.painter_at(rect);
214    let font = FontId::proportional(theme.text.small);
215    painter.text(
216        rect.left_top(),
217        Align2::LEFT_TOP,
218        name,
219        FontId::proportional(theme.text.body),
220        theme.ui.text,
221    );
222    let values = || {
223        row.values
224            .iter()
225            .take(page.periods.len())
226            .flatten()
227            .copied()
228            .filter(|value| value.is_finite())
229    };
230    let mut high = values().fold(0.0_f64, f64::max);
231    let low = values().fold(0.0_f64, f64::min);
232    if high == low {
233        high = 1.0;
234    }
235    // Room for the name and the values above, and the periods below.
236    let plot = Rect::from_min_max(rect.min + vec2(0.0, 40.0), rect.max - vec2(0.0, 20.0));
237    // Normalize first so opposite finite extremes cannot overflow the range.
238    let scale = high.max(-low);
239    let y = |value: f64| {
240        plot.bottom()
241            - ((value / scale - low / scale) / (high / scale - low / scale)) as f32 * plot.height()
242    };
243    let slot = plot.width() / page.periods.len() as f32;
244    painter.hline(
245        plot.x_range(),
246        y(0.0),
247        egui::Stroke::new(1.0, theme.ui.border),
248    );
249    for (i, period) in page.periods.iter().enumerate() {
250        let center = plot.left() + slot * (i as f32 + 0.5);
251        let cell = Rect::from_min_max(
252            pos2(center - slot * 0.5, rect.top()),
253            pos2(center + slot * 0.5, rect.bottom()),
254        );
255        let column_painter = painter.with_clip_rect(cell);
256        column_painter.text(
257            pos2(center, plot.bottom() + 4.0),
258            Align2::CENTER_TOP,
259            l10n::text(ui.ctx(), period),
260            font.clone(),
261            theme.ui.text_secondary,
262        );
263        let Some(value) = row
264            .values
265            .get(i)
266            .copied()
267            .flatten()
268            .filter(|v| v.is_finite())
269        else {
270            continue;
271        };
272        let column = Rect::from_two_pos(
273            pos2(center - slot * 0.3, y(0.0)),
274            pos2(center + slot * 0.3, y(value)),
275        );
276        let color = if value < 0.0 {
277            theme.ui.error
278        } else {
279            theme.ui.accent
280        };
281        painter.rect_filled(column, 2.0, color);
282        column_painter.text(
283            pos2(center, column.top() - 2.0),
284            Align2::CENTER_BOTTOM,
285            figure(Some(value)),
286            font.clone(),
287            theme.ui.text,
288        );
289    }
290}
291
292/// The table of `page`: the periods over the columns, the currency in the corner, and a row per
293/// [`FinancialRow`], the charted one highlighted. Returns the row whose name the user clicked.
294fn table(
295    ui: &mut Ui,
296    page: &FinancialPage,
297    currency: Option<&str>,
298    charted: usize,
299) -> Option<usize> {
300    let theme = ui.active_theme();
301    let width = NAME_WIDTH + PERIOD_WIDTH * page.periods.len() as f32;
302    let mut clicked = None;
303    let (header, _) = ui.allocate_exact_size(vec2(width, ROW_HEIGHT), Sense::hover());
304    let font = FontId::proportional(theme.text.body);
305    if let Some(currency) = currency {
306        let label = format!("{}: {currency}", l10n::text(ui.ctx(), "Currency"));
307        ui.painter_at(Rect::from_min_size(
308            header.min,
309            vec2(NAME_WIDTH, ROW_HEIGHT),
310        ))
311        .text(
312            pos2(header.left(), header.center().y),
313            Align2::LEFT_CENTER,
314            label,
315            font.clone(),
316            theme.ui.text_secondary,
317        );
318    }
319    for (i, period) in page.periods.iter().enumerate() {
320        let cell = Rect::from_min_size(
321            header.min + vec2(NAME_WIDTH + PERIOD_WIDTH * i as f32, 0.0),
322            vec2(PERIOD_WIDTH, ROW_HEIGHT),
323        );
324        ui.painter_at(cell).text(
325            pos2(cell.right() - 8.0, cell.center().y),
326            Align2::RIGHT_CENTER,
327            l10n::text(ui.ctx(), period),
328            font.clone(),
329            theme.ui.text_secondary,
330        );
331    }
332    ui.painter().hline(
333        header.x_range(),
334        header.bottom(),
335        egui::Stroke::new(1.0, theme.ui.border),
336    );
337    for (index, row) in page.rows.iter().enumerate() {
338        let (rect, response) = ui.allocate_exact_size(vec2(width, ROW_HEIGHT), Sense::click());
339        let name = l10n::text(ui.ctx(), &row.name);
340        response.widget_info(|| {
341            egui::WidgetInfo::selected(egui::Role::Row, true, index == charted, &name)
342        });
343        if index == charted || response.hovered() {
344            let fill = if index == charted {
345                theme.ui.selection_bg
346            } else {
347                theme.ui.btn_bg_hover
348            };
349            ui.painter().rect_filled(rect, 0.0, fill);
350        }
351        let name_rect = Rect::from_min_max(rect.min, pos2(rect.left() + NAME_WIDTH, rect.bottom()));
352        ui.painter_at(name_rect).text(
353            pos2(rect.left() + 8.0, rect.center().y),
354            Align2::LEFT_CENTER,
355            name,
356            font.clone(),
357            theme.ui.text,
358        );
359        for i in 0..page.periods.len() {
360            let cell = Rect::from_min_size(
361                rect.min + vec2(NAME_WIDTH + PERIOD_WIDTH * i as f32, 0.0),
362                vec2(PERIOD_WIDTH, ROW_HEIGHT),
363            );
364            ui.painter_at(cell).text(
365                pos2(cell.right() - 8.0, cell.center().y),
366                Align2::RIGHT_CENTER,
367                figure(row.values.get(i).copied().flatten()),
368                font.clone(),
369                theme.ui.text,
370            );
371        }
372        if response.clicked() {
373            clicked = Some(index);
374        }
375    }
376    clicked
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    /// Zero and missing rows keep their period labels; only finite figures become columns,
384    /// measured from zero even when a row contains both gains and losses.
385    #[test]
386    fn financial_columns_keep_periods_and_scale_finite_signed_values() {
387        for (values, heights) in [
388            (vec![Some(0.0), Some(0.0)], vec![0.0, 0.0]),
389            (vec![None, None], vec![]),
390            (vec![Some(-2.0), Some(6.0)], vec![35.0, 105.0]),
391            (vec![Some(f64::NAN), Some(f64::INFINITY)], vec![]),
392            (vec![Some(-f64::MAX), Some(f64::MAX)], vec![70.0, 70.0]),
393        ] {
394            let page = FinancialPage::new("Income", vec!["FY 2024".into(), "FY 2025".into()])
395                .with_row("Profit", values.clone());
396            let ctx = egui::Context::default();
397            let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
398                column_chart(ui, &page, &page.rows[0]);
399            });
400            output.textures_delta.clear();
401            for period in &page.periods {
402                assert!(output.shapes.iter().any(|shape| {
403                    matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == period)
404                }), "missing {period} for {values:?}");
405            }
406            let actual: Vec<_> = output
407                .shapes
408                .iter()
409                .filter_map(|shape| {
410                    if let egui::Shape::Rect(rect) = &shape.shape {
411                        assert!(rect.rect.is_finite(), "{values:?}");
412                        Some(rect.rect.height())
413                    } else {
414                        None
415                    }
416                })
417                .collect();
418            assert_eq!(actual, heights, "{values:?}");
419        }
420        assert_eq!(figure(Some(f64::NAN)), "-");
421        assert_eq!(figure(Some(f64::INFINITY)), "-");
422    }
423
424    #[test]
425    fn financials_keep_controls_and_cells_inside_narrow_windows() {
426        use egui_kittest::{Harness, kittest::Queryable};
427        for width in [220.0, 280.0, 1000.0] {
428            for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
429                let data = CompanyFinancials::new(
430                    Some("USD".into()),
431                    vec![
432                        FinancialPage::new(
433                            "Income statement",
434                            vec!["Fiscal year ending September 2024".into(), "FY 2025".into()],
435                        )
436                        .with_row(
437                            "A financial row name much wider than the name column",
438                            vec![Some(f64::MAX), Some(3.0)],
439                        ),
440                        FinancialPage::new("Balance sheet", vec!["FY 2025".into()])
441                            .with_row("Assets", vec![Some(3.0)]),
442                        FinancialPage::new("Statistics", vec!["FY 2025".into()])
443                            .with_row("Margin", vec![Some(2.0)]),
444                    ],
445                );
446                let mut panel =
447                    FinancialsPanel::new("A company name wider than a narrow window", Some(data));
448                let mut used = Rect::NOTHING;
449                let mut harness = Harness::builder()
450                    .with_size(vec2(width, 300.0))
451                    .with_context_setup(move |ctx| {
452                        egui_extras::install_image_loaders(ctx);
453                        crate::theme::apply_to_egui(ctx, &theme);
454                    })
455                    .build_ui(|ui| {
456                        panel.show(ui);
457                        used = ui.min_rect();
458                    });
459                harness.run();
460                let back = harness.get_by_label("Back to chart").rect();
461                assert!(
462                    back.right() <= width && back.left() >= 0.0,
463                    "back at {width}: {back:?}"
464                );
465                let title = harness
466                    .output()
467                    .shapes
468                    .iter()
469                    .find_map(|shape| {
470                        if let egui::Shape::Text(text) = &shape.shape
471                            && text.galley.text().starts_with("A company name")
472                        {
473                            Some(text.pos.x)
474                        } else {
475                            None
476                        }
477                    })
478                    .expect("company title");
479                assert!(
480                    (title - 8.0).abs() < 0.5,
481                    "title stays at the content edge: {title}"
482                );
483                let name = harness.output().shapes.iter().rev().find(|shape| matches!(&shape.shape,
484                    egui::Shape::Text(text) if text.galley.text().starts_with("A financial row name"))).unwrap();
485                assert!(
486                    name.clip_rect.width() <= NAME_WIDTH,
487                    "row names stay in their column"
488                );
489                for label in [
490                    "Fiscal year ending September 2024".to_owned(),
491                    figure(Some(f64::MAX)),
492                ] {
493                    let cell = harness
494                        .output()
495                        .shapes
496                        .iter()
497                        .rev()
498                        .find(|shape| {
499                            matches!(&shape.shape,
500                        egui::Shape::Text(text) if text.galley.text() == label)
501                        })
502                        .unwrap();
503                    assert!(
504                        cell.clip_rect.width() <= PERIOD_WIDTH,
505                        "period text stays in its column"
506                    );
507                }
508                drop(harness);
509                assert!(
510                    used.right() <= width,
511                    "view expanded beyond {width}: {used:?}"
512                );
513                assert!(
514                    used.bottom() <= 300.0,
515                    "table stays scrollable in short windows: {used:?}"
516                );
517            }
518        }
519    }
520}