1use crate::localization as l10n;
7use egui::{RichText, Ui};
8
9use crate::icons::icons;
10use crate::scales::{PriceFormatter, VolumeFormatter};
11use crate::theme::ThemeExt;
12use crate::ui_kit::IconButton;
13
14use super::super::format_price;
15use super::types::DataWindowInfo;
16use crate::ui_kit::ext::UiExt;
17
18impl DataWindowInfo {
19 pub fn from_chart(chart: &crate::widget::Chart) -> Self {
24 let bars = chart.state().shown();
25 let index = chart
26 .get_hover_bar_idx()
27 .filter(|index| index.is_finite() && index.round() >= 0.0)
28 .map(|index| index.round() as usize)
29 .filter(|index| *index < bars.len())
30 .unwrap_or_else(|| {
31 bars.iter()
32 .rposition(|bar| !bar.is_whitespace())
33 .unwrap_or(bars.len().saturating_sub(1))
34 });
35 let mut info = Self::new(chart.symbol())
36 .with_timeframe(chart.timeframe().map(|tf| tf.as_str()).unwrap_or_default());
37 if let Some(instrument) = chart.instrument() {
38 info = info.with_instrument(instrument);
39 }
40 if let Some(bar) = bars.get(index) {
41 info = info
42 .with_ohlcv(bar.open, bar.high, bar.low, bar.close, bar.volume)
43 .with_timestamp(chart.format_time(bar.time))
44 .with_prev_close(
45 crate::model::last_with_value(&bars[..index]).map_or(bar.open, |bar| bar.close),
46 );
47 info.studies = chart.study_values_at(index);
48 }
49 info.with_chart_formatters(chart)
50 }
51
52 pub fn with_chart_formatters(mut self, chart: &crate::widget::Chart) -> Self {
55 let formats = chart.value_formatters();
56 self.formatted_prices = formats.price.map(|formatter| {
57 [self.open, self.high, self.low, self.close].map(|value| formatter.format(value))
58 });
59 self.formatted_volume = formats
60 .volume
61 .map(|formatter| formatter.format(self.volume));
62 self.formatted_change =
63 (formats.price.is_some() || formats.percentage.is_some()).then(|| {
64 let absolute = formats.price.map_or_else(
65 || {
66 format!(
67 "{:+.*}",
68 usize::from(self.price_decimals.unwrap_or(2)),
69 self.change
70 )
71 },
72 |formatter| formatter.format(self.change),
73 );
74 let percentage = formats.percentage.map_or_else(
75 || format!("{:+.2}%", self.change_percent),
76 |formatter| formatter.format(self.change_percent),
77 );
78 format!("{absolute} ({percentage})")
79 });
80 self
81 }
82}
83
84pub fn show_data_window(ui: &mut Ui, data: &DataWindowInfo, expanded: bool) -> bool {
88 let mut is_expanded = expanded;
89
90 ui.horizontal(|ui| {
92 let icon = if is_expanded {
94 &icons::CHEVRON_DOWN
95 } else {
96 &icons::CHEVRON_RIGHT
97 };
98 if IconButton::new(icon)
99 .tooltip("Toggle Data Window")
100 .show(ui)
101 .clicked()
102 {
103 is_expanded = !is_expanded;
104 }
105
106 ui.strong_label("Data Window");
108
109 ui.right_aligned(|ui| {
111 if !data.symbol.is_empty() {
112 ui.label(
113 RichText::new(l10n::text(ui.ctx(), &data.symbol))
114 .small()
115 .color(ui.visuals().weak_text_color()),
116 );
117 }
118 });
119 });
120
121 if is_expanded {
123 ui.indent("data_window_content", |ui| {
124 ui.space_xs();
125
126 egui::Grid::new("bar_time").num_columns(2).show(ui, |ui| {
127 if !data.timestamp.is_empty() {
128 ui.hint_label("Time");
129 ui.label(l10n::text(ui.ctx(), &data.timestamp));
130 ui.end_row();
131 }
132 if !data.timeframe.is_empty() {
133 ui.hint_label("Interval");
134 ui.label(l10n::text(ui.ctx(), &data.timeframe));
135 ui.end_row();
136 }
137 });
138 ui.space_xs();
139
140 show_ohlcv_grid(ui, data);
142 if !data.studies.is_empty() {
143 ui.space_sm();
144 egui::Grid::new("study_values")
145 .num_columns(2)
146 .show(ui, |ui| {
147 for study in &data.studies {
148 ui.hint_label(study.label());
149 ui.label(
150 RichText::new(l10n::text(ui.ctx(), study.formatted_value()))
151 .color(study.color),
152 );
153 ui.end_row();
154 }
155 });
156 }
157
158 ui.space_sm();
159 });
160 }
161
162 is_expanded
163}
164
165fn show_ohlcv_grid(ui: &mut Ui, data: &DataWindowInfo) {
167 let change_color = if data.is_bullish() {
168 ui.active_theme().chart.candle_up
169 } else {
170 ui.active_theme().chart.candle_down
171 };
172
173 egui::Grid::new("ohlcv_grid")
174 .num_columns(2)
175 .spacing([ui.active_theme().spacing.md, ui.active_theme().spacing.xs])
176 .min_col_width(50.0)
177 .show(ui, |ui| {
178 ui.hint_label("O");
179 ui.label(l10n::text(
180 ui.ctx(),
181 data.formatted_prices.as_ref().map_or_else(
182 || format_price(data.open, data.price_decimals),
183 |values| values[0].clone(),
184 ),
185 ));
186 ui.end_row();
187 ui.hint_label("H");
188 ui.label(l10n::text(
189 ui.ctx(),
190 data.formatted_prices.as_ref().map_or_else(
191 || format_price(data.high, data.price_decimals),
192 |values| values[1].clone(),
193 ),
194 ));
195 ui.end_row();
196
197 ui.hint_label("L");
198 ui.label(l10n::text(
199 ui.ctx(),
200 data.formatted_prices.as_ref().map_or_else(
201 || format_price(data.low, data.price_decimals),
202 |values| values[2].clone(),
203 ),
204 ));
205 ui.end_row();
206 ui.hint_label("C");
207 ui.label(
208 RichText::new(l10n::text(
209 ui.ctx(),
210 data.formatted_prices.as_ref().map_or_else(
211 || format_price(data.close, data.price_decimals),
212 |values| values[3].clone(),
213 ),
214 ))
215 .color(change_color),
216 );
217 ui.end_row();
218
219 ui.hint_label("Vol");
220 ui.label(l10n::text(
221 ui.ctx(),
222 data.formatted_volume.clone().unwrap_or_else(|| {
223 data.volume_precision.map_or_else(
224 || format_volume(data.volume),
225 |decimals| {
226 VolumeFormatter {
227 precision: usize::from(decimals),
228 }
229 .format(data.volume)
230 },
231 )
232 }),
233 ));
234 ui.end_row();
235 ui.hint_label("Chg");
236 ui.label(
237 RichText::new(data.formatted_change.clone().unwrap_or_else(|| {
238 format!(
239 "{:+.*} ({:+.2}%)",
240 usize::from(data.price_decimals.unwrap_or(2)),
241 data.change,
242 data.change_percent
243 )
244 }))
245 .color(change_color)
246 .small(),
247 );
248 ui.end_row();
249 });
250}
251
252fn format_volume(volume: f64) -> String {
254 if volume == 0.0 {
255 return "\u{2014}".to_string();
256 }
257
258 crate::scales::StudyFormat::Volume.format(volume, None)
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn instrument_precision_formats_prices_change_and_volume() {
267 use crate::model::{Bar, BarData};
268 use crate::scales::CustomPriceFormatter;
269 use std::sync::Arc;
270 for custom in [false, true] {
271 let mut instrument = crate::model::Instrument::new("FX").with_volume_decimals(3);
272 instrument.tick_size = Some(0.00001);
273 let time = chrono::DateTime::from_timestamp(1_704_067_200, 0).unwrap();
274 let mut chart = crate::widget::Chart::new(BarData::from_bars(vec![
275 Bar::new(time, 1.23455, 1.23455, 1.23455, 1.23455, 0.0),
276 Bar::new(
277 time + chrono::TimeDelta::minutes(1),
278 0.0,
279 1.23456,
280 0.0,
281 1.23456,
282 1234.0,
283 ),
284 ]));
285 chart.set_instrument(Some(instrument));
286 if custom {
287 chart.set_price_formatter(Some(Arc::new(CustomPriceFormatter::new(|value| {
288 format!("p {value:.5}")
289 }))));
290 chart.set_percentage_formatter(Some(Arc::new(CustomPriceFormatter::new(
291 |value| format!("pct {value:.3}"),
292 ))));
293 chart.set_volume_formatter(Some(Arc::new(CustomPriceFormatter::new(|value| {
294 format!("{value:.0} units")
295 }))));
296 }
297 let data = DataWindowInfo::from_chart(&chart);
298 assert_eq!(data.price_decimals, Some(5));
299 let ctx = egui::Context::default();
300 let output = ctx.run_ui(egui::RawInput::default(), |ui| {
301 show_ohlcv_grid(ui, &data);
302 });
303 let texts: Vec<_> = output
304 .shapes
305 .iter()
306 .filter_map(|shape| match &shape.shape {
307 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
308 _ => None,
309 })
310 .collect();
311 output.drop_without_applying_deltas();
312 let expected = if custom {
314 [
315 "p 0.00000",
316 "p 1.23456",
317 "p 0.00001 (pct 0.001)",
318 "1234 units",
319 ]
320 } else {
321 ["0.00000", "1.23456", "+0.00001 (+0.00%)", "1.234K"]
322 };
323 for expected in expected {
324 assert!(
325 texts.iter().any(|text| text == expected),
326 "{expected}: {texts:?}"
327 );
328 }
329 }
330 }
331
332 #[test]
333 fn test_format_volume() {
334 use crate::scales::StudyFormat::*;
335 for (format, value, precision, expected) in [
336 (Volume, 1_500_000_000.0, None, "1.50B"),
337 (Volume, 2_500_000.0, None, "2.50M"),
338 (Volume, 3_500.0, None, "3.50K"),
339 (Volume, 500.0, None, "500"),
340 (Volume, -12_345_678.0, None, "-12.35M"),
341 (Volume, 0.0, Some(2), "0.00"),
342 (Volume, 1_234.0, Some(3), "1.234K"),
343 (Price, 1.23456, Some(5), "1.23456"),
344 (Price, -37.625, Some(3), "-37.625"),
345 (Percent, 5.125, None, "5.12%"),
346 (Percent, -2.5, Some(1), "-2.5%"),
347 (Fixed(4), 0.0004, None, "0.0004"),
348 (Fixed(4), 1.25, Some(1), "1.2"),
349 (Fixed(usize::MAX), 1.0, None, "1.000000000000"),
350 (Volume, f64::NAN, None, "-"),
351 (Percent, f64::INFINITY, None, "-"),
352 ] {
353 assert_eq!(format.format(value, precision), expected, "{format:?}");
354 if format == Volume && value > 0.0 && precision.is_none() {
355 assert_eq!(format_volume(value), expected);
356 }
357 }
358 }
359}