use crate::core::{Color, Rect};
use crate::style::WidgetStyle;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct IndexAxis {
pub origin_x: i32,
pub slot_width: i32,
pub count: usize,
}
impl IndexAxis {
pub fn new(origin_x: i32, width: i32, count: usize) -> Self {
let slot_width = if count == 0 { 0 } else { (width.max(0) / count as i32).max(1) };
Self { origin_x, slot_width, count }
}
pub fn x_for(&self, index: usize) -> i32 {
self.origin_x + self.slot_width * index as i32
}
pub fn center_for(&self, index: usize) -> i32 {
self.x_for(index) + self.slot_width / 2
}
pub fn body_width(&self) -> i32 {
if self.count == 0 {
0
} else if self.slot_width <= 2 {
1
} else {
self.slot_width - 2
}
}
pub fn index_at(&self, x: i32) -> Option<usize> {
if self.slot_width <= 0 || self.count == 0 {
return None;
}
let offset = x - self.origin_x;
if offset < 0 || offset >= self.slot_width * self.count as i32 {
return None;
}
Some((offset / self.slot_width) as usize)
}
pub fn total_width(&self) -> i32 {
self.slot_width * self.count as i32
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PriceAxis {
pub top: i32,
pub bottom: i32,
pub low: f64,
pub high: f64,
}
impl PriceAxis {
pub fn new(top: i32, bottom: i32, low: f64, high: f64) -> Self {
let mut adjusted_low = low;
let mut adjusted_high = high;
if !adjusted_low.is_finite() || !adjusted_high.is_finite() {
adjusted_low = 0.0;
adjusted_high = 1.0;
} else if adjusted_high <= adjusted_low {
let magnitude = adjusted_low.abs().max(1.0) * 0.001;
adjusted_low -= magnitude;
adjusted_high += magnitude;
}
Self { top, bottom, low: adjusted_low, high: adjusted_high }
}
pub fn y_for(&self, price: f64) -> i32 {
if !price.is_finite() {
return self.bottom;
}
let span = self.high - self.low;
if span <= 0.0 {
return self.bottom;
}
let fraction = (price - self.low) / span;
let y = self.bottom as f64 - fraction * (self.bottom - self.top) as f64;
(y.round() as i32).clamp(self.top.min(self.bottom), self.top.max(self.bottom))
}
pub fn height(&self) -> i32 {
(self.bottom - self.top).abs()
}
pub fn price_at(&self, y: i32) -> f64 {
let height = (self.bottom - self.top) as f64;
if height == 0.0 {
return self.low;
}
let fraction = (self.bottom - y) as f64 / height;
self.low + fraction * (self.high - self.low)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn index_at_honours_both_edges() {
let axis = IndexAxis::new(10, 100, 10);
assert_eq!(axis.index_at(9), None, "one pixel left of the plot is outside");
assert_eq!(axis.index_at(10), Some(0), "the plot's own left edge is bar 0");
assert_eq!(axis.index_at(109), Some(9), "the last bar's last pixel");
assert_eq!(axis.index_at(110), None, "the first pixel past the plot is outside");
}
#[test]
fn index_at_rejects_points_outside_a_negative_origin_plot() {
let axis = IndexAxis::new(-30, 100, 10);
assert_eq!(axis.index_at(-30), Some(0), "the plot starts at -30");
assert_eq!(axis.index_at(-31), None, "left of the plot's own left edge");
assert_eq!(axis.index_at(69), Some(9), "the last bar inside the plot");
assert_eq!(axis.index_at(70), None, "the first pixel past the plot");
}
#[test]
fn an_empty_axis_has_no_body_width() {
let axis = IndexAxis::new(0, 100, 0);
assert_eq!(axis.slot_width, 0);
assert_eq!(axis.body_width(), 0);
assert_eq!(axis.total_width(), 0);
}
#[test]
fn a_one_pixel_slot_still_has_a_visible_body() {
let axis = IndexAxis::new(0, 10, 10);
assert_eq!(axis.slot_width, 1);
assert_eq!(axis.body_width(), 1);
}
#[test]
fn a_degenerate_price_range_is_widened() {
let axis = PriceAxis::new(0, 100, 50.0, 50.0);
assert!(axis.high > axis.low, "the range must be usable");
let middle = axis.y_for(50.0);
assert!((0..=100).contains(&middle));
}
#[test]
fn a_higher_price_maps_to_a_smaller_y() {
let axis = PriceAxis::new(0, 100, 90.0, 110.0);
assert!(axis.y_for(110.0) < axis.y_for(90.0));
assert_eq!(axis.y_for(110.0), 0);
assert_eq!(axis.y_for(90.0), 100);
}
#[test]
fn every_stacked_pane_reserves_the_same_horizontal_margins() {
let rect = Rect::new(0, 0, 240, 120);
let reference = PlotArea::price_pane(rect);
for (name, area) in [
("volume", PlotArea::volume_pane(rect)),
("indicator", PlotArea::indicator_pane(rect)),
("generic", PlotArea::of(rect)),
] {
assert_eq!(area.rect.x, reference.rect.x, "{name} pane's left edge");
assert_eq!(area.rect.width, reference.rect.width, "{name} pane's width");
}
}
#[test]
fn an_indicator_pane_keeps_the_shared_margins_and_a_shorter_bottom() {
let area = PlotArea::indicator_pane(Rect::new(0, 0, 240, 120));
assert_eq!(area.left_margin, PlotArea::DEFAULT_LEFT_MARGIN);
assert_eq!(area.right_margin, PlotArea::DEFAULT_RIGHT_MARGIN);
assert!(area.bottom_margin < PlotArea::DEFAULT_BOTTOM_MARGIN);
}
#[test]
fn the_panel_ink_is_legible_on_the_panel_it_is_painted_on() {
let style = WidgetStyle::default()
.with_background(Color::rgb(18, 22, 28))
.with_text_color(Color::rgb(30, 34, 40));
let colors = panel_colors(Some(&style));
assert_eq!(colors.surface, Color::rgb(18, 22, 28), "the caller's surface wins");
assert!(
colors.ink.contrast_ratio(colors.surface) >= PANEL_MIN_CONTRAST,
"ink {} on surface {},{},{} measured {:.2}:1",
colors.ink.r,
colors.surface.r,
colors.surface.g,
colors.surface.b,
colors.ink.contrast_ratio(colors.surface)
);
}
#[test]
fn the_gridline_sits_between_the_surface_and_the_ink() {
let colors =
panel_colors(Some(&WidgetStyle::default().with_background(Color::rgb(18, 22, 28))));
assert_ne!(colors.grid, colors.surface, "a gridline in the panel colour is not a gridline");
assert_ne!(colors.grid, colors.ink, "a gridline at full ink is a rule, not a grid");
}
#[test]
fn the_crosshair_follows_the_surface_and_ink_it_is_drawn_from() {
let dark =
panel_colors(Some(&WidgetStyle::default().with_background(Color::rgb(18, 22, 28))));
let light =
panel_colors(Some(&WidgetStyle::default().with_background(Color::rgb(255, 255, 255))));
assert_ne!(
dark.crosshair, light.crosshair,
"a crosshair that does not move with the pane is the literal this test exists for"
);
for colors in [dark, light] {
assert_ne!(
colors.crosshair, colors.surface,
"a crosshair in the panel colour is invisible"
);
assert_ne!(colors.crosshair, colors.ink, "a crosshair at full ink is a data line");
assert_ne!(colors.grid, colors.crosshair, "the crosshair is the stronger hairline");
assert_ne!(colors.reference, colors.crosshair, "the level line is the weaker one");
assert_ne!(
colors.reference, colors.surface,
"a level line in the panel colour is invisible"
);
}
}
#[test]
fn a_surface_equal_to_the_window_fill_is_stepped_away_from_it() {
let window_fill = crate::style::theme_manager()
.current_theme()
.map(|active| active.colors.background)
.unwrap_or(Color::rgb(240, 240, 240));
let theme_derived = panel_colors(None);
assert_ne!(
theme_derived.surface, window_fill,
"a panel painted in the window fill has no visible extent"
);
let caller_choice = Color::rgb(1, 2, 3);
let caller_derived =
panel_colors(Some(&WidgetStyle::default().with_background(caller_choice)));
assert_eq!(
caller_derived.surface, caller_choice,
"the caller's colour is honoured verbatim"
);
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PlotArea {
pub rect: Rect,
pub left_margin: i32,
pub right_margin: i32,
pub top_margin: i32,
pub bottom_margin: i32,
}
impl PlotArea {
const DEFAULT_LEFT_MARGIN: i32 = 48;
const DEFAULT_RIGHT_MARGIN: i32 = 8;
const DEFAULT_TOP_MARGIN: i32 = 8;
pub(crate) const DEFAULT_BOTTOM_MARGIN: i32 = 20;
pub fn of(rect: Rect) -> Self {
Self::with_margins(
rect,
Self::DEFAULT_LEFT_MARGIN,
Self::DEFAULT_RIGHT_MARGIN,
Self::DEFAULT_TOP_MARGIN,
Self::DEFAULT_BOTTOM_MARGIN,
)
}
pub fn price_pane(rect: Rect) -> Self {
Self::of(rect)
}
pub fn volume_pane(rect: Rect) -> Self {
Self::with_margins(rect, Self::DEFAULT_LEFT_MARGIN, Self::DEFAULT_RIGHT_MARGIN, 6, 6)
}
pub fn indicator_pane(rect: Rect) -> Self {
Self::with_margins(
rect,
Self::DEFAULT_LEFT_MARGIN,
Self::DEFAULT_RIGHT_MARGIN,
Self::DEFAULT_TOP_MARGIN,
Self::DEFAULT_TOP_MARGIN,
)
}
pub fn with_margins(rect: Rect, left: i32, right: i32, top: i32, bottom: i32) -> Self {
let max_horizontal = (rect.width as i32).saturating_sub(4).max(0) / 2;
let left_margin = left.min(max_horizontal).max(0);
let right_margin = right.min(max_horizontal).max(0);
let max_vertical = (rect.height as i32).saturating_sub(2).max(0) / 2;
let top_margin = top.min(max_vertical).max(0);
let bottom_margin = bottom.min(max_vertical).max(0);
let width = (rect.width as i32 - left_margin - right_margin).max(0) as u32;
let height = (rect.height as i32 - top_margin - bottom_margin).max(0) as u32;
Self {
rect: Rect::new(rect.x + left_margin, rect.y + top_margin, width, height),
left_margin,
right_margin,
top_margin,
bottom_margin,
}
}
pub fn index_axis(&self, count: usize) -> IndexAxis {
IndexAxis::new(self.rect.x, self.rect.width as i32, count)
}
pub fn price_axis(&self, low: f64, high: f64) -> PriceAxis {
PriceAxis::new(self.rect.y, self.rect.y + self.rect.height as i32, low, high)
}
pub fn right(&self) -> i32 {
self.rect.x + self.rect.width as i32
}
pub fn bottom(&self) -> i32 {
self.rect.y + self.rect.height as i32
}
pub fn label_x(&self) -> i32 {
self.rect.x - 4
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PanelColors {
pub surface: Color,
pub ink: Color,
pub grid: Color,
pub crosshair: Color,
pub reference: Color,
}
impl PanelColors {
pub fn from_parts(surface: Color, ink: Color) -> Self {
PanelColors {
surface,
ink,
grid: surface.blend(&ink, 0.22),
crosshair: surface.blend(&ink, 0.45),
reference: surface.blend(&ink, 0.32),
}
}
}
pub const PANEL_MIN_CONTRAST: f32 = 4.5;
pub fn panel_colors(style: Option<&WidgetStyle>) -> PanelColors {
let theme = crate::style::resolved_theme_style("chart");
let window_fill = {
let manager = crate::style::theme_manager();
manager
.current_theme()
.map(|active| active.colors.background)
.unwrap_or(Color::rgb(240, 240, 240))
};
let resolved = style
.and_then(|s| s.background_color)
.or_else(|| theme.as_ref().and_then(|t| t.background_color));
let surface = match resolved {
Some(colour) if colour != window_fill => colour,
_ => {
let base = resolved.unwrap_or(window_fill);
base.blend(&base.contrast_color(), 0.08)
}
};
let ink = style
.and_then(|s| s.text_color)
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or_else(|| surface.contrast_color());
let ink = ink.legible_on(surface, PANEL_MIN_CONTRAST);
PanelColors::from_parts(surface, ink)
}