Skip to main content

egui_charts/ui/trading/
broker_picker.rs

1//! The broker picker: the dialog in which the user chooses the broker to trade with.
2
3use crate::localization as l10n;
4use egui::{
5    Align, Align2, Color32, Context, FontId, Layout, Rect, RichText, Sense, Stroke, Ui, vec2,
6};
7
8use super::widgets::semibold;
9use crate::icons::icons;
10use crate::theme::ThemeExt;
11use crate::ui_kit::IconButton;
12use crate::ui_kit::modal::Modal;
13
14const TILE: egui::Vec2 = vec2(164.0, 152.0);
15const GAP: f32 = 8.0;
16/// Width of a column of the comparison view.
17const COLUMN: f32 = 200.0;
18
19/// One row of a broker in the picker's comparison view.
20#[derive(Clone, Debug, PartialEq)]
21#[non_exhaustive]
22pub enum BrokerFact {
23    /// A value, such as a price or the leverage, formatted by the host.
24    Value(String),
25    /// Whether the broker supports a feature.
26    Supported(bool),
27}
28
29/// One broker of the [`BrokerPicker`], as the host offers it.
30#[derive(Clone, Debug)]
31#[non_exhaustive]
32pub struct BrokerTile {
33    /// The host's id for the broker, returned by [`BrokerPickerAction::Connect`].
34    pub id: String,
35    /// The broker's name.
36    pub name: String,
37    /// The broker's logo, drawn in a 48 point square. Without one the tile shows the name's
38    /// first letter.
39    pub icon: Option<egui::ImageSource<'static>>,
40    /// A line under the name, such as what the broker offers.
41    pub subtitle: Option<String>,
42    /// A rating out of 5, shown with a star under the name when there is no subtitle.
43    pub rating: Option<f32>,
44    /// Marks the tile as featured.
45    pub featured: bool,
46    /// The user's favourite; the user toggles it with the tile's star. Persist it in the host.
47    pub favorite: bool,
48    /// What the connect card says about the broker.
49    pub description: String,
50    /// The rows of the comparison view by label, such as pricing, leverage and features. A
51    /// label that another compared broker has and this one lacks shows as not available.
52    pub facts: Vec<(String, BrokerFact)>,
53}
54
55impl BrokerTile {
56    /// The id of [`paper`](Self::paper).
57    pub const PAPER: &'static str = "paper";
58
59    /// A tile with only a name.
60    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
61        Self {
62            id: id.into(),
63            name: name.into(),
64            icon: None,
65            subtitle: None,
66            rating: None,
67            featured: false,
68            favorite: false,
69            description: String::new(),
70            facts: Vec::new(),
71        }
72    }
73
74    /// The built-in paper account, for a [`PaperAccount`](crate::services::PaperAccount).
75    pub fn paper() -> Self {
76        let mut tile = Self::new(Self::PAPER, "Paper Trading");
77        tile.subtitle = Some("Brokerage simulator".to_owned());
78        tile.description = "Paper trading lets you practise buying and selling with simulated \
79            money: orders fill against the chart's prices as they would at a broker, and no \
80            real money is used.\n\nThe account keeps your positions, order history, balance \
81            history and a journal of every action, so you can review your trades."
82            .to_owned();
83        tile
84    }
85}
86
87/// What the user chose in the [`BrokerPicker`].
88#[derive(Clone, Debug, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum BrokerPickerAction {
91    /// Connect to the broker with this [`BrokerTile::id`], such as [`BrokerTile::PAPER`].
92    Connect(String),
93    /// The footer link: show how to open an account at a broker.
94    NeedBroker,
95}
96
97/// The dialog in which the user picks a broker: a grid of the host's [`BrokerTile`]s, the
98/// paper account first, with a search and a footer link. Its compare button shows the
99/// [`facts`](BrokerTile::facts) of two brokers side by side in aligned rows, with a control to
100/// add another and one to connect each. Picking a tile shows its connect card,
101/// whose Connect button returns [`BrokerPickerAction::Connect`] and closes the picker; the host
102/// then connects [`Trading`](super::Trading) to that broker. Open it with
103/// [`open`](Self::open), such as from the toolbar's Trade button while no broker is connected.
104pub struct BrokerPicker {
105    /// The tiles, in order. [`new`](Self::new) puts the paper account first.
106    pub tiles: Vec<BrokerTile>,
107    open: bool,
108    searching: bool,
109    query: String,
110    /// The tile whose connect card is open.
111    selected: Option<usize>,
112    /// The ids of the compared brokers, while the comparison view is shown.
113    compared: Option<Vec<String>>,
114}
115
116impl Default for BrokerPicker {
117    fn default() -> Self {
118        Self::new(Vec::new())
119    }
120}
121
122impl BrokerPicker {
123    /// A closed picker showing the paper account, then `tiles`.
124    pub fn new(tiles: Vec<BrokerTile>) -> Self {
125        Self {
126            tiles: std::iter::once(BrokerTile::paper()).chain(tiles).collect(),
127            open: false,
128            searching: false,
129            query: String::new(),
130            selected: None,
131            compared: None,
132        }
133    }
134
135    /// Opens the picker with an empty search.
136    pub fn open(&mut self) {
137        self.open = true;
138        self.searching = false;
139        self.query.clear();
140        self.selected = None;
141        self.compared = None;
142    }
143
144    /// Whether the picker is open.
145    pub fn is_open(&self) -> bool {
146        self.open
147    }
148
149    /// Shows the picker while it is open, and its connect card.
150    pub fn show(&mut self, ctx: &Context) -> Option<BrokerPickerAction> {
151        let mut action = None;
152        let theme = ctx.active_theme();
153        let query = self.query.to_lowercase();
154        let mut chosen = None;
155        let mut close = false;
156        Modal::new("broker_picker", "")
157            .fill(if theme.dark {
158                theme.ui.btn_bg_hover
159            } else {
160                theme.ui.panel_bg
161            })
162            .width(1000.0)
163            .height(760.0)
164            .closable(self.selected.is_none())
165            .show(ctx, &mut self.open, |ui| {
166                let inset = 52.0_f32.min((ui.available_width() - TILE.x).max(0.0) / 2.0);
167                let close_rect = Rect::from_min_size(
168                    ui.max_rect().right_top() - vec2(24.0, 0.0),
169                    vec2(24.0, 24.0),
170                );
171                close = ui
172                    .place(close_rect, |ui: &mut Ui| {
173                        IconButton::new(&icons::CLOSE).tooltip("Close").show(ui)
174                    })
175                    .clicked();
176                ui.add_space(32.0);
177                ui.horizontal(|ui| {
178                    ui.add_space(inset);
179                    if self.compared.is_some() {
180                        ui.label(semibold(
181                            &l10n::text(ui.ctx(), "Compare brokers"),
182                            20.0,
183                            theme.ui.text,
184                        ));
185                    } else if self.searching {
186                        let search = ui.add(
187                            egui::TextEdit::singleline(&mut self.query)
188                                .hint_text(l10n::text(ui.ctx(), "Search"))
189                                .desired_width((ui.available_width() - 88.0).max(40.0)),
190                        );
191                        search.request_focus();
192                    } else {
193                        ui.label(semibold(
194                            &l10n::text(ui.ctx(), "Trade with your broker"),
195                            if ui.available_width() < 360.0 {
196                                16.0
197                            } else {
198                                20.0
199                            },
200                            theme.ui.text,
201                        ));
202                    }
203                    ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
204                        ui.add_space(inset);
205                        if IconButton::new(&icons::COMPARE_ADD)
206                            .tooltip("Compare brokers")
207                            .selected(self.compared.is_some())
208                            .show(ui)
209                            .clicked()
210                        {
211                            self.compared = match self.compared {
212                                Some(_) => None,
213                                None => {
214                                    Some(self.tiles.iter().take(2).map(|t| t.id.clone()).collect())
215                                }
216                            };
217                        }
218                        if self.compared.is_none()
219                            && IconButton::new(&icons::QUICK_SEARCH)
220                                .tooltip("Search brokers")
221                                .selected(self.searching)
222                                .show(ui)
223                                .clicked()
224                        {
225                            self.searching = !self.searching;
226                            self.query.clear();
227                        }
228                    });
229                });
230                ui.add_space(20.0);
231
232                let footer = 56.0;
233                egui::ScrollArea::vertical()
234                    .max_height((ui.available_height() - footer).max(0.0))
235                    .auto_shrink([false, false])
236                    .show(ui, |ui| {
237                        if let Some(compared) = &mut self.compared {
238                            egui::ScrollArea::horizontal().show(ui, |ui| {
239                                ui.horizontal(|ui| {
240                                    ui.add_space(inset);
241                                    chosen = comparison(ui, &self.tiles, compared);
242                                });
243                            });
244                            return;
245                        }
246                        let width = ui.available_width() - inset * 2.0;
247                        let columns = ((width + GAP) / (TILE.x + GAP)).floor().max(1.0) as usize;
248                        let indent = inset;
249                        // Room for the featured badges, which overlap their tile's top edge.
250                        ui.add_space(8.0);
251                        let shown: Vec<usize> = (0..self.tiles.len())
252                            .filter(|&i| self.tiles[i].name.to_lowercase().contains(&query))
253                            .collect();
254                        if shown.is_empty() {
255                            ui.vertical_centered(|ui| {
256                                ui.add_space(40.0);
257                                ui.label(
258                                    RichText::new(l10n::text(
259                                        ui.ctx(),
260                                        "No brokers match your search",
261                                    ))
262                                    .color(theme.ui.text_secondary),
263                                );
264                            });
265                        }
266                        for row in shown.chunks(columns) {
267                            ui.horizontal(|ui| {
268                                ui.add_space(indent);
269                                ui.spacing_mut().item_spacing.x = GAP;
270                                for &index in row {
271                                    if tile(ui, &mut self.tiles[index]) {
272                                        chosen = Some(index);
273                                    }
274                                }
275                            });
276                            ui.add_space(GAP);
277                        }
278                    });
279
280                ui.vertical_centered(|ui| {
281                    ui.add_space(12.0);
282                    let link = ui
283                        .add(
284                            egui::Button::new((
285                                icons::SQUARE_PLUS.as_image_square(20.0),
286                                RichText::new(l10n::text(ui.ctx(), "Need a broker?"))
287                                    .size(theme.text.body + 2.0)
288                                    .color(theme.ui.text),
289                            ))
290                            .frame(false)
291                            .image_tint_follows_text_color(true),
292                        )
293                        .on_hover_cursor(egui::CursorIcon::PointingHand);
294                    if link.clicked() {
295                        action = Some(BrokerPickerAction::NeedBroker);
296                    }
297                });
298            });
299        if close {
300            self.open = false;
301        }
302        if chosen.is_some() {
303            self.selected = chosen;
304        }
305
306        let Some(index) = self.selected.filter(|_| self.open) else {
307            self.selected = None;
308            return action;
309        };
310        let Some(tile) = self.tiles.get(index) else {
311            self.selected = None;
312            return action;
313        };
314        let mut card = true;
315        Modal::new("broker_picker_connect", "")
316            .fill(theme.ui.panel_bg_floating)
317            .width(400.0)
318            .min_height(0.0)
319            .show(ctx, &mut card, |ui| {
320                egui::Frame::new().inner_margin(16).show(ui, |ui| {
321                    ui.horizontal(|ui| {
322                        let (icon, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
323                        paint_icon(ui, tile, icon);
324                        ui.add_space(4.0);
325                        ui.label(semibold(&tile.name, theme.text.title + 2.0, theme.ui.text));
326                        ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
327                            if IconButton::new(&icons::CLOSE)
328                                .tooltip("Back to brokers")
329                                .show(ui)
330                                .clicked()
331                            {
332                                self.selected = None;
333                            }
334                        });
335                    });
336                    ui.add_space(16.0);
337                    let connect = ui.add_sized(
338                        vec2(ui.available_width(), 40.0),
339                        egui::Button::new(semibold(
340                            &l10n::text(ui.ctx(), "Connect"),
341                            theme.text.body,
342                            theme.ui.panel_bg_floating,
343                        ))
344                        .fill(theme.ui.text)
345                        .corner_radius(ui.active_theme().radius.md),
346                    );
347                    if connect.clicked() {
348                        action = Some(BrokerPickerAction::Connect(tile.id.clone()));
349                        self.open = false;
350                    }
351                    if !tile.description.is_empty() {
352                        ui.add_space(12.0);
353                        ui.label(
354                            RichText::new(l10n::text(ui.ctx(), &tile.description))
355                                .size(theme.text.body)
356                                .color(theme.ui.text_secondary),
357                        );
358                    }
359                });
360                ui.separator();
361                ui.vertical_centered(|ui| {
362                    ui.add_space(4.0);
363                    ui.label(
364                        RichText::new(l10n::text(ui.ctx(), "Account connection"))
365                            .size(theme.text.small)
366                            .color(theme.ui.text_muted),
367                    );
368                });
369            });
370        if !card {
371            self.selected = None;
372        }
373        action
374    }
375}
376
377/// Draws one tile; returns whether the user picked it. Its star toggles the favourite.
378fn tile(ui: &mut Ui, tile: &mut BrokerTile) -> bool {
379    let theme = ui.active_theme();
380    let (rect, response) = ui.allocate_exact_size(TILE, Sense::click());
381    let translated_label = l10n::text(ui.ctx(), &tile.name);
382    response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, &translated_label));
383    let star = Rect::from_center_size(rect.right_top() + vec2(-18.0, 18.0), vec2(20.0, 20.0));
384    let star_response = ui
385        .interact(star, response.id.with("favorite"), Sense::click())
386        .on_hover_text(l10n::text(
387            ui.ctx(),
388            if tile.favorite {
389                "Remove from favourites"
390            } else {
391                "Add to favourites"
392            },
393        ));
394    if star_response.clicked() {
395        tile.favorite = !tile.favorite;
396    }
397    let painter = ui.painter();
398    let hovered = response.hovered() || star_response.hovered();
399    painter.rect(
400        rect,
401        8.0,
402        if hovered {
403            theme.ui.btn_bg_hover
404        } else {
405            Color32::TRANSPARENT
406        },
407        Stroke::new(1.0, theme.ui.border),
408        egui::StrokeKind::Inside,
409    );
410    if tile.featured {
411        let badge = painter.layout_no_wrap(
412            l10n::text(ui.ctx(), "FEATURED"),
413            FontId::proportional(theme.text.tiny),
414            theme.ui.panel_bg_floating,
415        );
416        let badge_rect = Rect::from_min_size(
417            rect.left_top() + vec2(6.0, -6.0),
418            badge.size() + vec2(10.0, 2.0),
419        );
420        painter.rect_filled(badge_rect, 3.0, theme.ui.text);
421        painter.galley(badge_rect.min + vec2(5.0, 1.0), badge, theme.ui.text);
422    }
423    let star_icon = if tile.favorite {
424        icons::STAR_FILLED.as_image_tinted(star.size() - vec2(2.0, 2.0), theme.ui.warning)
425    } else {
426        icons::STAR_EMPTY.as_image_tinted(star.size() - vec2(2.0, 2.0), theme.ui.text_secondary)
427    };
428    star_icon.paint_at(ui, star.shrink(1.0));
429    let icon = Rect::from_center_size(
430        egui::pos2(rect.center().x, rect.top() + 48.0),
431        vec2(48.0, 48.0),
432    );
433    paint_icon(ui, tile, icon);
434    let painter = ui.painter();
435    painter.text(
436        egui::pos2(rect.center().x, icon.bottom() + 18.0),
437        Align2::CENTER_CENTER,
438        l10n::text(ui.ctx(), &tile.name),
439        FontId::proportional(theme.text.body + 2.0),
440        theme.ui.text,
441    );
442    if let Some(subtitle) = &tile.subtitle {
443        let galley = painter.layout(
444            l10n::text(ui.ctx(), subtitle.clone()),
445            FontId::proportional(theme.text.small),
446            theme.ui.text_secondary,
447            rect.width() - 24.0,
448        );
449        let at = egui::pos2(
450            rect.center().x - galley.size().x / 2.0,
451            icon.bottom() + 30.0,
452        );
453        painter.galley(at, galley, theme.ui.text_secondary);
454    } else if let Some(rating) = tile.rating {
455        let text = painter.layout_no_wrap(
456            format!("{rating:.1}"),
457            FontId::proportional(theme.text.body),
458            theme.ui.text,
459        );
460        let width = 16.0 + 4.0 + text.size().x;
461        let left = rect.center().x - width / 2.0;
462        let y = icon.bottom() + 40.0;
463        icons::STAR_FILLED
464            .as_image_tinted(vec2(16.0, 16.0), theme.ui.text)
465            .paint_at(
466                ui,
467                Rect::from_min_size(egui::pos2(left, y - 8.0), vec2(16.0, 16.0)),
468            );
469        ui.painter().galley(
470            egui::pos2(left + 20.0, y - text.size().y / 2.0),
471            text,
472            theme.ui.text,
473        );
474    }
475    response.clicked() && !star_response.clicked()
476}
477
478/// Draws the compared brokers in columns beside the labels of their facts, with a column to add
479/// another broker; returns the broker whose Connect button the user pressed.
480fn comparison(ui: &mut Ui, tiles: &[BrokerTile], compared: &mut Vec<String>) -> Option<usize> {
481    let theme = ui.active_theme();
482    compared.retain(|id| tiles.iter().any(|tile| tile.id == *id));
483    let columns: Vec<_> = compared
484        .iter()
485        .filter_map(|id| tiles.iter().position(|tile| tile.id == *id))
486        .collect();
487    let mut labels: Vec<&str> = Vec::new();
488    for (label, _) in columns.iter().flat_map(|&i| &tiles[i].facts) {
489        if !labels.contains(&label.as_str()) {
490            labels.push(label);
491        }
492    }
493    let mut chosen = None;
494    let mut removed = None;
495    let mut added = None;
496    egui::Grid::new("broker_comparison")
497        .min_col_width(COLUMN)
498        .min_row_height(40.0)
499        .spacing(vec2(GAP, 4.0))
500        .show(ui, |ui| {
501            ui.label("");
502            for &index in &columns {
503                let tile = &tiles[index];
504                ui.vertical_centered(|ui| {
505                    ui.set_width(COLUMN);
506                    let (rect, _) = ui.allocate_exact_size(vec2(COLUMN, 48.0), Sense::hover());
507                    paint_icon(
508                        ui,
509                        tile,
510                        Rect::from_center_size(rect.center(), vec2(48.0, 48.0)),
511                    );
512                    let remove =
513                        Rect::from_min_size(rect.right_top() - vec2(24.0, 0.0), vec2(24.0, 24.0));
514                    if ui
515                        .place(remove, |ui: &mut Ui| {
516                            IconButton::new(&icons::CLOSE)
517                                .tooltip("Remove from comparison")
518                                .show(ui)
519                        })
520                        .clicked()
521                    {
522                        removed = Some(tile.id.clone());
523                    }
524                    ui.add_space(4.0);
525                    ui.add(
526                        egui::Label::new(semibold(
527                            &l10n::text(ui.ctx(), &tile.name),
528                            theme.text.body + 2.0,
529                            theme.ui.text,
530                        ))
531                        .truncate(),
532                    )
533                    .on_hover_text(&tile.name);
534                    if let Some(rating) = tile.rating {
535                        ui.label(
536                            RichText::new(format!("{rating:.1} / 5"))
537                                .color(theme.ui.text_secondary),
538                        );
539                    } else {
540                        // Keeps the Connect buttons of rated and unrated brokers in line.
541                        ui.label(" ");
542                    }
543                    ui.add_space(4.0);
544                    if ui.button(l10n::text(ui.ctx(), "Connect")).clicked() {
545                        chosen = Some(index);
546                    }
547                });
548            }
549            let others: Vec<_> = tiles
550                .iter()
551                .filter(|tile| !compared.contains(&tile.id))
552                .collect();
553            if !others.is_empty() {
554                ui.vertical_centered(|ui| {
555                    ui.set_width(COLUMN);
556                    ui.add_space(12.0);
557                    let add = ui.add(
558                        egui::Button::new((
559                            icons::PLUS.as_image_square(20.0),
560                            RichText::new(l10n::text(ui.ctx(), "Add broker"))
561                                .size(theme.text.body + 2.0)
562                                .color(theme.ui.text),
563                        ))
564                        .frame(false)
565                        .image_tint_follows_text_color(true),
566                    );
567                    egui::Popup::menu(&add).show(|ui| {
568                        for tile in others {
569                            if ui.button(l10n::text(ui.ctx(), &tile.name)).clicked() {
570                                added = Some(tile.id.clone());
571                            }
572                        }
573                    });
574                });
575            }
576            ui.end_row();
577            for label in &labels {
578                ui.label(
579                    RichText::new(l10n::text(ui.ctx(), *label)).color(theme.ui.text_secondary),
580                );
581                for &index in &columns {
582                    let fact = tiles[index]
583                        .facts
584                        .iter()
585                        .find(|(l, _)| l == label)
586                        .map(|(_, fact)| fact);
587                    fact_ui(ui, fact);
588                }
589                ui.end_row();
590            }
591        });
592    if let Some(id) = removed {
593        compared.retain(|other| *other != id);
594    }
595    compared.extend(added);
596    chosen
597}
598
599/// One cell of the comparison view: the value, a check for a supported feature, a cross for an
600/// unsupported one, and a dash for a fact the host did not give.
601fn fact_ui(ui: &mut Ui, fact: Option<&BrokerFact>) {
602    let theme = ui.active_theme();
603    let (rect, response) = ui.allocate_exact_size(vec2(COLUMN, 40.0), Sense::hover());
604    let (name, mark) = match fact {
605        Some(BrokerFact::Value(value)) => (l10n::text(ui.ctx(), value), None),
606        Some(BrokerFact::Supported(true)) => (
607            l10n::text(ui.ctx(), "Supported"),
608            Some((&icons::CHECK, theme.ui.success)),
609        ),
610        Some(BrokerFact::Supported(false)) => (
611            l10n::text(ui.ctx(), "Not supported"),
612            Some((&icons::CLOSE, theme.ui.error)),
613        ),
614        None => (l10n::text(ui.ctx(), "Not available"), None),
615    };
616    response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, &name));
617    let font = FontId::proportional(theme.text.body + 2.0);
618    match (fact, mark) {
619        (_, Some((icon, color))) => icon
620            .as_image_tinted(vec2(20.0, 20.0), color)
621            .paint_at(ui, Rect::from_center_size(rect.center(), vec2(20.0, 20.0))),
622        (Some(_), None) => {
623            ui.painter_at(rect).text(
624                rect.center(),
625                Align2::CENTER_CENTER,
626                &name,
627                font,
628                theme.ui.text,
629            );
630            response.on_hover_text(name);
631        }
632        (None, None) => {
633            ui.painter().text(
634                rect.center(),
635                Align2::CENTER_CENTER,
636                "-",
637                font,
638                theme.ui.text_muted,
639            );
640            response.on_hover_text(name);
641        }
642    }
643}
644
645/// Draws the tile's logo in `rect`: the host's image, a candlestick mark for the paper account,
646/// or the name's first letter.
647fn paint_icon(ui: &Ui, tile: &BrokerTile, rect: Rect) {
648    let theme = ui.active_theme();
649    let radius = rect.width() * 0.22;
650    if let Some(icon) = &tile.icon {
651        egui::Image::new(icon.clone())
652            .corner_radius(radius)
653            .paint_at(ui, rect);
654        return;
655    }
656    ui.painter().rect_filled(rect, radius, theme.ui.text);
657    if tile.id == BrokerTile::PAPER {
658        icons::CANDLESTICK
659            .as_image_tinted(rect.size() * 0.6, theme.ui.panel_bg_floating)
660            .paint_at(ui, Rect::from_center_size(rect.center(), rect.size() * 0.6));
661    } else {
662        let initial = tile
663            .name
664            .chars()
665            .next()
666            .unwrap_or(' ')
667            .to_uppercase()
668            .to_string();
669        ui.painter().text(
670            rect.center(),
671            Align2::CENTER_CENTER,
672            l10n::text(ui.ctx(), initial),
673            FontId::proportional(rect.height() * 0.5),
674            theme.ui.panel_bg_floating,
675        );
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682    use egui_kittest::{Harness, kittest::Queryable};
683
684    /// The compared brokers share one row per fact, in the order the brokers give them, and a
685    /// broker without a fact shows it as not available, unlike a feature it does not support.
686    #[test]
687    fn comparison_aligns_facts_and_tells_unavailable_from_unsupported() {
688        let mut a = BrokerTile::new("a", "A broker with a name wider than its comparison column");
689        a.rating = Some(4.8);
690        let pricing = "A host-supplied commission description wider than one comparison column";
691        a.facts = vec![
692            ("Leverage".to_owned(), BrokerFact::Value("30:1".to_owned())),
693            ("Crypto".to_owned(), BrokerFact::Supported(false)),
694            (
695                "Commission".to_owned(),
696                BrokerFact::Value(pricing.to_owned()),
697            ),
698        ];
699        let mut b = BrokerTile::new("b", "B");
700        b.facts = vec![("Crypto".to_owned(), BrokerFact::Supported(true))];
701        let tiles = [a, b, BrokerTile::new("c", "C"), BrokerTile::new("d", "D")];
702        let mut compared = vec!["a".to_owned(), "b".to_owned()];
703        let mut harness = Harness::builder()
704            .with_context_setup(egui_extras::install_image_loaders)
705            .with_size(vec2(1000.0, 600.0))
706            .build_ui(|ui| {
707                comparison(ui, &tiles, &mut compared);
708            });
709        harness.run();
710        let y = |label: &str| harness.get_by_label(label).rect().center().y;
711        let missing: Vec<_> = harness.query_all_by_label("Not available").collect();
712        assert_eq!(y("30:1"), missing[0].rect().center().y, "leverage row");
713        assert_eq!(y("Not supported"), y("Supported"), "crypto row");
714        assert!(y("Leverage") < y("Crypto"));
715        let connects: Vec<_> = harness
716            .query_all_by_label("Connect")
717            .map(|node| node.rect().center().y)
718            .collect();
719        assert_eq!(connects.len(), 2);
720        assert_eq!(
721            connects[0], connects[1],
722            "broker headers keep buttons aligned"
723        );
724        let cell = harness
725            .output()
726            .shapes
727            .iter()
728            .find(|shape| matches!(&shape.shape, egui::Shape::Text(text) if text.galley.text() == pricing))
729            .expect("the host's commission is painted");
730        assert!(
731            cell.clip_rect.width() <= COLUMN,
732            "values stay in their column"
733        );
734        drop(harness);
735        compared.push("c".to_owned());
736        let mut harness = Harness::builder()
737            .with_context_setup(egui_extras::install_image_loaders)
738            .with_size(vec2(1200.0, 600.0))
739            .build_ui(|ui| {
740                comparison(ui, &tiles, &mut compared);
741            });
742        harness.run();
743        assert!(
744            harness.query_by_label("Add broker").is_some(),
745            "a fourth offered broker can still be added"
746        );
747    }
748}