1use 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
12const CHART_HEIGHT: f32 = 200.0;
14const NAME_WIDTH: f32 = 240.0;
16const PERIOD_WIDTH: f32 = 96.0;
18const ROW_HEIGHT: f32 = 32.0;
20
21#[derive(Clone, Debug, Default, PartialEq)]
23#[non_exhaustive]
24pub struct CompanyFinancials {
25 pub currency: Option<String>,
27 pub pages: Vec<FinancialPage>,
30}
31
32#[derive(Clone, Debug, Default, PartialEq)]
34#[non_exhaustive]
35pub struct FinancialPage {
36 pub name: String,
38 pub periods: Vec<String>,
40 pub rows: Vec<FinancialRow>,
42}
43
44#[derive(Clone, Debug, Default, PartialEq)]
46#[non_exhaustive]
47pub struct FinancialRow {
48 pub name: String,
50 pub values: Vec<Option<f64>>,
53}
54
55impl FinancialPage {
56 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 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 pub fn new(currency: Option<String>, pages: Vec<FinancialPage>) -> Self {
78 Self { currency, pages }
79 }
80}
81
82#[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
92pub struct FinancialsPanel {
100 title: String,
101 financials: Option<CompanyFinancials>,
102 page: usize,
103 charted: usize,
104}
105
106impl FinancialsPanel {
107 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 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
197fn 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
205fn 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 let plot = Rect::from_min_max(rect.min + vec2(0.0, 40.0), rect.max - vec2(0.0, 20.0));
237 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
292fn 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 #[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}