use alloc::vec::Vec;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::render::RenderContext;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::special_widgets::finance::types::{BookLevel, OrderBook};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BookSide {
Bid,
Ask,
}
pub const DEFAULT_BOOK_DEPTH: usize = 5;
pub struct OrderBookWidget {
base: BaseWidget,
book: OrderBook,
depth: usize,
decimals: Option<usize>,
bid_color: Color,
ask_color: Color,
text_color: Color,
hovered: Option<(BookSide, usize)>,
pub level_clicked: crate::signal::Signal1<(BookSide, usize)>,
pub level_hovered: crate::signal::Signal1<(BookSide, usize)>,
pub level_unhovered: crate::signal::Signal1<(BookSide, usize)>,
}
impl OrderBookWidget {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::OrderBook, geometry, "OrderBook"),
book: OrderBook::new(),
depth: DEFAULT_BOOK_DEPTH,
decimals: None,
bid_color: Color::rgb(38, 166, 91),
ask_color: Color::rgb(220, 68, 70),
text_color: Color::rgb(214, 220, 228),
hovered: None,
level_clicked: crate::signal::Signal1::new(),
level_hovered: crate::signal::Signal1::new(),
level_unhovered: crate::signal::Signal1::new(),
}
}
pub fn book(&self) -> &OrderBook {
&self.book
}
pub fn set_book(&mut self, book: OrderBook) {
self.book = book;
if let Some((side, index)) = self.hovered {
let count = match side {
BookSide::Bid => self.book.bids().len(),
BookSide::Ask => self.book.asks().len(),
};
if index >= count {
self.hovered = None;
}
}
self.base.request_redraw();
}
pub fn depth(&self) -> usize {
self.depth
}
pub fn set_depth(&mut self, depth: usize) {
self.depth = depth;
if let Some((_, index)) = self.hovered {
if index >= depth {
self.hovered = None;
}
}
self.base.request_redraw();
}
pub fn decimals(&self) -> Option<usize> {
self.decimals
}
pub fn set_decimals(&mut self, decimals: Option<usize>) {
self.decimals = decimals.map(|value| value.min(6));
self.base.request_redraw();
}
pub fn colors(&self) -> (Color, Color) {
(self.bid_color, self.ask_color)
}
pub fn set_colors(&mut self, bid: Color, ask: Color) {
self.bid_color = bid;
self.ask_color = ask;
self.base.request_redraw();
}
pub fn hovered(&self) -> Option<(BookSide, usize)> {
self.hovered
}
pub fn levels(&self, side: BookSide) -> Vec<BookLevel> {
let levels = match side {
BookSide::Bid => self.book.bids(),
BookSide::Ask => self.book.asks(),
};
levels.iter().take(self.depth).copied().collect()
}
fn effective_decimals(&self) -> usize {
if let Some(decimals) = self.decimals {
return decimals;
}
for side in [BookSide::Bid, BookSide::Ask] {
for level in self.levels(side) {
if !level.price.is_finite() {
continue;
}
for decimals in 0..=6usize {
let scaled = level.price * 10f64.powi(decimals as i32);
if (scaled - scaled.round()).abs() < 1e-6 {
return decimals;
}
}
}
}
2
}
fn row_height(&self) -> i32 {
let height = self.base.geometry().height as i32;
let rows = (self.depth * 2).max(1) as i32;
(height / rows).max(1)
}
fn row_at(&self, y: i32) -> Option<(BookSide, usize)> {
let geometry = self.base.geometry();
let row_height = self.row_height();
let offset = y - geometry.y;
if offset < 0 {
return None;
}
let row = (offset / row_height) as usize;
let bid_rows = self.levels(BookSide::Bid).len();
if row < bid_rows {
return Some((BookSide::Bid, row));
}
let ask_row = row - self.levels(BookSide::Bid).len();
if ask_row < self.levels(BookSide::Ask).len() {
return Some((BookSide::Ask, ask_row));
}
None
}
}
fn format_price(value: f64, decimals: usize) -> alloc::string::String {
if !value.is_finite() {
return alloc::string::String::from("—");
}
let factor = 10f64.powi(decimals as i32);
let scaled = (value * factor).round() as i64;
let negative = scaled < 0;
let digits = scaled.unsigned_abs().to_string();
if decimals == 0 {
return if negative { alloc::format!("-{digits}") } else { digits };
}
let padded = if digits.len() <= decimals {
let mut padded = alloc::string::String::new();
for _ in 0..(decimals - digits.len()) {
padded.push('0');
}
padded.push_str(&digits);
padded
} else {
digits
};
let split = padded.len() - decimals;
let sign = if negative { "-" } else { "" };
alloc::format!("{sign}{}.{}", &padded[..split], &padded[split..])
}
impl Widget for OrderBookWidget {
fn base(&self) -> &BaseWidget {
&self.base
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn as_draw_mut(&mut self) -> Option<&mut dyn Draw> {
Some(self)
}
impl_widget_property_hooks!();
}
impl crate::event::EventHandler for OrderBookWidget {
fn handle_event(&mut self, event: &crate::event::Event) {
use crate::event::Event;
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
let next = self.row_at(pos.y);
if next != self.hovered {
if let Some(previous) = self.hovered {
self.level_unhovered.emit(previous);
}
if let Some(row) = next {
self.level_hovered.emit(row);
}
self.hovered = next;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if let Some(previous) = self.hovered.take() {
self.level_unhovered.emit(previous);
self.base.request_redraw();
}
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
if let Some(row) = self.row_at(pos.y) {
self.level_clicked.emit(row);
}
}
_ => {}
}
self.base.handle_event(event);
}
}
impl Draw for OrderBookWidget {
fn draw(&mut self, context: &mut RenderContext) {
let geometry = self.base.geometry();
if geometry.width == 0 || geometry.height == 0 || self.depth == 0 {
return;
}
context.fill_rect(geometry, Color::rgb(18, 22, 28));
let max_quantity = self.book.max_level_quantity();
let row_height = self.row_height();
let decimals = self.effective_decimals();
let spine = geometry.x + geometry.width as i32 / 2;
let bar_span = (geometry.width as i32 / 2 - 40).max(8);
let mut row = 0;
for side in [BookSide::Bid, BookSide::Ask] {
let levels = self.levels(side);
let color = if side == BookSide::Bid { self.bid_color } else { self.ask_color };
for (index, level) in levels.iter().enumerate() {
let y = geometry.y + row * row_height;
let is_hovered = self.hovered == Some((side, index));
if is_hovered {
context.fill_rect(
Rect::new(geometry.x, y, geometry.width, row_height as u32),
Color::rgb(38, 46, 58),
);
}
if max_quantity > 0.0 && level.quantity.is_finite() && level.quantity > 0.0 {
let length =
((level.quantity / max_quantity) * bar_span as f64).round().max(1.0) as i32;
let (bar_x, bar_width) = match side {
BookSide::Bid => (spine - length, length as u32),
BookSide::Ask => (spine, length as u32),
};
context.fill_rect(
Rect::new(bar_x, y + 1, bar_width, (row_height - 2).max(1) as u32),
color,
);
}
let price_text = format_price(level.price, decimals);
let quantity_text = if level.quantity.is_finite() {
alloc::format!("{:.0}", level.quantity)
} else {
alloc::string::String::from("—")
};
let (price_x, quantity_x) = match side {
BookSide::Bid => (geometry.x + 6, spine - bar_span - 46),
BookSide::Ask => {
(geometry.x + geometry.width as i32 - 58, spine + bar_span + 4)
}
};
let text_y = y + (row_height - 12) / 2;
context.draw_text(
Point { x: price_x, y: text_y },
&price_text,
&Font::simple("Sans", 11.0),
color,
HorizontalAlignment::Left,
);
if quantity_x > geometry.x && quantity_x < geometry.x + geometry.width as i32 - 8 {
context.draw_text(
Point { x: quantity_x, y: text_y },
&quantity_text,
&Font::simple("Sans", 11.0),
self.text_color,
HorizontalAlignment::Left,
);
}
row += 1;
}
}
if let Some(spread) = self.book.spread() {
let y = geometry.y + self.levels(BookSide::Bid).len() as i32 * row_height;
context.draw_line_stroke(
Point { x: geometry.x, y },
Point { x: geometry.x + geometry.width as i32, y },
Color::rgb(120, 120, 120),
1,
);
let text = alloc::format!("spread {}", format_price(spread, decimals));
context.draw_text(
Point { x: geometry.x + 6, y: y - 13 },
&text,
&Font::simple("Sans", 10.0),
Color::rgb(158, 158, 158),
HorizontalAlignment::Left,
);
}
}
}
impl WidgetProperties for OrderBookWidget {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"depth" => Ok(CapabilityValue::UInt(self.depth as u64)),
"decimals" => match self.decimals {
Some(decimals) => Ok(CapabilityValue::UInt(decimals as u64)),
None => Ok(CapabilityValue::Null),
},
"show_spread" => Ok(CapabilityValue::Bool(true)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"depth" => {
let CapabilityValue::UInt(depth) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_depth(depth as usize);
Ok(())
}
"decimals" => match value {
CapabilityValue::UInt(decimals) => {
self.set_decimals(Some(decimals as usize));
Ok(())
}
CapabilityValue::Null => {
self.set_decimals(None);
Ok(())
}
_ => Err(CapabilityAccessError::TypeMismatch),
},
"show_spread" => {
let CapabilityValue::Bool(_) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
Err(CapabilityAccessError::ReadOnlyProperty)
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["depth", "decimals", "show_spread", BASE_PROPERTY_NAMES]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::EventHandler;
use crate::widget::special_widgets::finance::types::fixtures;
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn a_ladder_carries_its_book() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
assert!(ladder.book().bids().is_empty());
ladder.set_book(fixtures::sample_book(5));
assert_eq!(ladder.book().bids().len(), 5);
}
#[test]
fn the_default_depth_is_five() {
let ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
assert_eq!(ladder.depth(), DEFAULT_BOOK_DEPTH);
assert_eq!(ladder.depth(), 5);
}
#[test]
fn levels_are_capped_at_the_depth() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
ladder.set_book(fixtures::sample_book(10));
assert_eq!(ladder.levels(BookSide::Bid).len(), 5);
assert_eq!(ladder.levels(BookSide::Ask).len(), 5);
ladder.set_depth(2);
assert_eq!(ladder.levels(BookSide::Bid).len(), 2);
assert_eq!(ladder.levels(BookSide::Ask).len(), 2);
}
#[test]
fn both_sides_are_ordered_best_first() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
let mut book = crate::widget::special_widgets::finance::types::OrderBook::new();
book.set_bids(alloc::vec![
crate::widget::special_widgets::finance::types::BookLevel::new(97.0, 1.0),
crate::widget::special_widgets::finance::types::BookLevel::new(99.0, 1.0),
crate::widget::special_widgets::finance::types::BookLevel::new(98.0, 1.0),
]);
book.set_asks(alloc::vec![
crate::widget::special_widgets::finance::types::BookLevel::new(103.0, 1.0),
crate::widget::special_widgets::finance::types::BookLevel::new(101.0, 1.0),
crate::widget::special_widgets::finance::types::BookLevel::new(102.0, 1.0),
]);
ladder.set_book(book);
let bids = ladder.levels(BookSide::Bid);
let asks = ladder.levels(BookSide::Ask);
assert!((bids[0].price - 99.0).abs() < 1e-9, "the best bid is the highest");
assert!(bids[0].price > bids[1].price && bids[1].price > bids[2].price);
assert!((asks[0].price - 101.0).abs() < 1e-9, "the best ask is the lowest");
assert!(asks[0].price < asks[1].price && asks[1].price < asks[2].price);
}
#[test]
fn decimals_can_be_forced_and_are_capped() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
ladder.set_book(fixtures::sample_book(5));
assert_eq!(ladder.decimals(), None, "inferred by default");
ladder.set_decimals(Some(2));
assert_eq!(ladder.decimals(), Some(2));
ladder.set_decimals(Some(99));
assert_eq!(ladder.decimals(), Some(6), "capped at the readable maximum");
ladder.set_decimals(None);
assert_eq!(ladder.decimals(), None);
}
#[test]
fn colours_round_trip() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
let (bid, ask) = ladder.colors();
assert_ne!(bid, ask);
ladder.set_colors(crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6));
assert_eq!(
ladder.colors(),
(crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6))
);
}
#[test]
fn an_empty_book_draws_without_panicking() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
ladder.draw(&mut context);
}
#[test]
fn a_zero_depth_draws_nothing() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
ladder.set_book(fixtures::sample_book(5));
ladder.set_depth(0);
assert!(ladder.levels(BookSide::Bid).is_empty());
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
ladder.draw(&mut context);
}
#[test]
fn a_malformed_book_draws_without_panicking() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
let mut book = crate::widget::special_widgets::finance::types::OrderBook::new();
book.set_bids(alloc::vec![
crate::widget::special_widgets::finance::types::BookLevel::new(f64::NAN, f64::NAN),
crate::widget::special_widgets::finance::types::BookLevel::new(99.0, f64::INFINITY),
]);
ladder.set_book(book);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
ladder.draw(&mut context);
}
#[test]
fn clicking_a_row_emits_its_position() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
ladder.set_book(fixtures::sample_book(5));
let count = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&count);
ladder.level_clicked.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
ladder.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 100, y: 10 },
button: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 1, "a press on a row emits it");
}
#[test]
fn replacing_a_shorter_book_clears_a_stale_hover() {
let mut ladder = OrderBookWidget::new(Rect::new(0, 0, 320, 240));
ladder.set_book(fixtures::sample_book(5));
ladder.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 100, y: 10 },
});
ladder.set_book(fixtures::sample_book(1));
assert!(
ladder.hovered().is_none_or(|(_, index)| index < 1),
"a hover must not survive past the book it pointed into"
);
}
}