Skip to main content

egui_charts/ui/dialogs/
data_table.rs

1//! Chart data table dialog.
2//!
3//! Lists the loaded bars with their volume and the visible study plots, aligned by chart time.
4//! It follows the chart it is shown with, including its replay cutoff, and loads nothing: only
5//! bars already loaded appear. The host opens it from
6//! [`ContextMenuAction::OpenDataTable`](crate::ui::context_menu::ContextMenuAction::OpenDataTable)
7//! and saves the text of [`DataTableAction::Export`].
8
9use crate::localization as l10n;
10use crate::model::{Bar, Timeframe};
11use crate::ui_kit::buttons::Button;
12use crate::ui_kit::modal::Modal;
13use crate::widget::{Chart, DataTable, SeriesId};
14use chrono::{DateTime, Utc};
15use egui::{Align2, Context, FontId, Rect, ScrollArea, Sense, Ui, vec2};
16use std::collections::BTreeSet;
17
18/// Width of the time column in logical points.
19const TIME_WIDTH: f32 = 210.0;
20/// Width of every other column in logical points.
21const COLUMN_WIDTH: f32 = 96.0;
22const ROW_HEIGHT: f32 = 22.0;
23
24/// What the dialog asks of the host.
25#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum DataTableAction {
28    /// Nothing happened.
29    None,
30    /// The dialog was closed.
31    Close,
32    /// CSV text of the displayed columns, for the host to save.
33    Export(String),
34}
35
36/// What the cached table was built from. A correction to a bar in the middle of the history
37/// that changes none of these shows after the next change of one of them.
38#[derive(PartialEq)]
39struct Source {
40    symbol: String,
41    timeframe: Option<Timeframe>,
42    len: usize,
43    first: Option<Bar>,
44    last: Option<Bar>,
45    replay: Option<DateTime<Utc>>,
46    studies: Vec<(SeriesId, bool)>,
47}
48
49impl Source {
50    fn of(chart: &Chart) -> Self {
51        let bars = chart.bars(SeriesId::MAIN);
52        Self {
53            symbol: chart.symbol().to_owned(),
54            timeframe: chart.timeframe(),
55            len: bars.len(),
56            first: bars.first().cloned(),
57            last: bars.last().cloned(),
58            replay: chart.replay_time(),
59            studies: chart
60                .indicators()
61                .map(|(id, study)| (id, study.is_visible()))
62                .collect(),
63        }
64    }
65}
66
67/// Chart data table dialog.
68pub struct DataTableDialog {
69    /// Whether the dialog is currently visible.
70    pub is_open: bool,
71    /// Titles of the columns the user turned off, so they stay off when studies come and go.
72    hidden: BTreeSet<String>,
73    cached: Option<(Source, DataTable)>,
74}
75
76impl Default for DataTableDialog {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl DataTableDialog {
83    /// Create a closed [`DataTableDialog`] showing every column.
84    pub fn new() -> Self {
85        Self {
86            is_open: false,
87            hidden: BTreeSet::new(),
88            cached: None,
89        }
90    }
91
92    /// Open the dialog.
93    pub fn open(&mut self) {
94        self.is_open = true;
95    }
96
97    /// Close the dialog and release its table.
98    pub fn close(&mut self) {
99        self.is_open = false;
100        self.cached = None;
101    }
102
103    /// Show the dialog over `chart`, the chart the user is looking at, and return the host's
104    /// request. Copying puts the displayed columns on the clipboard as tab-separated text.
105    pub fn show(&mut self, ctx: &Context, chart: &Chart) -> DataTableAction {
106        if !self.is_open {
107            return DataTableAction::None;
108        }
109        let source = Source::of(chart);
110        if self
111            .cached
112            .as_ref()
113            .is_none_or(|(cached, _)| *cached != source)
114        {
115            self.cached = Some((source, chart.data_table()));
116        }
117        let table = &self.cached.as_ref().expect("table was just built").1;
118        let hidden = &mut self.hidden;
119        let mut action = DataTableAction::None;
120        let mut open = true;
121        Modal::new("chart_data_table", l10n::text(ctx, "Chart data"))
122            .width(860.0)
123            .height(520.0)
124            .show(ctx, &mut open, |ui| {
125                action = Self::contents(ui, table, hidden);
126            });
127        if !open {
128            self.close();
129            return DataTableAction::Close;
130        }
131        action
132    }
133
134    fn contents(ui: &mut Ui, table: &DataTable, hidden: &mut BTreeSet<String>) -> DataTableAction {
135        let mut action = DataTableAction::None;
136        let shown: Vec<usize> = (0..table.headers.len())
137            .filter(|&column| !hidden.contains(&table.headers[column]))
138            .collect();
139        ui.horizontal(|ui| {
140            ui.menu_button(l10n::text(ui.ctx(), "Columns"), |ui| {
141                // The time column keys the rows, so it stays.
142                for header in table.headers.iter().skip(1) {
143                    let mut visible = !hidden.contains(header);
144                    if ui.checkbox(&mut visible, header).changed() {
145                        if visible {
146                            hidden.remove(header);
147                        } else {
148                            hidden.insert(header.clone());
149                        }
150                    }
151                }
152            });
153            if Button::new("Copy").show(ui).clicked() {
154                ui.ctx().copy_text(table.tsv(&shown));
155            }
156            if Button::new("Export CSV").show(ui).clicked()
157                && let Ok(csv) = table.csv(&shown)
158            {
159                action = DataTableAction::Export(csv);
160            }
161            ui.label(
162                egui::RichText::new(format!(
163                    "{} {}",
164                    table.rows.len(),
165                    l10n::text(ui.ctx(), "loaded bars")
166                ))
167                .color(ui.visuals().weak_text_color()),
168            );
169        });
170        ui.add_space(6.0);
171        let widths: Vec<f32> = shown
172            .iter()
173            .map(|&column| {
174                if column == 0 {
175                    TIME_WIDTH
176                } else {
177                    COLUMN_WIDTH
178                }
179            })
180            .collect();
181        let total = widths.iter().sum::<f32>();
182        ScrollArea::horizontal()
183            .id_salt("chart_data_table_columns")
184            .show(ui, |ui| {
185                Self::paint_row(
186                    ui,
187                    &widths,
188                    total,
189                    true,
190                    shown.iter().map(|&c| table.headers[c].as_str()),
191                );
192                ScrollArea::vertical()
193                    .id_salt("chart_data_table_rows")
194                    .auto_shrink([false; 2])
195                    .show_rows(ui, ROW_HEIGHT, table.rows.len(), |ui, range| {
196                        for index in range {
197                            let row = &table.rows[index];
198                            Self::paint_row(
199                                ui,
200                                &widths,
201                                total,
202                                false,
203                                shown.iter().map(|&c| row[c].as_str()),
204                            );
205                        }
206                    });
207            });
208        action
209    }
210
211    /// One row of cells, an en dash for each missing value.
212    fn paint_row<'a>(
213        ui: &mut Ui,
214        widths: &[f32],
215        total: f32,
216        header: bool,
217        cells: impl Iterator<Item = &'a str>,
218    ) {
219        let (rect, _) = ui.allocate_exact_size(vec2(total, ROW_HEIGHT), Sense::hover());
220        let font = FontId::monospace(12.0);
221        let weak = ui.visuals().weak_text_color();
222        let color = ui.visuals().text_color();
223        let mut x = rect.left();
224        for (index, (cell, width)) in cells.zip(widths).enumerate() {
225            let (text, color) = match (cell.is_empty(), header) {
226                (true, _) => ("\u{2013}", weak),
227                (false, true) => (cell, weak),
228                (false, false) => (cell, color),
229            };
230            // Times read left to right; numbers align on the right.
231            let (pos, align) = if index == 0 {
232                (egui::pos2(x + 4.0, rect.center().y), Align2::LEFT_CENTER)
233            } else {
234                (
235                    egui::pos2(x + width - 8.0, rect.center().y),
236                    Align2::RIGHT_CENTER,
237                )
238            };
239            ui.painter()
240                .with_clip_rect(Rect::from_min_size(
241                    egui::pos2(x, rect.top()),
242                    vec2(*width, ROW_HEIGHT),
243                ))
244                .text(pos, align, text, font.clone(), color);
245            x += width;
246        }
247    }
248}
249
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use crate::model::{Bar, BarData};
254    use chrono::DateTime;
255
256    #[test]
257    fn long_histories_paint_only_visible_rows_and_follow_the_replay_cutoff() {
258        let start = DateTime::from_timestamp(1_700_000_000, 0).unwrap();
259        let bars: Vec<_> = (0..5000)
260            .map(|i| {
261                let time = start + chrono::Duration::days(i);
262                Bar::new(time, 10.0, 12.0, 9.0, 11.0, 100.0)
263            })
264            .collect();
265        let mut chart = Chart::new(BarData::from_bars(bars));
266        let ctx = Context::default();
267        let mut dialog = DataTableDialog::new();
268        dialog.open();
269        let mut show = |chart: &Chart| {
270            let output = ctx.run_ui(Default::default(), |ui| {
271                dialog.show(ui.ctx(), chart);
272            });
273            let texts: Vec<String> = output
274                .shapes
275                .iter()
276                .filter_map(|shape| match &shape.shape {
277                    egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
278                    _ => None,
279                })
280                .collect();
281            output.drop_without_applying_deltas();
282            texts
283        };
284        show(&chart);
285        let live = show(&chart);
286        assert!(
287            live.iter().any(|text| text == "5000 loaded bars"),
288            "{live:?}"
289        );
290        assert!(live.len() < 400, "{} text shapes for 5000 rows", live.len());
291        chart.set_replay_time(Some(start + chrono::Duration::days(1000)));
292        show(&chart);
293        let replay = show(&chart);
294        assert!(
295            replay.iter().any(|text| text == "1000 loaded bars"),
296            "{replay:?}"
297        );
298    }
299}