Skip to main content

egui_charts/ui/trading/account_panel/
view.rs

1//! The account panel's view: [`Trading::show_account_panel`] with its tabs, tables and dialogs.
2
3use crate::localization as l10n;
4
5use egui::{
6    Align, Align2, Color32, FontId, ImageSource, Layout, Rect, RichText, Sense, Stroke, Ui, vec2,
7};
8
9use super::{
10    ACTIONS_WIDTH, AccountPanelAction, Cell, Column, HEADER_HEIGHT, ROW_HEIGHT, Row, RowAction,
11    TAB_HEIGHT, Tab, Trading, csv, kind, pnl_color, quantity_decimals, status, time,
12};
13use crate::chart::TradingLineAction;
14use crate::icons::icons;
15use crate::model::{Order, OrderSide, OrderType};
16use crate::painting::symbol_mark::paint_symbol_mark;
17use crate::theme::ThemeExt;
18use crate::ui::connection_status::{show_connection_status, show_connection_status_with_label};
19use crate::ui::top_toolbar::buttons::rule_color;
20use crate::ui::trading::widgets::{grouped, semibold, side_color};
21use crate::ui_kit::buttons::ButtonSize;
22use crate::ui_kit::{EmptyState, IconButton};
23
24impl Trading {
25    /// Shows the account panel in `ui`, such as a bottom panel of the terminal, for a chart
26    /// showing `chart_symbol`. Returns what the user asked of the host; cancelling, changing and
27    /// closing go to the broker after the user confirms them in [`show`](Self::show).
28    pub fn show_account_panel(
29        &mut self,
30        ui: &mut Ui,
31        chart_symbol: &str,
32    ) -> Option<AccountPanelAction> {
33        let mut action = None;
34        let theme = ui.active_theme();
35        let currency = self
36            .account
37            .as_ref()
38            .map_or(String::new(), |account| account.currency.clone());
39
40        // On the tab, which every panel height shows, until the broker reports it is live.
41        let mark = self.offline();
42
43        ui.painter().rect_filled(
44            Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), TAB_HEIGHT)),
45            0.0,
46            theme.ui.btn_bg_hover,
47        );
48        // Tab strip: maximize and minimize from the right edge, then the broker's tab with a Trade
49        // button in the rest of the row.
50        ui.horizontal(|ui| {
51            ui.set_height(TAB_HEIGHT);
52            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
53                if IconButton::new(&icons::MAXIMIZE)
54                    .icon_scale(0.9)
55                    .tooltip("Maximize panel")
56                    .show(ui)
57                    .clicked()
58                {
59                    action = Some(AccountPanelAction::Maximize);
60                }
61                if IconButton::new(&icons::MINUS)
62                    .icon_scale(0.9)
63                    .tooltip("Minimize panel")
64                    .show(ui)
65                    .clicked()
66                {
67                    action = Some(AccountPanelAction::Minimize);
68                }
69                ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
70                    let tab = egui::Frame::new()
71                        .fill(theme.ui.panel_bg)
72                        .corner_radius(ui.active_theme().radius.sm)
73                        .inner_margin(egui::Margin::symmetric(10, 4));
74                    tab.show(ui, |ui| {
75                        ui.add(icons::CANDLESTICK.as_image_tinted(vec2(20.0, 20.0), theme.ui.text));
76                        let (label, radius) =
77                            (l10n::text(ui.ctx(), "Trade"), ui.active_theme().radius.sm);
78                        let trade = || {
79                            egui::Button::new(label.clone())
80                                .min_size(vec2(63.0, 25.0))
81                                .corner_radius(radius)
82                        };
83                        let name = RichText::new(l10n::text(ui.ctx(), &self.account_panel.broker))
84                            .color(theme.ui.text);
85                        let width = |ui: &mut Ui, show: &dyn Fn(&mut Ui) -> egui::Response| {
86                            show(&mut ui.new_child(egui::UiBuilder::new().invisible()))
87                                .rect
88                                .width()
89                        };
90                        // The mark's label, then the broker's name, give way, so that Trade stays
91                        // clear of the panel's buttons; the mark's icon stays. The tab may take
92                        // the gap before the buttons, as its margin keeps Trade off them.
93                        let spacing = ui.spacing().item_spacing.x;
94                        let room =
95                            ui.available_width() + spacing - width(ui, &|ui| ui.add(trade()));
96                        let labelled = mark.as_ref().is_some_and(|mark| {
97                            width(ui, &|ui| ui.label(name.clone()))
98                                + width(ui, &|ui| show_connection_status_with_label(ui, mark))
99                                + 2.0 * spacing
100                                <= room
101                        });
102                        let status = mark.as_ref().map_or(0.0, |mark| match labelled {
103                            true => width(ui, &|ui| show_connection_status_with_label(ui, mark)),
104                            false => width(ui, &|ui| show_connection_status(ui, mark)),
105                        } + spacing);
106                        let name_width = (room - status - spacing).max(0.0);
107                        ui.allocate_ui(vec2(name_width, ui.available_height()), |ui| {
108                            ui.add(egui::Label::new(name).truncate());
109                        });
110                        if let Some(mark) = &mark {
111                            match labelled {
112                                true => show_connection_status_with_label(ui, mark),
113                                false => show_connection_status(ui, mark),
114                            };
115                        }
116                        if ui
117                            .add(trade())
118                            .on_hover_text(l10n::text(ui.ctx(), "Open the order ticket"))
119                            .clicked()
120                        {
121                            self.toggle_ticket(chart_symbol.to_owned());
122                        }
123                    });
124                });
125            });
126        });
127
128        if ui.available_height() < HEADER_HEIGHT {
129            return action;
130        }
131        // Keep selectors and every summary figure reachable in narrow panels.
132        egui::ScrollArea::horizontal()
133            .id_salt("account_summary")
134            .auto_shrink([false, true])
135            .show(ui, |ui| {
136                // Account selectors, then the summary from the right edge as far as there is room.
137                let (mut row, _) = ui
138                    .allocate_exact_size(vec2(ui.available_width(), HEADER_HEIGHT), Sense::hover());
139                let mut selectors = ui.new_child(
140                    egui::UiBuilder::new()
141                        .max_rect(row.with_min_x(row.left() + 8.0))
142                        .layout(Layout::left_to_right(Align::Center)),
143                );
144                let panel = &mut self.account_panel;
145                let chevron = || icons::CHEVRON_DOWN.as_image_square(16.0);
146                let broker = selectors.add(
147                    egui::Button::new((
148                        semibold(&panel.broker, theme.text.body, theme.ui.text),
149                        chevron(),
150                    ))
151                    .frame_when_inactive(false)
152                    .image_tint_follows_text_color(true),
153                );
154                egui::Popup::menu(&broker).show(|ui| {
155                    if ui.button(l10n::text(ui.ctx(), "Disconnect")).clicked() {
156                        action = Some(AccountPanelAction::Disconnect);
157                    }
158                });
159                let name = panel.accounts.get(panel.active).map_or("", String::as_str);
160                let account = selectors.add(
161                    egui::Button::new((format!("{name} {currency}").trim().to_owned(), chevron()))
162                        .frame_when_inactive(false)
163                        .image_tint_follows_text_color(true),
164                );
165                egui::Popup::menu(&account).show(|ui| {
166                    for (index, name) in panel.accounts.iter().enumerate() {
167                        if ui
168                            .selectable_label(index == panel.active, l10n::text(ui.ctx(), name))
169                            .clicked()
170                        {
171                            action = Some(AccountPanelAction::SelectAccount(index));
172                        }
173                    }
174                });
175                if IconButton::new(&icons::SETTINGS_NUT)
176                    .tooltip("Account settings")
177                    .show(&mut selectors)
178                    .clicked()
179                {
180                    action = Some(AccountPanelAction::Settings);
181                }
182                if let Some(account) = &self.account {
183                    let unrealized = account.equity.map(|equity| equity - account.balance);
184                    let figures = [
185                        ("Account balance", Some(account.balance), false, None),
186                        ("Equity", account.equity, false, None),
187                        ("Realized P&L", account.realized_pnl, true, None),
188                        ("Unrealized P&L", unrealized, true, None),
189                        (
190                            "Account margin",
191                            account.margin,
192                            false,
193                            Some("The margin the open positions hold"),
194                        ),
195                        (
196                            "Available funds",
197                            account.available_funds,
198                            false,
199                            Some("Equity less the margin of the positions and the working orders"),
200                        ),
201                        (
202                            "Orders margin",
203                            account.orders_margin,
204                            false,
205                            Some("The margin the working orders hold once filled"),
206                        ),
207                    ];
208                    let painter = ui.painter();
209                    let value_font = FontId::proportional(theme.text.title);
210                    let figures: Vec<_> = figures
211                        .into_iter()
212                        .filter_map(|(label, value, signed, info)| {
213                            let value = value?;
214                            let text = grouped(value, 2, signed);
215                            let color = if signed {
216                                pnl_color(&theme, value, &text)
217                            } else {
218                                theme.ui.text_secondary
219                            };
220                            let label = painter.layout_job(semibold(
221                                &l10n::text(ui.ctx(), label),
222                                theme.text.small,
223                                theme.ui.text,
224                            ));
225                            let value = painter.layout_no_wrap(
226                                l10n::text(ui.ctx(), text),
227                                value_font.clone(),
228                                color,
229                            );
230                            let info_width = if info.is_some() { 18.0 } else { 0.0 };
231                            let width = (label.size().x + info_width).max(value.size().x);
232                            Some((label, value, color, info, width))
233                        })
234                        .collect();
235                    let summary_width: f32 = figures.iter().map(|figure| figure.4 + 28.0).sum();
236                    row.max.x = row
237                        .right()
238                        .max(selectors.min_rect().right() + 24.0 + summary_width);
239                    ui.expand_to_include_rect(row);
240                    let mut right = row.right() - 16.0;
241                    for (label, value, color, info, width) in figures.into_iter().rev() {
242                        let left = right - width;
243                        let top = row.center().y - (label.size().y + value.size().y + 2.0) / 2.0;
244                        if let Some(info) = info {
245                            let icon = Rect::from_min_size(
246                                egui::pos2(
247                                    left + label.size().x + 4.0,
248                                    top + label.size().y / 2.0 - 7.0,
249                                ),
250                                vec2(14.0, 14.0),
251                            );
252                            icons::INFO
253                                .as_image_tinted(icon.size(), theme.ui.text_muted)
254                                .paint_at(ui, icon);
255                            ui.interact(icon, ui.scope_id().with(info), Sense::hover())
256                                .on_hover_text(l10n::text(ui.ctx(), info));
257                        }
258                        let painter = ui.painter();
259                        painter.galley(egui::pos2(left, top + label.size().y + 2.0), value, color);
260                        painter.galley(egui::pos2(left, top), label, theme.ui.text);
261                        right = left - 28.0;
262                    }
263                }
264            });
265
266        // Tabs, with the number of positions and working orders, over a track that carries the
267        // active tab's underline.
268        egui::ScrollArea::horizontal()
269            .id_salt("account_tabs")
270            .show(ui, |ui| {
271                let mut underline = None;
272                let tabs = ui.horizontal(|ui| {
273                    ui.add_space(8.0);
274                    ui.spacing_mut().item_spacing.x = 20.0;
275                    for tab in Tab::ALL {
276                        let count = match tab {
277                            Tab::Positions => self.positions.len(),
278                            Tab::Orders => self.orders.len(),
279                            _ => 0,
280                        };
281                        let mut title = RichText::new(l10n::text(ui.ctx(), tab.title()));
282                        let active = self.account_panel.tab == tab;
283                        title = title.color(if active {
284                            theme.ui.text
285                        } else {
286                            theme.ui.text_secondary
287                        });
288                        let label = if count > 0 {
289                            egui::Atoms::new((
290                                title,
291                                RichText::new(l10n::text(ui.ctx(), count.to_string()))
292                                    .color(theme.ui.text_secondary),
293                            ))
294                        } else {
295                            egui::Atoms::new(title)
296                        };
297                        let response = ui.add(egui::Button::new(label).frame(false));
298                        if active {
299                            underline = Some(response.rect.x_range());
300                        }
301                        if response.clicked() {
302                            self.account_panel.tab = tab;
303                        }
304                    }
305                });
306                let (track, _) = ui.allocate_exact_size(
307                    vec2(ui.available_width().max(tabs.response.rect.width()), 4.0),
308                    Sense::hover(),
309                );
310                ui.painter().rect_filled(track, 0.0, rule_color(&theme));
311                if let Some(x_range) = underline {
312                    ui.painter().rect_filled(
313                        Rect::from_x_y_ranges(x_range, track.y_range()),
314                        0.0,
315                        theme.ui.text,
316                    );
317                }
318            });
319        if self.account_panel.tab == Tab::History {
320            ui.horizontal(|ui| {
321                ui.selectable_value(
322                    &mut self.account_panel.show_executions,
323                    false,
324                    l10n::text(ui.ctx(), "Orders"),
325                );
326                ui.selectable_value(
327                    &mut self.account_panel.show_executions,
328                    true,
329                    l10n::text(ui.ctx(), "Executions"),
330                );
331            });
332        }
333
334        let tab = if self.account_panel.tab == Tab::History && self.account_panel.show_executions {
335            Tab::Executions
336        } else {
337            self.account_panel.tab
338        };
339        let capabilities = self.capabilities();
340        let price = |symbol: &str, value: f64| self.numbers(symbol, chart_symbol).price(value);
341        let quantity = |symbol: &str, value: f64| {
342            self.numbers(symbol, chart_symbol)
343                .quantity(value, quantity_decimals(value))
344        };
345        let money = |value: Option<f64>, signed: bool| {
346            let Some(value) = value else {
347                return (String::new(), theme.ui.text, String::new());
348            };
349            let text = grouped(value, 2, signed);
350            let color = if signed {
351                pnl_color(&theme, value, &text)
352            } else {
353                theme.ui.text
354            };
355            (text, color, currency.clone())
356        };
357        let plain = |text: String| Cell {
358            text,
359            color: theme.ui.text,
360            unit: String::new(),
361            symbol: false,
362            logo: None,
363        };
364        let symbol = |symbol: &str| Cell {
365            text: symbol.to_owned(),
366            color: theme.ui.text,
367            unit: String::new(),
368            symbol: true,
369            logo: self.logo(symbol).map(str::to_owned),
370        };
371        let side = |side: OrderSide, text: &str| Cell {
372            text: text.to_owned(),
373            color: side_color(&theme, side),
374            unit: String::new(),
375            symbol: false,
376            logo: None,
377        };
378        let colored = |(text, color, unit): (String, Color32, String)| Cell {
379            text,
380            color,
381            unit,
382            symbol: false,
383            logo: None,
384        };
385        let optional =
386            |symbol: &str, value: Option<f64>| value.map_or(String::new(), |v| price(symbol, v));
387        let ctx = ui.ctx().clone();
388        let order_cells = |order: &Order, finished: bool| {
389            let request = &order.request;
390            let (limit, stop) = match request.order_type {
391                OrderType::Limit(limit) => (Some(limit), None),
392                OrderType::Stop(stop) => (None, Some(stop)),
393                OrderType::StopLimit { stop, limit } => (Some(limit), Some(stop)),
394                _ => (None, None),
395            };
396            let mut cells = vec![
397                symbol(&request.symbol),
398                side(
399                    request.side,
400                    if request.side == OrderSide::Buy {
401                        "Buy"
402                    } else {
403                        "Sell"
404                    },
405                ),
406                plain(kind(&request.order_type).to_owned()),
407                plain(quantity(&request.symbol, request.quantity)),
408                plain(optional(&request.symbol, limit)),
409                plain(optional(&request.symbol, stop)),
410            ];
411            if finished {
412                cells.push(plain(optional(&request.symbol, order.fill_price)));
413            } else {
414                cells.push(plain(if request.take_profit_levels.is_empty() {
415                    optional(&request.symbol, request.take_profit)
416                } else {
417                    (request.take_profit_levels.iter())
418                        .map(|level| price(&request.symbol, level.price))
419                        .collect::<Vec<_>>()
420                        .join(", ")
421                }));
422                cells.push(plain(optional(&request.symbol, request.stop_loss)));
423            }
424            cells.push(plain(status(&ctx, &order.status)));
425            cells.push(plain(time(order.placed)));
426            if finished {
427                cells.push(plain(time(order.closed)));
428            }
429            cells.push(plain(order.id.to_string()));
430            cells
431        };
432        let rows: Vec<Row> = match tab {
433            Tab::Positions => {
434                self.positions
435                    .iter()
436                    .map(|position| {
437                        let s = position.symbol.as_str();
438                        let percent = position
439                            .unrealized_pnl
440                            .zip(position.trade_value)
441                            .filter(|(_, value)| *value != 0.0)
442                            .map(|(pnl, value)| pnl / value * 100.0);
443                        let (long, label) = if position.quantity > 0.0 {
444                            (OrderSide::Buy, "Long")
445                        } else {
446                            (OrderSide::Sell, "Short")
447                        };
448                        let size = position.quantity.abs();
449                        Row {
450                            cells: vec![
451                                symbol(s),
452                                side(long, label),
453                                plain(quantity(s, size)),
454                                plain(price(s, position.average_price)),
455                                plain(optional(s, position.last_price)),
456                                colored(money(position.unrealized_pnl, true)),
457                                colored({
458                                    let (text, color, _) = money(percent, true);
459                                    let text = if text.is_empty() { text } else { text + "%" };
460                                    (text, color, String::new())
461                                }),
462                                colored(money(position.trade_value, false)),
463                                colored(money(position.market_value, false)),
464                                plain(
465                                    position
466                                        .leverage
467                                        .map_or(String::new(), |leverage| format!("{leverage}:1")),
468                                ),
469                                colored(money(position.margin, false)),
470                                plain(position.expiration.map_or(String::new(), |date| {
471                                    date.format("%Y-%m-%d").to_string()
472                                })),
473                            ],
474                            symbol: Some(position.symbol.clone()),
475                            edit: None,
476                            close: capabilities
477                                .close
478                                .then(|| RowAction::Position(position.clone())),
479                        }
480                    })
481                    .collect()
482            }
483            Tab::Orders => self
484                .orders
485                .iter()
486                .map(|order| Row {
487                    cells: order_cells(order, false),
488                    symbol: Some(order.request.symbol.clone()),
489                    edit: capabilities.modify.then(|| RowAction::Order(order.clone())),
490                    close: Some(RowAction::Order(order.clone())),
491                })
492                .collect(),
493            Tab::History => self
494                .account_panel
495                .history
496                .iter()
497                .rev()
498                .map(|order| Row {
499                    cells: order_cells(order, true),
500                    symbol: Some(order.request.symbol.clone()),
501                    edit: None,
502                    close: None,
503                })
504                .collect(),
505            Tab::Executions => self
506                .account_panel
507                .fills
508                .executions
509                .iter()
510                .rev()
511                .map(|fill| {
512                    let order = self.account_panel.fills.orders.get(&fill.order);
513                    let name = order.map(|order| order.symbol.as_str()).unwrap_or("");
514                    Row {
515                        cells: vec![
516                            symbol(name),
517                            side(fill.side, fill.side.label()),
518                            plain(quantity(name, fill.quantity)),
519                            plain(price(name, fill.price)),
520                            plain(time(Some(fill.time))),
521                            plain(fill.order.to_string()),
522                        ],
523                        symbol: order.map(|order| order.symbol.clone()),
524                        edit: None,
525                        close: None,
526                    }
527                })
528                .collect(),
529            Tab::Balance => self
530                .account_panel
531                .balance
532                .iter()
533                .rev()
534                .map(|change| Row {
535                    cells: vec![
536                        plain(time(Some(change.time))),
537                        plain(grouped(change.balance - change.amount, 2, false)),
538                        plain(grouped(change.balance, 2, false)),
539                        colored(money(Some(change.amount), true)),
540                        plain(change.action.clone()),
541                    ],
542                    symbol: None,
543                    edit: None,
544                    close: None,
545                })
546                .collect(),
547            Tab::Journal => self
548                .account_panel
549                .journal
550                .iter()
551                .rev()
552                .map(|entry| Row {
553                    cells: vec![plain(time(Some(entry.time))), plain(entry.text.clone())],
554                    symbol: None,
555                    edit: None,
556                    close: None,
557                })
558                .collect(),
559        };
560
561        let columns: Vec<(usize, &Column)> = tab
562            .columns()
563            .iter()
564            .enumerate()
565            .filter(|(_, column)| !self.account_panel.hidden.contains(&(tab, column.title)))
566            .collect();
567        let font = FontId::proportional(theme.text.body);
568        let heading = FontId::proportional(theme.text.body);
569        let unit_font = FontId::proportional(theme.text.tiny);
570        // Columns take their smallest width, at least their title's, and share what is left; a
571        // narrow panel scrolls. Several take-profit levels share a cell, whose prices must not
572        // lose digits.
573        let widths: Vec<f32> = columns
574            .iter()
575            .map(|&(index, column)| {
576                let title = ui.painter().layout_no_wrap(
577                    l10n::text(ui.ctx(), column.title),
578                    heading.clone(),
579                    theme.ui.text,
580                );
581                let cells = rows
582                    .iter()
583                    .filter(|_| column.title == "Take Profit")
584                    .map(|row| {
585                        let text = row.cells[index].text.clone();
586                        let cell = ui
587                            .painter()
588                            .layout_no_wrap(text, font.clone(), theme.ui.text);
589                        cell.size().x + 16.0
590                    })
591                    .fold(0.0, f32::max);
592                column.width.max(title.size().x + 16.0).max(cells)
593            })
594            .collect();
595        let least: f32 = widths.iter().sum();
596        let width = ui.available_width().max(least + ACTIONS_WIDTH + 16.0);
597        let spare = (width - least - ACTIONS_WIDTH - 16.0) / columns.len().max(1) as f32;
598        let edges = |rect: Rect| {
599            let mut left = rect.left() + 8.0;
600            widths
601                .iter()
602                .map(|width| {
603                    let cell = Rect::from_min_max(
604                        egui::pos2(left, rect.top()),
605                        egui::pos2(left + width + spare - 12.0, rect.bottom()),
606                    );
607                    left += width + spare;
608                    cell
609                })
610                .collect::<Vec<_>>()
611        };
612
613        let mut row_action = None;
614        let mut at_end = false;
615        egui::ScrollArea::horizontal()
616            .id_salt("account_panel_columns")
617            .auto_shrink([false, false])
618            .show(ui, |ui| {
619                let (header, _) = ui.allocate_exact_size(vec2(width, 32.0), Sense::hover());
620                let chooser = Rect::from_min_size(
621                    egui::pos2(
622                        header.right().min(ui.clip_rect().right()) - 36.0,
623                        header.top() + 4.0,
624                    ),
625                    vec2(24.0, 24.0),
626                );
627                let mut header_clip = ui.clip_rect();
628                header_clip.max.x = chooser.left() - 4.0;
629                for ((_, column), cell) in columns.iter().zip(edges(header)) {
630                    let (x, align) = if column.right {
631                        (cell.right(), Align2::RIGHT_CENTER)
632                    } else {
633                        (cell.left(), Align2::LEFT_CENTER)
634                    };
635                    ui.painter()
636                        .with_clip_rect(cell.intersect(header_clip))
637                        .text(
638                            egui::pos2(x, cell.center().y),
639                            align,
640                            l10n::text(ui.ctx(), column.title),
641                            heading.clone(),
642                            theme.ui.text_muted,
643                        );
644                }
645                let chooser = ui.put(chooser, |ui: &mut Ui| {
646                    IconButton::new(&icons::COLUMNS)
647                        .size(ButtonSize::SM)
648                        .tooltip("Columns and export")
649                        .show(ui)
650                });
651                egui::Popup::menu(&chooser)
652                    .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
653                    .show(|ui| {
654                        for column in tab.columns() {
655                            let key = (tab, column.title);
656                            let mut shown = !self.account_panel.hidden.contains(&key);
657                            if ui
658                                .checkbox(&mut shown, l10n::text(ui.ctx(), column.title))
659                                .changed()
660                            {
661                                if shown {
662                                    self.account_panel.hidden.remove(&key);
663                                } else {
664                                    self.account_panel.hidden.insert(key);
665                                }
666                            }
667                        }
668                        ui.separator();
669                        if ui.button(l10n::text(ui.ctx(), "Export as CSV")).clicked() {
670                            action = Some(AccountPanelAction::Export {
671                                name: tab.title().to_owned(),
672                                csv: csv(&columns, &rows),
673                            });
674                            ui.close();
675                        }
676                    });
677
678                if rows.is_empty() {
679                    let mut empty =
680                        ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_size(
681                            egui::pos2(ui.clip_rect().left(), ui.cursor().top()),
682                            vec2(ui.clip_rect().width(), ui.available_height()),
683                        )));
684                    EmptyState::new(match tab {
685                        Tab::Positions => "No open positions",
686                        Tab::Orders => "No working orders",
687                        Tab::History => "No finished orders",
688                        Tab::Balance => "No balance changes",
689                        Tab::Journal => "No journal entries",
690                        Tab::Executions => "No executions",
691                    })
692                    .fill_height()
693                    .show(&mut empty);
694                    at_end = true;
695                    return;
696                }
697                egui::ScrollArea::vertical()
698                    .id_salt(("account_panel_rows", tab))
699                    .auto_shrink([false, false])
700                    .show_rows(ui, ROW_HEIGHT, rows.len(), |ui, range| {
701                        at_end |= range.end == rows.len();
702                        for row in &rows[range] {
703                            let (rect, _) =
704                                ui.allocate_exact_size(vec2(width, ROW_HEIGHT), Sense::hover());
705                            let hovered = ui.rect_contains_pointer(rect);
706                            if hovered {
707                                ui.painter().rect_filled(rect, 0.0, theme.ui.btn_bg_hover);
708                            }
709                            ui.painter().hline(
710                                rect.x_range(),
711                                rect.bottom() - 0.5,
712                                Stroke::new(1.0, theme.ui.border),
713                            );
714                            let has_actions = row.edit.is_some() || row.close.is_some();
715                            let visible = rect.right().min(ui.clip_rect().right());
716                            // Cells stop short of the actions, which stay in view at the end of a
717                            // row wider than a narrow panel.
718                            let mut cells_clip = ui.clip_rect();
719                            if has_actions {
720                                cells_clip.max.x = visible - ACTIONS_WIDTH;
721                            }
722                            let cells = ui.painter().with_clip_rect(cells_clip);
723                            for (&(index, column), cell_rect) in columns.iter().zip(edges(rect)) {
724                                let cell = &row.cells[index];
725                                if cell.symbol && !cell.text.is_empty() {
726                                    let galley = ui.painter().layout_job(semibold(
727                                        &cell.text,
728                                        theme.text.small,
729                                        theme.ui.text,
730                                    ));
731                                    let mark = Rect::from_center_size(
732                                        egui::pos2(cell_rect.left() + 9.0, rect.center().y),
733                                        vec2(18.0, 18.0),
734                                    );
735                                    paint_symbol_mark(
736                                        &cells.with_clip_rect(cell_rect),
737                                        mark,
738                                        &cell.text,
739                                        cell.logo.as_deref().map(ImageSource::from),
740                                    );
741                                    let left = mark.right() + 6.0;
742                                    let chip = Rect::from_min_size(
743                                        egui::pos2(left, rect.center().y - 15.0),
744                                        vec2(
745                                            (galley.size().x + 16.0)
746                                                .min((cell_rect.right() - left).max(0.0)),
747                                            30.0,
748                                        ),
749                                    );
750                                    let current = cell.text == chart_symbol;
751                                    let (fill, text) = if current {
752                                        (theme.ui.accent, theme.ui.accent_text)
753                                    } else {
754                                        (theme.ui.panel_bg_secondary, theme.ui.text)
755                                    };
756                                    let response = ui
757                                        .interact(
758                                            chip,
759                                            ui.scope_id().with((
760                                                "chip",
761                                                &cell.text,
762                                                rect.top() as i32,
763                                            )),
764                                            Sense::click(),
765                                        )
766                                        .on_hover_cursor(egui::CursorIcon::PointingHand);
767                                    cells.rect_filled(chip, 4.0, fill);
768                                    let galley = ui.painter().layout_job(semibold(
769                                        &cell.text,
770                                        theme.text.small,
771                                        text,
772                                    ));
773                                    cells.with_clip_rect(chip.shrink(4.0)).galley(
774                                        chip.left_center() + vec2(8.0, -galley.size().y / 2.0),
775                                        galley,
776                                        text,
777                                    );
778                                    if response.clicked() {
779                                        action = row
780                                            .symbol
781                                            .clone()
782                                            .map(AccountPanelAction::SelectSymbol);
783                                    }
784                                    continue;
785                                }
786                                let painter = cells.with_clip_rect(cell_rect);
787                                let y = rect.center().y;
788                                let mut right = cell_rect.right();
789                                if !cell.unit.is_empty() && !cell.text.is_empty() {
790                                    let unit = painter.text(
791                                        egui::pos2(right, y + 1.0),
792                                        Align2::RIGHT_CENTER,
793                                        l10n::text(ui.ctx(), &cell.unit),
794                                        unit_font.clone(),
795                                        theme.ui.text_muted,
796                                    );
797                                    right = unit.left() - 3.0;
798                                }
799                                let (x, align) = if column.right {
800                                    (right, Align2::RIGHT_CENTER)
801                                } else {
802                                    (cell_rect.left(), Align2::LEFT_CENTER)
803                                };
804                                painter.text(
805                                    egui::pos2(x, y),
806                                    align,
807                                    l10n::text(ui.ctx(), &cell.text),
808                                    font.clone(),
809                                    cell.color,
810                                );
811                            }
812                            if has_actions {
813                                let buttons = Rect::from_min_max(
814                                    egui::pos2(visible - ACTIONS_WIDTH, rect.top()),
815                                    egui::pos2(visible - 12.0, rect.bottom()),
816                                );
817                                let mut child = ui.new_child(
818                                    egui::UiBuilder::new()
819                                        .max_rect(buttons)
820                                        .layout(Layout::right_to_left(Align::Center)),
821                                );
822                                child.visuals_mut().widgets.inactive.fg_stroke.color =
823                                    theme.ui.text_muted;
824                                let (close, edit) = match row.close.as_ref().or(row.edit.as_ref()) {
825                                    Some(RowAction::Position(_)) => ("Close position", ""),
826                                    _ => ("Cancel order", "Modify order"),
827                                };
828                                if row.close.is_some()
829                                    && IconButton::new(&icons::CLOSE)
830                                        .size(ButtonSize::SM)
831                                        .tooltip(close)
832                                        .show(&mut child)
833                                        .clicked()
834                                {
835                                    row_action = row.close.as_ref().map(|a| (a, false));
836                                }
837                                if row.edit.is_some()
838                                    && IconButton::new(&icons::PENCIL)
839                                        .size(ButtonSize::SM)
840                                        .tooltip(edit)
841                                        .show(&mut child)
842                                        .clicked()
843                                {
844                                    row_action = row.edit.as_ref().map(|a| (a, true));
845                                }
846                            }
847                        }
848                    });
849            });
850
851        // A history shown to its end asks the broker for the page before it.
852        if at_end && let Some(kind) = tab.history() {
853            self.load_older(kind);
854        }
855        match row_action {
856            Some((RowAction::Position(position), _)) => {
857                self.pending = Some(TradingLineAction::Close(position.clone()));
858            }
859            Some((RowAction::Order(order), false)) => {
860                self.pending = Some(TradingLineAction::Cancel(order.clone()));
861            }
862            Some((RowAction::Order(order), true)) => {
863                self.ticket.open = false;
864                self.load_rules(&order.request.symbol);
865                self.account_panel.editing = Some((order.clone(), order.request.clone()));
866            }
867            None => {}
868        }
869        self.show_order_editor(ui.ctx(), chart_symbol);
870        action
871    }
872
873    /// The edit dialog of a working order: its quantity, prices and brackets. Saving sends the
874    /// change to the broker; the dialog is the user's confirmation.
875    fn show_order_editor(&mut self, ctx: &egui::Context, chart_symbol: &str) {
876        let Some(symbol) =
877            (self.account_panel.editing.as_ref()).map(|(order, _)| order.request.symbol.clone())
878        else {
879            return;
880        };
881        let decimals = self.numbers(&symbol, chart_symbol).decimals;
882        let brackets = self.capabilities().brackets;
883        let Some((order, request)) = &mut self.account_panel.editing else {
884            return;
885        };
886        let mut open = true;
887        let mut save = false;
888        egui::Window::new(l10n::format_text(
889            ctx,
890            "Modify order {0}",
891            &[("0", order.id.to_string())],
892        ))
893        .id(egui::Id::unique("account_panel_modify"))
894        .collapsible(false)
895        .resizable(false)
896        .open(&mut open)
897        .show(ctx, |ui| {
898            egui::Grid::new("account_panel_modify_grid")
899                .num_columns(2)
900                .spacing([12.0, 8.0])
901                .show(ui, |ui| {
902                    let price = |ui: &mut Ui, label: &str, value: &mut f64| {
903                        ui.label(l10n::text(ui.ctx(), label));
904                        ui.add(
905                            egui::DragValue::new(value)
906                                .range(0.0..=f64::MAX)
907                                .max_decimals(decimals)
908                                .speed(10f64.powi(-(decimals as i32))),
909                        );
910                        ui.end_row();
911                    };
912                    ui.label(l10n::text(ui.ctx(), "Quantity"));
913                    // The levels' quantities add up to it; they are changed on the chart.
914                    ui.add_enabled(
915                        request.take_profit_levels.is_empty(),
916                        egui::DragValue::new(&mut request.quantity).range(0.0..=f64::MAX),
917                    );
918                    ui.end_row();
919                    match &mut request.order_type {
920                        OrderType::Limit(limit) => price(ui, "Limit price", limit),
921                        OrderType::Stop(stop) => price(ui, "Stop price", stop),
922                        OrderType::StopLimit { stop, limit } => {
923                            price(ui, "Stop price", stop);
924                            price(ui, "Limit price", limit);
925                        }
926                        _ => {}
927                    }
928                    if brackets {
929                        let reference = match request.order_type {
930                            OrderType::Limit(p) | OrderType::Stop(p) => p,
931                            OrderType::StopLimit { limit, .. } => limit,
932                            _ => 0.0,
933                        };
934                        for (index, level) in request.take_profit_levels.iter_mut().enumerate() {
935                            let label = l10n::format_text(
936                                ui.ctx(),
937                                "Take profit {0}",
938                                &[("0", (index + 1).to_string())],
939                            );
940                            price(ui, &label, &mut level.price);
941                        }
942                        let single = request
943                            .take_profit_levels
944                            .is_empty()
945                            .then_some(("Take profit", &mut request.take_profit));
946                        for (label, bracket) in single
947                            .into_iter()
948                            .chain([("Stop loss", &mut request.stop_loss)])
949                        {
950                            let mut on = bracket.is_some();
951                            ui.checkbox(&mut on, label);
952                            if on {
953                                let value = bracket.get_or_insert(reference);
954                                ui.add(
955                                    egui::DragValue::new(value)
956                                        .range(0.0..=f64::MAX)
957                                        .max_decimals(decimals)
958                                        .speed(10f64.powi(-(decimals as i32))),
959                                );
960                            } else {
961                                *bracket = None;
962                            }
963                            ui.end_row();
964                        }
965                    }
966                });
967            ui.add_space(8.0);
968            let valid = self.rules_ready
969                && self.rules.symbol == request.symbol
970                && crate::ui::trading::ticket::order_quantity(request.quantity, &self.rules)
971                    .is_ok_and(|quantity| quantity == request.quantity)
972                && request != &order.request;
973            save = ui
974                .add_enabled(
975                    valid,
976                    egui::Button::new(l10n::text(ui.ctx(), "Modify order")),
977                )
978                .clicked();
979        });
980        if save {
981            let (order, request) = self.account_panel.editing.take().expect("editing");
982            self.execute(Some(TradingLineAction::Modify(order, request)));
983        } else if !open {
984            self.account_panel.editing = None;
985        }
986    }
987}