1use crate::localization as l10n;
5use std::collections::{HashMap, HashSet};
6use std::sync::Arc;
7
8use chrono::{DateTime, Offset, Utc};
9use egui::text::LayoutJob;
10use egui::{Align, Color32, Label, Layout, RichText, Sense, Stroke, Ui, pos2, vec2};
11use serde::{Deserialize, Serialize};
12
13use super::market_details::{MarketDetail, MarketDetailRequest, PeriodReturn, Seasonality};
14use super::price_decimals;
15use super::{HEADER_HEIGHT, grouped, signed, weighted};
16use crate::data::inbox::Inbox;
17use crate::data::{Datafeed, FeedError, SymbolDetails};
18use crate::icons::icons;
19use crate::model::{ExtendedSession, Instrument, Quote, TimezoneId};
20use crate::scales::StudyFormat;
21use crate::services::{Storage, StoreKind};
22use crate::theme::ThemeExt;
23use crate::ui_kit::IconButton;
24use crate::ui_kit::status_message::{MessageKind, status_message};
25
26const PRICE_SIZE: f32 = 26.0;
28const EXTENDED_PRICE_SIZE: f32 = 18.0;
30const SECTIONS: [&str; 6] = [
32 "Volume",
33 "Key statistics",
34 "Profile",
35 "Performance",
36 "Seasonality",
37 "Notes",
38];
39const MONTHS: [&str; 12] = [
40 "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
41];
42
43#[derive(Serialize, Deserialize)]
44struct NotesDocument {
45 version: u32,
46 notes: String,
47}
48
49#[derive(Default)]
51struct Notes {
52 text: String,
53 written: Option<String>,
56 saving: bool,
57}
58
59enum Request {
61 Details(String),
62 Load(String),
63 Save(String, String),
64}
65
66#[derive(Default)]
94pub struct SymbolDetailsPanel {
95 instrument: Option<Instrument>,
96 quote: Option<Quote>,
97 average_volume: Option<f64>,
99 timezone: TimezoneId,
100 hidden: HashSet<&'static str>,
101 details: Option<SymbolDetails>,
102 returns: Option<MarketDetail<Vec<PeriodReturn>>>,
103 seasonality: Option<MarketDetail<Seasonality>>,
104 error: Option<String>,
105 notes: HashMap<String, Notes>,
106 feed: Option<Arc<dyn Datafeed>>,
107 storage: Option<Arc<dyn Storage>>,
108 inbox: Arc<Inbox>,
109 requests: HashMap<u64, Request>,
110 next: u64,
111 financials_requested: bool,
112 hide_financials: bool,
113}
114
115impl SymbolDetailsPanel {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn set_feed(&mut self, feed: Arc<dyn Datafeed>) {
123 self.feed = Some(feed);
124 if let Some(symbol) = self.symbol().map(str::to_owned) {
125 self.request_details(symbol);
126 }
127 }
128
129 pub fn set_storage(&mut self, storage: Arc<dyn Storage>) {
131 self.storage = Some(storage);
132 self.notes.clear();
133 self.requests
134 .retain(|_, request| matches!(request, Request::Details(_)));
135 if let Some(symbol) = self.symbol().map(str::to_owned) {
136 self.load_notes(symbol);
137 }
138 }
139
140 pub fn set_instrument(&mut self, instrument: &Instrument) {
144 let new = self.symbol() != Some(instrument.symbol.as_str());
145 self.instrument = Some(instrument.clone());
146 if new {
147 self.quote = None;
148 self.average_volume = None;
149 self.details = None;
150 self.returns = None;
151 self.seasonality = None;
152 self.error = None;
153 self.request_details(instrument.symbol.clone());
154 let loading = self.requests.values().any(
156 |request| matches!(request, Request::Load(symbol) if *symbol == instrument.symbol),
157 );
158 if !loading
159 && self
160 .notes
161 .get(&instrument.symbol)
162 .is_none_or(|notes| notes.written.is_none())
163 {
164 self.load_notes(instrument.symbol.clone());
165 }
166 }
167 }
168
169 pub fn set_quote(&mut self, quote: Quote) {
172 self.quote = Some(quote);
173 }
174
175 pub fn set_average_volume(&mut self, average: f64) {
178 self.average_volume = Some(average);
179 }
180
181 pub fn set_timezone(&mut self, timezone: TimezoneId) {
183 self.timezone = timezone;
184 }
185
186 pub fn set_financials_visible(&mut self, visible: bool) {
189 self.hide_financials = !visible;
190 if !visible {
191 self.financials_requested = false;
192 }
193 }
194
195 pub fn take_financials_request(&mut self) -> bool {
197 std::mem::take(&mut self.financials_requested)
198 }
199
200 pub fn take_market_requests(&mut self) -> Vec<MarketDetailRequest> {
205 let Some(symbol) = self.symbol().map(str::to_owned) else {
206 return Vec::new();
207 };
208 let mut requests = Vec::new();
209 if self.returns.is_none() {
210 self.returns = Some(MarketDetail::Loading);
211 requests.push(MarketDetailRequest::PeriodReturns(symbol.clone()));
212 }
213 if self.seasonality.is_none() {
214 self.seasonality = Some(MarketDetail::Loading);
215 requests.push(MarketDetailRequest::Seasonality(symbol));
216 }
217 requests
218 }
219
220 pub fn set_period_returns(&mut self, symbol: &str, returns: MarketDetail<Vec<PeriodReturn>>) {
223 if self.symbol() == Some(symbol) {
224 self.returns = Some(returns);
225 }
226 }
227
228 pub fn set_seasonality(&mut self, symbol: &str, seasonality: MarketDetail<Seasonality>) {
231 if self.symbol() == Some(symbol) {
232 self.seasonality = Some(seasonality);
233 }
234 }
235
236 pub fn symbol(&self) -> Option<&str> {
238 self.instrument
239 .as_ref()
240 .map(|instrument| instrument.symbol.as_str())
241 }
242
243 fn key(&mut self, request: Request) -> u64 {
244 let key = self.next;
245 self.next += 1;
246 self.requests.insert(key, request);
247 key
248 }
249
250 fn request_details(&mut self, symbol: String) {
251 if let Some(feed) = self.feed.clone() {
252 let key = self.key(Request::Details(symbol.clone()));
253 feed.symbol_details(&symbol, self.inbox.reply(key));
254 }
255 }
256
257 fn load_notes(&mut self, symbol: String) {
258 let Some(storage) = self.storage.clone() else {
259 self.notes.entry(symbol).or_default().written = Some(String::new());
261 return;
262 };
263 self.notes.insert(symbol.clone(), Notes::default());
264 let key = self.key(Request::Load(symbol.clone()));
265 storage.load(StoreKind::SymbolNotes, &symbol, self.inbox.reply(key));
266 }
267
268 pub fn poll(&mut self, ctx: &egui::Context) {
271 let repaint = ctx.clone();
272 self.inbox.set_waker(move || repaint.request_repaint());
273 let shown = self.symbol().map(str::to_owned);
274 let current = |symbol: &str| shown.as_deref() == Some(symbol);
275 for (key, answer) in self.inbox.drain().answers {
276 let Some(request) = self.requests.remove(&key) else {
277 continue;
278 };
279 match request {
280 Request::Details(symbol) => {
281 let result = *answer
282 .downcast::<Result<SymbolDetails, FeedError>>()
283 .expect("symbol details");
284 if !current(&symbol) {
285 continue;
286 }
287 match result {
288 Ok(details) => self.details = Some(details),
289 Err(FeedError::NotSupported) => {}
290 Err(error) => {
291 self.error = Some(l10n::format_text(
292 ctx,
293 "Details unavailable: {error}",
294 &[("error", error.to_string().as_str())],
295 ))
296 }
297 }
298 }
299 Request::Load(symbol) => {
300 let result = answer
301 .downcast::<Result<Option<String>, FeedError>>()
302 .expect("notes load")
303 .map_err(|error| error.to_string())
304 .and_then(|document| {
305 let Some(document) = document else {
306 return Ok(String::new());
307 };
308 let document: NotesDocument = serde_json::from_str(&document)
309 .map_err(|error| error.to_string())?;
310 if document.version != 1 {
311 return Err("unsupported notes version".into());
312 }
313 Ok(document.notes)
314 });
315 match result {
316 Ok(text) => {
317 let notes = self.notes.entry(symbol).or_default();
318 notes.text = text.clone();
319 notes.written = Some(text);
320 }
321 Err(error) if current(&symbol) => {
322 self.error = Some(l10n::format_text(
323 ctx,
324 "Notes unavailable: {error}",
325 &[("error", error.to_string().as_str())],
326 ));
327 }
328 Err(_) => {}
329 }
330 }
331 Request::Save(symbol, text) => {
332 let result = *answer
333 .downcast::<Result<(), FeedError>>()
334 .expect("notes save");
335 if let Err(error) = result {
336 self.error = Some(l10n::format_text(
337 ctx,
338 "Notes for {symbol} not saved: {error}",
339 &[
340 ("symbol", symbol.as_str()),
341 ("error", error.to_string().as_str()),
342 ],
343 ));
344 }
345 if let Some(notes) = self.notes.get_mut(&symbol) {
347 notes.saving = false;
348 notes.written = Some(text);
349 }
350 }
351 }
352 }
353 self.save_notes();
354 }
355
356 fn save_notes(&mut self) {
358 let Some(storage) = &self.storage else {
359 return;
360 };
361 for (symbol, notes) in &mut self.notes {
362 if notes.saving
363 || notes
364 .written
365 .as_ref()
366 .is_none_or(|text| *text == notes.text)
367 {
368 continue;
369 }
370 let document = serde_json::to_string(&NotesDocument {
371 version: 1,
372 notes: notes.text.clone(),
373 })
374 .expect("a string field always serializes");
375 let key = self.next;
376 self.next += 1;
377 self.requests
378 .insert(key, Request::Save(symbol.clone(), notes.text.clone()));
379 notes.saving = true;
380 storage.save(
381 StoreKind::SymbolNotes,
382 symbol,
383 document,
384 self.inbox.reply(key),
385 );
386 }
387 }
388
389 pub fn show(&mut self, ui: &mut Ui) {
391 egui::Frame::NONE
393 .inner_margin(egui::Margin::symmetric(10, 0))
394 .show(ui, |ui| self.contents(ui));
395 }
396
397 fn contents(&mut self, ui: &mut Ui) {
398 let Self {
399 instrument,
400 quote,
401 average_volume,
402 timezone,
403 hidden,
404 details,
405 returns,
406 seasonality,
407 error,
408 notes,
409 financials_requested,
410 hide_financials,
411 ..
412 } = self;
413 let Some(instrument) = instrument else {
414 return;
415 };
416 let theme = ui.active_theme();
417 let mut edit_notes = false;
418 let header = vec2(ui.available_width(), HEADER_HEIGHT);
419 ui.allocate_ui_with_layout(header, Layout::right_to_left(Align::Center), |ui| {
420 ui.set_min_height(header.y);
421 ui.spacing_mut().item_spacing.x = theme.spacing.sm;
422 let more = IconButton::new(&icons::UI_MORE_HORIZONTAL)
423 .tooltip("More")
424 .show(ui);
425 egui::Popup::menu(&more).show(|ui| {
426 if !*hide_financials && ui.button(l10n::text(ui.ctx(), "Financials")).clicked() {
427 *financials_requested = true;
428 }
429 if ui.button(l10n::text(ui.ctx(), "Copy symbol")).clicked() {
430 ui.ctx().copy_text(instrument.symbol.clone());
431 }
432 });
433 edit_notes = IconButton::new(&icons::PENCIL)
434 .tooltip("Edit notes")
435 .show(ui)
436 .clicked();
437 let layout = IconButton::new(&icons::LAYOUT_GRID)
438 .tooltip("Layout")
439 .show(ui);
440 egui::Popup::menu(&layout)
441 .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
442 .show(|ui| {
443 for section in SECTIONS {
444 let mut shown = !hidden.contains(section);
445 if ui
446 .checkbox(&mut shown, l10n::text(ui.ctx(), section))
447 .changed()
448 {
449 if shown {
450 hidden.remove(section);
451 } else {
452 hidden.insert(section);
453 }
454 }
455 }
456 });
457 ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
458 let title = LayoutJob::single_section(
459 l10n::text(ui.ctx(), instrument.symbol.clone()),
460 weighted(theme.text.heading, 700.0, theme.ui.text),
461 );
462 ui.add(Label::new(title).truncate());
463 });
464 });
465 if edit_notes {
466 hidden.remove("Notes");
467 }
468 let shown = |section| !hidden.contains(section);
469 let ctx = ui.ctx().clone();
470 let update = |time: DateTime<Utc>| {
471 let local = time.with_timezone(&*timezone);
472 l10n::format_text(
473 &ctx,
474 "Last update at {time} {zone}",
475 &[
476 ("time", local.format("%H:%M").to_string().as_str()),
477 (
478 "zone",
479 zone_label(local.offset().fix().local_minus_utc()).as_str(),
480 ),
481 ],
482 )
483 };
484 egui::ScrollArea::vertical()
485 .auto_shrink([false, false])
486 .show(ui, |ui| {
487 let name: Vec<&str> = [&instrument.description, &instrument.exchange]
488 .into_iter()
489 .flatten()
490 .map(String::as_str)
491 .collect();
492 let url = details.as_ref().and_then(|details| details.url.as_deref());
493 if !name.is_empty() || url.is_some() {
494 ui.horizontal(|ui| {
495 ui.spacing_mut().item_spacing.x = theme.spacing.xs;
496 ui.scope(|ui| {
497 ui.set_max_width(ui.available_width() - 16.0);
498 ui.add(
499 Label::new(
500 RichText::new(l10n::text(ui.ctx(), name.join(" · ")))
501 .size(theme.text.label)
502 .color(theme.ui.text),
503 )
504 .truncate(),
505 );
506 });
507 if let Some(url) = url {
508 external_link(ui, url);
509 }
510 });
511 }
512 if let Some(category) = details.as_ref().and_then(|d| d.category.as_deref()) {
513 ui.add(
514 Label::new(
515 RichText::new(l10n::text(ui.ctx(), category))
516 .size(theme.text.small)
517 .color(theme.ui.text_secondary),
518 )
519 .truncate(),
520 );
521 }
522 if let Some(quote) = quote {
523 let decimals =
524 (instrument.price_decimals).unwrap_or_else(|| price_decimals(quote.last));
525 ui.add_space(theme.spacing.md);
526 price_line(
527 ui,
528 quote.last,
529 quote.change(),
530 quote.change_percent(),
531 decimals,
532 PRICE_SIZE,
533 instrument.currency.as_deref(),
534 );
535 ui.label(
536 RichText::new(l10n::text(ui.ctx(), update(quote.time)))
537 .size(theme.text.small)
538 .color(theme.ui.text_secondary),
539 );
540 if let Some(extended) = quote.extended {
541 let from_regular = Quote::new(extended.last, quote.last, extended.time);
542 ui.add_space(theme.spacing.md);
543 price_line(
544 ui,
545 from_regular.last,
546 from_regular.change(),
547 from_regular.change_percent(),
548 decimals,
549 EXTENDED_PRICE_SIZE,
550 instrument.currency.as_deref(),
551 );
552 let (session, color) = match extended.session {
553 ExtendedSession::PreMarket => ("Pre-market", theme.ui.warning),
554 ExtendedSession::PostMarket => ("Post-market", theme.ui.info),
555 };
556 ui.horizontal(|ui| {
557 ui.label(
558 RichText::new(l10n::text(ui.ctx(), session))
559 .size(theme.text.small)
560 .color(color),
561 );
562 ui.label(
563 RichText::new(l10n::text(ui.ctx(), update(extended.time)))
564 .size(theme.text.small)
565 .color(theme.ui.text_secondary),
566 );
567 });
568 }
569 }
570 if let Some((volume, average)) =
571 quote.as_ref().and_then(|q| q.volume).zip(*average_volume)
572 && shown("Volume")
573 {
574 ui.add_space(theme.spacing.md);
575 let decimals = instrument.volume_decimals.map(usize::from);
576 for (left, right, size, color) in [
577 (
578 "Volume".to_owned(),
579 "Avg. volume (30D)".to_owned(),
580 theme.text.small,
581 theme.ui.text_secondary,
582 ),
583 (
584 StudyFormat::Volume.format(volume, decimals),
585 StudyFormat::Volume.format(average, decimals),
586 theme.text.label,
587 theme.ui.text,
588 ),
589 ] {
590 ui.horizontal(|ui| {
591 ui.label(
592 RichText::new(l10n::text(ui.ctx(), left))
593 .size(size)
594 .color(color),
595 );
596 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
597 ui.label(
598 RichText::new(l10n::text(ui.ctx(), right))
599 .size(size)
600 .color(color),
601 );
602 });
603 });
604 }
605 volume_bar(ui, volume, average);
606 }
607 if let Some(details) = details {
608 for (title, rows) in [
609 ("Key statistics", &details.statistics),
610 ("Profile", &details.profile),
611 ] {
612 if rows.is_empty() || !shown(title) {
613 continue;
614 }
615 ui.add_space(theme.spacing.md);
616 let facts = |ui: &mut Ui| {
617 ui.label(RichText::new(l10n::text(ui.ctx(), title)).strong());
618 for (label, value) in rows {
619 ui.horizontal(|ui| {
620 ui.label(
621 RichText::new(l10n::text(ui.ctx(), label))
622 .color(theme.ui.text_secondary),
623 );
624 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
625 ui.add(
626 Label::new(l10n::text(ui.ctx(), value.as_str()))
627 .truncate(),
628 );
629 });
630 });
631 }
632 };
633 if title == "Key statistics" {
634 egui::Frame::new()
636 .fill(theme.ui.accent.gamma_multiply(0.1))
637 .corner_radius(theme.radius.md)
638 .inner_margin(theme.spacing.md)
639 .show(ui, |ui| {
640 ui.set_width(ui.available_width());
641 facts(ui);
642 });
643 } else {
644 facts(ui);
645 }
646 }
647 }
648 if let Some(returns) = returns.as_ref().filter(|_| shown("Performance")) {
649 detail_section(
650 ui,
651 "Performance",
652 returns,
653 |rows| rows.is_empty(),
654 |ui, rows| {
655 for row in rows {
656 ui.horizontal(|ui| {
657 ui.label(
658 RichText::new(l10n::text(ui.ctx(), &row.period))
659 .color(theme.ui.text_secondary),
660 );
661 ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
662 let (text, color) = percent_text(row.percent, &theme);
663 ui.label(
664 RichText::new(l10n::text(ui.ctx(), text)).color(color),
665 );
666 });
667 });
668 }
669 },
670 );
671 }
672 if let Some(seasonality) = seasonality.as_ref().filter(|_| shown("Seasonality")) {
673 detail_section(
674 ui,
675 "Seasonality",
676 seasonality,
677 |seasonality| seasonality.months.is_empty(),
678 |ui, seasonality| {
679 ui.label(
680 RichText::new(l10n::format_text(
681 ui.ctx(),
682 "Average monthly return over {0} years",
683 &[("0", seasonality.years.to_string())],
684 ))
685 .size(theme.text.small)
686 .color(theme.ui.text_secondary),
687 );
688 egui::Grid::new("seasonality_months")
689 .num_columns(4)
690 .spacing(vec2(theme.spacing.md, theme.spacing.sm))
691 .show(ui, |ui| {
692 for (index, month) in seasonality
693 .months
694 .iter()
695 .filter(|month| (1..=12).contains(&month.month))
696 .enumerate()
697 {
698 ui.vertical(|ui| {
699 ui.label(
700 RichText::new(l10n::text(
701 ui.ctx(),
702 MONTHS[usize::from(month.month) - 1],
703 ))
704 .size(theme.text.small)
705 .color(theme.ui.text_secondary),
706 );
707 let (text, color) =
708 percent_text(month.average_percent, &theme);
709 ui.label(
710 RichText::new(l10n::text(ui.ctx(), text))
711 .color(color),
712 );
713 let share = month.positive_share.map_or_else(
714 || "-".to_owned(),
715 |share| {
716 l10n::format_text(
717 ui.ctx(),
718 "{0}% up",
719 &[("0", format!("{:.0}", share * 100.0))],
720 )
721 },
722 );
723 ui.label(
724 RichText::new(share)
725 .size(theme.text.small)
726 .color(theme.ui.text_secondary),
727 );
728 });
729 if index % 4 == 3 {
730 ui.end_row();
731 }
732 }
733 });
734 },
735 );
736 }
737 if shown("Notes") {
738 ui.add_space(theme.spacing.md);
739 ui.label(RichText::new(l10n::text(ui.ctx(), "Notes")).strong());
740 let notes = notes.entry(instrument.symbol.clone()).or_default();
741 let field = ui.add_enabled(
742 notes.written.is_some(),
743 egui::TextEdit::multiline(&mut notes.text)
744 .hint_text(l10n::text(ui.ctx(), "Add a note"))
745 .desired_width(f32::INFINITY)
746 .desired_rows(4),
747 );
748 if edit_notes {
749 field.request_focus();
750 field.scroll_to_me(None);
751 }
752 }
753 if let Some(error) = error {
754 status_message(ui, MessageKind::Error, error);
755 }
756 });
757 }
758}
759
760fn percent_text(percent: Option<f64>, theme: &crate::theme::Theme) -> (String, Color32) {
762 match percent.filter(|percent| percent.is_finite()) {
763 Some(percent) => (
764 format!("{}%", signed(percent, 2)),
765 if percent > 0.0 {
766 theme.chart.candle_up
767 } else if percent < 0.0 {
768 theme.chart.candle_down
769 } else {
770 theme.ui.text
771 },
772 ),
773 None => ("-".to_owned(), theme.ui.text_secondary),
774 }
775}
776
777pub(super) fn detail_section<T>(
780 ui: &mut Ui,
781 title: &str,
782 state: &MarketDetail<T>,
783 is_empty: impl Fn(&T) -> bool,
784 body: impl FnOnce(&mut Ui, &T),
785) {
786 let theme = ui.active_theme();
787 ui.add_space(theme.spacing.md);
788 ui.label(RichText::new(l10n::text(ui.ctx(), title)).strong());
789 let note = |ui: &mut Ui, text: &str| {
790 ui.label(RichText::new(l10n::text(ui.ctx(), text)).color(theme.ui.text_secondary));
791 };
792 match state {
793 MarketDetail::Loading => note(ui, "Loading…"),
794 MarketDetail::Unavailable => note(ui, "Not available"),
795 MarketDetail::Failed(message) => status_message(ui, MessageKind::Error, message),
796 MarketDetail::Ready(value) if is_empty(value) => note(ui, "No data"),
797 MarketDetail::Ready(value) => body(ui, value),
798 }
799}
800
801fn price_line(
804 ui: &mut Ui,
805 price: f64,
806 change: f64,
807 percent: f64,
808 decimals: u8,
809 size: f32,
810 currency: Option<&str>,
811) {
812 let theme = ui.active_theme();
813 let color = if change > 0.0 {
814 theme.chart.candle_up
815 } else if change < 0.0 {
816 theme.chart.candle_down
817 } else {
818 theme.ui.text
819 };
820 let mut job = LayoutJob::single_section(
822 l10n::text(ui.ctx(), grouped(price, usize::from(decimals))),
823 weighted(size, 700.0, theme.ui.text),
824 );
825 if let Some(currency) = currency {
826 job.append(
827 currency,
828 theme.spacing.sm,
829 weighted(theme.text.small, 400.0, theme.ui.text),
830 );
831 }
832 let change_size = (size * 2.0 / 3.0).max(theme.text.body);
833 for text in [
834 signed(change, usize::from(decimals)),
835 format!("{}%", signed(percent, 2)),
836 ] {
837 job.append(&text, theme.spacing.sm, weighted(change_size, 600.0, color));
838 }
839 ui.add(Label::new(job).wrap());
840}
841
842fn zone_label(offset_seconds: i32) -> String {
845 if offset_seconds == 0 {
846 return "UTC".to_owned();
847 }
848 let sign = if offset_seconds < 0 { '-' } else { '+' };
849 let minutes = offset_seconds.unsigned_abs() / 60;
850 match minutes % 60 {
851 0 => format!("GMT{sign}{}", minutes / 60),
852 rest => format!("GMT{sign}{}:{rest:02}", minutes / 60),
853 }
854}
855
856fn external_link(ui: &mut Ui, url: &str) {
858 let theme = ui.active_theme();
859 let (rect, response) = ui.allocate_exact_size(vec2(12.0, 12.0), Sense::click());
860 let response = response
861 .on_hover_cursor(egui::CursorIcon::PointingHand)
862 .on_hover_text(l10n::text(ui.ctx(), url));
863 let translated_label = l10n::text(ui.ctx(), "Open instrument page");
864 response.widget_info(|| {
865 egui::WidgetInfo::labeled(egui::Role::Link, ui.is_enabled(), &translated_label)
866 });
867 let color = if response.hovered() {
868 theme.ui.accent
869 } else {
870 theme.ui.text_secondary
871 };
872 let stroke = Stroke::new(1.0, color);
873 let r = rect.shrink(1.5);
874 let painter = ui.painter();
875 painter.line(
877 vec![
878 pos2(r.center().x, r.top()),
879 r.left_top(),
880 r.left_bottom(),
881 r.right_bottom(),
882 pos2(r.right(), r.center().y),
883 ],
884 stroke,
885 );
886 painter.line_segment([r.center(), r.right_top()], stroke);
887 painter.line(
888 vec![
889 pos2(r.right() - 3.5, r.top()),
890 r.right_top(),
891 pos2(r.right(), r.top() + 3.5),
892 ],
893 stroke,
894 );
895 if response.clicked() {
896 ui.ctx().open_url(egui::OpenUrl::new_tab(url));
897 }
898}
899
900fn volume_bar(ui: &mut Ui, volume: f64, average: f64) {
903 let theme = ui.active_theme();
904 let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 4.0), Sense::hover());
905 let total = volume.max(0.0) + average.max(0.0);
906 if !total.is_finite() || total <= 0.0 {
907 return;
908 }
909 let gap = 2.0;
910 let split = rect.left() + (rect.width() - gap) * (volume.max(0.0) / total) as f32;
911 let painter = ui.painter();
912 let radius = rect.height() / 2.0;
913 let part = |left: f32, right: f32, color: Color32| {
914 if right > left {
915 painter.rect_filled(rect.with_min_x(left).with_max_x(right), radius, color);
916 }
917 };
918 part(rect.left(), split, theme.ui.accent);
919 part(
920 split + gap,
921 rect.right(),
922 theme.ui.accent.gamma_multiply(0.35),
923 );
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use crate::data::{HistoryPage, HistoryRequest, Reply};
930 use std::sync::Mutex;
931
932 #[derive(Default)]
934 struct Feed(Mutex<Vec<(String, Reply<SymbolDetails>)>>);
935
936 impl Datafeed for Feed {
937 fn history(&self, _: HistoryRequest, reply: Reply<HistoryPage>) {
938 reply.send(Ok(HistoryPage::default()));
939 }
940
941 fn symbol_details(&self, symbol: &str, reply: Reply<SymbolDetails>) {
942 self.0.lock().unwrap().push((symbol.into(), reply));
943 }
944 }
945
946 #[derive(Default)]
948 struct Store {
949 documents: Mutex<HashMap<String, String>>,
950 saves: Mutex<Vec<(String, String, Reply<()>)>>,
951 }
952
953 impl Store {
954 fn answer_saves(&self) -> Vec<String> {
955 let saves = std::mem::take(&mut *self.saves.lock().unwrap());
956 saves
957 .into_iter()
958 .map(|(name, document, reply)| {
959 self.documents
960 .lock()
961 .unwrap()
962 .insert(name, document.clone());
963 reply.send(Ok(()));
964 document
965 })
966 .collect()
967 }
968 }
969
970 impl Storage for Store {
971 fn list(&self, _: StoreKind, reply: Reply<Vec<String>>) {
972 reply.send(Ok(Vec::new()));
973 }
974
975 fn load(&self, kind: StoreKind, name: &str, reply: Reply<Option<String>>) {
976 assert_eq!(kind, StoreKind::SymbolNotes);
977 reply.send(if name == "BAD" {
978 Err(FeedError::Network("offline".into()))
979 } else {
980 Ok(self.documents.lock().unwrap().get(name).cloned())
981 });
982 }
983
984 fn save(&self, kind: StoreKind, name: &str, document: String, reply: Reply<()>) {
985 assert_eq!(kind, StoreKind::SymbolNotes);
986 self.saves
987 .lock()
988 .unwrap()
989 .push((name.into(), document, reply));
990 }
991
992 fn delete(&self, _: StoreKind, _: &str, reply: Reply<()>) {
993 reply.send(Ok(()));
994 }
995 }
996
997 fn texts(ctx: &egui::Context, panel: &mut SymbolDetailsPanel) -> Vec<String> {
998 let output = ctx.run_ui(egui::RawInput::default(), |ui| panel.show(ui));
999 let texts = output
1000 .shapes
1001 .iter()
1002 .filter_map(|shape| match &shape.shape {
1003 egui::Shape::Text(text) => Some(text.galley.text().to_owned()),
1004 _ => None,
1005 })
1006 .collect();
1007 output.drop_without_applying_deltas();
1008 texts
1009 }
1010
1011 #[test]
1012 fn details_follow_the_symbol_and_notes_are_written_one_at_a_time() {
1013 let ctx = egui::Context::default();
1014 l10n::set_translator(&ctx, |text| {
1015 (text == "Key statistics").then(|| "Facts".to_owned())
1016 });
1017 let feed = Arc::new(Feed::default());
1018 let store = Arc::new(Store::default());
1019 let mut panel = SymbolDetailsPanel::new();
1020 panel.set_feed(feed.clone());
1021 panel.set_storage(store.clone());
1022 panel.set_instrument(&Instrument::new("AAPL"));
1023 panel.set_instrument(&Instrument::new("MSFT"));
1024 let requests = std::mem::take(&mut *feed.0.lock().unwrap());
1026 for (symbol, reply) in requests.into_iter().rev() {
1027 let statistics = vec![("Market cap".into(), format!("{symbol} cap"))];
1028 reply.send(Ok(SymbolDetails::new(statistics, Vec::new())
1029 .with_category(format!("{symbol} category"))));
1030 }
1031 panel.poll(&ctx);
1032 let shown = texts(&ctx, &mut panel);
1033 assert!(shown.contains(&"MSFT cap".into()), "{shown:?}");
1034 assert!(!shown.contains(&"AAPL cap".into()), "{shown:?}");
1035 assert!(shown.contains(&"MSFT category".into()), "{shown:?}");
1036 assert!(!shown.contains(&"AAPL category".into()), "{shown:?}");
1037 assert!(shown.contains(&"Facts".into()), "{shown:?}");
1038 assert!(!shown.contains(&"Key statistics".into()), "{shown:?}");
1039 assert!(!shown.contains(&"Profile".into()), "{shown:?}");
1040
1041 let edit = |panel: &mut SymbolDetailsPanel, text: &str| {
1043 panel.notes.get_mut("MSFT").unwrap().text = text.into();
1044 panel.poll(&ctx);
1045 };
1046 edit(&mut panel, "first");
1047 edit(&mut panel, "second");
1048 assert_eq!(store.answer_saves().len(), 1);
1049 panel.poll(&ctx);
1050 assert_eq!(store.answer_saves(), [r#"{"version":1,"notes":"second"}"#]);
1051 panel.poll(&ctx);
1052 assert!(store.saves.lock().unwrap().is_empty());
1053
1054 let mut restored = SymbolDetailsPanel::new();
1055 restored.set_storage(store.clone());
1056 restored.set_instrument(&Instrument::new("MSFT"));
1057 restored.poll(&ctx);
1058 assert_eq!(restored.notes["MSFT"].text, "second");
1059
1060 restored.set_instrument(&Instrument::new("BAD"));
1062 restored.poll(&ctx);
1063 restored.notes.get_mut("BAD").unwrap().text = "lost".into();
1064 restored.poll(&ctx);
1065 assert!(store.saves.lock().unwrap().is_empty());
1066 assert!(restored.error.is_some());
1067 restored.set_instrument(&Instrument::new("MSFT"));
1069 restored.set_instrument(&Instrument::new("BAD"));
1070 restored.poll(&ctx);
1071 assert!(restored.error.is_some());
1072 }
1073
1074 #[cfg(feature = "timezones")]
1077 #[test]
1078 fn extended_hours_change_from_the_regular_last_price() {
1079 let ctx = egui::Context::default();
1080 let mut panel = SymbolDetailsPanel::new();
1081 panel.set_instrument(&Instrument::new("QQQ").with_price_decimals(2));
1082 panel.set_timezone("Europe/Berlin".parse().unwrap());
1083 let quote = Quote::new(704.47, 725.20, "2026-10-22T19:59:00Z".parse().unwrap());
1084 panel.set_quote(quote.with_extended(crate::model::ExtendedHoursQuote {
1085 session: ExtendedSession::PreMarket,
1086 last: 703.65,
1087 time: "2026-10-26T12:30:00Z".parse().unwrap(),
1089 }));
1090 let shown = texts(&ctx, &mut panel);
1091 for text in [
1092 "704.47-20.73-2.86%",
1093 "Last update at 21:59 GMT+2",
1094 "703.65-0.82-0.12%",
1095 "Last update at 13:30 GMT+1",
1096 ] {
1097 assert!(shown.contains(&text.into()), "{text}: {shown:?}");
1098 }
1099 for (offset, label) in [(0, "UTC"), (-5 * 3600, "GMT-5"), (-12600, "GMT-3:30")] {
1100 assert_eq!(zone_label(offset), label);
1101 }
1102 }
1103
1104 #[test]
1108 fn market_detail_sections_ask_once_and_state_what_the_host_answered() {
1109 use crate::ui::widget_bar::SeasonalityMonth;
1110 let ctx = egui::Context::default();
1111 let mut panel = SymbolDetailsPanel::new();
1112 assert!(panel.take_market_requests().is_empty(), "no symbol");
1113 panel.set_instrument(&Instrument::new("AAA"));
1114 assert!(!texts(&ctx, &mut panel).contains(&"Performance".into()));
1115 assert_eq!(
1116 panel.take_market_requests(),
1117 [
1118 MarketDetailRequest::PeriodReturns("AAA".into()),
1119 MarketDetailRequest::Seasonality("AAA".into())
1120 ]
1121 );
1122 assert!(panel.take_market_requests().is_empty());
1123 let shown = texts(&ctx, &mut panel);
1124 assert_eq!(shown.iter().filter(|t| *t == "Loading…").count(), 2);
1125 panel.set_period_returns(
1126 "OLD",
1127 MarketDetail::Ready(vec![PeriodReturn::new("1M", Some(9.0))]),
1128 );
1129 panel.set_period_returns(
1130 "AAA",
1131 MarketDetail::Ready(vec![
1132 PeriodReturn::new("1M", Some(2.5)),
1133 PeriodReturn::new("1Y", Some(-10.0)),
1134 PeriodReturn::new("5Y", None),
1135 ]),
1136 );
1137 panel.set_seasonality("AAA", MarketDetail::Failed("feed offline".into()));
1138 let shown = texts(&ctx, &mut panel);
1139 for text in ["+2.50%", "-10.00%", "-", "feed offline"] {
1140 assert!(shown.contains(&text.into()), "{text}: {shown:?}");
1141 }
1142 assert!(!shown.contains(&"+9.00%".into()));
1143 panel.set_period_returns("AAA", MarketDetail::Ready(vec![]));
1144 panel.set_seasonality(
1145 "AAA",
1146 MarketDetail::Ready(Seasonality::new(
1147 20,
1148 vec![SeasonalityMonth::new(1, Some(1.25), Some(0.65))],
1149 )),
1150 );
1151 let shown = texts(&ctx, &mut panel);
1152 for text in [
1153 "No data",
1154 "Jan",
1155 "+1.25%",
1156 "65% up",
1157 "Average monthly return over 20 years",
1158 ] {
1159 assert!(shown.contains(&text.into()), "{text}: {shown:?}");
1160 }
1161 panel.set_period_returns("AAA", MarketDetail::Unavailable);
1162 assert!(texts(&ctx, &mut panel).contains(&"Not available".into()));
1163 panel.set_instrument(&Instrument::new("BBB"));
1164 assert_eq!(
1165 panel.take_market_requests().len(),
1166 2,
1167 "a new symbol asks again"
1168 );
1169 }
1170}