1use 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
18const TIME_WIDTH: f32 = 210.0;
20const COLUMN_WIDTH: f32 = 96.0;
22const ROW_HEIGHT: f32 = 22.0;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
26#[non_exhaustive]
27pub enum DataTableAction {
28 None,
30 Close,
32 Export(String),
34}
35
36#[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
67pub struct DataTableDialog {
69 pub is_open: bool,
71 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 pub fn new() -> Self {
85 Self {
86 is_open: false,
87 hidden: BTreeSet::new(),
88 cached: None,
89 }
90 }
91
92 pub fn open(&mut self) {
94 self.is_open = true;
95 }
96
97 pub fn close(&mut self) {
99 self.is_open = false;
100 self.cached = None;
101 }
102
103 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 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 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 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}