use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Modifier, Style},
symbols,
text::{Line, Span},
widgets::{
Axis, Block, BorderType, Borders, Cell, Chart, Clear, Dataset, GraphType, Paragraph, Row,
Table,
},
Frame,
};
use crate::app::{App, ConfirmAction, Modal, OrderEntryState, OrderField};
use crate::ui::{charts, popup_area};
pub fn render(frame: &mut Frame, area: Rect, modal: &Modal, app: &mut App) {
match modal {
Modal::Help => render_help(frame, area, app),
Modal::About => render_about(frame, area, app),
Modal::OrderEntry(state) => render_order_entry(frame, area, state, app),
Modal::SymbolDetail(symbol) => render_symbol_detail(frame, area, symbol, app),
Modal::Confirm {
message,
action,
confirmed,
} => render_confirm(frame, area, message, action, *confirmed, app),
Modal::ConfirmRemoveWatchlist { symbol, .. } => {
render_confirm_remove_watchlist(frame, area, symbol, app)
}
Modal::AddSymbol { input, .. } => render_add_symbol(frame, area, input, app),
}
}
fn render_help(frame: &mut Frame, area: Rect, app: &App) {
let popup = popup_area(area, 50, 70);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" Keyboard Shortcuts ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.accent_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let rows = vec![
("NAVIGATION", ""),
("1/2/3/4 or Tab", "Switch panels"),
("j / k or ↑/↓", "Move cursor"),
("g / G", "Top / Bottom"),
("Enter", "Open detail"),
("Esc", "Close / Cancel"),
("", ""),
("ACTIONS", ""),
("o", "New order (pre-fills symbol)"),
("c", "Cancel selected order"),
("a", "Add symbol to watchlist"),
("d", "Remove symbol from watchlist"),
("r", "Force refresh"),
("/", "Search / filter watchlist"),
("", ""),
("GLOBAL", ""),
("T", "Cycle theme (Default → Dark → High-contrast)"),
("q / Ctrl-C", "Quit"),
("?", "This help screen"),
("A", "About this app"),
];
let header = Row::new(vec![
Cell::from("Key").style(c.header_style()),
Cell::from("Action").style(c.header_style()),
]);
let table_rows: Vec<Row> = rows
.iter()
.map(|(k, v)| {
if v.is_empty() {
Row::new(vec![
Cell::from(*k)
.style(Style::default().fg(c.accent).add_modifier(Modifier::BOLD)),
Cell::from(""),
])
} else {
Row::new(vec![Cell::from(*k).style(c.dim_style()), Cell::from(*v)])
}
})
.collect();
let table =
Table::new(table_rows, [Constraint::Length(18), Constraint::Min(20)]).header(header);
frame.render_widget(table, inner);
let footer_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let footer = Paragraph::new(" Press any key to close")
.alignment(Alignment::Center)
.style(c.dim_style());
frame.render_widget(footer, footer_area);
}
fn render_about(frame: &mut Frame, area: Rect, app: &App) {
let popup = popup_area(area, 50, 60);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" About alpaca-trader-rs ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.accent_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let lines = vec![
Line::from(""),
Line::from(vec![
Span::styled(
" alpaca-trader-rs",
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" v{}", env!("CARGO_PKG_VERSION")),
c.accent_style(),
),
]),
Line::from(""),
Line::from(Span::styled(
" Alpaca Markets TUI trading terminal",
c.dim_style(),
)),
Line::from(Span::styled(
" and async REST client library.",
c.dim_style(),
)),
Line::from(""),
Line::from(Span::styled(
" ── Author ─────────────────────────────",
c.accent_style(),
)),
Line::from(" Arunkumar Mourougappane"),
Line::from(Span::styled(" amouroug.dev@gmail.com", c.dim_style())),
Line::from(Span::styled(
" github.com/arunkumar-mourougappane",
c.dim_style(),
)),
Line::from(Span::styled(" anengineersrant.com", c.dim_style())),
Line::from(""),
Line::from(Span::styled(
" ── Project ────────────────────────────",
c.accent_style(),
)),
Line::from(Span::styled(
" github.com/arunkumar-mourougappane/",
c.dim_style(),
)),
Line::from(Span::styled(" alpaca-trader-rs", c.dim_style())),
Line::from(Span::styled(" docs.rs/alpaca-trader-rs", c.dim_style())),
Line::from(""),
Line::from(Span::styled(
" ── License ────────────────────────────",
c.accent_style(),
)),
Line::from(Span::styled(
format!(" {}", env!("CARGO_PKG_LICENSE")),
c.dim_style(),
)),
Line::from(""),
];
let content_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(1),
};
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, content_area);
let footer_area = Rect {
x: inner.x,
y: inner.y + inner.height.saturating_sub(1),
width: inner.width,
height: 1,
};
let footer = Paragraph::new(" Press any key to close")
.alignment(Alignment::Center)
.style(c.dim_style());
frame.render_widget(footer, footer_area);
}
fn render_order_entry(frame: &mut Frame, area: Rect, state: &OrderEntryState, app: &mut App) {
let popup = popup_area(area, 45, 65);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" New Order ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.accent_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
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), Constraint::Length(1), Constraint::Length(1), ])
.split(inner);
let market_open = app.clock.as_ref().map(|c| c.is_open).unwrap_or(true);
app.hit_areas.modal_fields = vec![
(OrderField::Symbol, chunks[0]),
(OrderField::Side, chunks[2]),
(OrderField::OrderType, chunks[3]),
(OrderField::Qty, chunks[4]),
(OrderField::Price, chunks[5]),
(OrderField::TimeInForce, chunks[6]),
];
app.hit_areas.modal_submit = Some(chunks[12]);
let focused = |field: &OrderField| *field == state.focused_field;
let field_style = |field: &OrderField| {
if focused(field) {
Style::default().fg(c.accent).add_modifier(Modifier::BOLD)
} else {
Style::default()
}
};
frame.render_widget(
field_line(
"Symbol",
&format!(
"{}{}",
state.symbol,
if focused(&OrderField::Symbol) {
"▋"
} else {
""
}
),
field_style(&OrderField::Symbol),
c.dim_style(),
),
chunks[0],
);
let side_line = Line::from(vec![
Span::styled(" Side ", c.dim_style()),
radio(state.side == crate::app::OrderSide::Buy, "BUY", &c),
Span::raw(" "),
radio(state.side == crate::app::OrderSide::Sell, "SELL", &c),
Span::raw(" "),
radio(
state.side == crate::app::OrderSide::SellShort,
"SELL SHORT",
&c,
),
]);
frame.render_widget(Paragraph::new(side_line), chunks[2]);
let type_line = Line::from(vec![
Span::styled(" Type ", c.dim_style()),
radio(!state.market_order, "LIMIT", &c),
Span::raw(" "),
radio(state.market_order, "MARKET", &c),
]);
frame.render_widget(Paragraph::new(type_line), chunks[3]);
frame.render_widget(
field_line(
"Qty ",
&format!(
"{}{}",
state.qty_input,
if focused(&OrderField::Qty) { "▋" } else { "" }
),
field_style(&OrderField::Qty),
c.dim_style(),
),
chunks[4],
);
if !state.market_order {
frame.render_widget(
field_line(
"Price ",
&format!(
"{}{}",
state.price_input,
if focused(&OrderField::Price) {
"▋"
} else {
""
}
),
field_style(&OrderField::Price),
c.dim_style(),
),
chunks[5],
);
} else {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Price ", c.dim_style()),
Span::styled("N/A (Market order)", c.dim_style()),
])),
chunks[5],
);
}
let tif_line = Line::from(vec![
Span::styled(" TIF ", c.dim_style()),
radio(!state.gtc_order, "DAY", &c),
Span::raw(" "),
radio(state.gtc_order, "GTC", &c),
]);
frame.render_widget(Paragraph::new(tif_line), chunks[6]);
let est_total = estimate_total(state);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Est. Total ", c.dim_style()),
Span::styled(est_total, c.bold_style()),
])),
chunks[8],
);
let bp = app
.account
.as_ref()
.map(|a| format!("${:.2}", a.buying_power.parse::<f64>().unwrap_or(0.0)))
.unwrap_or_else(|| "—".into());
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Buying Power ", c.dim_style()),
Span::styled(bp, c.bold_style()),
])),
chunks[9],
);
if !market_open && !state.gtc_order {
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" ⚠ Market closed — switch to GTC or wait",
Style::default().fg(c.neutral).add_modifier(Modifier::BOLD),
)])),
chunks[11],
);
}
let market_closed_day = !market_open && !state.gtc_order;
let submit_style = if focused(&OrderField::Submit) && !market_closed_day {
Style::default()
.fg(c.accent)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else if market_closed_day {
c.dim_style()
} else {
Style::default()
};
let buttons = Line::from(vec![
Span::styled(" [ Submit Order ]", submit_style),
Span::raw(" "),
Span::styled("[ Esc: Cancel ]", c.dim_style()),
]);
frame.render_widget(Paragraph::new(buttons), chunks[12]);
frame.render_widget(
Paragraph::new(" Tab:Next ←/→:Toggle Enter:Advance Esc:Close").style(c.dim_style()),
chunks[13],
);
}
fn render_symbol_detail(frame: &mut Frame, area: Rect, symbol: &str, app: &App) {
let popup = popup_area(area, 55, 88);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let asset = app
.watchlist
.as_ref()
.and_then(|w| w.assets.iter().find(|a| a.symbol == symbol));
let name = asset.map(|a| a.name.as_str()).unwrap_or(symbol);
let in_watchlist = app
.watchlist
.as_ref()
.map(|w| w.assets.iter().any(|a| a.symbol == symbol))
.unwrap_or(false);
let block = Block::default()
.title(format!(" {} — {} ", symbol, name))
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.accent_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let quote = app.quotes.get(symbol);
let snapshot = app.snapshots.get(symbol);
let daily = snapshot.and_then(|s| s.daily_bar.as_ref());
let prev = snapshot.and_then(|s| s.prev_daily_bar.as_ref());
let price: Option<f64> = quote
.and_then(|q| match (q.ap, q.bp) {
(Some(a), Some(b)) => Some((a + b) / 2.0),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
_ => None,
})
.or_else(|| daily.map(|b| b.c));
let change_pct: Option<f64> = price.zip(prev.map(|b| b.c)).map(|(p, pc)| {
if pc != 0.0 {
(p - pc) / pc * 100.0
} else {
0.0
}
});
let price_str = price
.map(|p| format!("${:.2}", p))
.unwrap_or_else(|| "—".into());
let change_str = change_pct
.map(|c| format!("{:+.2}%", c))
.unwrap_or_else(|| "—".into());
let value_style = change_pct
.map(|pct| {
if pct >= 0.0 {
c.positive_style()
} else {
c.negative_style()
}
})
.unwrap_or_else(|| c.bold_style());
let open_str = daily
.map(|b| format!("${:.2}", b.o))
.unwrap_or_else(|| "—".into());
let high_str = daily
.map(|b| format!("${:.2}", b.h))
.unwrap_or_else(|| "—".into());
let low_str = daily
.map(|b| format!("${:.2}", b.l))
.unwrap_or_else(|| "—".into());
let vol_str = daily
.map(|b| crate::ui::watchlist::format_volume(b.v))
.unwrap_or_else(|| "—".into());
let wl_label = if in_watchlist {
"w:− Watchlist"
} else {
"w:+ Watchlist"
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(inner);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Price ", c.dim_style()),
Span::styled(price_str, value_style),
Span::raw(" "),
Span::styled("Change ", c.dim_style()),
Span::styled(change_str, value_style),
])),
chunks[1],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Open ", c.dim_style()),
Span::styled(open_str, c.bold_style()),
Span::raw(" "),
Span::styled("High ", c.dim_style()),
Span::styled(high_str, c.positive_style()),
])),
chunks[2],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Low ", c.dim_style()),
Span::styled(low_str, c.negative_style()),
Span::raw(" "),
Span::styled("Volume ", c.dim_style()),
Span::styled(vol_str, c.bold_style()),
])),
chunks[3],
);
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
" ── Intraday ──",
c.dim_style(),
)])),
chunks[5],
);
match app.intraday_bars.get(symbol) {
None => {
frame.render_widget(Paragraph::new(" Loading…").style(c.dim_style()), chunks[6]);
}
Some(bars) if bars.is_empty() => {
frame.render_widget(
Paragraph::new(" No intraday data available").style(c.dim_style()),
chunks[6],
);
}
Some(bars) => {
let data_points = charts::price_points(bars);
let n = data_points.len() as f64;
let [y_min, y_max] = charts::y_bounds(&data_points);
let line_color = charts::trend_color(&data_points, &c);
let dataset = Dataset::default()
.marker(symbols::Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(line_color))
.data(&data_points);
let chart = Chart::new(vec![dataset])
.x_axis(
Axis::default()
.bounds([0.0, (n - 1.0).max(0.0)])
.labels(["09:30", "16:00"]),
)
.y_axis(Axis::default().bounds([y_min, y_max]));
frame.render_widget(chart, chunks[6]);
}
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Exchange ", c.dim_style()),
Span::styled(
asset
.map(|a| a.exchange.as_str())
.unwrap_or("—")
.to_string(),
c.bold_style(),
),
Span::raw(" "),
Span::styled("Class ", c.dim_style()),
Span::styled(
asset
.map(|a| a.asset_class.as_str())
.unwrap_or("—")
.to_string(),
c.bold_style(),
),
])),
chunks[8],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Tradable ", c.dim_style()),
Span::styled(
flag(asset.map(|a| a.tradable).unwrap_or(false)),
c.positive_style(),
),
Span::raw(" "),
Span::styled("Shortable ", c.dim_style()),
Span::styled(
flag(asset.map(|a| a.shortable).unwrap_or(false)),
c.positive_style(),
),
])),
chunks[9],
);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" Fractional ", c.dim_style()),
Span::styled(
flag(asset.map(|a| a.fractionable).unwrap_or(false)),
c.positive_style(),
),
Span::raw(" "),
Span::styled("ETB ", c.dim_style()),
Span::styled(
flag(asset.map(|a| a.easy_to_borrow).unwrap_or(false)),
c.positive_style(),
),
])),
chunks[10],
);
frame.render_widget(
Paragraph::new(Line::from(vec![Span::styled(
format!(" o:Buy s:Sell {} Esc:Close", wl_label),
c.dim_style(),
)])),
chunks[12],
);
}
fn render_confirm(
frame: &mut Frame,
area: Rect,
message: &str,
_action: &ConfirmAction,
confirmed: bool,
app: &mut App,
) {
let popup = popup_area(area, 40, 25);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" Confirm ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.negative_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
app.hit_areas.modal_confirm_buttons = Some(chunks[2]);
frame.render_widget(
Paragraph::new(format!(" {}", message)).style(c.bold_style()),
chunks[0],
);
let yes_style = if confirmed {
Style::default()
.fg(c.positive)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
c.positive_style()
};
let no_style = if !confirmed {
Style::default()
.fg(c.negative)
.add_modifier(Modifier::BOLD | Modifier::REVERSED)
} else {
c.negative_style()
};
let buttons = Line::from(vec![
Span::styled(" [ y: Yes ]", yes_style),
Span::raw(" "),
Span::styled("[ n: No ]", no_style),
]);
frame.render_widget(Paragraph::new(buttons), chunks[2]);
}
fn render_confirm_remove_watchlist(frame: &mut Frame, area: Rect, symbol: &str, app: &mut App) {
let popup = popup_area(area, 42, 22);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" Remove from Watchlist ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.negative_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
app.hit_areas.modal_confirm_buttons = Some(chunks[2]);
frame.render_widget(
Paragraph::new(format!(" Remove {} from watchlist?", symbol)).style(c.bold_style()),
chunks[0],
);
let buttons = Line::from(vec![
Span::styled(" [y / Enter] Yes", c.positive_style()),
Span::raw(" "),
Span::styled("[n / Esc] No", c.negative_style()),
]);
frame.render_widget(Paragraph::new(buttons), chunks[2]);
}
fn render_add_symbol(frame: &mut Frame, area: Rect, input: &str, app: &App) {
let popup = popup_area(area, 35, 20);
frame.render_widget(Clear, popup);
let c = app.current_theme.colors();
let block = Block::default()
.title(" Add Symbol ")
.borders(Borders::ALL)
.border_type(BorderType::Double)
.border_style(c.accent_style());
let inner = block.inner(popup);
frame.render_widget(block, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(inner);
frame.render_widget(
Paragraph::new(" Enter ticker symbol:").style(c.dim_style()),
chunks[0],
);
let input_line = Line::from(vec![
Span::raw(" "),
Span::styled(input.to_string(), c.bold_style()),
Span::styled("▋", c.accent_style()),
]);
frame.render_widget(Paragraph::new(input_line), chunks[1]);
frame.render_widget(
Paragraph::new(" Enter:Add Esc:Cancel").style(c.dim_style()),
chunks[2],
);
}
fn field_line(label: &str, value: &str, style: Style, dim_style: Style) -> Paragraph<'static> {
Paragraph::new(Line::from(vec![
Span::styled(format!(" {:<8}", label), dim_style),
Span::styled(value.to_string(), style),
]))
}
fn radio(selected: bool, label: &str, c: &crate::ui::theme::ThemeColors) -> Span<'static> {
let marker = if selected { "● " } else { "○ " };
let style = if selected {
Style::default().fg(c.accent).add_modifier(Modifier::BOLD)
} else {
c.dim_style()
};
Span::styled(format!("{}{}", marker, label), style)
}
fn flag(v: bool) -> &'static str {
if v {
"✓"
} else {
"✗"
}
}
fn estimate_total(state: &OrderEntryState) -> String {
let qty: f64 = state.qty_input.parse().unwrap_or(0.0);
let price: f64 = state.price_input.parse().unwrap_or(0.0);
if qty > 0.0 && price > 0.0 {
format!("${:.2}", qty * price)
} else {
"—".into()
}
}
#[cfg(test)]
mod tests {
use ratatui::{backend::TestBackend, Terminal};
use super::*;
use crate::app::test_helpers::{make_test_app, make_watchlist};
fn render_symbol_detail_to_string(app: &mut App, symbol: &str) -> String {
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_symbol_detail(frame, area, symbol, app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let width = buffer.area().width as usize;
let height = buffer.area().height as usize;
(0..height)
.map(|row| {
(0..width)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn render_symbol_detail_shows_loading_when_no_bars_key() {
let mut app = make_test_app();
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("Loading"),
"should show Loading when intraday_bars has no entry for symbol"
);
}
#[test]
fn render_symbol_detail_shows_no_data_when_bars_empty() {
let mut app = make_test_app();
app.intraday_bars.insert("AAPL".into(), vec![]);
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("No intraday data"),
"should show 'No intraday data available' when bars vec is empty"
);
}
#[test]
fn render_symbol_detail_renders_line_chart_with_bars() {
let mut app = make_test_app();
app.intraday_bars
.insert("AAPL".into(), vec![15000, 15050, 15100, 15080, 15120]);
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
!output.contains("Loading"),
"should not show Loading when bars are present"
);
assert!(
!output.contains("No intraday data"),
"should not show no-data message when bars are present"
);
}
#[test]
fn render_symbol_detail_shows_ohlcv_labels() {
let mut app = make_test_app();
let output = render_symbol_detail_to_string(&mut app, "TSLA");
assert!(output.contains("Price"), "should show Price label");
assert!(output.contains("Open"), "should show Open label");
assert!(output.contains("High"), "should show High label");
assert!(output.contains("Low"), "should show Low label");
assert!(output.contains("Volume"), "should show Volume label");
}
#[test]
fn render_symbol_detail_shows_footer_actions() {
let mut app = make_test_app();
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(output.contains("o:Buy"), "footer should contain buy action");
assert!(
output.contains("s:Sell"),
"footer should contain sell action"
);
assert!(
output.contains("Esc:Close"),
"footer should contain close hint"
);
}
#[test]
fn render_symbol_detail_shows_watchlist_label_not_in_watchlist() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["TSLA"]));
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("w:+"),
"footer should show 'w:+ Watchlist' when symbol not in watchlist"
);
}
#[test]
fn render_symbol_detail_shows_watchlist_label_in_watchlist() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["AAPL", "TSLA"]));
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("w:\u{2212}") || output.contains("w:-"),
"footer should show 'w:− Watchlist' when symbol is in watchlist"
);
}
#[test]
fn render_symbol_detail_uses_asset_name_in_title() {
let mut app = make_test_app();
app.watchlist = Some(make_watchlist(&["AAPL"]));
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("AAPL Corp"),
"title should include asset name from watchlist"
);
}
#[test]
fn render_symbol_detail_falls_back_to_symbol_as_name() {
let mut app = make_test_app();
let output = render_symbol_detail_to_string(&mut app, "NVDA");
assert!(
output.contains("NVDA"),
"title should contain symbol when no asset info available"
);
}
#[test]
fn render_symbol_detail_with_quote_shows_price() {
use crate::types::Quote;
let mut app = make_test_app();
app.quotes.insert(
"AAPL".into(),
Quote {
symbol: "AAPL".into(),
ap: Some(185.50),
bp: Some(185.40),
..Default::default()
},
);
let output = render_symbol_detail_to_string(&mut app, "AAPL");
assert!(
output.contains("185.45"),
"should display midpoint price from quote"
);
}
fn render_order_entry_to_string(app: &mut App, state: crate::app::OrderEntryState) -> String {
let backend = TestBackend::new(120, 30);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
let modal = Modal::OrderEntry(state);
render(frame, area, &modal, app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let width = buffer.area().width as usize;
let height = buffer.area().height as usize;
(0..height)
.map(|row| {
(0..width)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn render_order_entry_buy_shows_buy_selected() {
use crate::app::{OrderEntryState, OrderSide};
let mut app = make_test_app();
let mut state = OrderEntryState::new("AAPL".into());
state.side = OrderSide::Buy;
let output = render_order_entry_to_string(&mut app, state);
assert!(output.contains("BUY"), "order entry should show BUY option");
assert!(
output.contains("SELL"),
"order entry should show SELL option"
);
assert!(
output.contains("SELL SHORT"),
"order entry should show SELL SHORT option"
);
}
#[test]
fn render_order_entry_sell_short_shows_sell_short_selected() {
use crate::app::{OrderEntryState, OrderSide};
let mut app = make_test_app();
let mut state = OrderEntryState::new("TSLA".into());
state.side = OrderSide::SellShort;
let output = render_order_entry_to_string(&mut app, state);
assert!(
output.contains("SELL SHORT"),
"order entry with SellShort should display SELL SHORT option"
);
}
fn render_about_to_string() -> String {
let app = make_test_app();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_about(frame, area, &app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let width = buffer.area().width as usize;
let height = buffer.area().height as usize;
(0..height)
.map(|row| {
(0..width)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn render_about_shows_app_name() {
let output = render_about_to_string();
assert!(
output.contains("alpaca-trader-rs"),
"About modal should display the app name"
);
}
#[test]
fn render_about_shows_version() {
let output = render_about_to_string();
let expected = format!("v{}", env!("CARGO_PKG_VERSION"));
assert!(
output.contains(&expected),
"About modal should display the version"
);
}
#[test]
fn render_about_shows_author() {
let output = render_about_to_string();
assert!(
output.contains("Arunkumar"),
"About modal should display the author name"
);
}
#[test]
fn render_about_shows_license() {
let output = render_about_to_string();
assert!(
output.contains("MIT"),
"About modal should display the license"
);
}
#[test]
fn render_about_shows_close_hint() {
let output = render_about_to_string();
assert!(
output.contains("Press any key to close"),
"About modal should display close hint"
);
}
#[test]
fn render_dispatch_about_modal() {
let mut app = make_test_app();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render(frame, area, &Modal::About, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let output: String = (0..buffer.area().height as usize)
.map(|row| {
(0..buffer.area().width as usize)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
output.contains("alpaca-trader-rs"),
"render() with Modal::About should display app name"
);
}
fn render_confirm_remove_watchlist_to_string(symbol: &str) -> String {
let mut app = make_test_app();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render_confirm_remove_watchlist(frame, area, symbol, &mut app);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let width = buffer.area().width as usize;
let height = buffer.area().height as usize;
(0..height)
.map(|row| {
(0..width)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn render_confirm_remove_watchlist_shows_title() {
let output = render_confirm_remove_watchlist_to_string("AAPL");
assert!(
output.contains("Remove from Watchlist"),
"modal title should say 'Remove from Watchlist', got: {output}"
);
}
#[test]
fn render_confirm_remove_watchlist_shows_symbol() {
let output = render_confirm_remove_watchlist_to_string("TSLA");
assert!(
output.contains("TSLA"),
"modal should display the symbol being removed, got: {output}"
);
}
#[test]
fn render_confirm_remove_watchlist_shows_yes_button() {
let output = render_confirm_remove_watchlist_to_string("AAPL");
assert!(
output.contains("Yes"),
"modal should show Yes button, got: {output}"
);
}
#[test]
fn render_confirm_remove_watchlist_shows_no_button() {
let output = render_confirm_remove_watchlist_to_string("AAPL");
assert!(
output.contains("No"),
"modal should show No button, got: {output}"
);
}
#[test]
fn render_dispatch_confirm_remove_watchlist_modal() {
let mut app = make_test_app();
let backend = TestBackend::new(120, 40);
let mut terminal = Terminal::new(backend).unwrap();
terminal
.draw(|frame| {
let area = frame.area();
render(
frame,
area,
&Modal::ConfirmRemoveWatchlist {
symbol: "NVDA".into(),
watchlist_id: "wl-1".into(),
},
&mut app,
);
})
.unwrap();
let buffer = terminal.backend().buffer().clone();
let output: String = (0..buffer.area().height as usize)
.map(|row| {
(0..buffer.area().width as usize)
.map(|col| {
buffer
.cell(ratatui::layout::Position {
x: col as u16,
y: row as u16,
})
.map(|c| c.symbol().to_string())
.unwrap_or_default()
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(
output.contains("NVDA"),
"render() with Modal::ConfirmRemoveWatchlist should display the symbol"
);
}
}