maolan-widgets 0.0.2

Widgets used for Maolan DAW
Documentation
use iced::widget::{
    canvas,
    canvas::{Geometry, Path, Text},
};
use iced::{Color, Element, Length, Point, Rectangle, Renderer, Theme, mouse};
use std::{
    cell::Cell,
    collections::hash_map::DefaultHasher,
    hash::{Hash, Hasher},
};

const SCALE_WIDTH: f32 = 22.0;
const SCALE_GAP: f32 = 3.0;
const OUTER_PAD_Y: f32 = 7.0;

#[derive(Default)]
struct State {
    cache: canvas::Cache,
    last_hash: Cell<u64>,
}

#[derive(Clone)]
struct TicksCanvas {
    range: std::ops::RangeInclusive<f32>,
    fader_height: f32,
}

impl TicksCanvas {
    fn static_hash(&self, bounds: Rectangle) -> u64 {
        let mut hasher = DefaultHasher::new();
        bounds.width.to_bits().hash(&mut hasher);
        bounds.height.to_bits().hash(&mut hasher);
        self.range.start().to_bits().hash(&mut hasher);
        self.range.end().to_bits().hash(&mut hasher);
        self.fader_height.to_bits().hash(&mut hasher);
        hasher.finish()
    }

    fn value_to_y(range: &std::ops::RangeInclusive<f32>, value: f32, fader_height: f32) -> f32 {
        let start = *range.start();
        let end = *range.end();
        let span = (end - start).abs().max(f32::EPSILON);
        let normalized = ((value - start) / span).clamp(0.0, 1.0);
        fader_height * (1.0 - normalized)
    }

    fn tick_layout(&self) -> Vec<(f32, String)> {
        Self::tick_values(&self.range)
            .into_iter()
            .map(|value| {
                let y = Self::value_to_y(&self.range, value, self.fader_height)
                    .clamp(0.0, self.fader_height - 1.0);
                let label_y = (y - 4.0).clamp(0.0, (self.fader_height - 10.0).max(0.0));
                (label_y, Self::format_tick_label(value))
            })
            .collect()
    }

    fn tick_values(range: &std::ops::RangeInclusive<f32>) -> Vec<f32> {
        let start = *range.start();
        let end = *range.end();
        let min = start.min(end);
        let max = start.max(end);
        let span = (max - min).max(f32::EPSILON);
        let step = Self::nice_step(span / 9.0);

        let mut values = Vec::new();
        values.push(min);

        let first = (min / step).ceil() * step;
        let mut value = first;
        while value < max {
            if (value - min).abs() > 0.0001 && (value - max).abs() > 0.0001 {
                values.push(Self::normalize_zero(value));
            }
            value += step;
        }

        if (max - min).abs() > 0.0001 {
            values.push(max);
        }

        values.sort_by(|a, b| a.total_cmp(b));
        values.dedup_by(|a, b| (*a - *b).abs() < 0.0001);
        values
    }

    fn nice_step(rough_step: f32) -> f32 {
        if rough_step <= 0.0 {
            return 1.0;
        }

        let base_exp = rough_step.log10().floor() as i32;
        let mut best = rough_step;
        let mut best_diff = f32::INFINITY;

        for exp in (base_exp - 1)..=(base_exp + 1) {
            let scale = 10_f32.powi(exp);
            for multiplier in [1.0, 2.0, 2.5, 5.0, 10.0] {
                let candidate = multiplier * scale;
                let diff = (candidate - rough_step).abs();
                if diff < best_diff {
                    best = candidate;
                    best_diff = diff;
                }
            }
        }

        best.max(f32::EPSILON)
    }

    fn normalize_zero(value: f32) -> f32 {
        if value.abs() < 0.0001 { 0.0 } else { value }
    }

    fn format_tick_label(value: f32) -> String {
        let value = Self::normalize_zero(value);
        if value == 0.0 {
            return "0".to_string();
        }

        if (value.fract()).abs() < 0.0001 {
            format!("{value:+.0}")
        } else {
            format!("{value:+.1}")
        }
    }
}

impl<Message> canvas::Program<Message> for TicksCanvas {
    type State = State;

    fn draw(
        &self,
        state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<Geometry> {
        if bounds.width <= 0.0 || bounds.height <= 0.0 {
            return vec![];
        }

        let static_hash = self.static_hash(bounds);
        if state.last_hash.get() != static_hash {
            state.cache.clear();
            state.last_hash.set(static_hash);
        }

        let static_geometry = state.cache.draw(renderer, bounds.size(), |frame| {
            let tick_x = SCALE_GAP;
            for (label_y, label) in self.tick_layout() {
                frame.fill(
                    &Path::rectangle(
                        Point::new(tick_x, OUTER_PAD_Y + label_y + 4.0),
                        iced::Size::new(4.0, 1.0),
                    ),
                    Color::from_rgba(0.62, 0.67, 0.77, 0.78),
                );
                frame.fill_text(Text {
                    content: label,
                    position: Point::new(tick_x + 6.0, OUTER_PAD_Y + label_y),
                    color: Color::from_rgba(0.9, 0.92, 0.96, 0.9),
                    size: 8.0.into(),
                    ..Default::default()
                });
            }
        });

        vec![static_geometry]
    }
}

pub fn ticks<'a, Message>(
    range: std::ops::RangeInclusive<f32>,
    fader_height: f32,
) -> Element<'a, Message>
where
    Message: 'a,
{
    canvas(TicksCanvas {
        range,
        fader_height,
    })
    .width(Length::Fixed(SCALE_GAP + SCALE_WIDTH))
    .height(Length::Fixed(fader_height + (OUTER_PAD_Y * 2.0)))
    .into()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn value_to_y_maps_extremes_and_midpoint() {
        let height = 110.0;
        let range = -90.0..=20.0;

        assert_eq!(TicksCanvas::value_to_y(&range, 20.0, height), 0.0);
        assert_eq!(TicksCanvas::value_to_y(&range, -90.0, height), height);

        let midpoint = (-90.0 + 20.0) * 0.5;
        assert!((TicksCanvas::value_to_y(&range, midpoint, height) - 55.0).abs() < 0.001);
    }

    #[test]
    fn tick_values_include_range_endpoints() {
        let range = -90.0..=20.0;
        let values = TicksCanvas::tick_values(&range);

        assert_eq!(values.first().copied(), Some(-90.0));
        assert_eq!(values.last().copied(), Some(20.0));
    }

    #[test]
    fn tick_values_are_generated_from_range() {
        let range = -90.0..=20.0;
        let values = TicksCanvas::tick_values(&range);

        assert!(values.contains(&0.0));
        assert!(values.contains(&10.0));
        assert!(!values.contains(&12.0));
    }

    #[test]
    fn tick_layout_positions_stay_in_bounds() {
        let height = 110.0;
        let canvas = TicksCanvas {
            range: -90.0..=20.0,
            fader_height: height,
        };
        let layout = canvas.tick_layout();

        for (y, _) in layout {
            assert!(y >= 0.0);
            assert!(y <= (height - 10.0).max(0.0));
        }
    }

    #[test]
    fn format_tick_label_formats_zero_integer_and_decimal_values() {
        assert_eq!(TicksCanvas::format_tick_label(0.0), "0");
        assert_eq!(TicksCanvas::format_tick_label(12.0), "+12");
        assert_eq!(TicksCanvas::format_tick_label(-3.5), "-3.5");
    }
}