Skip to main content

egui_charts/studies/
table.rs

1//! Labelled study tables, independent of bar-indexed plots.
2
3/// A cell in a study table.
4#[derive(Clone, Debug, PartialEq)]
5pub enum StudyCell {
6    /// A row label or other text.
7    Text(String),
8    /// A percentage, coloured by sign; `None` displays an unavailable value.
9    Percent(Option<f64>),
10}
11
12/// A labelled table drawn in the lower right of the study's pane.
13#[derive(Clone, Debug, Default, PartialEq)]
14pub struct StudyTable {
15    /// Caption above the column headings.
16    pub title: String,
17    /// One heading per column.
18    pub columns: Vec<String>,
19    /// Cells in each row, in column order.
20    pub rows: Vec<Vec<StudyCell>>,
21}
22
23#[cfg(feature = "widget")]
24fn rating_color(rating: &str) -> egui::Color32 {
25    match rating {
26        "Strong Buy" => super::palette::BLUE,
27        "Buy" => egui::Color32::from_rgb(91, 156, 246),
28        "Strong Sell" => super::palette::BRIGHT_RED,
29        "Sell" => egui::Color32::from_rgb(239, 154, 154),
30        _ => super::palette::DISABLED,
31    }
32}
33
34#[cfg(feature = "widget")]
35pub(crate) fn paint(
36    ui: &mut egui::Ui,
37    rect: egui::Rect,
38    table: &StudyTable,
39    id: egui::Id,
40) -> egui::Rect {
41    use crate::theme::ThemeExt;
42    use egui::{Align2, FontId, Rect, Stroke, StrokeKind, pos2, vec2};
43
44    let rating_table = table.title == "Technical Ratings";
45    let painter = ui.painter().clone();
46    let theme = ui.active_theme();
47    let font = FontId::proportional(11.0);
48    let text = |cell: &StudyCell| match cell {
49        StudyCell::Text(text) => text.clone(),
50        StudyCell::Percent(Some(value)) if value.is_finite() => format!("{value:+.2}%"),
51        StudyCell::Percent(_) => "N/A".into(),
52    };
53    let mut widths: Vec<_> = table
54        .columns
55        .iter()
56        .map(|column| {
57            painter
58                .layout_no_wrap(column.clone(), font.clone(), theme.ui.text)
59                .size()
60                .x
61                + 16.0
62        })
63        .collect();
64    for row in &table.rows {
65        for (width, cell) in widths.iter_mut().zip(row) {
66            *width = width.max(
67                painter
68                    .layout_no_wrap(text(cell), font.clone(), theme.ui.text)
69                    .size()
70                    .x
71                    + 16.0,
72            );
73        }
74    }
75    let width = widths.iter().sum::<f32>();
76    let height = 22.0 * (table.rows.len() + 2) as f32;
77    let mut available = rect.shrink(8.0);
78    available.min.y += 24.0;
79    if !available.is_positive() || rect.height() < 44.0 {
80        return Rect::from_min_size(rect.min, egui::Vec2::ZERO);
81    }
82    let title_width = painter
83        .layout_no_wrap(table.title.clone(), font.clone(), theme.ui.text)
84        .size()
85        .x
86        + 16.0;
87    let size = vec2(
88        width.max(title_width).min(available.width()),
89        height.min(available.height()).min(rect.height() * 0.5),
90    );
91    let bounds = Rect::from_min_size(available.max - size, size);
92    let painter = painter.with_clip_rect(bounds.intersect(ui.clip_rect()));
93    painter.rect_filled(bounds, 3.0, theme.ui.panel_bg_floating);
94    painter.rect_stroke(
95        bounds,
96        3.0,
97        Stroke::new(
98            if rating_table { 2.0 } else { 1.0 },
99            if rating_table {
100                egui::Color32::WHITE
101            } else {
102                theme.ui.border
103            },
104        ),
105        StrokeKind::Inside,
106    );
107    painter.text(
108        bounds.min + vec2(8.0, 11.0),
109        Align2::LEFT_CENTER,
110        &table.title,
111        font.clone(),
112        theme.ui.text,
113    );
114    let mut child = ui.new_child(
115        egui::UiBuilder::new()
116            .id_salt(id)
117            .max_rect(Rect::from_min_max(bounds.min + vec2(0.0, 22.0), bounds.max)),
118    );
119    child.set_clip_rect(painter.clip_rect());
120    egui::ScrollArea::both()
121        .auto_shrink([false, false])
122        .show(&mut child, |ui| {
123            let (content, _) =
124                ui.allocate_exact_size(vec2(width, height - 22.0), egui::Sense::hover());
125            let painter = ui.painter();
126            let mut y = content.top();
127            for (row_index, row) in std::iter::once(
128                table
129                    .columns
130                    .iter()
131                    .cloned()
132                    .map(StudyCell::Text)
133                    .collect::<Vec<_>>(),
134            )
135            .chain(table.rows.iter().cloned())
136            .enumerate()
137            {
138                let mut x = content.left();
139                for (cell, width) in row.iter().zip(&widths) {
140                    let cell_rect = Rect::from_min_size(pos2(x, y), vec2(*width, 22.0));
141                    let color = match cell {
142                        _ if rating_table && row_index == 0 => {
143                            painter.rect_filled(
144                                cell_rect,
145                                0.0,
146                                egui::Color32::from_rgb(41, 91, 121),
147                            );
148                            egui::Color32::WHITE
149                        }
150                        _ if rating_table => {
151                            let rating = match row.get(2) {
152                                Some(StudyCell::Text(text)) => text.as_str(),
153                                _ => "Neutral",
154                            };
155                            let fill = rating_color(rating);
156                            painter.rect_filled(cell_rect, 0.0, fill);
157                            painter.rect_stroke(
158                                cell_rect,
159                                0.0,
160                                Stroke::new(1.0, egui::Color32::WHITE),
161                                StrokeKind::Inside,
162                            );
163                            crate::theme::readable_color(
164                                egui::Color32::WHITE,
165                                egui::Color32::BLACK,
166                                &[fill],
167                                4.5,
168                            )
169                        }
170                        StudyCell::Percent(Some(value)) if value.is_finite() => {
171                            let color = if *value >= 0.0 {
172                                egui::Color32::from_rgb(8, 153, 129)
173                            } else {
174                                egui::Color32::from_rgb(242, 55, 69)
175                            };
176                            let tint = color.gamma_multiply(
177                                0.1 + if table.title == "Seasonality" {
178                                    0.4
179                                } else {
180                                    0.2
181                                } * (value.abs() / 10.0).min(1.0) as f32,
182                            );
183                            painter.rect_filled(
184                                Rect::from_min_size(pos2(x, y), vec2(*width, 22.0)),
185                                0.0,
186                                tint,
187                            );
188                            crate::theme::readable_color(
189                                color,
190                                theme.ui.text,
191                                &[theme.ui.panel_bg_floating.blend(tint)],
192                                4.5,
193                            )
194                        }
195                        _ if matches!(table.title.as_str(), "Performance" | "Seasonality") => {
196                            let alpha = if matches!(cell, StudyCell::Percent(None)) {
197                                if table.title == "Seasonality" {
198                                    128
199                                } else {
200                                    26
201                                }
202                            } else {
203                                51
204                            };
205                            painter.rect_filled(
206                                cell_rect,
207                                0.0,
208                                egui::Color32::from_rgba_unmultiplied(120, 123, 134, alpha),
209                            );
210                            theme.ui.text
211                        }
212                        _ => theme.ui.text,
213                    };
214                    painter.text(
215                        pos2(x + 8.0, y + 11.0),
216                        Align2::LEFT_CENTER,
217                        text(cell),
218                        font.clone(),
219                        color,
220                    );
221                    x += width;
222                }
223                y += 22.0;
224            }
225        });
226    bounds
227}
228
229#[cfg(all(test, feature = "widget"))]
230mod tests {
231    use super::*;
232
233    /// A narrow or short pane keeps overflowing columns and years within a scrollable table.
234    #[test]
235    fn overflowing_tables_fit_the_pane() {
236        use egui::{Event, MouseWheelUnit, RawInput, Rect, Shape, TouchPhase, vec2};
237
238        let table = StudyTable {
239            title: "Seasonality".into(),
240            columns: (0..13).map(|i| format!("Column {i}")).collect(),
241            rows: (0..40)
242                .map(|i| {
243                    let mut row = vec![StudyCell::Percent(Some(12.34)); 13];
244                    row[12] = StudyCell::Text(format!("Last {i}"));
245                    row
246                })
247                .collect(),
248        };
249        for (_, theme) in crate::theme::presets() {
250            for width in [240.0, 800.0] {
251                let ctx = egui::Context::default();
252                crate::theme::apply_to_egui(&ctx, &theme);
253                let rect = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0));
254                for frame in 0..20 {
255                    let mut events =
256                        vec![Event::PointerMoved(rect.right_bottom() - vec2(100.0, 50.0))];
257                    if frame == 1 {
258                        events.push(Event::MouseWheel {
259                            unit: MouseWheelUnit::Point,
260                            delta: vec2(-2000.0, -2000.0),
261                            phase: TouchPhase::Move,
262                            source: egui::MouseWheelSource::Unknown,
263                            modifiers: Default::default(),
264                        });
265                    }
266                    let output = ctx.run_ui(
267                        RawInput {
268                            screen_rect: Some(rect),
269                            time: Some(frame as f64 / 60.0),
270                            events,
271                            ..Default::default()
272                        },
273                        |ui| {
274                            let bounds = paint(ui, rect, &table, ui.scope_id().with("table"));
275                            assert!(rect.contains_rect(bounds), "{bounds:?} outside {rect:?}");
276                        },
277                    );
278                    if frame == 19 {
279                        assert!(output.shapes.iter().any(|clipped| matches!(&clipped.shape,
280                            Shape::Text(text) if text.galley.text() == "Last 39"
281                                && clipped.clip_rect.contains_rect(Rect::from_min_size(text.pos, text.galley.size()))
282                        )), "the last column of the last year must be reachable at width {width}");
283                    }
284                    output.drop_without_applying_deltas();
285                }
286            }
287        }
288    }
289}