use alloc::string::String;
use alloc::vec::Vec;
use crate::core::{Color, Font, HorizontalAlignment, Point, Rect};
use crate::render::RenderContext;
use crate::signal::Signal1;
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::layout::PlotArea;
use crate::widget::special_widgets::finance::types::Quote;
use crate::widget::special_widgets::finance::volume_chart::draw_empty_pane;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum QuoteColumn {
Symbol,
Name,
Last,
Change,
ChangePercent,
High,
Low,
Volume,
}
impl QuoteColumn {
pub fn title(self) -> &'static str {
match self {
QuoteColumn::Symbol => "Symbol",
QuoteColumn::Name => "Name",
QuoteColumn::Last => "Last",
QuoteColumn::Change => "Change",
QuoteColumn::ChangePercent => "Chg%",
QuoteColumn::High => "High",
QuoteColumn::Low => "Low",
QuoteColumn::Volume => "Volume",
}
}
pub fn is_signed(self) -> bool {
matches!(self, QuoteColumn::Change | QuoteColumn::ChangePercent)
}
pub fn is_numeric(self) -> bool {
!matches!(self, QuoteColumn::Symbol | QuoteColumn::Name)
}
pub fn as_str(self) -> &'static str {
match self {
QuoteColumn::Symbol => "symbol",
QuoteColumn::Name => "name",
QuoteColumn::Last => "last",
QuoteColumn::Change => "change",
QuoteColumn::ChangePercent => "change_percent",
QuoteColumn::High => "high",
QuoteColumn::Low => "low",
QuoteColumn::Volume => "volume",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"symbol" => QuoteColumn::Symbol,
"name" => QuoteColumn::Name,
"last" => QuoteColumn::Last,
"change" => QuoteColumn::Change,
"change_percent" => QuoteColumn::ChangePercent,
"high" => QuoteColumn::High,
"low" => QuoteColumn::Low,
"volume" => QuoteColumn::Volume,
_ => return None,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QuoteSort {
#[default]
None,
Symbol,
LastDescending,
ChangeMagnitude,
}
pub struct QuoteBoard {
base: BaseWidget,
quotes: Vec<Quote>,
original_order: Vec<Quote>,
columns: Vec<QuoteColumn>,
sort: QuoteSort,
hovered: Option<usize>,
selected: Option<usize>,
rising_color: Color,
falling_color: Color,
text_color: Color,
pub quote_clicked: Signal1<String>,
pub quote_hovered: Signal1<String>,
pub selection_changed: Signal1<Option<String>>,
}
impl QuoteBoard {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::QuoteBoard, geometry, "QuoteBoard"),
quotes: Vec::new(),
original_order: Vec::new(),
columns: alloc::vec![
QuoteColumn::Symbol,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::Volume,
],
sort: QuoteSort::default(),
hovered: None,
selected: None,
rising_color: Color::rgb(38, 166, 91),
falling_color: Color::rgb(220, 68, 70),
text_color: Color::rgb(214, 220, 228),
quote_clicked: Signal1::new(),
quote_hovered: Signal1::new(),
selection_changed: Signal1::new(),
}
}
pub fn quotes(&self) -> &[Quote] {
&self.quotes
}
pub fn set_quotes(&mut self, quotes: Vec<Quote>) {
self.quotes = quotes;
self.original_order = self.quotes.clone();
self.apply_sort();
self.revalidate_selection();
self.base.request_redraw();
}
pub fn columns(&self) -> &[QuoteColumn] {
&self.columns
}
pub fn set_columns(&mut self, columns: Vec<QuoteColumn>) {
self.columns = columns;
self.base.request_redraw();
}
pub fn sort(&self) -> QuoteSort {
self.sort
}
pub fn set_sort(&mut self, sort: QuoteSort) {
self.sort = sort;
self.apply_sort();
self.revalidate_selection();
self.base.request_redraw();
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_symbol(&self) -> Option<&str> {
self.selected.and_then(|index| self.quotes.get(index)).map(|quote| quote.symbol.as_str())
}
pub fn select(&mut self, index: Option<usize>) {
let next = index.filter(|value| *value < self.quotes.len());
if next == self.selected {
return;
}
self.selected = next;
let symbol = self.selected_symbol().map(String::from);
self.selection_changed.emit(symbol);
self.base.request_redraw();
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered
}
pub fn colors(&self) -> (Color, Color) {
(self.rising_color, self.falling_color)
}
pub fn set_colors(&mut self, rising: Color, falling: Color) {
self.rising_color = rising;
self.falling_color = falling;
self.base.request_redraw();
}
fn apply_sort(&mut self) {
match self.sort {
QuoteSort::None => {
if self.original_order.len() == self.quotes.len() {
self.quotes.clone_from(&self.original_order);
}
}
QuoteSort::Symbol => {
self.quotes.sort_by(|left, right| left.symbol.cmp(&right.symbol));
}
QuoteSort::LastDescending => {
self.quotes.sort_by(|left, right| {
right.last.partial_cmp(&left.last).unwrap_or(core::cmp::Ordering::Equal)
});
}
QuoteSort::ChangeMagnitude => {
self.quotes.sort_by(|left, right| {
let left_magnitude = left.change().abs();
let right_magnitude = right.change().abs();
right_magnitude
.partial_cmp(&left_magnitude)
.unwrap_or(core::cmp::Ordering::Equal)
});
}
}
}
fn revalidate_selection(&mut self) {
if self.selected.is_some_and(|index| index >= self.quotes.len()) {
self.selected = None;
}
if self.hovered.is_some_and(|index| index >= self.quotes.len()) {
self.hovered = None;
}
}
fn row_height(&self) -> i32 {
22
}
fn first_row_y(&self) -> i32 {
self.base.geometry().y + self.row_height() + 1
}
fn row_at(&self, y: i32) -> Option<usize> {
let geometry = self.base.geometry();
if y < geometry.y || y >= geometry.y + geometry.height as i32 {
return None;
}
let offset = y - self.first_row_y();
if offset < 0 {
return None;
}
let index = (offset / self.row_height()) as usize;
if index < self.quotes.len() {
Some(index)
} else {
None
}
}
fn column_ranges(&self) -> Vec<(i32, i32)> {
let geometry = self.base.geometry();
let width = geometry.width as i32;
let count = self.columns.len().max(1) as i32;
let slot = width / count;
(0..self.columns.len() as i32)
.map(|index| {
let start = geometry.x + index * slot;
let column_width =
if index == count - 1 { geometry.x + width - start } else { slot };
(start, column_width)
})
.collect()
}
fn cell_text(&self, quote: &Quote, column: QuoteColumn, decimals: usize) -> String {
match column {
QuoteColumn::Symbol => quote.symbol.clone(),
QuoteColumn::Name => quote.name.clone(),
QuoteColumn::Last => format_decimal(quote.last, decimals),
QuoteColumn::Change => {
let change = quote.change();
if change.is_finite() {
let sign = if change >= 0.0 { "+" } else { "" };
alloc::format!("{sign}{}", format_decimal(change, decimals))
} else {
String::from("—")
}
}
QuoteColumn::ChangePercent => {
let percent = quote.change_percent();
if percent.is_finite() {
let sign = if percent >= 0.0 { "+" } else { "" };
alloc::format!("{sign}{:.2}%", percent)
} else {
String::from("—")
}
}
QuoteColumn::High => format_decimal(quote.high, decimals),
QuoteColumn::Low => format_decimal(quote.low, decimals),
QuoteColumn::Volume => format_volume(quote.volume),
}
}
fn inferred_decimals(&self) -> usize {
for quote in &self.quotes {
if !quote.last.is_finite() {
continue;
}
for decimals in 0..=4usize {
let scaled = quote.last * 10f64.powi(decimals as i32);
if (scaled - scaled.round()).abs() < 1e-6 {
return decimals;
}
}
}
2
}
}
fn format_decimal(value: f64, decimals: usize) -> String {
if !value.is_finite() {
return 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 = 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..])
}
fn format_volume(volume: f64) -> String {
if !volume.is_finite() {
return String::from("—");
}
if volume.abs() >= 1_000_000_000.0 {
alloc::format!("{:.2}B", volume / 1_000_000_000.0)
} else if volume.abs() >= 1_000_000.0 {
alloc::format!("{:.2}M", volume / 1_000_000.0)
} else if volume.abs() >= 1_000.0 {
alloc::format!("{:.2}K", volume / 1_000.0)
} else {
alloc::format!("{volume:.0}")
}
}
impl Widget for QuoteBoard {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
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 QuoteBoard {
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(index) = next {
if let Some(quote) = self.quotes.get(index) {
self.quote_hovered.emit(quote.symbol.clone());
}
}
self.hovered = next;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } if self.hovered.take().is_some() => {
self.base.request_redraw();
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
if let Some(index) = self.row_at(pos.y) {
if let Some(quote) = self.quotes.get(index) {
self.quote_clicked.emit(quote.symbol.clone());
}
self.select(Some(index));
}
}
_ => {}
}
self.base.handle_event(event);
}
}
impl Draw for QuoteBoard {
fn draw(&mut self, context: &mut RenderContext) {
let geometry = self.base.geometry();
if geometry.width == 0 || geometry.height == 0 {
return;
}
let style = self.base.style().clone();
let theme = crate::style::resolved_theme_style("quote_board");
let (window_fill, foreground, secondary) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => {
(active.colors.background, active.colors.foreground, active.colors.secondary)
}
None => (Color::rgb(240, 240, 240), Color::BLACK, Color::rgb(158, 158, 158)),
}
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let panel = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.08),
};
context.fill_rect(geometry, panel);
if self.columns.is_empty() {
return;
}
let header_fill = panel.blend(&ink, 0.10);
let selected_fill = panel.blend(&foreground, 0.24);
let hovered_fill = panel.blend(&ink, 0.10);
let header_text = ink.blend(&secondary, 0.45);
let body_text = secondary;
let ranges = self.column_ranges();
let row_height = self.row_height();
let decimals = self.inferred_decimals();
context.fill_rect(
Rect::new(geometry.x, geometry.y, geometry.width, row_height as u32),
header_fill,
);
for (column, (start, width)) in self.columns.iter().zip(ranges.iter()) {
let font = Font::simple("Sans", HEADING_FONT_SIZE);
let title = column.title();
let label_x = heading_origin(*start, *width, title, &font);
if heading_fits(*width, title, &font) {
context.draw_text(
Point { x: label_x, y: geometry.y + 5 },
title,
&font,
header_text,
HorizontalAlignment::Left,
);
} else {
let band = Rect::new(
*start,
geometry.y + 5,
(*width).max(1) as u32,
font.size().max(1.0) as u32,
);
context.draw_text_fitted(
band,
title,
&font,
header_text,
HorizontalAlignment::Left,
);
}
}
let available = ((geometry.height as i32 - row_height) / row_height).max(0) as usize;
if self.quotes.is_empty() {
let body = Rect::new(
geometry.x,
geometry.y + row_height,
geometry.width,
geometry.height.saturating_sub(row_height as u32),
);
if body.height > 0 {
draw_empty_pane(
context,
&PlotArea::of(body),
crate::widget::special_widgets::finance::layout::PanelColors {
grid: panel.blend(&secondary, 0.45),
..crate::widget::special_widgets::finance::layout::PanelColors::from_parts(
panel, ink,
)
},
);
}
return;
}
for (index, quote) in self.quotes.iter().enumerate().take(available) {
let y = self.first_row_y() + index as i32 * row_height;
if self.selected == Some(index) {
context.fill_rect(
Rect::new(geometry.x, y, geometry.width, row_height as u32),
selected_fill,
);
} else if self.hovered == Some(index) {
context.fill_rect(
Rect::new(geometry.x, y, geometry.width, row_height as u32),
hovered_fill,
);
}
for (column, (start, width)) in self.columns.iter().zip(ranges.iter()) {
let start = *start;
let width = *width;
let text = self.cell_text(quote, *column, decimals);
let color = if column.is_signed() {
if quote.is_up() {
self.rising_color
} else {
self.falling_color
}
} else if *column == QuoteColumn::Symbol {
self.text_color
} else {
body_text
};
let font = Font::simple("Sans", BODY_FONT_SIZE);
let text_width = measure_body_text(&text, &font) as i32;
let cell = if column.is_numeric() {
Rect::new(start + width - 8 - text_width, y, text_width.max(1) as u32, 0)
} else {
Rect::new(start + 8, y, (width - 16).max(1) as u32, 0)
};
let line = context.text_line(Rect { height: ROW_TEXT_HEIGHT, ..cell }, &font);
context.draw_text_fitted(line, &text, &font, color, HorizontalAlignment::Left);
}
}
}
}
const BODY_FONT_SIZE: f32 = 11.0;
const HEADING_FONT_SIZE: f32 = BODY_FONT_SIZE;
const ROW_TEXT_HEIGHT: u32 = 16;
fn measure_body_text(text: &str, font: &Font) -> u32 {
crate::widget::metrics::estimate_text_width(text, font, 1.0)
}
fn heading_origin(start: i32, width: i32, title: &str, font: &Font) -> i32 {
let title_width = measure_body_text(title, font) as i32;
let inset = start + 8;
let right_most = start + width - 8 - title_width;
if right_most >= inset {
inset
} else {
right_most.max(start)
}
}
fn heading_fits(width: i32, title: &str, font: &Font) -> bool {
measure_body_text(title, font) as i32 + 16 <= width
}
impl WidgetProperties for QuoteBoard {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"sort" => {
let token = match self.sort {
QuoteSort::None => "none",
QuoteSort::Symbol => "symbol",
QuoteSort::LastDescending => "last_descending",
QuoteSort::ChangeMagnitude => "change_magnitude",
};
Ok(CapabilityValue::String(alloc::string::String::from(token)))
}
"selected_index" => match self.selected {
Some(index) => Ok(CapabilityValue::UInt(index as u64)),
None => Ok(CapabilityValue::Null),
},
"row_height" => Ok(CapabilityValue::UInt(self.row_height() as u64)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"sort" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
let sort = match text.as_str() {
"none" => QuoteSort::None,
"symbol" => QuoteSort::Symbol,
"last_descending" => QuoteSort::LastDescending,
"change_magnitude" => QuoteSort::ChangeMagnitude,
_ => return Err(CapabilityAccessError::OutOfRange),
};
self.set_sort(sort);
Ok(())
}
"selected_index" => {
match value {
CapabilityValue::UInt(index) => {
if index as usize >= self.quotes.len() {
return Err(CapabilityAccessError::OutOfRange);
}
self.select(Some(index as usize));
}
CapabilityValue::Null => self.select(None),
_ => return Err(CapabilityAccessError::TypeMismatch),
}
Ok(())
}
"row_height" => Err(CapabilityAccessError::ReadOnlyProperty),
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["sort", "selected_index", "row_height", BASE_PROPERTY_NAMES]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"add_overlay" => Err(CapabilityAccessError::UnsupportedOnWidget),
"set_series" => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::{Event, EventHandler};
use crate::widget::special_widgets::finance::types::{fixtures, Quote};
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn a_board_carries_its_quotes() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
assert!(board.quotes().is_empty());
board.set_quotes(fixtures::sample_quotes(8));
assert_eq!(board.quotes().len(), 8);
}
#[test]
fn the_default_columns_are_the_usual_set() {
let board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
assert_eq!(
board.columns(),
&[
QuoteColumn::Symbol,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::Volume,
]
);
}
#[test]
fn columns_can_be_replaced() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_columns(alloc::vec![QuoteColumn::Symbol, QuoteColumn::Name]);
assert_eq!(board.columns().len(), 2);
board.set_columns(alloc::vec![]);
assert!(board.columns().is_empty(), "an empty list is honoured as given");
}
#[test]
fn column_names_round_trip() {
for column in [
QuoteColumn::Symbol,
QuoteColumn::Name,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::High,
QuoteColumn::Low,
QuoteColumn::Volume,
] {
let name = column.as_str();
assert_eq!(QuoteColumn::from_name(name), Some(column), "{name} must round-trip");
assert!(!column.title().is_empty(), "every column needs a heading");
}
assert_eq!(QuoteColumn::from_name("nonsense"), None);
}
#[test]
fn column_roles_are_classified() {
assert!(QuoteColumn::Change.is_signed());
assert!(QuoteColumn::ChangePercent.is_signed());
assert!(!QuoteColumn::Last.is_signed(), "a price is not signed");
assert!(!QuoteColumn::Symbol.is_numeric());
assert!(!QuoteColumn::Name.is_numeric());
assert!(QuoteColumn::Volume.is_numeric());
}
#[test]
fn sorting_by_symbol_orders_the_rows() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(6));
board.set_sort(QuoteSort::Symbol);
let symbols: alloc::vec::Vec<&str> =
board.quotes().iter().map(|quote| quote.symbol.as_str()).collect();
let mut expected = symbols.clone();
expected.sort_unstable();
assert_eq!(symbols, expected);
}
#[test]
fn sorting_by_price_puts_the_highest_first() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(6));
board.set_sort(QuoteSort::LastDescending);
let prices: alloc::vec::Vec<f64> = board.quotes().iter().map(|quote| quote.last).collect();
for window in prices.windows(2) {
assert!(window[0] >= window[1], "the highest price must come first");
}
}
#[test]
fn sorting_by_magnitude_ignores_direction() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(6));
board.set_sort(QuoteSort::ChangeMagnitude);
let magnitudes: alloc::vec::Vec<f64> =
board.quotes().iter().map(|quote| quote.change().abs()).collect();
for window in magnitudes.windows(2) {
assert!(window[0] >= window[1], "the biggest movers must come first");
}
}
#[test]
fn clearing_the_sort_restores_the_supplied_order() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
let mut quotes = fixtures::sample_quotes(6);
quotes.reverse();
board.set_quotes(quotes);
let original: alloc::vec::Vec<alloc::string::String> =
board.quotes().iter().map(|quote| quote.symbol.clone()).collect();
let mut by_symbol = original.clone();
by_symbol.sort();
assert_ne!(by_symbol, original, "the fixture must not already be symbol-sorted");
board.set_sort(QuoteSort::LastDescending);
board.set_sort(QuoteSort::Symbol);
board.set_sort(QuoteSort::ChangeMagnitude);
board.set_sort(QuoteSort::None);
let restored: alloc::vec::Vec<alloc::string::String> =
board.quotes().iter().map(|quote| quote.symbol.clone()).collect();
assert_eq!(restored, original, "the caller's order must come back exactly");
}
#[test]
fn selection_emits_and_is_idempotent() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(4));
let count = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&count);
board.selection_changed.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
board.select(Some(1));
assert_eq!(count.load(Ordering::SeqCst), 1, "a real move emits once");
assert_eq!(board.selected_symbol(), Some("SYM1"));
board.select(Some(1));
assert_eq!(count.load(Ordering::SeqCst), 1, "re-selecting the same row is a no-op");
board.select(None);
assert_eq!(count.load(Ordering::SeqCst), 2, "clearing emits");
assert_eq!(board.selected_symbol(), None);
}
#[test]
fn an_out_of_range_selection_clears() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(3));
board.select(Some(99));
assert_eq!(board.selected_index(), None, "there is no row 99 to select");
}
#[test]
fn an_empty_board_draws_without_panicking() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 480, 240));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(480, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
board.draw(&mut context);
}
#[test]
fn an_empty_board_draws_the_no_data_message() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 480, 240));
let empty_svg =
crate::widget::svg::render_widget_to_svg(&mut board, Rect::new(0, 0, 480, 240));
let header_height = board.row_height();
let body_runs = crate::widget::svg::text_ink_boxes(&empty_svg)
.into_iter()
.filter(|(_, top, _, _)| *top >= header_height)
.count();
assert!(
body_runs > 0,
"the empty body must say so rather than showing a header over nothing: \
{body_runs} body text runs, header height {header_height}"
);
let mut filled = QuoteBoard::new(Rect::new(0, 0, 480, 240));
filled.set_quotes(fixtures::sample_quotes(8));
let filled_svg =
crate::widget::svg::render_widget_to_svg(&mut filled, Rect::new(0, 0, 480, 240));
let filled_runs = crate::widget::svg::text_ink_boxes(&filled_svg)
.into_iter()
.filter(|(_, top, _, _)| *top >= header_height)
.count();
assert!(
filled_runs > body_runs,
"eight quotes must draw more body runs ({filled_runs}) than the message ({body_runs})"
);
}
#[test]
fn every_column_draws() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(4));
board.set_columns(alloc::vec![
QuoteColumn::Symbol,
QuoteColumn::Name,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::High,
QuoteColumn::Low,
QuoteColumn::Volume,
]);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(640, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
board.draw(&mut context);
}
#[test]
fn a_heading_is_placed_with_the_advance_model_it_is_drawn_with() {
let font = Font::simple("Sans", BODY_FONT_SIZE);
for title in ["Symbol", "Name", "Last", "Change", "Chg%", "High", "Low", "Volume"] {
let measured = measure_body_text(title, &font);
assert_eq!(
measured,
crate::widget::metrics::estimate_text_width(title, &font, 1.0),
"the heading {title:?} must be measured with the renderer's own advance model"
);
assert!(measured > 0, "the heading {title:?} must have a positive width");
}
for title in ["Symbol", "Name", "Last", "Change", "Chg%", "High", "Low", "Volume"] {
let measured = measure_body_text(title, &font) as i32;
let naive = title.chars().count() as i32 * 7;
assert!(
(measured - naive).abs() <= 2,
"{title:?} measures {measured} against a 7 px-per-character guess of {naive}; \
if these diverge the guess is a second, separate defect to fix"
);
}
}
#[test]
fn a_heading_starts_at_its_columns_inset() {
let font = Font::simple("Sans", BODY_FONT_SIZE);
for title in ["Symbol", "Name", "Last", "Change", "Chg%", "High", "Low", "Volume"] {
let title_width = measure_body_text(title, &font) as i32;
for (start, width) in [(0, 60), (60, 60), (120, 60), (180, 60), (0, 48), (0, 80)] {
let label_x = heading_origin(start, width, title, &font);
assert!(
label_x >= start,
"the heading {title:?} starts at {label_x}, left of its column at {start}"
);
assert!(
label_x + title_width <= start + width,
"the heading {title:?} ends at {}, past its column's right edge {} \
(it starts at {label_x} and measures {title_width})",
label_x + title_width,
start + width
);
if title_width + 16 <= width {
assert_eq!(
label_x,
start + 8,
"the heading {title:?} fits {width} px and must start at its inset"
);
}
}
}
}
#[test]
fn the_first_heading_stays_inside_a_narrow_first_column() {
let font = Font::simple("Sans", BODY_FONT_SIZE);
let title = QuoteColumn::Symbol.title();
let label_x = heading_origin(0, 48, title, &font);
let title_width = measure_body_text(title, &font) as i32;
assert!(label_x >= 0, "'Symbol' must not start left of its column, got {label_x}");
assert!(
title_width + 2 * 8 > 48,
"this case is only interesting while the heading cannot hold both insets: \
'{title}' measures {title_width} in a 48 px column"
);
assert!(
label_x + title_width <= 48,
"'Symbol' ends at {} in a 48 px column (starts at {label_x}, measures {title_width})",
label_x + title_width
);
}
#[test]
fn no_heading_escapes_its_own_column() {
let font = Font::simple("Sans", BODY_FONT_SIZE);
for (start, width) in [(0, 48), (48, 48), (96, 48), (144, 48), (192, 48), (0, 30), (0, 200)]
{
for column in [
QuoteColumn::Symbol,
QuoteColumn::Name,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::High,
QuoteColumn::Low,
QuoteColumn::Volume,
] {
let title = column.title();
let label_x = heading_origin(start, width, title, &font);
assert!(
label_x >= start,
"{title:?} starts at {label_x}, left of its column at {start}"
);
let painted = if heading_fits(width, title, &font) {
measure_body_text(title, &font) as i32
} else {
(width - 16).max(0)
};
assert!(
label_x + painted <= start + width,
"{title:?} ends at {} past its column's right edge {} (starts at {label_x}, \
paints {painted} px of a {width} px column)",
label_x + painted,
start + width
);
}
}
}
#[test]
fn a_malformed_quote_draws_without_panicking() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 480, 240));
let mut quote = Quote::new("BAD", f64::NAN, 0.0);
quote.high = f64::INFINITY;
quote.low = f64::NEG_INFINITY;
quote.volume = f64::NAN;
board.set_quotes(alloc::vec![quote]);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(480, 240), 1.0);
let mut context = RenderContext::new(&mut backend);
board.draw(&mut context);
}
#[test]
fn colours_round_trip() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 480, 240));
let (up, down) = board.colors();
assert_ne!(up, down);
board.set_colors(crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6));
assert_eq!(
board.colors(),
(crate::core::Color::rgb(1, 2, 3), crate::core::Color::rgb(4, 5, 6))
);
}
#[test]
fn clicking_a_row_emits_and_selects() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 640, 240));
board.set_quotes(fixtures::sample_quotes(5));
let count = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&count);
board.quote_clicked.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
board.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 100, y: 40 },
button: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 1, "a press on a row emits its symbol");
assert!(board.selected_index().is_some(), "and selects it");
}
#[test]
fn quote_board_hover_is_bounded_by_the_geometry() {
let geometry = Rect::new(0, 20, 400, 120);
let hover_at = |y: i32| {
let mut board = QuoteBoard::new(geometry);
board.set_quotes(fixtures::sample_quotes(3));
board.handle_event(&Event::MouseMove { pos: Point::new(150, y) });
board.hovered_index()
};
assert_eq!(hover_at(19), None, "above the widget");
assert_eq!(hover_at(140), None, "the bottom edge is outside");
assert_eq!(hover_at(500), None, "far below the widget");
}
}