Skip to main content

egui_charts/ui/widget_bar/panels/
symbol_details.rs

1//! Facts about the chart's symbol: its quote, the key statistics and profile the feed supplies,
2//! and the user's notes, kept through the storage service.
3
4use 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
26/// Text size of the last price, the panel's largest figure: digits about 19 points high.
27const PRICE_SIZE: f32 = 26.0;
28/// Text size of the extended-hours price.
29const EXTENDED_PRICE_SIZE: f32 = 18.0;
30/// The sections the layout menu shows or hides, in panel order.
31const 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/// The notes of one symbol.
50#[derive(Default)]
51struct Notes {
52    text: String,
53    /// The text last read from or sent to storage, `None` until it is read. Writes wait for a
54    /// successful read, so a failed read never lets an empty field replace the stored notes.
55    written: Option<String>,
56    saving: bool,
57}
58
59/// What an inbox answer is for.
60enum Request {
61    Details(String),
62    Load(String),
63    Save(String, String),
64}
65
66/// Details of the chart's symbol, fed by the host's [`Datafeed`] and [`Storage`].
67///
68/// Keep it on the chart's symbol with [`set_instrument`](Self::set_instrument), its latest quote
69/// with [`set_quote`](Self::set_quote), such as the one
70/// [`WatchlistPanel::quote`](super::WatchlistPanel::quote) holds, and its average volume with
71/// [`set_average_volume`](Self::set_average_volume). The quote's last price, change, update time,
72/// extended-hours trade and session volume are shown, the extended-hours change measured from the
73/// regular last price; update times are shown in the time zone set with
74/// [`set_timezone`](Self::set_timezone), UTC by default. The header's layout button shows or
75/// hides sections, the edit button moves to the notes, and a feed that supplies
76/// [`SymbolDetails::url`] gets a link to the instrument beside its name, and one that supplies
77/// [`SymbolDetails::category`] gets the category under it. For each new symbol the panel asks the
78/// feed for [`Datafeed::symbol_details`] and shows the statistics, on a card, and the profile it
79/// answers; an empty section, or a feed that does not support the request, shows nothing. Notes
80/// are kept in memory per symbol, and with a storage service attached they are read from and
81/// written to one [`StoreKind::SymbolNotes`] document per symbol as they are edited, one write per
82/// document at a time. Call [`poll`](Self::poll) each frame, even while the panel is hidden, then
83/// [`show`](Self::show) while it is open. The More menu's Financials item is reported by
84/// [`take_financials_request`](Self::take_financials_request), for the host to open a
85/// [`FinancialsPanel`](crate::ui::financials::FinancialsPanel) with the company's figures.
86///
87/// The Performance and Seasonality sections show what the host supplies. Call
88/// [`take_market_requests`](Self::take_market_requests) each frame: the first call for a symbol
89/// returns its [`MarketDetailRequest`]s and shows both sections as loading, and the host answers
90/// with [`set_period_returns`](Self::set_period_returns) and
91/// [`set_seasonality`](Self::set_seasonality). A host that never calls it sees neither section.
92/// Each section says whether it is loading, empty, unavailable or failed.
93#[derive(Default)]
94pub struct SymbolDetailsPanel {
95    instrument: Option<Instrument>,
96    quote: Option<Quote>,
97    /// Average daily volume over the last 30 days.
98    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    /// A panel without a symbol, feed or storage.
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    /// Ask `feed` for the details of each symbol from now on, starting with the shown one.
122    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    /// Keep notes in `storage`, reading them again from it. Edits not yet written are dropped.
130    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    /// Show `instrument`, such as the chart's. A new symbol clears the quote and the average
141    /// volume and asks for its details and notes; the same symbol only takes the new name,
142    /// exchange and currency.
143    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            // Notes not read yet and not being read, such as after a failed read, are read again.
155            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    /// Sets the latest quote of the shown symbol. Its extended-hours trade shows below the
170    /// regular price while it is set.
171    pub fn set_quote(&mut self, quote: Quote) {
172        self.quote = Some(quote);
173    }
174
175    /// Sets the average daily volume of the shown symbol over the last 30 days. With the quote's
176    /// session volume, both are drawn as a bar split in proportion to the two.
177    pub fn set_average_volume(&mut self, average: f64) {
178        self.average_volume = Some(average);
179    }
180
181    /// Shows update times in `timezone`, such as the chart's.
182    pub fn set_timezone(&mut self, timezone: TimezoneId) {
183        self.timezone = timezone;
184    }
185
186    /// Show or hide Financials in the More menu, shown by default. Hide it when the host cannot
187    /// supply company financials.
188    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    /// Whether the user chose Financials in the More menu since the last call.
196    pub fn take_financials_request(&mut self) -> bool {
197        std::mem::take(&mut self.financials_requested)
198    }
199
200    /// The market detail the sections still lack for the shown symbol, each returned once; it
201    /// marks them loading. Call it every frame and answer with
202    /// [`set_period_returns`](Self::set_period_returns) and
203    /// [`set_seasonality`](Self::set_seasonality).
204    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    /// Answers the [`MarketDetailRequest::PeriodReturns`] of `symbol`. An answer for a symbol the
221    /// panel no longer shows is ignored.
222    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    /// Answers the [`MarketDetailRequest::Seasonality`] of `symbol`. An answer for a symbol the
229    /// panel no longer shows is ignored.
230    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    /// The shown symbol, if any.
237    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            // Without storage there is nothing to read: the notes start empty and stay in memory.
260            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    /// Receive the feed's and storage's answers, write edited notes, and wake the UI when the
269    /// next answer arrives.
270    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                    // After a failure too, so the next edit writes again rather than every frame.
346                    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    /// Write the notes that differ from storage and have no write in flight.
357    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    /// Render the shown symbol after [`poll`](Self::poll).
390    pub fn show(&mut self, ui: &mut Ui) {
391        // Inset like the watchlist's symbol column, so the title and prices line up with it.
392        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                            // The key facts stand apart from the profile on an accent-tinted card.
635                            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
760/// A signed percentage in the rising or falling colour, or a dash when the host lacks it.
761fn 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
777/// A titled section that says what a host-supplied answer is: still loading, empty, not supplied
778/// or failed, and otherwise draws it with `body`.
779pub(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
801/// A price set large and bold with its currency, then its signed change and change percent in
802/// the rising or falling colour.
803fn 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    // One text, so the figures of different sizes share a baseline.
821    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
842/// "UTC" for no offset, otherwise "GMT" and the signed offset in hours, with minutes when they
843/// are not zero, such as "GMT+2" or "GMT+5:30".
844fn 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
856/// A small external-link glyph opening `url` in a new tab.
857fn 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    // A box open at its top right, with an arrow leaving through the gap.
876    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
900/// A bar split at `volume / (volume + average)`: the session volume in the accent colour, the
901/// average volume in a lighter shade.
902fn 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    /// Keeps detail requests for the test to answer in any order.
933    #[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    /// Answers reads at once, failing for `BAD`, and keeps writes for the test to answer.
947    #[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        // The answer for the previous symbol arrives last and must not replace the current one.
1025        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        // A second edit waits for the first write of the same document, then writes the latest text.
1042        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        // Notes that could not be read are never written over.
1061        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        // They are read again when the symbol comes back.
1068        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    /// The extended-hours change is measured from the regular last price, not the previous close,
1075    /// and update times carry the offset of the chart's time zone on that date.
1076    #[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            // Summer time ends in Berlin on October 25.
1088            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    /// The sections ask once per symbol, ignore answers for another symbol, and say whether they
1105    /// are loading, empty, unavailable or failed instead of leaving a blank. A regression shows
1106    /// the previous symbol's figures, asks every frame, or hides a failure.
1107    #[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}