1use 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;
16const COLUMN: f32 = 200.0;
18
19#[derive(Clone, Debug, PartialEq)]
21#[non_exhaustive]
22pub enum BrokerFact {
23 Value(String),
25 Supported(bool),
27}
28
29#[derive(Clone, Debug)]
31#[non_exhaustive]
32pub struct BrokerTile {
33 pub id: String,
35 pub name: String,
37 pub icon: Option<egui::ImageSource<'static>>,
40 pub subtitle: Option<String>,
42 pub rating: Option<f32>,
44 pub featured: bool,
46 pub favorite: bool,
48 pub description: String,
50 pub facts: Vec<(String, BrokerFact)>,
53}
54
55impl BrokerTile {
56 pub const PAPER: &'static str = "paper";
58
59 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 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#[derive(Clone, Debug, PartialEq, Eq)]
89#[non_exhaustive]
90pub enum BrokerPickerAction {
91 Connect(String),
93 NeedBroker,
95}
96
97pub struct BrokerPicker {
105 pub tiles: Vec<BrokerTile>,
107 open: bool,
108 searching: bool,
109 query: String,
110 selected: Option<usize>,
112 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 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 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 pub fn is_open(&self) -> bool {
146 self.open
147 }
148
149 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 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
377fn 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
478fn 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 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
599fn 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
645fn 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 #[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}