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::metrics::role_font;
use crate::widget::special_widgets::finance::layout::{
panel_colors, PanelColors, PlotArea, PANEL_MIN_CONTRAST,
};
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>,
view: Option<(usize, usize)>,
pub view_changed: Signal1<(usize, 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,
view: None,
view_changed: Signal1::new(),
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;
self.view = self.clamped_view(self.view);
if self.hovered_index.is_some_and(|index| index >= self.series.len()) {
self.hovered_index = None;
}
self.base.request_redraw();
}
pub fn view_range(&self) -> Option<(usize, usize)> {
self.view
}
pub fn set_view_range(&mut self, range: Option<(usize, usize)>) {
let range = self.clamped_view(range);
if self.view == range {
return;
}
self.view = range;
let (start, end) = self.view_bounds();
self.view_changed.emit((start, end));
self.base.request_redraw();
}
pub fn reset_view(&mut self) {
self.set_view_range(None);
}
fn clamped_view(&self, range: Option<(usize, usize)>) -> Option<(usize, usize)> {
range.map(|(start, end)| {
let total = self.series.len();
let start = start.min(total.saturating_sub(1));
let end = end.clamp(start + 1, total.max(start + 1));
(start, end)
})
}
fn view_bounds(&self) -> (usize, usize) {
match self.view {
Some((start, end)) => (start.min(self.series.len()), end.min(self.series.len())),
None => (0, self.series.len()),
}
}
pub fn color_mode(&self) -> VolumeColorMode {
self.color_mode
}
pub fn set_color_mode(&mut self, mode: VolumeColorMode) {
if self.color_mode == mode {
return;
}
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
}
pub fn hovered_summary(&self) -> Vec<crate::widget::companions::TooltipLine> {
use crate::widget::companions::TooltipLine;
let Some(index) = self.hovered_index else { return Vec::new() };
let Some(bar) = self.series.bars().get(index) else { return Vec::new() };
alloc::vec![
TooltipLine::new(match self.series.label_at(index) {
Some(label) => alloc::string::String::from(label),
None => alloc::format!("#{index}"),
}),
TooltipLine::colored(alloc::format!("vol {:.0}", bar.volume), self.bar_color(index)),
]
}
fn plot_area(&self) -> PlotArea {
PlotArea::volume_pane(self.base.geometry())
}
fn chrome(&self) -> PanelColors {
panel_colors(Some(self.base.style()))
}
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 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 {
"bar_clicked" => Some(EventSignalRef::mapped("bar_clicked", &self.bar_clicked, |v| {
CapabilityValue::UInt(*v as u64)
})),
"bar_hovered" => Some(EventSignalRef::mapped("bar_hovered", &self.bar_hovered, |v| {
CapabilityValue::UInt(*v as u64)
})),
"view_changed" => {
Some(EventSignalRef::mapped("view_changed", &self.view_changed, |v| {
CapabilityValue::Tuple(crate::compat::Vec::from([
CapabilityValue::UInt(v.0 as u64),
CapabilityValue::UInt(v.1 as u64),
]))
}))
}
_ => None,
}
}
}
impl EventHandler for VolumeChart {
fn handle_event(&mut self, event: &crate::event::Event) {
use crate::event::Event;
if !self.base.is_enabled() {
return;
}
match event {
Event::MouseMove { pos } | Event::PointerMove { pos, .. } => {
let area = self.plot_area();
let (start, end) = self.view_bounds();
let visible = end.saturating_sub(start);
let index_axis = area.index_axis(visible);
let next = index_axis
.index_at(pos.x)
.map(|index| start + index)
.filter(|index| *index < end);
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::Wheel { delta, .. } if delta.y != 0 => {
let total = self.series.len();
if total > 4 {
let (start, end) = self.view_bounds();
let anchor = self.hovered_index.unwrap_or((start + end) / 2);
let span = (end - start).max(4);
let factor = if delta.y > 0 { 1.25 } else { 0.8 };
let new_span = ((span as f64 * factor).round() as usize).clamp(4, total);
let half = new_span / 2;
let new_start = anchor.saturating_sub(half).min(total - new_span);
self.set_view_range(Some((new_start, new_start + new_span)));
}
}
Event::MousePress { pos, .. } | Event::PointerPress { pos, .. } => {
let area = self.plot_area();
let (start, end) = self.view_bounds();
let visible = end.saturating_sub(start);
let index_axis = area.index_axis(visible);
if let Some(index) = index_axis
.index_at(pos.x)
.map(|index| start + index)
.filter(|index| *index < end)
{
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;
}
let chrome = self.chrome();
context.fill_rect(area.rect, chrome.surface);
let bars = self.series.bars();
let (start, end) = self.view_bounds();
let visible = &bars[start..end];
let max_volume = visible
.iter()
.filter(|bar| bar.volume.is_finite())
.map(|bar| bar.volume)
.fold(0.0f64, f64::max);
if visible.is_empty() || max_volume <= 0.0 {
draw_empty_pane(context, &area, chrome, &role_font(self.style(), 12.0));
return;
}
let usable_height = (area.rect.height as f64 * self.headroom).max(1.0);
let index_axis = area.index_axis(visible.len());
let body_width = index_axis.body_width();
let baseline = area.bottom();
let axis_font = role_font(self.style(), 10.0);
let label_left = area.rect.x - area.left_margin;
let label_width = (area.label_x() - label_left).max(0) as u32;
for step in 0..=3 {
let fraction = step as f64 / 3.0;
let y = area.rect.y + area.rect.height as i32 * step / 3;
context.draw_line(
crate::core::Point { x: area.rect.x, y },
crate::core::Point { x: area.right(), y },
chrome.grid,
);
let value = max_volume * (1.0 - fraction);
let text = alloc::format!("{value:.0}");
let line_height = context.measure_text(&text, &axis_font).height.max(1);
context.draw_text_fitted_cow(
Rect::new(label_left, y - line_height as i32 / 2, label_width, line_height),
&text,
&axis_font,
chrome.ink.with_alpha(190),
crate::core::HorizontalAlignment::Right,
);
}
for (offset, bar) in visible.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(offset),
baseline - height as i32,
body_width as u32,
height,
),
self.bar_color(start + offset),
);
}
draw_index_ticks(
context,
&area,
start,
end,
|index| match self.series.label_at(index) {
Some(label) => alloc::string::String::from(label),
None => alloc::format!("{index}"),
},
&axis_font,
chrome.ink.with_alpha(190),
);
if let Some(index) = self.hovered_index.filter(|index| *index >= start && *index < end) {
let x = index_axis.center_for(index - start);
context.draw_line_stroke(
crate::core::Point { x, y: area.rect.y },
crate::core::Point { x, y: area.bottom() },
chrome.crosshair,
1,
);
}
}
}
pub(crate) fn draw_index_ticks(
context: &mut RenderContext,
area: &PlotArea,
start: usize,
end: usize,
label_at: impl Fn(usize) -> alloc::string::String,
font: &crate::core::Font,
ink: Color,
) {
let visible = end.saturating_sub(start);
if visible == 0 || area.bottom_margin < 14 {
return;
}
let axis = area.index_axis(visible);
let target = (area.rect.width as i32 / 80).max(1) as usize;
let step = visible.div_ceil(target).max(1);
let band_width = (axis.slot_width * step as i32).max(28) as u32;
let y = area.bottom() + 2;
let height = (area.bottom_margin - 2).max(10) as u32;
let mut index = start;
while index < end {
let x = axis.center_for(index - start);
let text = label_at(index);
context.draw_text_fitted_cow(
Rect::new(x - band_width as i32 / 2, y, band_width, height),
&text,
font,
ink,
crate::core::HorizontalAlignment::Center,
);
index += step;
}
}
pub(crate) fn draw_empty_pane(
context: &mut RenderContext,
area: &PlotArea,
chrome: crate::widget::special_widgets::finance::layout::PanelColors,
font: &crate::core::Font,
) {
context.draw_rect(area.rect, chrome.grid);
let mid_y = area.rect.y + area.rect.height as i32 / 2;
context.draw_line(
crate::core::Point { x: area.rect.x, y: mid_y },
crate::core::Point { x: area.right(), y: mid_y },
chrome.grid,
);
let line = context.text_line(area.rect, font);
context.draw_text_fitted_cow(
line,
"No data",
font,
chrome.ink.legible_on(chrome.surface, PANEL_MIN_CONTRAST).with_alpha(160),
crate::core::HorizontalAlignment::Center,
);
}
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]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"add_overlay" => Err(CapabilityAccessError::UnsupportedOnWidget),
"set_series" | "set_color_mode" | "set_headroom" => {
Err(CapabilityAccessError::OutOfRange)
}
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
#[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 a_view_range_restricts_the_pane_and_reports_changes() {
let mut chart = VolumeChart::new(Rect::new(0, 0, 240, 80));
let mut series = PriceSeries::new();
for value in [1.0, 2.0, 3.0, 4.0, 5.0] {
series.push(Bar::flat(0.0, value));
}
chart.set_series(series);
let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::<(usize, usize)>::new()));
let sink = std::sync::Arc::clone(&seen);
chart.view_changed.connect(move |range| sink.lock().unwrap().push(*range));
chart.set_view_range(Some((2, 4)));
assert_eq!(chart.view_range(), Some((2, 4)));
assert_eq!(*seen.lock().unwrap(), vec![(2, 4)]);
let area = chart.plot_area();
let click = area.rect.x + 1;
assert_eq!(area.index_axis(2).index_at(click).map(|index| 2 + index), Some(2));
chart.reset_view();
assert_eq!(*seen.lock().unwrap(), vec![(2, 4), (0, 5)]);
}
#[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,
modifiers: 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,
modifiers: 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"
);
}
}