maolan-widgets 0.0.14

Widgets used for Maolan DAW
Documentation
use iced::{
    Alignment, Background, Border, Color, Element, Length, Theme,
    widget::{button, column, container, row, text_input},
};
use iced_fonts::lucide::{chevron_down, chevron_up};
use std::{
    fmt::Display,
    ops::{Add, RangeInclusive, Sub},
    str::FromStr,
};

fn spinner_button_style(theme: &Theme, status: button::Status) -> button::Style {
    let palette = theme.extended_palette();
    let active_bg = palette.primary.strong.color;
    let hovered_bg = palette.primary.base.color;
    let disabled_bg = Color {
        a: active_bg.a * 0.4,
        ..active_bg
    };
    let mut style = button::Style {
        background: Some(Background::Color(match status {
            button::Status::Hovered | button::Status::Pressed => hovered_bg,
            button::Status::Disabled => disabled_bg,
            _ => active_bg,
        })),
        text_color: match status {
            button::Status::Disabled => Color {
                a: palette.primary.strong.text.a * 0.45,
                ..palette.primary.strong.text
            },
            _ => palette.primary.strong.text,
        },
        ..button::Style::default()
    };
    style.border = Border {
        color: Color::from_rgba(0.0, 0.0, 0.0, 0.0),
        width: 1.0,
        radius: 3.0.into(),
    };
    style
}

fn shell_style(_theme: &Theme) -> container::Style {
    container::Style {
        text_color: Some(Color::from_rgb(0.92, 0.92, 0.92)),
        background: Some(Background::Color(Color::from_rgba(0.10, 0.10, 0.10, 1.0))),
        border: Border {
            color: Color::from_rgba(0.28, 0.28, 0.28, 1.0),
            width: 1.0,
            radius: 2.0.into(),
        },
        ..container::Style::default()
    }
}

fn input_style(theme: &Theme, status: text_input::Status) -> text_input::Style {
    let mut style = text_input::default(theme, status);
    style.background = Background::Color(Color::TRANSPARENT);
    style.border = Border {
        color: Color::TRANSPARENT,
        width: 0.0,
        radius: 0.0.into(),
    };
    style
}

pub fn number_input<'a, T, Message>(
    value: &'a T,
    bounds: RangeInclusive<T>,
    on_change: impl Fn(T) -> Message + 'a + Copy,
) -> Element<'a, Message>
where
    T: Copy + Display + FromStr + PartialOrd + Add<Output = T> + Sub<Output = T> + From<u8> + 'a,
    Message: Clone + 'a,
{
    let min = *bounds.start();
    let max = *bounds.end();
    let current = *value;
    let step = T::from(1_u8);
    let dec_value = if current > min + step {
        current - step
    } else {
        min
    };
    let inc_value = if current < max - step {
        current + step
    } else {
        max
    };

    let input = text_input("", &current.to_string())
        .on_input(move |raw| {
            raw.parse::<T>()
                .map(|parsed| {
                    let clamped = if parsed < min {
                        min
                    } else if parsed > max {
                        max
                    } else {
                        parsed
                    };
                    on_change(clamped)
                })
                .unwrap_or_else(|_| on_change(current))
        })
        .style(input_style)
        .padding([5, 8])
        .width(Length::Fixed(72.0))
        .size(14);

    let decrement = button(
        container(chevron_down().size(14))
            .center_x(Length::Fill)
            .center_y(Length::Fill),
    )
    .style(spinner_button_style)
    .padding(0)
    .width(Length::Fixed(22.0))
    .height(Length::Fixed(15.0));
    let decrement = if current > min {
        decrement.on_press(on_change(dec_value))
    } else {
        decrement
    };

    let increment = button(
        container(chevron_up().size(14))
            .center_x(Length::Fill)
            .center_y(Length::Fill),
    )
    .style(spinner_button_style)
    .padding(0)
    .width(Length::Fixed(22.0))
    .height(Length::Fixed(15.0));
    let increment = if current < max {
        increment.on_press(on_change(inc_value))
    } else {
        increment
    };

    container(
        row![
            container(input)
                .width(Length::Fixed(72.0))
                .center_y(Length::Fixed(30.0)),
            column![increment, decrement]
                .spacing(0)
                .width(Length::Fixed(22.0))
                .align_x(Alignment::Center),
        ]
        .spacing(0)
        .align_y(Alignment::Center),
    )
    .style(shell_style)
    .into()
}

fn format_decimal_value(value: f32) -> String {
    let mut formatted = format!("{value:.3}");
    while formatted.contains('.') && formatted.ends_with('0') {
        formatted.pop();
    }
    if formatted.ends_with('.') {
        formatted.pop();
    }
    formatted
}

pub fn number_input_f32<'a, Message>(
    value: &'a str,
    bounds: RangeInclusive<f32>,
    step: f32,
    on_change: impl Fn(String) -> Message + 'a + Copy,
) -> Element<'a, Message>
where
    Message: Clone + 'a,
{
    let min = *bounds.start();
    let max = *bounds.end();
    let parsed_current = value.trim().parse::<f32>().ok();
    let current = parsed_current.unwrap_or(min).clamp(min, max);
    let dec_value = (current - step).clamp(min, max);
    let inc_value = (current + step).clamp(min, max);

    let input = text_input("", value)
        .on_input(on_change)
        .style(input_style)
        .padding([5, 8])
        .width(Length::Fixed(72.0))
        .size(14);

    let decrement = button(
        container(chevron_down().size(14))
            .center_x(Length::Fill)
            .center_y(Length::Fill),
    )
    .style(spinner_button_style)
    .padding(0)
    .width(Length::Fixed(22.0))
    .height(Length::Fixed(15.0));
    let decrement = if current > min {
        decrement.on_press(on_change(format_decimal_value(dec_value)))
    } else {
        decrement
    };

    let increment = button(
        container(chevron_up().size(14))
            .center_x(Length::Fill)
            .center_y(Length::Fill),
    )
    .style(spinner_button_style)
    .padding(0)
    .width(Length::Fixed(22.0))
    .height(Length::Fixed(15.0));
    let increment = if current < max {
        increment.on_press(on_change(format_decimal_value(inc_value)))
    } else {
        increment
    };

    container(
        row![
            container(input)
                .width(Length::Fixed(72.0))
                .center_y(Length::Fixed(30.0)),
            column![increment, decrement]
                .spacing(0)
                .width(Length::Fixed(22.0))
                .align_x(Alignment::Center),
        ]
        .spacing(0)
        .align_y(Alignment::Center),
    )
    .style(shell_style)
    .into()
}

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

    #[test]
    fn format_decimal_value_trims_trailing_zeroes() {
        assert_eq!(format_decimal_value(6.1), "6.1");
        assert_eq!(format_decimal_value(6.0), "6");
        assert_eq!(format_decimal_value(6.125), "6.125");
    }

    #[test]
    fn format_decimal_value_handles_negative() {
        assert_eq!(format_decimal_value(-6.5), "-6.5");
        assert_eq!(format_decimal_value(-6.0), "-6");
    }

    #[test]
    fn format_decimal_value_handles_zero() {
        assert_eq!(format_decimal_value(0.0), "0");
    }

    #[test]
    fn format_decimal_value_handles_large_numbers() {
        assert_eq!(format_decimal_value(1234.567), "1234.567");
        assert_eq!(format_decimal_value(1000.0), "1000");
    }

    #[test]
    fn format_decimal_value_handles_small_decimals() {
        assert_eq!(format_decimal_value(0.001), "0.001");
        assert_eq!(format_decimal_value(0.000), "0");
    }
}