use uzor::render::RenderContext;
use crate::coord::PlotArea;
use crate::interact::action::{FigureInputAction, FigureOutputAction};
use crate::scale::Scale;
use crate::theme::FigureTheme;
const HANDLE_WIDTH: f64 = 3.0;
const FILL_ALPHA: f64 = 0.18;
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct BrushState {
pub anchor_px: f64,
pub current_px: f64,
pub active: bool,
}
impl BrushState {
pub fn start(&mut self, px: f64) {
self.anchor_px = px;
self.current_px = px;
self.active = true;
}
pub fn update(&mut self, px: f64) {
if self.active {
self.current_px = px;
}
}
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn is_active(&self) -> bool {
self.active
}
pub fn pixel_range(&self) -> (f64, f64) {
(self.anchor_px.min(self.current_px), self.anchor_px.max(self.current_px))
}
pub fn domain_interval(&self, area: &PlotArea, scale: &dyn Scale) -> Option<(f64, f64)> {
if !self.active {
return None;
}
let width = area.rect.width;
if width.abs() < f64::EPSILON {
return None;
}
let (lo_px, hi_px) = self.pixel_range();
let t0 = ((lo_px - area.rect.x) / width).clamp(0.0, 1.0);
let t1 = ((hi_px - area.rect.x) / width).clamp(0.0, 1.0);
let d0 = scale.invert(t0);
let d1 = scale.invert(t1);
Some((d0.min(d1), d0.max(d1)))
}
pub fn handle(&mut self, action: &FigureInputAction, area: &PlotArea, scale: &dyn Scale) -> FigureOutputAction {
match *action {
FigureInputAction::DragStart { x, .. } => {
self.start(x);
FigureOutputAction::Redraw
}
FigureInputAction::DragMove { x, .. } if self.active => {
self.update(x);
FigureOutputAction::BrushChanged { interval: self.domain_interval(area, scale) }
}
FigureInputAction::DragEnd { x, .. } if self.active => {
self.update(x);
FigureOutputAction::BrushChanged { interval: self.domain_interval(area, scale) }
}
_ => FigureOutputAction::None,
}
}
}
pub fn draw_brush_overlay(ctx: &mut dyn RenderContext, area: &PlotArea, state: &BrushState, theme: &FigureTheme) {
if !state.active {
return;
}
let (x0, x1) = state.pixel_range();
let width = (x1 - x0).max(0.0);
let accent = &theme.palette[0];
ctx.set_fill_color(accent);
ctx.set_global_alpha(FILL_ALPHA);
ctx.fill_rect(x0, area.rect.y, width, area.rect.height);
ctx.set_global_alpha(1.0);
ctx.set_fill_color(accent);
let left_handle_x = (x0 - HANDLE_WIDTH / 2.0).max(area.rect.x);
let right_handle_x = (x1 - HANDLE_WIDTH / 2.0).min(area.rect.right() - HANDLE_WIDTH);
ctx.fill_rect(left_handle_x, area.rect.y, HANDLE_WIDTH, area.rect.height);
ctx.fill_rect(right_handle_x, area.rect.y, HANDLE_WIDTH, area.rect.height);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scale::LinearScale;
use uzor::types::Rect;
fn area() -> PlotArea {
PlotArea::new(Rect::new(0.0, 0.0, 200.0, 100.0))
}
#[test]
fn inactive_brush_reports_no_interval() {
let brush = BrushState::default();
assert!(!brush.is_active());
assert_eq!(brush.domain_interval(&area(), &LinearScale::new(0.0, 100.0)), None);
}
#[test]
fn drag_sequence_domain_interval_matches_scale_invert() {
let a = area();
let scale = LinearScale::new(0.0, 100.0);
let mut brush = BrushState::default();
brush.start(50.0); brush.update(150.0); let (d0, d1) = brush.domain_interval(&a, &scale).expect("active brush should report an interval");
assert!((d0 - 25.0).abs() < 1e-9);
assert!((d1 - 75.0).abs() < 1e-9);
brush.update(10.0); let (d0, d1) = brush.domain_interval(&a, &scale).expect("still active after another update");
assert!((d0 - 5.0).abs() < 1e-9);
assert!((d1 - 25.0).abs() < 1e-9);
brush.clear();
assert_eq!(brush.domain_interval(&a, &scale), None);
}
#[test]
fn handle_drag_lifecycle_produces_expected_outputs() {
let a = area();
let scale = LinearScale::new(0.0, 100.0);
let mut brush = BrushState::default();
let out = brush.handle(&FigureInputAction::DragStart { x: 50.0, y: 0.0 }, &a, &scale);
assert_eq!(out, FigureOutputAction::Redraw);
assert!(brush.is_active());
let out = brush.handle(&FigureInputAction::DragMove { x: 150.0, y: 0.0 }, &a, &scale);
match out {
FigureOutputAction::BrushChanged { interval: Some((d0, d1)) } => {
assert!((d0 - 25.0).abs() < 1e-9);
assert!((d1 - 75.0).abs() < 1e-9);
}
other => panic!("expected BrushChanged with an interval, got {other:?}"),
}
let out = brush.handle(&FigureInputAction::DragEnd { x: 150.0, y: 0.0 }, &a, &scale);
assert!(matches!(out, FigureOutputAction::BrushChanged { interval: Some(_) }));
assert!(brush.is_active(), "selection persists after drag end");
}
#[test]
fn handle_ignores_non_drag_actions() {
let a = area();
let scale = LinearScale::new(0.0, 100.0);
let mut brush = BrushState::default();
let out = brush.handle(&FigureInputAction::Hover { x: 10.0, y: 10.0 }, &a, &scale);
assert_eq!(out, FigureOutputAction::None);
assert!(!brush.is_active());
}
#[test]
fn handle_ignores_drag_move_end_without_a_prior_start() {
let a = area();
let scale = LinearScale::new(0.0, 100.0);
let mut brush = BrushState::default();
let out = brush.handle(&FigureInputAction::DragMove { x: 50.0, y: 0.0 }, &a, &scale);
assert_eq!(out, FigureOutputAction::None);
assert!(!brush.is_active());
}
}