use alloc::string::{String, ToString};
use alloc::vec::Vec;
use crate::core::{Color, Font, HorizontalAlignment, 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::indicators;
use crate::widget::special_widgets::finance::layout::PlotArea;
use crate::widget::special_widgets::finance::types::{Bar, PriceLevelKind, PriceLine, PriceSeries};
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Overlay {
MovingAverage {
period: usize,
},
ExponentialMovingAverage {
period: usize,
},
BollingerBands {
period: usize,
multiplier: f64,
},
Vwap {
period: usize,
},
DonchianChannel {
period: usize,
},
}
impl Overlay {
pub fn moving_average(period: usize) -> Self {
Overlay::MovingAverage { period }
}
pub fn exponential_moving_average(period: usize) -> Self {
Overlay::ExponentialMovingAverage { period }
}
pub fn bollinger_bands(period: usize, multiplier: f64) -> Self {
Overlay::BollingerBands { period, multiplier }
}
pub fn vwap(period: usize) -> Self {
Overlay::Vwap { period }
}
pub fn donchian_channel(period: usize) -> Self {
Overlay::DonchianChannel { period }
}
pub fn warm_up_bars(self) -> usize {
match self {
Overlay::MovingAverage { period } => period,
Overlay::ExponentialMovingAverage { period } => period,
Overlay::BollingerBands { period, .. } => period,
Overlay::Vwap { period } => period,
Overlay::DonchianChannel { period } => period,
}
}
pub fn as_str(self) -> &'static str {
match self {
Overlay::MovingAverage { .. } => "moving_average",
Overlay::ExponentialMovingAverage { .. } => "exponential_moving_average",
Overlay::BollingerBands { .. } => "bollinger_bands",
Overlay::Vwap { .. } => "vwap",
Overlay::DonchianChannel { .. } => "donchian_channel",
}
}
}
pub struct CandlestickChart {
base: BaseWidget,
series: PriceSeries,
overlays: Vec<Overlay>,
price_lines: Vec<PriceLine>,
hovered_index: Option<usize>,
pub bar_clicked: Signal1<usize>,
pub bar_hovered: Signal1<usize>,
pub bar_unhovered: Signal1<usize>,
}
impl CandlestickChart {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::CandlestickChart, geometry, "CandlestickChart"),
series: PriceSeries::new(),
overlays: Vec::new(),
price_lines: Vec::new(),
hovered_index: None,
bar_clicked: Signal1::new(),
bar_hovered: Signal1::new(),
bar_unhovered: 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 overlays(&self) -> &[Overlay] {
&self.overlays
}
pub fn add_overlay(&mut self, overlay: Overlay) {
self.overlays.push(overlay);
self.base.request_redraw();
}
pub fn clear_overlays(&mut self) {
self.overlays.clear();
self.base.request_redraw();
}
pub fn price_lines(&self) -> &[PriceLine] {
&self.price_lines
}
pub fn add_price_line(&mut self, line: PriceLine) {
self.price_lines.push(line);
self.base.request_redraw();
}
pub fn clear_price_lines(&mut self) {
self.price_lines.clear();
self.base.request_redraw();
}
pub fn hovered_index(&self) -> Option<usize> {
self.hovered_index
}
pub fn hovered_bar(&self) -> Option<Bar> {
self.hovered_index.and_then(|index| self.series.bars().get(index).copied())
}
fn plot_area(&self) -> PlotArea {
PlotArea::of(self.base.geometry())
}
fn rising_color() -> Color {
Color::rgb(38, 166, 91)
}
fn falling_color() -> Color {
Color::rgb(220, 68, 70)
}
fn overlay_color(index: usize) -> Color {
const PALETTE: [Color; 6] = [
Color::rgb(255, 193, 7),
Color::rgb(33, 150, 243),
Color::rgb(156, 39, 176),
Color::rgb(0, 188, 212),
Color::rgb(255, 87, 34),
Color::rgb(139, 195, 74),
];
PALETTE[index % PALETTE.len()]
}
fn draw_series_line(
context: &mut RenderContext,
values: &[f64],
price_axis: &crate::widget::special_widgets::finance::layout::PriceAxis,
index_axis: &crate::widget::special_widgets::finance::layout::IndexAxis,
color: Color,
width: u32,
) {
let mut previous: Option<(i32, i32)> = None;
for (index, value) in values.iter().enumerate() {
if !value.is_finite() || index >= index_axis.count {
previous = None;
continue;
}
let point = (index_axis.center_for(index), price_axis.y_for(*value));
if let Some(from) = previous {
context.draw_line_stroke(
crate::core::Point { x: from.0, y: from.1 },
crate::core::Point { x: point.0, y: point.1 },
color,
width,
);
}
previous = Some(point);
}
}
fn draw_candles(&self, context: &mut RenderContext, area: &PlotArea) {
let bars = self.series.bars();
if bars.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let index_axis = area.index_axis(bars.len());
let body_width = index_axis.body_width();
let wick_x_offset = body_width / 2;
for (index, bar) in bars.iter().enumerate() {
if !bar.open.is_finite()
|| !bar.high.is_finite()
|| !bar.low.is_finite()
|| !bar.close.is_finite()
{
continue;
}
let slot_x = index_axis.x_for(index);
let color = if bar.is_rising() { Self::rising_color() } else { Self::falling_color() };
let wick_x = slot_x + wick_x_offset;
context.draw_line_stroke(
crate::core::Point { x: wick_x, y: price_axis.y_for(bar.high) },
crate::core::Point { x: wick_x, y: price_axis.y_for(bar.low) },
color,
1,
);
let y_open = price_axis.y_for(bar.open);
let y_close = price_axis.y_for(bar.close);
let body_top = y_open.min(y_close);
context.fill_rect(
Rect::new(
slot_x,
body_top,
body_width as u32,
(y_open - y_close).unsigned_abs().max(1),
),
color,
);
}
}
fn draw_overlays(&self, context: &mut RenderContext, area: &PlotArea) {
let bars = self.series.bars();
if bars.is_empty() || self.overlays.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
let index_axis = area.index_axis(bars.len());
let closes = self.series.closes();
let highs = self.series.highs();
let lows = self.series.lows();
let volumes = self.series.volumes();
for (overlay_index, overlay) in self.overlays.iter().enumerate() {
let color = Self::overlay_color(overlay_index);
match *overlay {
Overlay::MovingAverage { period } => {
Self::draw_series_line(
context,
&indicators::sma(&closes, period),
&price_axis,
&index_axis,
color,
1,
);
}
Overlay::ExponentialMovingAverage { period } => {
Self::draw_series_line(
context,
&indicators::ema(&closes, period),
&price_axis,
&index_axis,
color,
1,
);
}
Overlay::BollingerBands { period, multiplier } => {
let (lower, middle, upper) =
indicators::bollinger_bands(&closes, period, multiplier);
Self::draw_series_line(context, &upper, &price_axis, &index_axis, color, 1);
Self::draw_series_line(context, &middle, &price_axis, &index_axis, color, 1);
Self::draw_series_line(context, &lower, &price_axis, &index_axis, color, 1);
}
Overlay::Vwap { period } => {
Self::draw_series_line(
context,
&indicators::vwap(&highs, &lows, &closes, &volumes, period),
&price_axis,
&index_axis,
color,
1,
);
}
Overlay::DonchianChannel { period } => {
let (lower, middle, upper) =
indicators::donchian_channel(&highs, &lows, period);
Self::draw_series_line(context, &upper, &price_axis, &index_axis, color, 1);
Self::draw_series_line(context, &middle, &price_axis, &index_axis, color, 1);
Self::draw_series_line(context, &lower, &price_axis, &index_axis, color, 1);
}
}
}
}
fn draw_price_lines(&self, context: &mut RenderContext, area: &PlotArea) {
if self.price_lines.is_empty() {
return;
}
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
for line in &self.price_lines {
if !line.price.is_finite() {
continue;
}
let y = price_axis.y_for(line.price);
if line.price < price_axis.low || line.price > price_axis.high {
continue;
}
let color = match line.kind {
PriceLevelKind::Support => Color::rgb(38, 166, 91),
PriceLevelKind::Resistance => Color::rgb(220, 68, 70),
PriceLevelKind::PreviousClose => Color::rgb(158, 158, 158),
};
let mut x = area.rect.x;
while x < area.right() {
let dash_end = (x + 4).min(area.right());
context.draw_line_stroke(
crate::core::Point { x, y },
crate::core::Point { x: dash_end, y },
color,
1,
);
x += 8;
}
}
}
fn draw_price_labels(&self, context: &mut RenderContext, area: &PlotArea) {
let bar = match self.series.bars().first() {
Some(bar) => bar,
None => return,
};
let decimals = self.inferred_decimals();
let (low, high) = self.effective_price_extent();
let price_axis = area.price_axis(low, high);
for step in 0..=3 {
let fraction = step as f64 / 3.0;
let price = price_axis.low + fraction * (price_axis.high - price_axis.low);
let y = price_axis.y_for(price);
let text = format_fixed(price, decimals);
let width = text.len() as i32 * 7;
context.draw_text(
crate::core::Point { x: area.rect.x - 6 - width, y: y - 6 },
&text,
&Font::simple("Sans", 10.0),
Color::rgb(158, 158, 158),
HorizontalAlignment::Left,
);
let _ = bar;
}
}
fn inferred_decimals(&self) -> usize {
for bar in self.series.bars() {
for value in [bar.open, bar.high, bar.low, bar.close] {
if !value.is_finite() {
continue;
}
for decimals in 0..=4usize {
let scaled = value * 10f64.powi(decimals as i32);
if (scaled - scaled.round()).abs() < 1e-6 {
return decimals;
}
}
}
}
2
}
fn effective_price_extent(&self) -> (f64, f64) {
let (mut low, mut high) = self.series.price_extent();
if !low.is_finite() || !high.is_finite() {
return (0.0, 1.0);
}
let closes = self.series.closes();
let highs = self.series.highs();
let lows = self.series.lows();
let volumes = self.series.volumes();
let mut consider = |values: &[f64]| {
let (value_low, value_high) = indicators::series_extent(values);
if value_low.is_finite() {
low = low.min(value_low);
}
if value_high.is_finite() {
high = high.max(value_high);
}
};
for overlay in &self.overlays {
match *overlay {
Overlay::MovingAverage { period } => consider(&indicators::sma(&closes, period)),
Overlay::ExponentialMovingAverage { period } => {
consider(&indicators::ema(&closes, period))
}
Overlay::BollingerBands { period, multiplier } => {
let (lower, middle, upper) =
indicators::bollinger_bands(&closes, period, multiplier);
consider(&lower);
consider(&middle);
consider(&upper);
}
Overlay::Vwap { period } => {
consider(&indicators::vwap(&highs, &lows, &closes, &volumes, period))
}
Overlay::DonchianChannel { period } => {
let (lower, middle, upper) =
indicators::donchian_channel(&highs, &lows, period);
consider(&lower);
consider(&middle);
consider(&upper);
}
}
}
for line in &self.price_lines {
if line.price.is_finite() {
low = low.min(line.price);
high = high.max(line.price);
}
}
let span = high - low;
let pad = if span > 0.0 { span * 0.02 } else { high.abs().max(1.0) * 0.01 };
(low - pad, high + pad)
}
fn draw_crosshair(&self, context: &mut RenderContext, area: &PlotArea) {
let Some(index) = self.hovered_index else {
return;
};
let bars = self.series.bars();
if index >= bars.len() {
return;
}
let index_axis = area.index_axis(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,
);
let bar = bars[index];
let text =
format!("O {:.2} H {:.2} L {:.2} C {:.2}", bar.open, bar.high, bar.low, bar.close);
let width = text.len() as i32 * 7;
let label_x = if x + 8 + width > area.right() { x - 8 - width } else { x + 8 };
context.push_clip(area.rect.x, area.rect.y, area.rect.width, area.rect.height);
context.draw_text(
crate::core::Point { x: label_x.max(area.rect.x), y: area.rect.y + 2 },
&text,
&Font::simple("Sans", 11.0),
Color::rgb(230, 230, 230),
HorizontalAlignment::Left,
);
context.pop_clip();
}
}
fn format_fixed(value: f64, decimals: usize) -> String {
if !value.is_finite() {
return "—".to_string();
}
let factor = 10f64.powi(decimals as i32);
let rounded = (value * factor).round() / factor;
let mut text = alloc::format!("{:.1}", rounded);
if decimals > 1 {
let extra = (rounded * factor).round() as i64;
let digits = extra.unsigned_abs().to_string();
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 sign = if extra < 0 { "-" } else { "" };
text = alloc::format!(
"{sign}{}.{}",
&padded[..padded.len() - decimals],
&padded[padded.len() - decimals..]
);
}
text
}
impl Widget for CandlestickChart {
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 CandlestickChart {
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(previous) = self.hovered_index {
self.bar_unhovered.emit(previous);
}
if let Some(index) = next {
self.bar_hovered.emit(index);
}
self.hovered_index = next;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
if let Some(previous) = self.hovered_index {
self.bar_unhovered.emit(previous);
self.hovered_index = None;
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 CandlestickChart {
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));
self.draw_price_labels(context, &area);
self.draw_candles(context, &area);
self.draw_overlays(context, &area);
self.draw_price_lines(context, &area);
self.draw_crosshair(context, &area);
}
}
impl WidgetProperties for CandlestickChart {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"series" => Ok(CapabilityValue::String(serialise_closes(&self.series))),
"overlay_count" => Ok(CapabilityValue::UInt(self.overlays.len() as u64)),
"show_price_levels" => Ok(CapabilityValue::Bool(!self.price_lines.is_empty())),
_ => 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 price: f64 =
token.trim().parse().map_err(|_| CapabilityAccessError::OutOfRange)?;
series.push(Bar::flat(price, 0.0));
}
self.set_series(series);
Ok(())
}
"overlay_count" => {
let CapabilityValue::UInt(count) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.overlays.truncate(count as usize);
while self.overlays.len() < count as usize {
self.overlays.push(Overlay::moving_average(20));
}
self.base.request_redraw();
Ok(())
}
"show_price_levels" => {
let CapabilityValue::Bool(show) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
if !show {
self.clear_price_lines();
}
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of!["series", "overlay_count", "show_price_levels", BASE_PROPERTY_NAMES]
}
}
fn serialise_closes(series: &PriceSeries) -> alloc::string::String {
let mut text = alloc::string::String::new();
for (index, bar) in series.bars().iter().enumerate() {
if index > 0 {
text.push(',');
}
text.push_str(&alloc::format!("{}", bar.close));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
use crate::widget::special_widgets::finance::types::fixtures;
use alloc::sync::Arc;
use core::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn a_chart_reports_the_series_it_was_given() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
assert!(chart.series().is_empty());
chart.set_series(fixtures::sample_series(40));
assert_eq!(chart.series().len(), 40);
}
#[test]
fn overlays_accumulate_and_clear() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.add_overlay(Overlay::moving_average(20));
chart.add_overlay(Overlay::exponential_moving_average(9));
assert_eq!(chart.overlays().len(), 2);
assert_eq!(chart.overlays()[0], Overlay::MovingAverage { period: 20 });
chart.clear_overlays();
assert!(chart.overlays().is_empty());
}
#[test]
fn every_overlay_reports_its_warm_up() {
for overlay in [
Overlay::moving_average(20),
Overlay::exponential_moving_average(9),
Overlay::bollinger_bands(20, 2.0),
Overlay::vwap(14),
Overlay::donchian_channel(20),
] {
assert!(overlay.warm_up_bars() > 0, "{overlay:?} must report a warm-up");
assert!(!overlay.as_str().is_empty());
}
}
#[test]
fn overlay_names_are_stable() {
for overlay in [
Overlay::moving_average(1),
Overlay::exponential_moving_average(1),
Overlay::bollinger_bands(1, 1.0),
Overlay::vwap(1),
Overlay::donchian_channel(1),
] {
let name = overlay.as_str();
assert!(!name.is_empty(), "every overlay needs a factory spelling");
}
}
#[test]
fn price_lines_accumulate_and_clear() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.add_price_line(PriceLine::new(95.0, PriceLevelKind::Support));
chart.add_price_line(PriceLine::new(120.0, PriceLevelKind::Resistance));
assert_eq!(chart.price_lines().len(), 2);
chart.clear_price_lines();
assert!(chart.price_lines().is_empty());
}
#[test]
fn replacing_the_series_drops_a_stale_hover() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(50));
chart.set_geometry(Rect::new(0, 0, 640, 360));
chart.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 640 - 4, y: 100 },
});
chart.set_series(fixtures::sample_series(3));
assert!(
chart.hovered_index().is_none_or(|index| index < 3),
"a hover must not survive past the series it pointed into"
);
}
#[test]
fn an_empty_series_draws_without_panicking() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.add_overlay(Overlay::moving_average(20));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 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(f64::NAN, f64::NAN, f64::NAN, f64::NAN, f64::NAN));
series.push(Bar::new(10.0, 5.0, 20.0, 12.0, 100.0));
series.push(Bar::flat(10.0, 0.0));
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.set_series(series);
chart.add_overlay(Overlay::bollinger_bands(20, 2.0));
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn a_flat_series_draws_without_panicking() {
let mut series = PriceSeries::new();
for _ in 0..10 {
series.push(Bar::flat(50.0, 10.0));
}
let mut chart = CandlestickChart::new(Rect::new(0, 0, 320, 200));
chart.set_series(series);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(320, 200), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
#[test]
fn every_overlay_kind_draws() {
for overlay in [
Overlay::moving_average(10),
Overlay::exponential_moving_average(10),
Overlay::bollinger_bands(10, 2.0),
Overlay::vwap(10),
Overlay::donchian_channel(10),
] {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 480, 320));
chart.set_series(fixtures::sample_series(60));
chart.add_overlay(overlay);
let mut backend =
crate::render::SoftwarePaintBackend::new(crate::core::Size::new(480, 320), 1.0);
let mut context = RenderContext::new(&mut backend);
chart.draw(&mut context);
}
}
#[test]
fn clicking_a_bar_emits_its_index() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(20));
let seen = Arc::new(AtomicUsize::new(usize::MAX));
let sink = Arc::clone(&seen);
chart.bar_clicked.connect(move |index| sink.store(*index, Ordering::SeqCst));
chart.handle_event(&crate::event::Event::MousePress {
pos: crate::core::Point { x: 70, y: 100 },
button: 0,
});
let index = seen.load(Ordering::SeqCst);
assert_ne!(index, usize::MAX, "a press inside the plot must emit a bar index");
assert!(index < 20, "and the index must be within the series");
}
#[test]
fn clicking_outside_the_plot_emits_nothing() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
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: 5, y: 100 },
button: 0,
});
assert_eq!(count.load(Ordering::SeqCst), 0, "a press on the price axis is not on a bar");
}
#[test]
fn hovering_emits_and_exiting_un_hovers() {
let mut chart = CandlestickChart::new(Rect::new(0, 0, 640, 360));
chart.set_series(fixtures::sample_series(20));
let hovered = Arc::new(AtomicUsize::new(usize::MAX));
let unhovered = Arc::new(AtomicUsize::new(usize::MAX));
let hovered_sink = Arc::clone(&hovered);
let unhovered_sink = Arc::clone(&unhovered);
chart.bar_hovered.connect(move |index| hovered_sink.store(*index, Ordering::SeqCst));
chart.bar_unhovered.connect(move |index| unhovered_sink.store(*index, Ordering::SeqCst));
chart.handle_event(&crate::event::Event::MouseMove {
pos: crate::core::Point { x: 70, y: 100 },
});
let hovered_index = hovered.load(Ordering::SeqCst);
assert_ne!(hovered_index, usize::MAX, "moving over a bar emits it");
chart.handle_event(&crate::event::Event::MouseLeave {
pos: crate::core::Point { x: 0, y: 0 },
});
assert_eq!(
unhovered.load(Ordering::SeqCst),
hovered_index,
"leaving reports the bar that was hovered"
);
assert!(chart.hovered_index().is_none());
}
}