1use crate::localization as l10n;
7use crate::model::{Instrument, Quote, percent_change};
8use crate::theme::ThemeExt;
9use crate::tokens::DESIGN_TOKENS;
10
11use crate::widget::QuoteSide;
12use egui::{Color32, Pos2, Rect, Response, Sense, Stroke, Ui, Vec2};
13
14#[derive(Clone, Debug, Default)]
16pub struct OhlcData {
17 pub open: f64,
19 pub high: f64,
21 pub low: f64,
23 pub close: f64,
25 pub change: f64,
27 pub change_percent: f64,
29 pub volume: f64,
31}
32
33impl OhlcData {
34 pub fn new(open: f64, high: f64, low: f64, close: f64, volume: f64) -> Self {
36 let change = close - open;
37 let change_percent = percent_change(open, close);
38 Self {
39 open,
40 high,
41 low,
42 close,
43 change,
44 change_percent,
45 volume,
46 }
47 }
48
49 pub fn is_bullish(&self) -> bool {
51 self.close >= self.open
52 }
53}
54
55#[derive(Clone, Debug)]
57pub struct SymbolHeaderConfig {
58 pub height: f32,
60 pub bg_color: Color32,
62 pub text_color: Color32,
64 pub label_color: Color32,
66 pub bullish_color: Color32,
68 pub bearish_color: Color32,
70 pub separator_color: Color32,
72 pub show_company_name: bool,
74 pub show_bid_ask: bool,
76 pub show_volume: bool,
78}
79
80impl Default for SymbolHeaderConfig {
81 fn default() -> Self {
82 Self {
84 height: DESIGN_TOKENS.sizing.symbol_header.height,
85 bg_color: Color32::TRANSPARENT, text_color: Color32::PLACEHOLDER,
87 label_color: Color32::PLACEHOLDER,
88 bullish_color: Color32::PLACEHOLDER,
89 bearish_color: Color32::PLACEHOLDER,
90 separator_color: Color32::PLACEHOLDER,
91 show_company_name: true,
92 show_bid_ask: true,
93 show_volume: true,
94 }
95 }
96}
97
98impl SymbolHeaderConfig {
99 pub fn from_theme(theme: &crate::theme::Theme) -> Self {
101 let ui = &theme.ui;
102 Self {
103 text_color: ui.text,
104 label_color: ui.text_secondary,
105 bullish_color: ui.success,
106 bearish_color: ui.error,
107 separator_color: ui.border,
108 ..Self::default()
109 }
110 }
111
112 fn resolved(&self, ui: &Ui) -> Self {
114 let visuals = ui.visuals();
115 let colors = ui.active_theme().ui;
117 let or = |c: Color32, style: Color32| if c == Color32::PLACEHOLDER { style } else { c };
118 Self {
119 bullish_color: or(self.bullish_color, colors.success),
120 bearish_color: or(self.bearish_color, colors.error),
121 text_color: or(self.text_color, visuals.strong_text_color()),
122 label_color: or(
123 self.label_color,
124 visuals.widgets.noninteractive.fg_stroke.color,
125 ),
126 separator_color: or(
127 self.separator_color,
128 visuals.widgets.noninteractive.bg_stroke.color,
129 ),
130 ..self.clone()
131 }
132 }
133}
134
135pub struct SymbolHeader {
137 pub instrument: Instrument,
139 pub timeframe: String,
141 pub ohlc: OhlcData,
143 pub quote: Option<Quote>,
146 pub config: SymbolHeaderConfig,
148}
149
150impl SymbolHeader {
151 pub fn new(instrument: Instrument, timeframe: impl Into<String>) -> Self {
153 Self {
154 instrument,
155 timeframe: timeframe.into(),
156 ohlc: OhlcData::default(),
157 quote: None,
158 config: SymbolHeaderConfig::default(),
159 }
160 }
161
162 pub fn with_ohlc(mut self, ohlc: OhlcData) -> Self {
164 self.ohlc = ohlc;
165 self
166 }
167
168 pub fn with_quote(mut self, quote: Quote) -> Self {
170 self.quote = Some(quote);
171 self
172 }
173
174 pub fn with_config(mut self, config: SymbolHeaderConfig) -> Self {
176 self.config = config;
177 self
178 }
179
180 pub fn show(&self, ui: &mut Ui) -> Response {
182 self.show_with_quote_action(ui).0
183 }
184
185 pub fn show_with_quote_action(&self, ui: &mut Ui) -> (Response, Option<QuoteSide>) {
188 let width = ui.available_width();
189 egui::ScrollArea::horizontal()
190 .auto_shrink([false, true])
191 .show(ui, |ui| {
192 let config = &self.config.resolved(ui);
193 let response =
194 ui.allocate_response(Vec2::new(width, config.height), Sense::hover());
195 let painter = ui.painter().clone();
196 let rect = response.rect;
197
198 let mut x = rect.min.x + ui.active_theme().spacing.sm;
199 let center_y = rect.center().y;
200
201 let symbol_galley = painter.layout_no_wrap(
203 self.instrument.symbol.clone(),
204 egui::FontId::proportional(ui.active_theme().text.title),
205 config.text_color,
206 );
207 painter.galley(
208 Pos2::new(x, center_y - symbol_galley.size().y / 2.0),
209 symbol_galley.clone(),
210 Color32::TRANSPARENT,
211 );
212 x += symbol_galley.size().x + ui.active_theme().spacing.sm;
213
214 let info = &self.instrument;
216 let info_text = [
217 info.description.as_deref().unwrap_or_default(),
218 info.exchange.as_deref().unwrap_or_default(),
219 &self.timeframe,
220 ]
221 .into_iter()
222 .filter(|field| !field.is_empty())
223 .collect::<Vec<_>>()
224 .join(" • ");
225 if config.show_company_name && !info_text.is_empty() {
226 let info_galley = painter.layout_no_wrap(
227 info_text,
228 egui::FontId::proportional(ui.active_theme().text.body),
229 config.label_color,
230 );
231 painter.galley(
232 Pos2::new(x, center_y - info_galley.size().y / 2.0),
233 info_galley.clone(),
234 Color32::TRANSPARENT,
235 );
236 x += info_galley.size().x + ui.active_theme().spacing.md;
237 }
238
239 self.draw_separator(
241 &painter,
242 config,
243 x,
244 rect.min.y + ui.active_theme().spacing.xs,
245 rect.max.y - ui.active_theme().spacing.xs,
246 );
247 x += ui.active_theme().spacing.md;
248
249 let price_color = if self.ohlc.is_bullish() {
251 config.bullish_color
252 } else {
253 config.bearish_color
254 };
255
256 let change_text = format!(
257 "{:.2} {}{:.2} ({}{:.2}%)",
258 self.ohlc.close,
259 if self.ohlc.change >= 0.0 { "+" } else { "" },
260 self.ohlc.change,
261 if self.ohlc.change_percent >= 0.0 {
262 "+"
263 } else {
264 ""
265 },
266 self.ohlc.change_percent
267 );
268 let change_galley = painter.layout_no_wrap(
269 change_text,
270 egui::FontId::proportional(ui.active_theme().text.body),
271 price_color,
272 );
273 painter.galley(
274 Pos2::new(x, center_y - change_galley.size().y / 2.0),
275 change_galley.clone(),
276 Color32::TRANSPARENT,
277 );
278 x += change_galley.size().x + ui.active_theme().spacing.md;
279
280 self.draw_separator(
282 &painter,
283 config,
284 x,
285 rect.min.y + ui.active_theme().spacing.xs,
286 rect.max.y - ui.active_theme().spacing.xs,
287 );
288 x += ui.active_theme().spacing.md;
289
290 let mut quote_action = None;
292 let bid_ask = (self.quote.as_ref())
293 .and_then(|quote| quote.bid.zip(quote.ask))
294 .filter(|(bid, ask)| bid.is_finite() && ask.is_finite());
295 if config.show_bid_ask
296 && let Some((bid, ask)) = bid_ask
297 {
298 let sell = self.draw_quote_box(
300 &painter,
301 x,
302 center_y,
303 bid,
304 "SELL",
305 config.bearish_color,
306 );
307 x = sell.right() + ui.active_theme().spacing.xs;
308 let buy = self.draw_quote_box(
310 &painter,
311 x,
312 center_y,
313 ask,
314 "BUY",
315 config.bullish_color,
316 );
317 x = buy.right() + ui.active_theme().spacing.md;
318 for (rect, side) in [(sell, QuoteSide::Sell), (buy, QuoteSide::Buy)] {
319 let id = response.id.with(side == QuoteSide::Buy);
320 let quote_response = ui.interact(rect, id, Sense::click());
321 let translated_label = l10n::text(
322 ui.ctx(),
323 if side == QuoteSide::Buy {
324 "Buy"
325 } else {
326 "Sell"
327 },
328 );
329 quote_response.widget_info(|| {
330 egui::WidgetInfo::labeled(
331 egui::Role::Button,
332 quote_response.enabled(),
333 &translated_label,
334 )
335 });
336 if quote_response.clicked() {
337 quote_action = Some(side);
338 }
339 }
340
341 self.draw_separator(
343 &painter,
344 config,
345 x,
346 rect.min.y + ui.active_theme().spacing.xs,
347 rect.max.y - ui.active_theme().spacing.xs,
348 );
349 x += ui.active_theme().spacing.md;
350 }
351
352 if config.show_volume {
354 let vol_text = l10n::format_text(
355 ui.ctx(),
356 "Vol {volume}",
357 &[("volume", self.format_volume(self.ohlc.volume))],
358 );
359 let vol_galley = painter.layout_no_wrap(
360 vol_text,
361 egui::FontId::proportional(ui.active_theme().text.body),
362 config.label_color,
363 );
364 let width = vol_galley.size().x;
365 painter.galley(
366 Pos2::new(x, center_y - vol_galley.size().y / 2.0),
367 vol_galley,
368 Color32::TRANSPARENT,
369 );
370 x += width;
371 }
372 ui.set_min_width(x - rect.left() + ui.active_theme().spacing.sm);
373 (response, quote_action)
374 })
375 .inner
376 }
377
378 fn draw_separator(
379 &self,
380 painter: &egui::Painter,
381 config: &SymbolHeaderConfig,
382 x: f32,
383 y1: f32,
384 y2: f32,
385 ) {
386 painter.line_segment(
387 [Pos2::new(x, y1), Pos2::new(x, y2)],
388 Stroke::new(DESIGN_TOKENS.stroke.hairline, config.separator_color),
389 );
390 }
391
392 fn draw_quote_box(
393 &self,
394 painter: &egui::Painter,
395 x: f32,
396 center_y: f32,
397 price: f64,
398 label: &str,
399 bg_color: Color32,
400 ) -> Rect {
401 let text = format!("{price:.2} {}", l10n::text(painter.ctx(), label));
402 let galley = painter.layout_no_wrap(
403 text,
404 egui::FontId::proportional(painter.ctx().active_theme().text.small),
405 if crate::theme::contrast(Color32::WHITE, bg_color)
406 >= crate::theme::contrast(Color32::BLACK, bg_color)
407 {
408 Color32::WHITE
409 } else {
410 Color32::BLACK
411 },
412 );
413 let padding = painter.ctx().active_theme().spacing.xs;
414 let box_width = galley.size().x + padding * 2.0;
415 let box_height = DESIGN_TOKENS.sizing.symbol_header.quote_box_height;
416
417 let box_rect = Rect::from_min_size(
418 Pos2::new(x, center_y - box_height / 2.0),
419 Vec2::new(box_width, box_height),
420 );
421
422 painter.rect_filled(box_rect, painter.ctx().active_theme().radius.sm, bg_color);
423 painter.galley(
424 Pos2::new(x + padding, center_y - galley.size().y / 2.0),
425 galley.clone(),
426 Color32::TRANSPARENT,
427 );
428
429 box_rect
430 }
431
432 fn format_volume(&self, vol: f64) -> String {
433 if vol >= 1_000_000_000.0 {
434 format!("{:.2}B", vol / 1_000_000_000.0)
435 } else if vol >= 1_000_000.0 {
436 format!("{:.2}M", vol / 1_000_000.0)
437 } else if vol >= 1_000.0 {
438 format!("{:.2}K", vol / 1_000.0)
439 } else {
440 format!("{vol:.0}")
441 }
442 }
443}
444
445#[cfg(test)]
446mod tests {
447 use super::OhlcData;
448
449 #[test]
450 fn quote_buttons_remain_clickable_in_narrow_headers() {
451 use super::*;
452 for theme in [crate::theme::Theme::light(), crate::theme::Theme::dark()] {
453 for width in [800.0, 320.0] {
454 for (translated, label, expected) in [
455 (false, "SELL", QuoteSide::Sell),
456 (false, "BUY", QuoteSide::Buy),
457 (true, "VERKAUFEN", QuoteSide::Sell),
458 (true, "KAUFEN", QuoteSide::Buy),
459 ] {
460 let ctx = egui::Context::default();
461 crate::theme::apply_to_egui(&ctx, &theme);
462 if translated {
463 l10n::set_translator(&ctx, |english| match english {
464 "SELL" => Some("VERKAUFEN".into()),
465 "BUY" => Some("KAUFEN".into()),
466 _ => None,
467 });
468 }
469 let mut header = SymbolHeader::new(
470 Instrument::new("SYM").with_description("A long company name"),
471 "",
472 );
473 let quote = Quote::new(100.5, 100.0, Default::default());
474 header.quote = Some(quote.with_bid_ask(100.0, 101.0));
475 let mut selected = None;
476 let mut target = None;
477 let mut pressed = false;
478 for pass in 0..30 {
479 let mut events = vec![];
480 if let Some(pos) = target {
481 events.push(egui::Event::PointerMoved(pos));
482 events.push(egui::Event::PointerButton {
483 pos,
484 button: egui::PointerButton::Primary,
485 pressed: !pressed,
486 modifiers: Default::default(),
487 });
488 pressed = !pressed;
489 } else if pass > 2 {
490 events.push(egui::Event::PointerMoved(egui::pos2(width / 2.0, 20.0)));
491 events.push(egui::Event::MouseWheel {
492 unit: egui::MouseWheelUnit::Point,
493 source: egui::MouseWheelSource::Wheel,
494 phase: egui::TouchPhase::Move,
495 delta: egui::vec2(-60.0, 0.0),
496 modifiers: Default::default(),
497 });
498 }
499 let output = ctx.run_ui(
500 egui::RawInput {
501 screen_rect: Some(Rect::from_min_size(
502 Pos2::ZERO,
503 Vec2::new(width, 200.0),
504 )),
505 events,
506 ..Default::default()
507 },
508 |ui| {
509 selected = header.show_with_quote_action(ui).1;
510 },
511 );
512 if pass > 14 && target.is_none() {
513 target = output.shapes.iter().find_map(|shape| match &shape.shape {
514 egui::Shape::Text(text)
515 if text.galley.text().ends_with(&format!(" {label}")) =>
516 {
517 let rect = text.galley.rect.translate(text.pos.to_vec2());
518 shape.clip_rect.contains_rect(rect).then_some(rect.center())
519 }
520 _ => None,
521 });
522 }
523 output.drop_without_applying_deltas();
524 if selected.is_some() {
525 break;
526 }
527 }
528 assert_eq!(selected, Some(expected), "{label} at {width}");
529 }
530 }
531 }
532 }
533
534 #[test]
535 fn ohlc_change_percent_follows_the_sign_of_the_change() {
536 for (open, close, expected) in [(100.0, 105.0, 5.0), (-10.0, -5.0, 50.0), (0.0, 5.0, 0.0)] {
538 let data = OhlcData::new(open, close, open, close, 0.0);
539 assert_eq!(data.change_percent, expected, "{open} -> {close}");
540 }
541 }
542}