use crate::core::{Color, Rect};
use crate::event::EventHandler;
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::Bar;
use crate::widget::special_widgets::finance::types::PriceSeries;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VolumeColorMode {
#[default]
Direction,
Uniform,
}
impl VolumeColorMode {
pub fn as_str(self) -> &'static str {
match self {
VolumeColorMode::Direction => "direction",
VolumeColorMode::Uniform => "uniform",
}
}
pub fn from_name(name: &str) -> Option<Self> {
Some(match name {
"direction" => VolumeColorMode::Direction,
"uniform" => VolumeColorMode::Uniform,
_ => return None,
})
}
}
pub struct VolumeChart {
base: BaseWidget,
series: PriceSeries,
color_mode: VolumeColorMode,
headroom: f64,
hovered_index: Option<usize>,
pub bar_clicked: Signal1<usize>,
pub bar_hovered: Signal1<usize>,
}
impl VolumeChart {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::VolumeChart, geometry, "VolumeChart"),
series: PriceSeries::new(),
color_mode: VolumeColorMode::default(),
headroom: 0.92,
hovered_index: None,
bar_clicked: Signal1::new(),
bar_hovered: Signal1::new(),
}
}
pub fn series(&self) -> &PriceSeries {
&self.series
}
pub fn set_series(&mut self, series: PriceSeries) {
self.series = series;
if self.hovered_index.is_some_and(|index| index >= self.series.len()) {
self.hovered_index = None;
}
self.base.request_redraw();
}
pub fn color_mode(&self) -> VolumeColorMode {
self.color_mode
}
pub fn set_color_mode(&mut self, mode: VolumeColorMode) {
self.color_mode = mode;
self.base.request_redraw();
}
pub fn headroom(&self) -> f64 {
self.headroom
}
pub fn set_headroom(&mut self, headroom: f64) {
if headroom.is_finite() {
self.headroom = headroom.clamp(0.1, 1.0);
self.base.request_redraw();
}
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered_index
}
fn plot_area(&self) -> PlotArea {
PlotArea::with_margins(self.base.geometry(), 48, 8, 6, 6)
}
fn bar_color(&self, index: usize) -> Color {
match self.color_mode {
VolumeColorMode::Uniform => Color::rgb(96, 125, 139),
VolumeColorMode::Direction => {
let rising = self.series.bars().get(index).is_some_and(|bar| bar.is_rising());
if rising {
Color::rgb(38, 166, 91)
} else {
Color::rgb(220, 68, 70)
}
}
}
}
}
impl Widget for VolumeChart {
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 EventHandler for VolumeChart {
fn handle_event(&mut self, event: &crate::event::Event) {
use crate::event::Event;
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
let area = self.plot_area();
let index_axis = area.index_axis(self.series.len());
let next = index_axis.index_at(pos.x).filter(|index| *index < self.series.len());
if next != self.hovered_index {
if let Some(index) = next {
self.bar_hovered.emit(index);
}
self.hovered_index = next;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if self.hovered_index.take().is_some() {
self.base.request_redraw();
}
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
let area = self.plot_area();
let index_axis = area.index_axis(self.series.len());
if let Some(index) =
index_axis.index_at(pos.x).filter(|index| *index < self.series.len())
{
self.bar_clicked.emit(index);
}
}
_ => {}
}
self.base.handle_event(event);
}
}
impl Draw for VolumeChart {
fn draw(&mut self, context: &mut RenderContext) {
let area = self.plot_area();
if area.rect.width == 0 || area.rect.height == 0 {
return;
}
context.fill_rect(area.rect, Color::rgb(18, 22, 28));
let bars = self.series.bars();
if bars.is_empty() {
return;
}
let max_volume = self.series.max_volume();
if max_volume <= 0.0 {
return;
}
let usable_height = (area.rect.height as f64 * self.headroom).max(1.0);
let index_axis = area.index_axis(bars.len());
let body_width = index_axis.body_width();
let baseline = area.bottom();
for (index, bar) in bars.iter().enumerate() {
if !bar.volume.is_finite() || bar.volume <= 0.0 {
continue;
}
let height = (bar.volume / max_volume * usable_height).round().max(1.0) as u32;
context.fill_rect(
Rect::new(
index_axis.x_for(index),
baseline - height as i32,
body_width as u32,
height,
),
self.bar_color(index),
);
}
if let Some(index) = self.hovered_index.filter(|index| *index < bars.len()) {
let x = index_axis.center_for(index);
context.draw_line_stroke(
crate::core::Point { x, y: area.rect.y },
crate::core::Point { x, y: area.bottom() },
Color::rgb(120, 120, 120),
1,
);
}
}
}
impl WidgetProperties for VolumeChart {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"series" => {
let mut text = alloc::string::String::new();
for (index, bar) in self.series.bars().iter().enumerate() {
if index > 0 {
text.push(',');
}
text.push_str(&alloc::format!("{}", bar.volume));
}
Ok(CapabilityValue::String(text))
}
"color_mode" => {
Ok(CapabilityValue::String(alloc::string::String::from(self.color_mode.as_str())))
}
"headroom" => Ok(CapabilityValue::Float(self.headroom)),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"series" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
let mut series = PriceSeries::new();
for token in text.split([',', ' ', ';']).filter(|token| !token.is_empty()) {
let volume: f64 =
token.trim().parse().map_err(|_| CapabilityAccessError::OutOfRange)?;
series.push(Bar::flat(0.0, volume));
}
self.set_series(series);
Ok(())
}
"color_mode" => {
let CapabilityValue::String(text) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
let mode =
VolumeColorMode::from_name(&text).ok_or(CapabilityAccessError::OutOfRange)?;
self.set_color_mode(mode);
Ok(())
}
"headroom" => {
let headroom = match value {
CapabilityValue::Float(value) => value,
CapabilityValue::UInt(value) => value as f64,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_headroom(headroom);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["series", "color_mode", "headroom", BASE_PROPERTY_NAMES]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::EventHandler;
use crate::widget::special_widgets::finance::types::fixtures;
use crate::widget::special_widgets::finance::types::{Bar, PriceSeries};
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn a_volume_pane_carries_its_series() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
assert!(chart.series().is_empty());
chart.set_series(fixtures::sample_series(30));
assert_eq!(chart.series().len(), 30);
}
#[test]
fn the_colour_mode_defaults_to_direction() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
assert_eq!(chart.color_mode(), VolumeColorMode::Direction);
chart.set_color_mode(VolumeColorMode::Uniform);
assert_eq!(chart.color_mode(), VolumeColorMode::Uniform);
}
#[test]
fn colour_mode_names_round_trip() {
for mode in [VolumeColorMode::Direction, VolumeColorMode::Uniform] {
let name = mode.as_str();
assert_eq!(VolumeColorMode::from_name(name), Some(mode));
}
assert_eq!(VolumeColorMode::from_name("nonsense"), None);
}
#[test]
fn headroom_is_clamped() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
chart.set_headroom(0.0);
assert!(chart.headroom() >= 0.1, "zero headroom would hide every bar");
chart.set_headroom(5.0);
assert!(chart.headroom() <= 1.0, "headroom above 1.0 would overflow the pane");
chart.set_headroom(0.75);
assert!((chart.headroom() - 0.75).abs() < 1e-9);
}
#[test]
fn a_non_finite_headroom_is_ignored() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
let before = chart.headroom();
chart.set_headroom(f64::NAN);
assert_eq!(chart.headroom(), before, "a NaN headroom must not replace a good one");
}
#[test]
fn an_empty_series_draws_without_panicking() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 320, 160));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn an_all_zero_volume_series_draws_without_panicking() {
let mut series = PriceSeries::new();
for _ in 0..10 {
series.push(Bar::new(10.0, 11.0, 9.0, 10.5, 0.0));
}
let mut chart = VolumeChart::new(Rect::new(0, 0, 320, 160));
chart.set_series(series);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn a_malformed_series_draws_without_panicking() {
let mut series = PriceSeries::new();
series.push(Bar::new(10.0, 11.0, 9.0, 10.5, f64::NAN));
series.push(Bar::new(10.0, 11.0, 9.0, 9.5, f64::INFINITY));
series.push(Bar::new(10.0, 11.0, 9.0, 10.0, -5.0));
let mut chart = VolumeChart::new(Rect::new(0, 0, 320, 160));
chart.set_series(series);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn every_colour_mode_draws() {
for mode in [VolumeColorMode::Direction, VolumeColorMode::Uniform] {
let mut chart = VolumeChart::new(Rect::new(0, 0, 320, 160));
chart.set_series(fixtures::sample_series(40));
chart.set_color_mode(mode);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 160), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
}
#[test]
fn clicks_report_only_inside_the_plot() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
chart.set_series(fixtures::sample_series(20));
let count = Arc::new(AtomicUsize::new(0));
let sink = Arc::clone(&count);
chart.bar_clicked.connect(move |_| {
sink.fetch_add(1, Ordering::SeqCst);
});
chart.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 300, y: 80 },
button: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 1, "a press inside the plot emits once");
chart.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 2, y: 80 },
button: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 1, "a press on the axis emits nothing");
}
#[test]
fn replacing_the_series_drops_a_stale_hover() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 480, 160));
chart.set_series(fixtures::sample_series(40));
chart.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 470, y: 80 },
});
chart.set_series(fixtures::sample_series(2));
assert!(
chart.hovered_index().is_none_or(|index| index < 2),
"a hover must not survive past its series"
);
}
}