use alloc::string::String;
use alloc::vec::Vec;
use core::fmt::Write as _;
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::metrics::role_font;
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::view_widgets::filter_expr::{FilterCondition, FilterOperator};
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,
SymbolAscending,
SymbolDescending,
LastAscending,
LastDescending,
ChangeMagnitudeAscending,
ChangeMagnitudeDescending,
}
impl QuoteSort {
pub fn as_str(self) -> &'static str {
match self {
QuoteSort::None => "none",
QuoteSort::SymbolAscending => "symbol_ascending",
QuoteSort::SymbolDescending => "symbol_descending",
QuoteSort::LastAscending => "last_ascending",
QuoteSort::LastDescending => "last_descending",
QuoteSort::ChangeMagnitudeAscending => "change_magnitude_ascending",
QuoteSort::ChangeMagnitudeDescending => "change_magnitude_descending",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"none" => QuoteSort::None,
"symbol" | "symbol_ascending" => QuoteSort::SymbolAscending,
"symbol_descending" => QuoteSort::SymbolDescending,
"last_ascending" => QuoteSort::LastAscending,
"last" | "last_descending" => QuoteSort::LastDescending,
"change_magnitude" | "change_magnitude_descending" => {
QuoteSort::ChangeMagnitudeDescending
}
"change_magnitude_ascending" => QuoteSort::ChangeMagnitudeAscending,
_ => return None,
})
}
}
pub struct QuoteBoard {
base: BaseWidget,
quotes: Vec<Quote>,
original_order: Vec<Quote>,
columns: Vec<QuoteColumn>,
sort: QuoteSort,
hovered: Option<usize>,
selected: Option<usize>,
scroll_offset: usize,
rising_color: Color,
falling_color: Color,
data_generation: u64,
ranges_cache: Option<(ColumnRangesKey, alloc::vec::Vec<(i32, i32)>)>,
flashes: alloc::vec::Vec<(String, bool, u8)>,
sortable_headers: bool,
filters: alloc::vec::Vec<FilterCondition>,
decimals_cache: core::cell::Cell<Option<(u64, usize)>>,
pub quote_clicked: Signal1<String>,
pub quote_hovered: Signal1<String>,
pub selection_changed: Signal1<Option<String>>,
}
#[derive(Clone)]
struct ColumnRangesKey {
width: i32,
decimals: usize,
quote_count: usize,
data_generation: u64,
heading_font: Font,
body_font: Font,
inset: i32,
}
impl PartialEq for ColumnRangesKey {
fn eq(&self, other: &Self) -> bool {
self.width == other.width
&& self.decimals == other.decimals
&& self.quote_count == other.quote_count
&& self.data_generation == other.data_generation
&& self.heading_font == other.heading_font
&& self.body_font == other.body_font
&& self.inset == other.inset
}
}
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,
scroll_offset: 0,
rising_color: Color::rgb(38, 166, 91),
falling_color: Color::rgb(220, 68, 70),
data_generation: 0,
ranges_cache: None,
flashes: alloc::vec::Vec::new(),
sortable_headers: false,
filters: alloc::vec::Vec::new(),
decimals_cache: core::cell::Cell::new(None),
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.flashes.clear();
let mut previous: alloc::collections::BTreeMap<&str, f64> =
alloc::collections::BTreeMap::new();
for quote in &self.quotes {
previous.insert(quote.symbol.as_str(), quote.last);
}
for quote in "es {
if let Some(old) = previous.get(quote.symbol.as_str()) {
if (quote.last - old).abs() > f64::EPSILON {
self.flashes.push((quote.symbol.clone(), quote.last >= *old, 200));
}
}
}
self.quotes = quotes;
self.original_order = self.quotes.clone();
self.data_generation = self.data_generation.wrapping_add(1);
self.apply_sort();
self.revalidate_selection();
self.clamp_scroll();
self.base.request_redraw();
}
pub fn set_sortable_headers(&mut self, sortable: bool) {
if self.sortable_headers == sortable {
return;
}
self.sortable_headers = sortable;
self.base.request_redraw();
}
pub fn sortable_headers(&self) -> bool {
self.sortable_headers
}
pub fn is_flashing(&self) -> bool {
!self.flashes.is_empty()
}
fn next_sort_for(&self, column: QuoteColumn) -> QuoteSort {
let (preferred, opposite) = match column {
QuoteColumn::Symbol | QuoteColumn::Name => {
(QuoteSort::SymbolAscending, QuoteSort::SymbolDescending)
}
QuoteColumn::Change | QuoteColumn::ChangePercent => {
(QuoteSort::ChangeMagnitudeDescending, QuoteSort::ChangeMagnitudeAscending)
}
_ => (QuoteSort::LastDescending, QuoteSort::LastAscending),
};
if self.sort == preferred {
opposite
} else if self.sort == opposite {
QuoteSort::None
} else {
preferred
}
}
fn sort_header_at(&mut self, pos: Point) -> bool {
let geometry = self.base.geometry();
if !self.sortable_headers || pos.y >= self.first_row_y() || pos.y < geometry.y {
return false;
}
let ranges = self.column_ranges();
for (index, (start, end)) in ranges.iter().enumerate() {
if pos.x >= *start && pos.x < *end {
if let Some(column) = self.columns.get(index).copied() {
let next = self.next_sort_for(column);
self.set_sort(next);
return true;
}
}
}
false
}
pub fn columns(&self) -> &[QuoteColumn] {
&self.columns
}
pub fn set_columns(&mut self, columns: Vec<QuoteColumn>) {
self.columns = columns;
self.data_generation = self.data_generation.wrapping_add(1);
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) {
let mut rows = self.original_order.clone();
if !self.filters.is_empty() {
rows.retain(|quote| {
self.filters.iter().all(|condition| self.condition_accepts(quote, condition))
});
}
match self.sort {
QuoteSort::None => {}
QuoteSort::SymbolAscending => {
rows.sort_by(|left, right| left.symbol.cmp(&right.symbol))
}
QuoteSort::SymbolDescending => {
rows.sort_by(|left, right| right.symbol.cmp(&left.symbol))
}
QuoteSort::LastAscending => rows.sort_by(|left, right| {
left.last.partial_cmp(&right.last).unwrap_or(core::cmp::Ordering::Equal)
}),
QuoteSort::LastDescending => rows.sort_by(|left, right| {
right.last.partial_cmp(&left.last).unwrap_or(core::cmp::Ordering::Equal)
}),
QuoteSort::ChangeMagnitudeAscending => rows.sort_by(|left, right| {
let left_magnitude = left.change().abs();
let right_magnitude = right.change().abs();
left_magnitude.partial_cmp(&right_magnitude).unwrap_or(core::cmp::Ordering::Equal)
}),
QuoteSort::ChangeMagnitudeDescending => rows.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)
}),
}
self.quotes = rows;
self.data_generation = self.data_generation.wrapping_add(1);
self.clamp_scroll();
}
pub fn set_filter(&mut self, conditions: &[FilterCondition]) {
self.filters = conditions.to_vec();
self.apply_sort();
self.revalidate_selection();
self.base.request_redraw();
}
pub fn clear_filter(&mut self) {
if self.filters.is_empty() {
return;
}
self.filters.clear();
self.apply_sort();
self.revalidate_selection();
self.base.request_redraw();
}
pub fn filters(&self) -> &[FilterCondition] {
&self.filters
}
pub fn filter_columns(&self) -> Vec<(String, bool)> {
Self::FILTER_COLUMNS
.iter()
.map(|column| (column.title().to_string(), column.is_numeric()))
.collect()
}
const FILTER_COLUMNS: [QuoteColumn; 8] = [
QuoteColumn::Symbol,
QuoteColumn::Name,
QuoteColumn::Last,
QuoteColumn::Change,
QuoteColumn::ChangePercent,
QuoteColumn::High,
QuoteColumn::Low,
QuoteColumn::Volume,
];
fn condition_accepts(&self, quote: &Quote, condition: &FilterCondition) -> bool {
let Some(column) = Self::FILTER_COLUMNS.get(condition.column).copied() else {
return true;
};
if condition.operator.is_numeric() {
let Some(value) = self.numeric_value(quote, column).filter(|value| value.is_finite())
else {
return false;
};
let Ok(target) = condition.operand.trim().parse::<f64>() else { return false };
return match condition.operator {
FilterOperator::EqualsNumber => (value - target).abs() <= 1e-9,
FilterOperator::GreaterThan => value > target,
FilterOperator::GreaterOrEqual => value >= target,
FilterOperator::LessThan => value < target,
FilterOperator::LessOrEqual => value <= target,
_ => false,
};
}
let mut text = String::new();
self.write_cell_text(quote, column, self.inferred_decimals(), &mut text);
condition.operator.accepts(Some(&text), &condition.operand)
}
fn numeric_value(&self, quote: &Quote, column: QuoteColumn) -> Option<f64> {
Some(match column {
QuoteColumn::Last => quote.last,
QuoteColumn::Change => quote.change(),
QuoteColumn::ChangePercent => quote.change_percent(),
QuoteColumn::High => quote.high,
QuoteColumn::Low => quote.low,
QuoteColumn::Volume => quote.volume,
QuoteColumn::Symbol | QuoteColumn::Name => return None,
})
}
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 = self.scroll_offset + (offset / self.row_height()) as usize;
if index < self.quotes.len() {
Some(index)
} else {
None
}
}
fn visible_row_count(&self) -> usize {
let geometry = self.base.geometry();
((geometry.height as i32 - self.row_height()) / self.row_height()).max(0) as usize
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn set_scroll_offset(&mut self, offset: usize) {
let max = self.quotes.len().saturating_sub(self.visible_row_count());
let offset = offset.min(max);
if self.scroll_offset == offset {
return;
}
self.scroll_offset = offset;
self.base.request_redraw();
}
pub fn scroll_by(&mut self, delta: isize) {
let next = (self.scroll_offset as isize + delta).max(0) as usize;
self.set_scroll_offset(next);
}
fn clamp_scroll(&mut self) {
let max = self.quotes.len().saturating_sub(self.visible_row_count());
self.scroll_offset = self.scroll_offset.min(max);
}
fn column_ranges(&mut self) -> Vec<(i32, i32)> {
let geometry = self.base.geometry();
let width = geometry.width as i32;
if self.columns.is_empty() || width <= 0 {
return Vec::new();
}
let style = self.style();
let heading_font = role_font(style, HEADING_FONT_SIZE);
let body_font = role_font(style, BODY_FONT_SIZE);
let decimals = self.inferred_decimals();
let inset = self.cell_inset();
let key = ColumnRangesKey {
width,
decimals,
quote_count: self.quotes.len(),
data_generation: self.data_generation,
heading_font: heading_font.clone(),
body_font: body_font.clone(),
inset,
};
if let Some((cached_key, cached_ranges)) = &self.ranges_cache {
if *cached_key == key {
return cached_ranges.clone();
}
}
let desired: Vec<i32> = self
.columns
.iter()
.map(|column| {
let mut widest = measure_body_text(column.title(), &heading_font) as i32;
const MAX_MEASURED_ROWS: usize = 64;
let mut text = String::new();
for quote in self.quotes.iter().take(MAX_MEASURED_ROWS) {
self.write_cell_text(quote, *column, decimals, &mut text);
widest = widest.max(measure_body_text(&text, &body_font) as i32);
}
widest + inset * 2
})
.collect();
let total: i32 = desired.iter().sum();
if total <= 0 {
let count = self.columns.len() as i32;
let slot = width / count;
let ranges: Vec<(i32, i32)> = (0..count)
.map(|index| {
let start = geometry.x + index * slot;
let w = if index == count - 1 { geometry.x + width - start } else { slot };
(start, w)
})
.collect();
self.ranges_cache = Some((key, ranges.clone()));
return ranges;
}
let mut ranges = Vec::with_capacity(self.columns.len());
let mut x = geometry.x;
let mut remaining = width;
let mut remaining_desired = total;
for (index, want) in desired.iter().enumerate() {
let w = if index + 1 == self.columns.len() || remaining_desired <= 0 {
remaining
} else {
((*want as i64 * remaining as i64) / remaining_desired as i64) as i32
};
let w = w.clamp(0, remaining);
ranges.push((x, w));
x += w;
remaining -= w;
remaining_desired -= want;
}
self.ranges_cache = Some((key, ranges.clone()));
ranges
}
fn cell_inset(&self) -> i32 {
if self.base.geometry().width < 420 {
4
} else {
8
}
}
fn write_cell_text(
&self,
quote: &Quote,
column: QuoteColumn,
decimals: usize,
out: &mut String,
) {
out.clear();
match column {
QuoteColumn::Symbol => out.push_str("e.symbol),
QuoteColumn::Name => out.push_str("e.name),
QuoteColumn::Last => write_decimal(quote.last, decimals, out),
QuoteColumn::Change => {
let change = quote.change();
if change.is_finite() {
if change >= 0.0 {
out.push('+');
}
write_decimal(change, decimals, out);
} else {
out.push('—');
}
}
QuoteColumn::ChangePercent => {
let percent = quote.change_percent();
if percent.is_finite() {
if percent >= 0.0 {
out.push('+');
}
let _ = write!(out, "{percent:.2}%");
} else {
out.push('—');
}
}
QuoteColumn::High => write_decimal(quote.high, decimals, out),
QuoteColumn::Low => write_decimal(quote.low, decimals, out),
QuoteColumn::Volume => write_volume(quote.volume, out),
}
}
fn inferred_decimals(&self) -> usize {
if let Some((generation, decimals)) = self.decimals_cache.get() {
if generation == self.data_generation {
return decimals;
}
}
let decimals = self.compute_decimals();
self.decimals_cache.set(Some((self.data_generation, decimals)));
decimals
}
fn compute_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 write_decimal(value: f64, decimals: usize, out: &mut String) {
if !value.is_finite() {
out.push('—');
return;
}
let factor = 10f64.powi(decimals as i32);
let scaled = (value * factor).round() as i64;
if scaled < 0 {
out.push('-');
}
let digits = scaled.unsigned_abs();
if decimals == 0 {
let _ = write!(out, "{digits}");
return;
}
let digits_len = digit_count(digits);
if digits_len <= decimals {
out.push('.');
for _ in 0..(decimals - digits_len) {
out.push('0');
}
let _ = write!(out, "{digits}");
} else {
let divisor = 10u64.pow(decimals as u32);
let whole = digits / divisor;
let fraction = digits % divisor;
let _ = write!(out, "{whole}.");
let fraction_digits = digit_count(fraction);
for _ in 0..(decimals - fraction_digits) {
out.push('0');
}
let _ = write!(out, "{fraction}");
}
}
fn digit_count(mut value: u64) -> usize {
let mut digits = 1;
while value >= 10 {
value /= 10;
digits += 1;
}
digits
}
fn write_volume(volume: f64, out: &mut String) {
if !volume.is_finite() {
out.push('—');
return;
}
if volume.abs() >= 1_000_000_000.0 {
let _ = write!(out, "{:.2}B", volume / 1_000_000_000.0);
} else if volume.abs() >= 1_000_000.0 {
let _ = write!(out, "{:.2}M", volume / 1_000_000.0);
} else if volume.abs() >= 1_000.0 {
let _ = write!(out, "{:.2}K", volume / 1_000.0);
} else {
let _ = write!(out, "{volume:.0}");
}
}
impl Widget for QuoteBoard {
fn tick(&mut self, delta_ms: u32) -> bool {
if self.flashes.is_empty() {
return false;
}
let decay = (delta_ms / 3).min(255) as u8;
for (_, _, alpha) in &mut self.flashes {
*alpha = alpha.saturating_sub(decay);
}
self.flashes.retain(|(_, _, alpha)| *alpha > 0);
self.base.request_redraw();
true
}
fn is_animating(&self) -> bool {
!self.flashes.is_empty()
}
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!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"quote_clicked" => {
Some(EventSignalRef::mapped("quote_clicked", &self.quote_clicked, |v| {
CapabilityValue::String(v.clone())
}))
}
"quote_hovered" => {
Some(EventSignalRef::mapped("quote_hovered", &self.quote_hovered, |v| {
CapabilityValue::String(v.clone())
}))
}
"selection_changed" => {
Some(EventSignalRef::mapped("selection_changed", &self.selection_changed, |v| {
match v {
Some(value) => CapabilityValue::String(value.clone()),
None => CapabilityValue::Null,
}
}))
}
_ => None,
}
}
}
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 self.sort_header_at(*pos) {
return;
}
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));
}
}
Event::Wheel { delta, .. } if delta.y != 0 => {
self.scroll_by(if delta.y > 0 { 3 } else { -3 });
}
_ => {}
}
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 ranges = self.column_ranges();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("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 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,
);
let heading_font = role_font(style, HEADING_FONT_SIZE);
for (column, (start, width)) in self.columns.iter().zip(ranges.iter()) {
let title = column.title();
let label_x = heading_origin(*start, *width, title, &heading_font);
if heading_fits(*width, title, &heading_font) {
context.draw_text(
Point { x: label_x, y: geometry.y + 5 },
title,
&heading_font,
header_text,
HorizontalAlignment::Left,
);
} else {
let band = Rect::new(
*start,
geometry.y + 5,
(*width).max(1) as u32,
heading_font.size().max(1.0) as u32,
);
context.draw_text_fitted_cow(
band,
title,
&heading_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,
)
},
&role_font(style, 12.0),
);
}
return;
}
let cell_font = role_font(style, BODY_FONT_SIZE);
let mut cell_text = String::new();
for (index, quote) in
self.quotes.iter().enumerate().skip(self.scroll_offset).take(available)
{
let y = self.first_row_y() + (index - self.scroll_offset) 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,
);
}
if let Some((_, rising, alpha)) =
self.flashes.iter().find(|(symbol, _, _)| symbol == "e.symbol)
{
let flash = if *rising { self.rising_color } else { self.falling_color };
context.fill_rect(
Rect::new(geometry.x, y, geometry.width, row_height as u32),
flash.with_alpha(*alpha),
);
}
for (column, (start, width)) in self.columns.iter().zip(ranges.iter()) {
let start = *start;
let width = *width;
self.write_cell_text(quote, *column, decimals, &mut cell_text);
let text = cell_text.as_str();
let color = if column.is_signed() {
if quote.is_up() {
self.rising_color
} else {
self.falling_color
}
} else if *column == QuoteColumn::Symbol {
ink
} else {
body_text
};
let font = &cell_font;
let text_width = measure_body_text(text, font) as i32;
let inset = self.cell_inset();
let band = if column.is_numeric() {
Rect::new(start + width - inset - text_width, y, text_width.max(1) as u32, 0)
} else {
Rect::new(start + inset, y, (width - inset * 2).max(1) as u32, 0)
};
let line = context.text_line(Rect { height: ROW_TEXT_HEIGHT, ..band }, font);
if column.is_numeric() {
context.draw_text(
Point { x: line.x, y: line.y },
text,
font,
color,
HorizontalAlignment::Left,
);
} else {
context.draw_text_fitted_cow(
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" => Ok(CapabilityValue::String(alloc::string::String::from(self.sort.as_str()))),
"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)),
"scroll_offset" => Ok(CapabilityValue::UInt(self.scroll_offset() 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 QuoteSort::from_name(text.as_str()) {
Some(sort) => sort,
None => 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),
"scroll_offset" => {
let CapabilityValue::UInt(offset) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_scroll_offset(usize::try_from(offset).unwrap_or(usize::MAX));
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"sort",
"selected_index",
"row_height",
"scroll_offset",
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 scrolling_reaches_rows_past_the_first_window() {
let mut board = QuoteBoard::new(Rect::new(0, 0, 240, 120));
let quotes: Vec<Quote> = (0..20)
.map(|index| Quote {
symbol: format!("S{index}"),
name: format!("Name {index}"),
last: 10.0 + index as f64,
..Quote::default()
})
.collect();
board.set_quotes(quotes);
assert_eq!(board.visible_row_count(), 4);
board.set_scroll_offset(10);
assert_eq!(board.scroll_offset(), 10);
let first_row_y = board.first_row_y();
assert_eq!(board.row_at(first_row_y + 1), Some(10));
assert_eq!(board.row_at(first_row_y + 22 + 1), Some(11));
board.handle_event(&Event::wheel(0, 1, 0));
assert_eq!(board.scroll_offset(), 13);
board.handle_event(&Event::wheel(0, -1, 0));
assert_eq!(board.scroll_offset(), 10);
assert!(board.set("scroll_offset", CapabilityValue::UInt(12)).is_ok());
assert_eq!(board.scroll_offset(), 12);
}
#[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::SymbolAscending);
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::ChangeMagnitudeDescending);
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::SymbolAscending);
board.set_sort(QuoteSort::ChangeMagnitudeDescending);
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 a_four_letter_symbol_keeps_its_whole_column_at_the_census_width() {
let _theme_guard = crate::style::theme_test_guard();
let mut board = QuoteBoard::new(crate::widget::census::CENSUS_RECT);
let mut quote = Quote::new("MSFT", 412.8, 416.1);
quote.volume = 21_400_000.0;
board.set_quotes(alloc::vec![quote]);
let style = board.style().clone();
let font = role_font(&style, BODY_FONT_SIZE);
let inset = board.cell_inset();
let ranges = board.column_ranges();
let (_, symbol_width) = ranges[0];
let needed = measure_body_text("MSFT", &font) as i32 + inset * 2;
assert!(
symbol_width >= needed,
"the symbol column is {symbol_width} px but `MSFT` plus its insets needs {needed}"
);
let total: i32 = ranges.iter().map(|(_, width)| *width).sum();
assert_eq!(total, crate::widget::census::CENSUS_RECT.width as i32);
}
#[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");
}
let narrow = measure_body_text("i", &font);
let wide = measure_body_text("W", &font);
assert!(
wide > narrow,
"a proportional face must advance `W` past `i` ({wide} vs {narrow}); a flat \
per-character guess cannot express this"
);
}
#[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,
modifiers: 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");
}
}