1use 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 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 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 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 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 egui::ScrollArea::horizontal()
133 .id_salt("account_summary")
134 .auto_shrink([false, true])
135 .show(ui, |ui| {
136 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 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 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 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 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 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 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}