1#[derive(Clone, Debug, PartialEq)]
5pub enum StudyCell {
6 Text(String),
8 Percent(Option<f64>),
10}
11
12#[derive(Clone, Debug, Default, PartialEq)]
14pub struct StudyTable {
15 pub title: String,
17 pub columns: Vec<String>,
19 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 #[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}