1use super::Chart;
4use crate::localization as l10n;
5use crate::scales::StudyFormat;
6use crate::theme::ThemeExt;
7use chrono::{DateTime, Utc};
8use std::collections::HashMap;
9use std::sync::Arc;
10
11#[derive(Debug, Clone)]
13#[non_exhaustive]
14pub struct ImageExportOptions {
15 pub size: [u32; 2],
17 pub include_title: bool,
19 pub include_timestamp: bool,
21 pub include_watermark: bool,
24 pub transparent_background: bool,
26 pub include_crosshair: bool,
29 pub textures: HashMap<egui::TextureId, Arc<egui::ColorImage>>,
36}
37
38impl Default for ImageExportOptions {
39 fn default() -> Self {
40 Self {
41 size: [1440, 900],
42 include_title: true,
43 include_timestamp: true,
44 include_watermark: true,
45 transparent_background: false,
46 include_crosshair: false,
47 textures: HashMap::new(),
48 }
49 }
50}
51
52#[must_use = "Render the frame and apply its texture deltas, or clear them before dropping"]
59#[non_exhaustive]
60pub struct ChartImage {
61 pub size: [u32; 2],
63 pub primitives: Vec<egui::ClippedPrimitive>,
65 pub textures_delta: egui::TexturesDelta,
67}
68
69#[derive(Debug, Clone, PartialEq)]
72#[non_exhaustive]
73pub struct DataTable {
74 pub headers: Vec<String>,
76 pub rows: Vec<Vec<String>>,
78}
79
80impl DataTable {
81 #[cfg(feature = "csv")]
83 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
84 pub fn csv(
85 &self,
86 columns: &[usize],
87 ) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
88 let mut writer = csv::Writer::from_writer(Vec::new());
89 writer.write_record(columns.iter().map(|&column| &self.headers[column]))?;
90 for row in &self.rows {
91 writer.write_record(columns.iter().map(|&column| &row[column]))?;
92 }
93 Ok(String::from_utf8(writer.into_inner()?)?)
94 }
95
96 pub fn tsv(&self, columns: &[usize]) -> String {
98 let line = |cells: &mut dyn Iterator<Item = &String>| {
99 cells.map(String::as_str).collect::<Vec<_>>().join("\t")
100 };
101 let mut lines = vec![line(
102 &mut columns.iter().map(|&column| &self.headers[column]),
103 )];
104 lines.extend(
105 self.rows
106 .iter()
107 .map(|row| line(&mut columns.iter().map(|&column| &row[column]))),
108 );
109 lines.join("\n")
110 }
111}
112
113fn add_host_textures(
115 delta: &mut egui::TexturesDelta,
116 options: &ImageExportOptions,
117) -> Result<(), String> {
118 for (&id, image) in &options.textures {
119 if delta.set.contains_key(&id) {
120 return Err(format!(
121 "Host texture {id:?} conflicts with an offscreen texture; use TextureId::User"
122 ));
123 }
124 let [width, height] = image.size;
125 if width == 0
126 || height == 0
127 || width > u32::MAX as usize
128 || height > u32::MAX as usize
129 || width.checked_mul(height) != Some(image.pixels.len())
130 {
131 return Err(format!(
132 "Host texture {id:?} has invalid dimensions or pixels"
133 ));
134 }
135 delta.push(
136 id,
137 egui::epaint::ImageDelta::full(
138 egui::ImageData::Color(Arc::clone(image)),
139 egui::TextureOptions::LINEAR,
140 ),
141 );
142 }
143 Ok(())
144}
145
146#[derive(Clone, Debug, PartialEq, Eq)]
149#[non_exhaustive]
150pub enum ExportError {
151 InvalidSize {
153 width: u32,
155 height: u32,
157 },
158 NoFrame,
160 Unsupported(String),
163 Encode(String),
165}
166
167impl std::fmt::Display for ExportError {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 match self {
170 Self::InvalidSize { width, height } => write!(
171 f,
172 "Image size {width} by {height} must be 100 to 7680 pixels per side and at most 33,177,600 pixels"
173 ),
174 Self::NoFrame => {
175 f.write_str("Run the egui context's first frame before exporting an image")
176 }
177 Self::Unsupported(reason) => f.write_str(reason),
178 Self::Encode(message) => write!(f, "PNG encoding failed: {message}"),
179 }
180 }
181}
182impl std::error::Error for ExportError {}
183
184impl Chart {
185 fn export_time(&self, time: DateTime<Utc>) -> String {
186 match self.display_timezone() {
187 crate::scales::TimezoneMode::Local => time.with_timezone(&chrono::Local).to_rfc3339(),
188 zone => time
189 .with_timezone(&zone.to_tz().unwrap_or(crate::model::TimezoneId::utc()))
190 .to_rfc3339(),
191 }
192 }
193
194 #[cfg(feature = "csv")]
204 #[cfg_attr(docsrs, doc(cfg(feature = "csv")))]
205 pub fn export_csv(&self) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
206 let table = self.data_table();
207 table.csv(&(0..table.headers.len()).collect::<Vec<_>>())
208 }
209
210 pub fn data_table(&self) -> DataTable {
214 let bars = &self.state.data().bars;
215 let price_precision = self
216 .instrument()
217 .and_then(crate::scales::instrument_price_precision)
218 .map_or(2, usize::from);
219 let mut studies: Vec<_> = self
220 .indicators()
221 .map(|(_, study)| study.clone_box())
222 .collect();
223 let mut columns = Vec::new();
224 let mut header = vec![
225 "time".into(),
226 "open".into(),
227 "high".into(),
228 "low".into(),
229 "close".into(),
230 "volume".into(),
231 ];
232 let clock = self.bar_clock();
233 for study in &mut studies {
234 study.set_instrument(self.instrument());
235 study.set_time_scale_events(&self.time_scale_events);
236 if let Some(timeframe) = self.timeframe {
237 study.set_timeframe(timeframe);
238 }
239 study.set_bar_clock(&clock);
240 }
241 let requests = studies
242 .iter()
243 .flat_map(|study| study.data_requests())
244 .collect();
245 let sources =
246 self.study_data
247 .data(requests, &self.symbol, self.timeframe, self.replay_time());
248 super::studies::calculate(&mut studies, &self.study_ids, bars, &sources);
249 for (index, study) in studies
250 .iter_mut()
251 .enumerate()
252 .filter(|(_, study)| study.is_visible())
253 {
254 let names = study.line_names();
255 for line in (0..study.line_count()).filter(|line| study.plot_style(*line).visible) {
256 header.push(format!(
257 "{}: {}",
258 study.name(),
259 names.get(line).map_or("", String::as_str)
260 ));
261 let precision = study.precision().unwrap_or(match study.value_format() {
262 StudyFormat::Price => price_precision,
263 StudyFormat::Fixed(decimals) => decimals.min(12),
264 _ => 2,
265 });
266 columns.push((index, line, precision));
267 }
268 }
269 let number = |value: f64, precision: usize| {
270 if value.is_finite() {
271 format!("{value:.precision$}")
272 } else {
273 String::new()
274 }
275 };
276 let mut rows = Vec::with_capacity(bars.len());
277 for (index, bar) in bars.iter().enumerate() {
278 let mut row = vec![if self.horizontal_scale.is_some() {
279 self.format_time(bar.time)
280 } else {
281 self.export_time(bar.time)
282 }];
283 row.extend(
284 [bar.open, bar.high, bar.low, bar.close]
285 .map(|value| number(value, price_precision)),
286 );
287 row.push(if bar.volume.is_finite() {
288 bar.volume.to_string()
289 } else {
290 String::new()
291 });
292 row.extend(columns.iter().map(|&(study, line, precision)| {
293 studies[study]
294 .plot_value(line, index)
295 .map_or_else(String::new, |value| number(value, precision))
296 }));
297 rows.push(row);
298 }
299 DataTable {
300 headers: header,
301 rows,
302 }
303 }
304
305 pub fn export_image(
317 &self,
318 ctx: &egui::Context,
319 options: &ImageExportOptions,
320 ) -> Result<ChartImage, ExportError> {
321 self.export_layout_image(ctx, options, |ui, chart| {
322 chart.show(ui);
323 })
324 }
325
326 pub fn export_layout_image(
347 &self,
348 ctx: &egui::Context,
349 options: &ImageExportOptions,
350 layout: impl FnMut(&mut egui::Ui, &mut Chart),
351 ) -> Result<ChartImage, ExportError> {
352 let (shapes, textures_delta, image_ctx) = self.export_shapes(ctx, options, layout)?;
353 Ok(ChartImage {
354 size: options.size,
355 primitives: image_ctx.tessellate(shapes, 1.0),
356 textures_delta,
357 })
358 }
359
360 fn export_shapes(
363 &self,
364 ctx: &egui::Context,
365 options: &ImageExportOptions,
366 mut layout: impl FnMut(&mut egui::Ui, &mut Chart),
367 ) -> Result<
368 (
369 Vec<egui::epaint::ClippedShape>,
370 egui::TexturesDelta,
371 egui::Context,
372 ),
373 ExportError,
374 > {
375 let size = options.size;
376 if size.iter().any(|&side| !(100..=7680).contains(&side))
377 || u64::from(size[0]) * u64::from(size[1]) > 33_177_600
378 {
379 return Err(ExportError::InvalidSize {
380 width: size[0],
381 height: size[1],
382 });
383 }
384 if ctx.cumulative_pass_nr() == 0 {
385 return Err(ExportError::NoFrame);
386 }
387 let mut chart = self.export_snapshot(ctx, options);
388 let theme = chart
389 .theme_override
390 .clone()
391 .unwrap_or_else(|| ctx.active_theme());
392 let image_ctx = egui::Context::default();
393 image_ctx.set_global_style(ctx.global_style());
394 crate::theme::apply_to_egui(&image_ctx, &theme);
395 image_ctx.set_fonts(ctx.fonts(|fonts| fonts.definitions().clone()));
396 let timestamp = options
397 .include_timestamp
398 .then(|| self.export_time(Utc::now()));
399 let title = options.include_title.then(|| self.symbol().to_owned());
400 let input = egui::RawInput {
401 screen_rect: Some(egui::Rect::from_min_size(
402 egui::Pos2::ZERO,
403 egui::vec2(size[0] as f32, size[1] as f32),
404 )),
405 ..Default::default()
406 };
407 let mut textures_delta = egui::TexturesDelta::default();
408 let passes = chart.studies.indicators().len() + 3;
410 let mut shapes = Vec::new();
411 for pass in 0..passes {
412 let output = image_ctx.run_ui(
413 egui::RawInput {
414 time: Some(pass as f64 * 3600.0),
415 ..input.clone()
416 },
417 |ui| {
418 if !options.transparent_background {
419 ui.painter()
420 .rect_filled(ui.max_rect(), 0.0, chart.config.background_color);
421 }
422 ui.set_min_size(ui.available_size());
423 if title.is_some() || timestamp.is_some() {
424 ui.horizontal(|ui| {
425 if let Some(title) = &title {
426 ui.label(l10n::text(ui.ctx(), title));
427 }
428 if let Some(timestamp) = ×tamp {
429 ui.label(timestamp);
430 }
431 });
432 }
433 layout(ui, &mut chart);
434 },
435 );
436 textures_delta.append(output.textures_delta);
437 shapes = output.shapes;
438 }
439 if let Err(error) = add_host_textures(&mut textures_delta, options) {
440 textures_delta.clear();
441 return Err(ExportError::Unsupported(error));
442 }
443 Ok((shapes, textures_delta, image_ctx))
444 }
445
446 #[cfg(feature = "png-export")]
456 #[cfg_attr(docsrs, doc(cfg(feature = "png-export")))]
457 pub fn export_png(
458 &self,
459 ctx: &egui::Context,
460 options: &ImageExportOptions,
461 ) -> Result<Vec<u8>, ExportError> {
462 self.export_layout_png(ctx, options, |ui, chart| {
463 chart.show(ui);
464 })
465 }
466
467 #[cfg(feature = "png-export")]
470 #[cfg_attr(docsrs, doc(cfg(feature = "png-export")))]
471 pub fn export_layout_png(
472 &self,
473 ctx: &egui::Context,
474 options: &ImageExportOptions,
475 layout: impl FnMut(&mut egui::Ui, &mut Chart),
476 ) -> Result<Vec<u8>, ExportError> {
477 let mut image = self.export_layout_image(ctx, options, layout)?;
478 let pixels = super::raster::rasterize(image.size, &image.primitives, &image.textures_delta);
479 image.textures_delta.clear();
480 super::raster::png(image.size, &pixels.map_err(ExportError::Unsupported)?)
481 .map_err(ExportError::Encode)
482 }
483
484 pub fn export_svg(
496 &self,
497 ctx: &egui::Context,
498 options: &ImageExportOptions,
499 ) -> Result<String, ExportError> {
500 self.export_layout_svg(ctx, options, |ui, chart| {
501 chart.show(ui);
502 })
503 }
504
505 pub fn export_layout_svg(
508 &self,
509 ctx: &egui::Context,
510 options: &ImageExportOptions,
511 layout: impl FnMut(&mut egui::Ui, &mut Chart),
512 ) -> Result<String, ExportError> {
513 let (shapes, mut textures_delta, _) = self.export_shapes(ctx, options, layout)?;
514 let svg = super::svg::svg(options.size, &shapes, &textures_delta);
515 textures_delta.clear();
516 svg.map_err(ExportError::Unsupported)
518 }
519
520 pub fn export_snapshot(&self, ctx: &egui::Context, options: &ImageExportOptions) -> Self {
526 let mut chart = self.frozen_export_snapshot();
527 chart.config.skip_background = options.transparent_background;
528 chart.config.show_symbol_labels &= options.include_title;
529 if !options.include_watermark {
530 chart.config.show_watermark = false;
531 chart.watermark = None;
532 }
533 let mut theme = self
534 .theme_override
535 .clone()
536 .unwrap_or_else(|| ctx.active_theme());
537 if options.transparent_background {
538 theme.chart.bg = egui::Color32::TRANSPARENT;
539 }
540 chart.theme_override = Some(theme);
541 if let Some((time, price)) = self.last_crosshair.filter(|_| options.include_crosshair) {
542 match price {
543 Some(price) => chart.set_crosshair_position(Some((time, price))),
544 None => {
545 chart.set_synced_crosshair_time(Some(time));
546 chart.chart_options.crosshair.horz_line_visible = false;
547 chart.chart_options.crosshair.horz_label_visible = false;
548 }
549 }
550 }
551 chart
552 }
553
554 fn frozen_export_snapshot(&self) -> Self {
555 let mut chart = Chart::with_config(Default::default(), self.config.clone());
556 chart.state = self.state.clone();
557 chart.chart_options = self.chart_options.clone();
558 chart
559 .chart_options
560 .time_scale
561 .lock_visible_time_range_on_resize = true;
562 chart.prev_width = self.prev_width;
563 chart.chart_options.time_scale.timezone = self.display_timezone();
564 chart.chart_type = self.chart_type;
565 chart.chart_type_params = self.chart_type_params.clone();
566 #[cfg(feature = "studies")]
567 {
568 chart.trade_profiles = self.trade_profiles.clone();
569 }
570 chart.pending_box_sizes = self.pending_box_sizes;
571 chart.main_series_visible = self.main_series_visible;
572 chart.main_series_present = self.main_series_present;
573 chart.main_series_scale = self.main_series_scale;
574 chart.price_series = self
575 .price_series
576 .iter()
577 .map(|series| super::series::PriceSeries {
578 id: series.id,
579 options: series.options.clone(),
580 visible: series.visible,
581 fed: series.fed,
582 bars: series.bars.clone(),
583 revision: series.revision,
584 heikin_ashi: Default::default(),
585 feed: series.feed.clone(),
586 markers: series.markers.clone(),
587 marker_options: series.marker_options,
588 change_marks: None,
589 lines: series.lines.clone(),
590 })
591 .collect();
592 chart.series_scales = self.series_scales.clone();
593 chart.symbol = self.symbol.clone();
594 chart.timeframe = self.timeframe;
595 chart.feed_link = self.feed_link.as_ref().map(super::feed::FeedLink::frozen);
596 chart.legend_instrument = self.instrument().cloned();
597 chart.legend_badge = self.legend_badge.clone();
598 chart.watermark = self.watermark.clone();
599 chart.legend_statuses = self.legend_statuses.clone();
600 chart.quote = self.quote.clone();
601 chart.session_quote = self.session_quote.clone();
602 chart.session_provider = self.session_provider.clone();
603 chart.horizontal_scale = self.horizontal_scale.clone();
604 chart.locale = self.locale.clone();
605 chart.date_format = self.date_format.clone();
606 chart.time_formatter = self.time_formatter.clone();
607 chart.price_formatter = self.price_formatter.clone();
608 chart.percentage_formatter = self.percentage_formatter.clone();
609 chart.volume_formatter = self.volume_formatter.clone();
610 chart.series_formatters = self.series_formatters.clone();
611 chart.extended_hours = self.extended_hours;
612 chart.theme_override = self.theme_override.clone();
613 chart.inherited_theme = self.inherited_theme.clone();
614 #[cfg(feature = "drawings")]
615 {
616 chart.drawings.registry = self.drawings.registry.clone();
617 chart.drawings.drawings = self.drawings.drawings.clone();
618 chart.drawings.curr_timeframe = self.drawings.curr_timeframe.clone();
619 }
620 chart.marks = self.marks.clone();
621 chart.marker_options = self.marker_options;
622 chart.price_lines = self.price_lines.clone();
623 chart.events = self.events.clone();
624 chart.time_scale_events = self.time_scale_events.clone();
625 chart.event_visibility = self.event_visibility.clone();
626 if let Some(source) = &self.mark_source {
628 chart.marks = self
629 .marks
630 .iter()
631 .chain(&source.page.bar_marks)
632 .cloned()
633 .collect::<Vec<_>>()
634 .into();
635 chart.events = self
636 .events
637 .iter()
638 .chain(&source.page.time_scale_marks)
639 .cloned()
640 .collect::<Vec<_>>()
641 .into();
642 chart.time_scale_events = self
643 .time_scale_events
644 .iter()
645 .chain(&source.page.events)
646 .cloned()
647 .collect::<Vec<_>>()
648 .into();
649 chart.frozen_source_events = source.page.events.len();
650 }
651 chart.desired_visible_bars = self.desired_visible_bars;
652 chart.apply_visible_bars_once = self.apply_visible_bars_once;
653 chart.start_idx = self.start_idx;
654 for (_, study) in self.indicators() {
655 chart.add_indicator(study.clone_box());
656 }
657 chart.study_ids = self.study_ids.clone();
658 chart.study_inputs = self.study_inputs.clone();
659 chart.study_autoscale = self.study_autoscale.clone();
660 chart.next_series = self.next_series;
661 chart.pane_layout = self.pane_layout.clone();
663 chart.pane_options = self.pane_options;
664 #[cfg(feature = "studies")]
665 {
666 chart.volume_study = self.volume_study;
667 chart.volume_setting = self.volume_setting;
668 }
669 chart.primitives = self.primitives.clone();
670 chart.next_primitive = self.next_primitive;
671 chart.settle_primitives();
672 chart.study_data = self.study_data.snapshot();
673 chart.recalculate_studies();
674 chart
675 }
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681 #[cfg(feature = "csv")]
682 use crate::model::Instrument;
683 #[cfg(any(feature = "csv", feature = "drawings"))]
684 use crate::model::{Bar, BarData, Timeframe};
685 #[cfg(any(feature = "csv", feature = "drawings"))]
686 use crate::studies::CustomIndicator;
687 #[cfg(feature = "csv")]
688 use crate::studies::IndicatorValue;
689 use crate::widget::SeriesId;
690
691 #[test]
692 #[cfg(feature = "csv")]
693 fn csv_aligns_fresh_visible_plots_and_formats_chart_times_and_prices() {
694 let times: Vec<_> = [
695 "2024-11-03T05:30:00.125Z",
696 "2024-11-03T06:30:00.125Z",
697 "2024-11-03T07:30:00.125Z",
698 ]
699 .map(|time| DateTime::parse_from_rfc3339(time).unwrap().to_utc())
700 .into();
701 let zones = [
702 (
703 crate::scales::TimezoneMode::Utc,
704 [
705 "2024-11-03T05:30:00.125+00:00",
706 "2024-11-03T06:30:00.125+00:00",
707 ],
708 ),
709 #[cfg(feature = "timezones")]
710 (
711 crate::scales::TimezoneMode::Timezone("America/New_York".parse().unwrap()),
712 [
713 "2024-11-03T01:30:00.125-04:00",
714 "2024-11-03T01:30:00.125-05:00",
715 ],
716 ),
717 #[cfg(feature = "timezones")]
718 (
719 crate::scales::TimezoneMode::Exchange(crate::model::TimezoneId::utc()),
720 [
721 "2024-11-03T01:30:00.125-04:00",
722 "2024-11-03T01:30:00.125-05:00",
723 ],
724 ),
725 ];
726 for (zone, expected_times) in zones {
727 let bars: Vec<_> = times
728 .iter()
729 .zip([10.0, 14.0, 18.0])
730 .map(|(&time, close)| {
731 Bar::new(time, close, close + 1.0, close - 1.0, close, 1234.5)
732 })
733 .collect();
734 let mut chart = Chart::new(BarData::from_bars(bars.clone()));
735 chart.set_timeframe(Timeframe::Hour1);
736 chart.chart_options.time_scale.timezone = zone;
737 let mut instrument = Instrument::new("TEST");
738 instrument.price_decimals = Some(2);
739 instrument.time_zone = crate::model::TimezoneId::named("America/New_York");
740 chart.set_instrument(Some(instrument.clone()));
741 let study = chart.add_indicator(Box::new(
742 CustomIndicator::new("Average, pair", |bars| {
743 bars.iter()
744 .enumerate()
745 .map(|(index, bar)| {
746 let mean = if index == 0 {
747 f64::NAN
748 } else {
749 (bars[index - 1].close + bar.close) / 2.0
750 };
751 IndicatorValue::Multiple(vec![mean, mean, bar.close * 2.0])
752 })
753 .collect()
754 })
755 .with_line_count(3)
756 .with_line_names(vec!["mean".into(), "shifted".into(), "hidden".into()])
757 .with_offsets(vec![0, -1, 0]),
758 ));
759 let settings_study = chart.indicator_mut(study).unwrap();
760 let mut settings = settings_study.settings();
761 settings.plots[2].visible = false;
762 settings_study.apply_settings(&[], settings).unwrap();
763 let hidden = chart
764 .add_indicator(Box::new(CustomIndicator::new("Hidden study", |bars| {
765 vec![IndicatorValue::Single(999.0); bars.len()]
766 })));
767 chart.set_indicator_visible(hidden, false);
768 chart.add_indicator(Box::new(
769 CustomIndicator::new("Fixed", |bars| {
770 vec![IndicatorValue::Single(1.23456); bars.len()]
771 })
772 .with_value_format(StudyFormat::Fixed(4)),
773 ));
774 let override_id = chart
775 .add_indicator(Box::new(CustomIndicator::new("Override", |bars| {
776 vec![IndicatorValue::Single(9.876); bars.len()]
777 })));
778 let indicator = chart.indicator_mut(override_id).unwrap();
779 let mut settings = indicator.settings();
780 settings.precision = Some(1);
781 indicator.apply_settings(&[], settings).unwrap();
782 instrument.price_decimals = Some(3);
783 chart.set_instrument(Some(instrument));
784 let mut last = bars[2].clone();
785 last.close = 22.0;
786 last.high = 23.0;
787 chart.update(SeriesId::MAIN, last);
788 let csv = chart.export_csv();
789 fn assert_send_sync<T: Send + Sync>(_: &T) {}
790 assert_send_sync(&csv);
791 let csv = csv.unwrap();
792 let mut reader = csv::Reader::from_reader(csv.as_bytes());
793 assert_eq!(
794 reader.headers().unwrap().iter().collect::<Vec<_>>(),
795 [
796 "time",
797 "open",
798 "high",
799 "low",
800 "close",
801 "volume",
802 "Average, pair: mean",
803 "Average, pair: shifted",
804 "Fixed: Fixed",
805 "Override: Override"
806 ]
807 );
808 let rows = reader.records().collect::<Result<Vec<_>, _>>().unwrap();
809 assert_eq!(rows.len(), 3);
810 assert_eq!(&rows[0][0], expected_times[0]);
811 assert_eq!(&rows[1][0], expected_times[1]);
812 assert_eq!(&rows[0][1], "10.000");
813 assert_eq!(&rows[2][4], "22.000");
814 assert_eq!(&rows[0][5], "1234.5");
815 assert_eq!(
817 rows.iter().map(|row| &row[6]).collect::<Vec<_>>(),
818 ["", "12.000", "18.000"]
819 );
820 assert_eq!(
821 rows.iter().map(|row| &row[7]).collect::<Vec<_>>(),
822 ["12.000", "18.000", ""]
823 );
824 assert_eq!(&rows[0][8], "1.2346");
825 assert_eq!(&rows[0][9], "9.9");
826 assert!(chart.pending_from[0].is_some());
828 }
829 let empty = Chart::new(BarData::new()).export_csv().unwrap();
830 assert_eq!(empty, "time,open,high,low,close,volume\n");
831 }
832
833 #[cfg(feature = "studies")]
836 #[test]
837 fn exported_studies_do_not_restart_at_a_frozen_mark_source_page() {
838 use crate::data::Reply;
839 use crate::model::{EventKind, TimeScaleEvent};
840 use crate::services::{Marks, MarksPage, MarksRequest};
841 use crate::studies::{VolumeWeightedAvgPrice, VwapAnchor};
842 use crate::widget::test_frames;
843 use std::sync::Arc;
844
845 struct Source(MarksPage);
846 impl Marks for Source {
847 fn marks(&self, _: MarksRequest, reply: Reply<MarksPage>) {
848 reply.send(Ok(self.0.clone()));
849 }
850 }
851 let ctx = egui::Context::default();
852 let mut chart = test_frames::chart(&[1.0, 5.0, 9.0], &[2.0, 6.0, 10.0]);
853 test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
854 let time = chart.state.data().bars[70].time;
855 let id = chart.add_indicator(Box::new(
856 VolumeWeightedAvgPrice::new().with_anchor(VwapAnchor::Earnings),
857 ));
858 chart.set_mark_source(Some(Arc::new(Source(MarksPage {
859 bar_marks: vec![],
860 time_scale_marks: vec![],
861 events: vec![TimeScaleEvent {
862 id: "earnings".into(),
863 time,
864 kind: EventKind::Earnings,
865 title: String::new(),
866 details: String::new(),
867 }],
868 }))));
869 chart.pump_marks(&ctx);
870
871 let mut snapshot = chart.frozen_export_snapshot();
872 assert_eq!(snapshot.time_scale_events.len(), 1);
873 test_frames::frame(&ctx, &mut snapshot, 0.1, vec![]);
874 let value = |chart: &Chart| {
875 let (_, study) = chart.indicators().find(|(study, _)| *study == id).unwrap();
876 study.values().value(0, 80)
877 };
878 assert!(value(&chart).is_some());
879 assert_eq!(value(&snapshot), value(&chart));
880 }
881
882 #[test]
883 fn images_retain_loaded_marks_and_event_visibility_without_fetching() {
884 use crate::data::Reply;
885 use crate::model::{EventKind, EventVisibility, Marker, TimeScaleEvent};
886 use crate::services::{Marks, MarksPage, MarksRequest};
887 use crate::widget::test_frames;
888 use std::sync::{
889 Arc,
890 atomic::{AtomicUsize, Ordering},
891 };
892
893 struct Source {
894 page: MarksPage,
895 calls: AtomicUsize,
896 }
897 impl Marks for Source {
898 fn marks(&self, _: MarksRequest, reply: Reply<MarksPage>) {
899 self.calls.fetch_add(1, Ordering::Relaxed);
900 reply.send(Ok(self.page.clone()));
901 }
902 }
903 let ctx = egui::Context::default();
904 let mut chart = test_frames::chart(&[95.0], &[105.0]);
905 test_frames::frame(&ctx, &mut chart, 0.0, vec![]);
906 let time = chart.state.shown()[70].time;
907 let event = TimeScaleEvent {
908 id: "earnings".into(),
909 time,
910 kind: EventKind::Earnings,
911 title: "Host earnings".into(),
912 details: String::new(),
913 };
914 chart.set_markers(SeriesId::MAIN, vec![Marker::new(time).with_text("Manual")]);
915 chart.set_events(vec![Marker::event(time, "Manual axis")]);
916 chart.set_time_scale_events(vec![event.clone()]);
917 chart.set_event_visibility(EventVisibility {
918 show_earnings: true,
919 show_news: false,
920 ..Default::default()
921 });
922 let source = Arc::new(Source {
923 page: MarksPage {
924 bar_marks: vec![Marker::new(time).with_text("Source bar")],
925 time_scale_marks: vec![Marker::event(time, "Source axis")],
926 events: vec![TimeScaleEvent {
927 kind: EventKind::News,
928 ..event
929 }],
930 },
931 calls: AtomicUsize::new(0),
932 });
933 chart.set_mark_source(Some(source.clone()));
934 chart.pump_marks(&ctx);
935 assert_eq!(source.calls.load(Ordering::Relaxed), 1);
936
937 let mut snapshot = chart.frozen_export_snapshot();
938 assert!(snapshot.mark_source.is_none());
939 assert_eq!(snapshot.event_visibility(), chart.event_visibility());
940 assert_eq!(snapshot.time_scale_events.len(), 2);
941 let shapes = test_frames::shapes(&ctx, &mut snapshot, 0.1, vec![]);
942 for label in ["Manual", "Manual axis", "Source bar", "Source axis", "E"] {
943 assert!(
944 shapes.iter().any(|shape| matches!(shape,
945 egui::Shape::Text(text) if text.galley.text() == label)),
946 "missing {label}"
947 );
948 }
949 assert!(!shapes.iter().any(|shape| matches!(shape,
950 egui::Shape::Text(text) if text.galley.text() == "N")));
951 let mut image = chart
952 .export_image(&ctx, &ImageExportOptions::default())
953 .unwrap();
954 image.textures_delta.clear();
955 assert_eq!(source.calls.load(Ordering::Relaxed), 1);
956 assert_eq!(chart.marks.len(), 1);
957 assert_eq!(chart.events.len(), 1);
958 assert_eq!(chart.time_scale_events.len(), 1);
959 }
960
961 #[test]
962 #[cfg(feature = "drawings")]
963 fn images_apply_options_and_drawing_visibility_without_changing_the_live_view() {
964 let ctx = egui::Context::default();
965 let mut chart = Chart::new(BarData::new());
966 chart.symbol = "EXPORT TEST".into();
967 chart.add_indicator(Box::new(
968 CustomIndicator::new("Pane", |_| vec![]).with_overlay(false),
969 ));
970 ctx.run_ui(Default::default(), |_| {})
971 .drop_without_applying_deltas();
972 let before = chart.get_time_scale_state();
973 let vertices = |image: &ChartImage| {
974 image
975 .primitives
976 .iter()
977 .map(|primitive| match &primitive.primitive {
978 egui::epaint::Primitive::Mesh(mesh) => mesh.vertices.len(),
979 _ => 0,
980 })
981 .sum::<usize>()
982 };
983 let base = ImageExportOptions {
984 size: [480, 320],
985 include_title: false,
986 include_timestamp: false,
987 include_watermark: true,
988 transparent_background: false,
989 include_crosshair: false,
990 ..Default::default()
991 };
992 let mut opaque = chart.export_image(&ctx, &base).unwrap();
993 let has_textures = !opaque.textures_delta.set.is_empty();
994 opaque.textures_delta.clear();
995 assert_eq!(opaque.size, [480, 320]);
996 assert!(has_textures);
997 for (title, timestamp) in [(true, false), (false, true)] {
998 let mut image = chart
999 .export_image(
1000 &ctx,
1001 &ImageExportOptions {
1002 include_title: title,
1003 include_timestamp: timestamp,
1004 ..base.clone()
1005 },
1006 )
1007 .unwrap();
1008 image.textures_delta.clear();
1009 assert!(
1010 vertices(&image) > vertices(&opaque),
1011 "missing requested header"
1012 );
1013 }
1014 let mut transparent = chart
1015 .export_image(
1016 &ctx,
1017 &ImageExportOptions {
1018 transparent_background: true,
1019 ..base.clone()
1020 },
1021 )
1022 .unwrap();
1023 transparent.textures_delta.clear();
1024 assert!(
1025 vertices(&transparent) < vertices(&opaque),
1026 "backgrounds were retained"
1027 );
1028 assert_eq!(chart.get_time_scale_state(), before);
1029 assert!(
1030 chart.last_rendered_price_rect.is_negative(),
1031 "live geometry changed"
1032 );
1033 use crate::drawings::{ChartPoint, Drawing, DrawingToolType, TimeframeVisibility};
1034 let mut drawing = Drawing::new(1, DrawingToolType::HorizontalLine);
1035 drawing.completed = true;
1036 drawing.chart_points = vec![ChartPoint::new(0.0, 10.0)];
1037 drawing.color = [71, 89, 123, 255];
1038 drawing.timeframe_visibility = TimeframeVisibility::specific(&["1h"]);
1039 chart.drawings.drawings.push(drawing);
1040 chart.set_data(
1041 SeriesId::MAIN,
1042 vec![Bar::new(
1043 DateTime::from_timestamp(0, 0).unwrap(),
1044 10.0,
1045 11.0,
1046 9.0,
1047 10.0,
1048 1.0,
1049 )],
1050 );
1051 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
1052 chart.theme_override = Some(theme);
1053 for (timeframe, visible) in [(Timeframe::Min1, false), (Timeframe::Hour1, true)] {
1054 chart.set_timeframe(timeframe);
1055 let mut image = chart.export_image(&ctx, &base).unwrap();
1056 image.textures_delta.clear();
1057 assert_eq!(image.primitives.iter().any(|primitive| {
1058 matches!(&primitive.primitive, egui::epaint::Primitive::Mesh(mesh)
1059 if mesh.vertices.iter().any(|vertex| vertex.color == egui::Color32::from_rgb(71, 89, 123)))
1060 }), visible, "drawing visibility at {timeframe:?}");
1061 }
1062 }
1063 for [width, height] in [[0, 320], [480, u32::MAX], [7680, 7680]] {
1064 assert_eq!(
1065 chart
1066 .export_image(
1067 &ctx,
1068 &ImageExportOptions {
1069 size: [width, height],
1070 ..base.clone()
1071 }
1072 )
1073 .err(),
1074 Some(ExportError::InvalidSize { width, height })
1075 );
1076 }
1077 }
1078
1079 #[test]
1080 fn table_text_holds_the_chosen_columns_with_their_missing_cells() {
1081 let table = DataTable {
1082 headers: vec!["time".into(), "close".into(), "SMA: 5".into()],
1083 rows: vec![
1084 vec!["t0".into(), "1.50".into(), String::new()],
1085 vec!["t1".into(), "2.50".into(), "2.00".into()],
1086 ],
1087 };
1088 #[cfg(feature = "csv")]
1089 assert_eq!(table.csv(&[0, 2]).unwrap(), "time,SMA: 5\nt0,\nt1,2.00\n");
1090 assert_eq!(table.tsv(&[2, 1]), "SMA: 5\tclose\n\t1.50\n2.00\t2.50");
1091 }
1092
1093 #[test]
1094 #[cfg(feature = "png-export")]
1095 fn svg_images_apply_the_options_and_report_what_they_cannot_hold() {
1096 use crate::widget::test_frames::{chart, frame};
1097 let ctx = egui::Context::default();
1098 let mut chart = chart(&[10.0, 12.0], &[14.0, 13.0]);
1099 chart.symbol = "EXPORT TEST".into();
1100 chart.config.show_watermark = true;
1101 chart.config.watermark_text = Some("WATERMARK".into());
1102 frame(&ctx, &mut chart, 0.0, vec![]);
1103 let base = ImageExportOptions {
1104 size: [480, 320],
1105 include_title: false,
1106 include_timestamp: false,
1107 include_watermark: true,
1108 transparent_background: false,
1109 include_crosshair: false,
1110 ..Default::default()
1111 };
1112 let svg = |options: &ImageExportOptions| chart.export_svg(&ctx, options).unwrap();
1113 let opaque = svg(&base);
1114 assert!(opaque.starts_with("<svg") && opaque.contains(r#"width="480" height="320""#));
1115 assert!(opaque.contains("WATERMARK"), "watermark was dropped");
1116 #[cfg(feature = "icons")]
1117 assert!(
1118 opaque.contains("data:image/png;base64,"),
1119 "frame textures were dropped"
1120 );
1121 assert!(!opaque.contains("EXPORT TEST"));
1122 let no_watermark = svg(&ImageExportOptions {
1123 include_watermark: false,
1124 include_title: true,
1125 ..base.clone()
1126 });
1127 assert!(!no_watermark.contains("WATERMARK") && no_watermark.contains("EXPORT TEST"));
1128 let [r, g, b, _] = chart.config.background_color.to_srgba_unmultiplied();
1129 let bg = format!(r#"fill="rgb({r},{g},{b})""#);
1130 assert!(opaque.contains(&bg));
1131 let transparent = svg(&ImageExportOptions {
1132 transparent_background: true,
1133 ..base.clone()
1134 });
1135 assert!(!transparent.contains(&bg), "background was retained");
1136 let callback = |ui: &mut egui::Ui, _: &mut Chart| {
1137 ui.painter().add(egui::PaintCallback {
1138 rect: ui.max_rect(),
1139 callback: std::sync::Arc::new(()),
1140 });
1141 };
1142 let host_texture = |ui: &mut egui::Ui, _: &mut Chart| {
1143 ui.painter().image(
1144 egui::TextureId::User(7),
1145 ui.max_rect(),
1146 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1147 egui::Color32::WHITE,
1148 );
1149 };
1150 for (error, reason) in [
1151 (chart.export_layout_svg(&ctx, &base, callback), "callbacks"),
1152 (
1153 chart
1154 .export_layout_png(&ctx, &base, callback)
1155 .map(|_| String::new()),
1156 "callbacks",
1157 ),
1158 (
1159 chart.export_layout_svg(&ctx, &base, host_texture),
1160 "host textures",
1161 ),
1162 (
1163 chart
1164 .export_layout_png(&ctx, &base, host_texture)
1165 .map(|_| String::new()),
1166 "host textures",
1167 ),
1168 ] {
1169 let Err(ExportError::Unsupported(error)) = error else {
1170 panic!("{reason}")
1171 };
1172 assert!(error.contains(reason), "{error}");
1173 }
1174 let red = egui::Color32::from_rgb(200, 30, 30);
1175 let green = egui::Color32::from_rgb(30, 200, 30);
1176 let high_id = egui::TextureId::User(7 | 1 << 63);
1177 let supplied = ImageExportOptions {
1178 textures: HashMap::from([
1179 (
1180 egui::TextureId::User(7),
1181 Arc::new(egui::ColorImage::new([2, 1], vec![red, green])),
1182 ),
1183 (high_id, Arc::new(egui::ColorImage::filled([1, 1], green))),
1184 ]),
1185 ..base.clone()
1186 };
1187 let additive = ImageExportOptions {
1188 textures: HashMap::from([(
1189 egui::TextureId::User(7),
1190 Arc::new(egui::ColorImage::filled(
1191 [1, 1],
1192 egui::Color32::from_rgb_additive(200, 0, 0),
1193 )),
1194 )]),
1195 ..base.clone()
1196 };
1197 let error = chart
1198 .export_layout_svg(&ctx, &additive, host_texture)
1199 .unwrap_err();
1200 let ExportError::Unsupported(error) = error else {
1201 panic!("expected an unsupported texture")
1202 };
1203 assert!(error.contains("Additive texture User(7)"), "{error}");
1204 let layout = |ui: &mut egui::Ui, chart: &mut Chart| {
1205 let (rect, _) = ui.allocate_exact_size(egui::vec2(128.0, 32.0), egui::Sense::hover());
1206 ui.painter().image(
1207 egui::TextureId::User(7),
1208 rect,
1209 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1210 egui::Color32::WHITE,
1211 );
1212 ui.label("LAYOUT TOOLBAR");
1213 ui.painter().add(
1214 egui::epaint::TextShape::new(
1215 egui::pos2(300.0, 20.0),
1216 ui.painter().layout_no_wrap(
1217 "TURNED LABEL".into(),
1218 egui::FontId::proportional(12.0),
1219 egui::Color32::WHITE,
1220 ),
1221 egui::Color32::WHITE,
1222 )
1223 .with_angle(-std::f32::consts::FRAC_PI_2),
1224 );
1225 for (id, x) in [(high_id, 160.0), (egui::TextureId::User(7), 208.0)] {
1226 ui.painter().image(
1227 id,
1228 egui::Rect::from_min_size(egui::pos2(x, 8.0), egui::vec2(32.0, 32.0)),
1229 egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)),
1230 egui::Color32::from_rgba_premultiplied(128, 64, 32, 128),
1231 );
1232 }
1233 chart.show(ui);
1234 };
1235 let svg = chart.export_layout_svg(&ctx, &supplied, layout).unwrap();
1236 assert!(svg.contains("LAYOUT TOOLBAR") && svg.contains("WATERMARK"));
1237 let (_, group) = svg.split_once(r#"<g transform="rotate("#).unwrap();
1239 let (rotation, group) = group.split_once(r#")">"#).unwrap();
1240 let rotation: Vec<f32> = rotation.split(' ').map(|n| n.parse().unwrap()).collect();
1241 for (value, expected) in rotation.iter().zip([-90.0, 300.0, 20.0]) {
1242 assert!((value - expected).abs() < 1e-3, "{rotation:?}");
1243 }
1244 assert!(group.split_once("</g>").unwrap().0.contains("TURNED LABEL"));
1245 assert!(svg.contains(r#"width="2" height="1""#));
1246 for id in [7, 7 | 1u64 << 63] {
1247 assert!(svg.contains(&format!(r#"id="tu{id}_"#)));
1248 }
1249 #[cfg(feature = "icons")]
1250 {
1251 let tree = resvg::usvg::Tree::from_str(&svg, &Default::default()).unwrap();
1252 let mut groups = vec![tree.root()];
1253 let mut colors = Vec::new();
1254 while let Some(group) = groups.pop() {
1255 for node in group.children() {
1256 match node {
1257 resvg::usvg::Node::Group(group) => groups.push(group),
1258 resvg::usvg::Node::Image(image) => {
1259 let resvg::usvg::ImageKind::PNG(data) = image.kind() else {
1260 panic!("expected an embedded PNG");
1261 };
1262 let mut reader =
1263 png::Decoder::new(data.as_slice()).read_info().unwrap();
1264 let mut pixels = vec![0; reader.output_buffer_size()];
1265 reader.next_frame(&mut pixels).unwrap();
1266 let [r, g, b, a] = pixels[..4].try_into().unwrap();
1267 colors
1268 .push(egui::Color32::from_rgba_unmultiplied(r, g, b, a).to_array());
1269 }
1270 _ => {}
1271 }
1272 }
1273 }
1274 for expected in [[15, 50, 4, 128], [100, 8, 4, 128], red.to_array()] {
1276 assert!(
1277 colors.contains(&expected),
1278 "missing {expected:?} in {colors:?}"
1279 );
1280 }
1281 }
1282 let png = chart.export_layout_png(&ctx, &supplied, layout).unwrap();
1283 let mut reader = png::Decoder::new(png.as_slice()).read_info().unwrap();
1284 let mut pixels = vec![0; reader.output_buffer_size()];
1285 reader.next_frame(&mut pixels).unwrap();
1286 assert_eq!(pixels[(20 * 480 + 20) * 4..][..4], red.to_array());
1288 assert_eq!(pixels[(20 * 480 + 120) * 4..][..4], green.to_array());
1289 for (id, size, pixels, reason) in [
1290 (
1291 egui::TextureId::User(7),
1292 [0, 1],
1293 vec![],
1294 "invalid dimensions",
1295 ),
1296 (
1297 egui::TextureId::User(7),
1298 [2, 1],
1299 vec![red],
1300 "invalid dimensions",
1301 ),
1302 (egui::TextureId::Managed(0), [1, 1], vec![red], "conflicts"),
1303 ] {
1304 let options = ImageExportOptions {
1305 textures: HashMap::from([(
1306 id,
1307 Arc::new(egui::ColorImage {
1308 size,
1309 source_size: egui::vec2(size[0] as f32, size[1] as f32),
1310 pixels,
1311 }),
1312 )]),
1313 ..base.clone()
1314 };
1315 for error in [
1316 chart.export_svg(&ctx, &options).unwrap_err(),
1317 chart.export_png(&ctx, &options).unwrap_err(),
1318 ] {
1319 let ExportError::Unsupported(error) = error else {
1320 panic!("{reason}")
1321 };
1322 assert!(
1323 error.contains(reason) && error.contains(&format!("{id:?}")),
1324 "{error}"
1325 );
1326 }
1327 }
1328 }
1329
1330 #[test]
1331 #[cfg(not(feature = "png-export"))]
1332 fn svg_exports_vectors_and_refuses_textures_without_png_export() {
1333 use crate::widget::test_frames::{chart, frame};
1334 let ctx = egui::Context::default();
1335 let mut chart = chart(&[10.0, 12.0], &[14.0, 13.0]);
1336 frame(&ctx, &mut chart, 0.0, vec![]);
1337 let options = ImageExportOptions {
1338 size: [480, 320],
1339 include_timestamp: false,
1340 ..Default::default()
1341 };
1342 assert!(
1343 chart
1344 .export_layout_svg(&ctx, &options, |ui, _| {
1345 ui.label("Vector text");
1346 })
1347 .unwrap()
1348 .contains("Vector text")
1349 );
1350 let id = egui::TextureId::User(7);
1351 let options = ImageExportOptions {
1352 textures: HashMap::from([(
1353 id,
1354 Arc::new(egui::ColorImage::filled([1, 1], egui::Color32::RED)),
1355 )]),
1356 ..options
1357 };
1358 assert_eq!(
1359 chart.export_layout_svg(&ctx, &options, |ui, _| {
1360 ui.image((id, egui::vec2(32.0, 32.0)));
1361 }),
1362 Err(ExportError::Unsupported(
1363 "SVG image texture User(7) requires the png-export feature".into()
1364 ))
1365 );
1366 }
1367
1368 #[test]
1369 #[cfg(feature = "png-export")]
1370 fn png_images_rasterise_the_chart_with_the_last_crosshair_on_request() {
1371 use crate::widget::test_frames::{chart, frame};
1372 use egui::{Event, Pos2};
1373 let ctx = egui::Context::default();
1374 let mut chart = chart(&[100.0], &[110.0]);
1375 frame(
1376 &ctx,
1377 &mut chart,
1378 0.0,
1379 vec![Event::PointerMoved(Pos2::new(400.0, 200.0))],
1380 );
1381 frame(&ctx, &mut chart, 0.1, vec![Event::PointerGone]);
1382 let decode = |chart: &Chart, include_crosshair| {
1383 let png = chart
1384 .export_png(
1385 &ctx,
1386 &ImageExportOptions {
1387 size: [480, 320],
1388 include_title: false,
1389 include_timestamp: false,
1390 include_crosshair,
1391 ..Default::default()
1392 },
1393 )
1394 .unwrap();
1395 let mut reader = png::Decoder::new(png.as_slice()).read_info().unwrap();
1396 let mut pixels = vec![0; reader.output_buffer_size()];
1397 let info = reader.next_frame(&mut pixels).unwrap();
1398 assert_eq!(
1399 (info.width, info.height, info.color_type),
1400 (480, 320, png::ColorType::Rgba)
1401 );
1402 pixels.as_chunks::<4>().0.to_vec()
1403 };
1404 let (plain, crosshair) = (decode(&chart, false), decode(&chart, true));
1405 let bullish = chart.config().bullish_color.to_srgba_unmultiplied();
1407 assert!(plain.iter().filter(|pixel| **pixel == bullish).count() > 1000);
1408 let changed = |x: usize, y: usize| plain[y * 480 + x] != crosshair[y * 480 + x];
1410 let column = (0..480).map(|x| (0..320).filter(|&y| changed(x, y)).count());
1411 let row = (0..320).map(|y| (0..480).filter(|&x| changed(x, y)).count());
1412 assert!(column.max() > Some(60) && row.max() > Some(100));
1413
1414 chart.chart_options.crosshair.vert_label_background_color = egui::Color32::RED;
1415 chart.chart_options.crosshair.horz_label_background_color = egui::Color32::BLUE;
1416 let (time, _) = chart.last_crosshair.unwrap();
1417 for price in [Some(105.0), None] {
1418 chart.last_crosshair = Some((time, price));
1419 let crosshair = decode(&chart, true);
1420 assert!(crosshair.contains(&[255, 0, 0, 255]));
1421 assert_eq!(crosshair.contains(&[0, 0, 255, 255]), price.is_some());
1422 }
1423 }
1424}