use std::time::Instant;
use super::*;
use super::super::super::state::{TwapBot, TwapStatus, TwapsView};
use super::super::super::trading::TradingSide;
pub(in crate::tui::ui) fn render_twap_modal(
f: &mut Frame,
area: ratatui::layout::Rect,
trading: &TradingState,
_symbol: &str,
) {
let s = strings();
let draft = &trading.twap_draft;
let has_error = draft.error.is_some();
let desired_h: u16 = if has_error { 16 } else { 14 };
let popup_h: u16 = desired_h.min(area.height.saturating_sub(2));
let popup_w: u16 = 72.min(area.width.saturating_sub(4));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = ratatui::layout::Rect::new(x, y, popup_w, popup_h);
f.render_widget(ratatui::widgets::Clear, popup_area);
let title = Line::from(vec![
Span::raw(" "),
Span::styled(
"🐦🔥 ",
Style::default()
.fg(FIRE_ORANGE)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"Phoenix ",
Style::default()
.fg(FIRE_ORANGE)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.twap_modal_title),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]);
let footer = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.twap_nav_field),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"←→ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.twap_cycle_market),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"Tab ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.twap_toggle_side),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.twap_start),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"Esc ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.cancel),
Style::default().fg(Color::DarkGray),
),
])
.left_aligned();
let block = Block::default()
.title(title)
.title_bottom(footer)
.borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
let mut constraints = vec![
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ];
if has_error {
constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); }
constraints.push(Constraint::Min(0));
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", s.twap_modal_intro),
Style::default().fg(Color::DarkGray),
))),
rows[0],
);
let summary = derive_summary(draft);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(summary, Style::default().fg(Color::DarkGray)),
])),
rows[1],
);
let market_value = if draft.selected_field == 0 {
format!("{} [←→]", draft.market)
} else {
draft.market.clone()
};
render_form_row(
f,
rows[3],
s.twap_field_market,
Span::styled(
market_value,
Style::default()
.fg(if draft.selected_field == 0 {
Color::Cyan
} else {
Color::White
})
.add_modifier(Modifier::BOLD),
),
draft.selected_field == 0,
);
let side_value: String = match draft.side {
TradingSide::Long => format!("{} [Tab]", s.long_label),
TradingSide::Short => format!("{} [Tab]", s.short_label),
};
let side_color = draft.side.color();
render_form_row(
f,
rows[4],
s.twap_field_side,
Span::styled(
side_value,
Style::default().fg(side_color).add_modifier(Modifier::BOLD),
),
draft.selected_field == 1,
);
render_form_row(
f,
rows[5],
s.twap_field_size,
editable_value_span(&draft.size_buffer, draft.selected_field == 2, &draft.market),
draft.selected_field == 2,
);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", s.twap_field_total_time),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
))),
rows[6],
);
render_form_row(
f,
rows[7],
s.twap_field_hours,
editable_value_span(
&draft.duration_hour_buffer,
draft.selected_field == 3,
s.twap_unit_hr,
),
draft.selected_field == 3,
);
render_form_row(
f,
rows[8],
s.twap_field_mins,
editable_value_span(
&draft.duration_min_buffer,
draft.selected_field == 4,
s.twap_unit_min,
),
draft.selected_field == 4,
);
render_form_row(
f,
rows[9],
s.twap_field_secs,
editable_value_span(
&draft.duration_sec_buffer,
draft.selected_field == 5,
s.twap_unit_sec,
),
draft.selected_field == 5,
);
let start_label = format!("[ Enter — {} ]", s.twap_start);
let label_w = start_label.chars().count() as u16;
let pad = inner.width.saturating_sub(label_w) / 2;
f.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" ".repeat(pad as usize)),
Span::styled(
start_label,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
])),
rows[11],
);
if has_error {
let err = draft.error.as_deref().unwrap_or("");
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" ✗ ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(err.to_string(), Style::default().fg(Color::LightRed)),
])),
rows[13],
);
}
if draft.pending_confirm {
let confirm_h: u16 = 3;
if inner.height >= confirm_h {
let confirm_w = inner.width;
let confirm_y = inner.y + inner.height.saturating_sub(confirm_h);
let confirm_area = ratatui::layout::Rect::new(inner.x, confirm_y, confirm_w, confirm_h);
f.render_widget(ratatui::widgets::Clear, confirm_area);
let confirm_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let confirm_inner = confirm_block.inner(confirm_area);
f.render_widget(confirm_block, confirm_area);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" ⚠ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
s.twap_confirm_start.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
])),
confirm_inner,
);
}
}
}
fn editable_value_span<'a>(buffer: &'a str, is_selected: bool, unit: &'a str) -> Span<'a> {
let cursor = if is_selected { "_" } else { "" };
if buffer.is_empty() && !is_selected {
Span::styled(format!("— {}", unit), Style::default().fg(Color::DarkGray))
} else {
Span::styled(
format!("{}{} {}", buffer, cursor, unit),
Style::default()
.fg(if is_selected {
Color::Cyan
} else {
Color::White
})
.add_modifier(if is_selected {
Modifier::BOLD | Modifier::UNDERLINED
} else {
Modifier::BOLD
}),
)
}
}
fn render_form_row(
f: &mut Frame,
rect: ratatui::layout::Rect,
label: &str,
value: Span<'_>,
is_selected: bool,
) {
let cursor = if is_selected { "▸" } else { " " };
let label_style = if is_selected {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
let line = Line::from(vec![
Span::styled(format!(" {} ", cursor), label_style),
Span::styled(format!("{:<12}", label), label_style),
Span::raw(" "),
value,
]);
f.render_widget(Paragraph::new(line), rect);
}
fn derive_summary(draft: &super::super::super::state::TwapDraft) -> String {
let s = strings();
let size: Option<f64> = draft.size_buffer.parse::<f64>().ok().filter(|v| *v > 0.0);
let hours: u32 = draft.duration_hour_buffer.parse::<u32>().unwrap_or(0);
let mins: u32 = draft.duration_min_buffer.parse::<u32>().unwrap_or(0);
let secs: u32 = draft.duration_sec_buffer.parse::<u32>().unwrap_or(0);
let total_seconds: Option<u64> = (hours as u64)
.checked_mul(3600)
.and_then(|h| h.checked_add((mins as u64).checked_mul(60)?))
.and_then(|hm| hm.checked_add(secs as u64));
if let (Some(size), Some(total_seconds)) = (size, total_seconds) {
if total_seconds < 1 {
return s.twap_summary_placeholder.to_string();
}
let (slice_count, interval_unit) = derive_schedule(hours, mins, secs, total_seconds);
let slice_size = size / slice_count as f64;
format!(
"{} × {:.4} {} · 1/{}",
slice_count, slice_size, draft.market, interval_unit
)
} else {
s.twap_summary_placeholder.to_string()
}
}
fn derive_schedule(hours: u32, mins: u32, secs: u32, total_seconds: u64) -> (u32, &'static str) {
let s = strings();
let total_minutes = hours.saturating_mul(60).saturating_add(mins);
if secs > 0 || total_minutes == 0 {
let count = (total_seconds.min(u32::MAX as u64) as u32).max(1);
(count, s.twap_unit_sec)
} else {
(total_minutes.max(1), s.twap_unit_min)
}
}
pub(in crate::tui::ui) fn render_bots_modal(
f: &mut Frame,
area: ratatui::layout::Rect,
view: &TwapsView,
active_symbol: &str,
) {
let row_count = view.bots.len().max(1) as u16;
let max_width: u16 = 79;
let popup_w = max_width.min(area.width.saturating_sub(4));
let popup_h = (row_count + 6).min(area.height.saturating_sub(2));
let x = area.x + (area.width.saturating_sub(popup_w)) / 2;
let y = area.y + (area.height.saturating_sub(popup_h)) / 2;
let popup_area = ratatui::layout::Rect::new(x, y, popup_w, popup_h);
f.render_widget(ratatui::widgets::Clear, popup_area);
let s = strings();
let title = Line::from(vec![
Span::styled(
" 🐦🔥 ",
Style::default()
.fg(FIRE_ORANGE)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"Phoenix ",
Style::default()
.fg(FIRE_ORANGE)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.bots_title),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({}) ", view.bots.len()),
Style::default().fg(Color::DarkGray),
),
]);
let footer = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.select),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"p ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.bots_pause_resume),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"s ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.bots_stop),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"r ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.bots_restart),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"x ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", s.bots_remove),
Style::default().fg(Color::DarkGray),
),
Span::styled(
"Esc ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled(format!("{} ", s.back), Style::default().fg(Color::DarkGray)),
])
.left_aligned();
let block = Block::default()
.title(title)
.title_bottom(footer)
.borders(Borders::ALL)
.border_style(Style::default().fg(MODAL_BORDER));
let inner = block.inner(popup_area);
f.render_widget(block, popup_area);
if view.bots.is_empty() {
let empty = Paragraph::new(Line::from(Span::styled(
format!(" {}", s.bots_empty),
Style::default().fg(Color::DarkGray),
)));
f.render_widget(empty, inner);
return;
}
let header = Row::new(vec![
Cell::from(Span::styled(
format!(" {}", s.market),
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(
s.bots_kind,
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(s.side, Style::default().fg(Color::DarkGray))),
Cell::from(Span::styled(
s.bots_progress,
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(s.size, Style::default().fg(Color::DarkGray))),
Cell::from(Span::styled(
s.bots_interval,
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(
s.bots_next,
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(
s.bots_state,
Style::default().fg(Color::DarkGray),
)),
]);
let detail_h: u16 = if inner.height >= 3 { 1 } else { 0 };
let table_h = inner.height.saturating_sub(detail_h);
let table_area = ratatui::layout::Rect::new(inner.x, inner.y, inner.width, table_h);
let detail_area = ratatui::layout::Rect::new(inner.x, inner.y + table_h, inner.width, detail_h);
let visible_slots = table_h.saturating_sub(1) as usize;
let scroll_offset = if view.selected_index >= visible_slots && visible_slots > 0 {
view.selected_index - visible_slots + 1
} else {
0
};
let now = Instant::now();
let table_rows: Vec<Row> = view
.bots
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_slots)
.map(|(i, b)| bot_row(b, active_symbol, i == view.selected_index, now))
.collect();
let widths = [
Constraint::Length(13),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(8),
Constraint::Length(9),
Constraint::Length(10),
Constraint::Length(10),
Constraint::Length(8),
];
let table = Table::new(table_rows, widths)
.header(header)
.column_spacing(1);
f.render_widget(table, table_area);
if detail_h > 0
&& let Some(b) = view.bots.get(view.selected_index)
{
let (text, color) = if !b.last_status.is_empty() {
let color = if b.slices_failed > 0 || b.slices_unconfirmed > 0 {
Color::LightYellow
} else {
Color::Gray
};
(b.last_status.clone(), color)
} else if let Some(reason) = b.defer_reason.as_deref() {
(reason.to_string(), Color::DarkGray)
} else {
(String::new(), Color::DarkGray)
};
if !text.is_empty() {
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!(" {}", text),
Style::default().fg(color),
))),
detail_area,
);
}
}
if let Some(pending) = view.pending_confirm {
use super::super::super::state::TwapBotConfirm;
let prompt = match pending {
TwapBotConfirm::Stop(_) => s.twap_confirm_stop,
TwapBotConfirm::Restart(_) => s.twap_confirm_restart,
TwapBotConfirm::Remove(_) => s.twap_confirm_remove,
};
let confirm_h: u16 = 3;
if inner.height >= confirm_h {
let confirm_w = inner.width;
let confirm_y = inner.y + inner.height.saturating_sub(confirm_h);
let confirm_area = ratatui::layout::Rect::new(inner.x, confirm_y, confirm_w, confirm_h);
f.render_widget(ratatui::widgets::Clear, confirm_area);
let confirm_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow));
let confirm_inner = confirm_block.inner(confirm_area);
f.render_widget(confirm_block, confirm_area);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
" ⚠ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
prompt.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
])),
confirm_inner,
);
}
}
}
fn progress_span(b: &TwapBot) -> Span<'static> {
let resolved = b.slices_submitted + b.slices_failed + b.slices_unconfirmed;
let color = if b.slices_failed > 0 || b.slices_unconfirmed > 0 {
Color::LightYellow
} else {
Color::White
};
Span::styled(
format!("{}/{}", resolved, b.slice_count),
Style::default().fg(color),
)
}
fn bot_row<'a>(b: &'a TwapBot, active_symbol: &str, is_selected: bool, now: Instant) -> Row<'a> {
let s = strings();
let cursor_str = if is_selected { "▸" } else { " " };
let is_active_market = b.symbol == active_symbol;
let sym_str = if is_active_market {
format!("{} {} ●", cursor_str, b.symbol)
} else {
format!("{} {}", cursor_str, b.symbol)
};
let side_color = b.side.color();
let side_label = match b.side {
TradingSide::Long => s.buy,
TradingSide::Short => s.sell,
};
let status_label = match b.status {
TwapStatus::Running => s.bots_status_running,
TwapStatus::Paused => s.bots_status_paused,
TwapStatus::Stopped => s.bots_status_stopped,
TwapStatus::Completed => s.bots_status_completed,
};
let status_color = match b.status {
TwapStatus::Running => Color::Green,
TwapStatus::Paused => Color::Yellow,
TwapStatus::Stopped => Color::Red,
TwapStatus::Completed => Color::DarkGray,
};
let interval_secs = b.slice_interval.as_secs();
let interval_str = if interval_secs == 0 {
"instant".to_string()
} else {
format!("{}s", interval_secs)
};
let next_str = match b.status {
TwapStatus::Running => match b.last_slice_at {
None => "now".to_string(),
Some(prev) => {
let target = prev.checked_add(b.slice_interval).unwrap_or(now);
if target <= now {
"now".to_string()
} else {
let remaining = target.saturating_duration_since(now);
let secs = remaining.as_secs();
if secs >= 60 {
format!("{}m{:02}s", secs / 60, secs % 60)
} else {
format!("{}s", secs)
}
}
}
},
TwapStatus::Paused | TwapStatus::Stopped | TwapStatus::Completed => "—".to_string(),
};
let next_color = match b.status {
TwapStatus::Running => Color::White,
_ => Color::DarkGray,
};
let row_style = if is_selected {
Style::default()
.fg(Color::White)
.bg(MODAL_HIGHLIGHT_BG)
.add_modifier(Modifier::BOLD)
} else if is_active_market {
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::White)
};
Row::new(vec![
Cell::from(Span::styled(
sym_str,
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
)),
Cell::from(Span::styled("TWAP", Style::default().fg(Color::White))),
Cell::from(Span::styled(
side_label,
Style::default().fg(side_color).add_modifier(Modifier::BOLD),
)),
Cell::from(progress_span(b)),
Cell::from(Span::styled(
fmt_size(b.total_size, 4),
Style::default().fg(Color::White),
)),
Cell::from(Span::styled(
interval_str,
Style::default().fg(Color::DarkGray),
)),
Cell::from(Span::styled(next_str, Style::default().fg(next_color))),
Cell::from(Span::styled(
status_label,
Style::default()
.fg(status_color)
.add_modifier(Modifier::BOLD),
)),
])
.style(row_style)
}