liora-components 0.1.3

Enterprise-style native GPUI component library for Liora applications.
Documentation
use crate::Input;
use gpui::{
    App, Context, Entity, FocusHandle, Focusable, MouseButton, Render, Window, prelude::*, px,
};
use liora_core::Config;
use liora_icons::Icon;
use liora_icons_lucide::IconName;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputNumberControlsPosition {
    Horizontal,
    Right,
}

pub struct InputNumber {
    value: f64,
    min: f64,
    max: f64,
    step: f64,
    precision: usize,
    disabled: bool,
    controls_position: InputNumberControlsPosition,
    input: Entity<Input>,
    focus_handle: FocusHandle,
    on_change: Option<Box<dyn Fn(f64, &mut Window, &mut App) + 'static>>,
}

impl InputNumber {
    pub fn new(value: f64, cx: &mut Context<Self>) -> Self {
        let input = cx.new(|cx| {
            Input::new(format!("{:.*}", 0, value), cx).filter(|text| {
                if text.is_empty() {
                    return true;
                }
                let Some(first) = text.chars().next() else {
                    return true;
                };
                if first == '+' || first == '-' {
                    let rest: String = text.chars().skip(1).collect();
                    if rest.is_empty() {
                        return true;
                    }
                    rest.chars().all(|c| c.is_ascii_digit() || c == '.')
                        && rest.matches('.').count() <= 1
                } else {
                    text.chars().all(|c| c.is_ascii_digit() || c == '.')
                        && text.matches('.').count() <= 1
                }
            })
        });

        let focus_handle = cx.focus_handle();

        Self {
            value,
            min: f64::MIN,
            max: f64::MAX,
            step: 1.0,
            precision: 0,
            disabled: false,
            controls_position: InputNumberControlsPosition::Horizontal,
            input,
            focus_handle,
            on_change: None,
        }
    }

    pub fn min(mut self, min: f64) -> Self {
        self.min = min;
        self
    }
    pub fn max(mut self, max: f64) -> Self {
        self.max = max;
        self
    }
    pub fn step(mut self, step: f64) -> Self {
        self.step = step;
        self
    }
    pub fn precision(mut self, p: usize) -> Self {
        self.precision = p;
        self
    }
    pub fn disabled(mut self, d: bool, cx: &mut Context<Self>) -> Self {
        self.disabled = d;
        self.input.update(cx, |input, cx| {
            input.set_disabled(d, cx);
        });
        self
    }
    pub fn controls_position(mut self, pos: InputNumberControlsPosition) -> Self {
        self.controls_position = pos;
        self
    }

    pub fn on_change(mut self, cb: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
        self.on_change = Some(Box::new(cb));
        self
    }

    fn set_value(&mut self, val: f64, window: &mut Window, cx: &mut Context<Self>) {
        let val = val.clamp(self.min, self.max);
        if (val - self.value).abs() > f64::EPSILON || self.value == 0.0 {
            self.value = val;
            let formatted = format!("{:.*}", self.precision, self.value);
            self.input.update(cx, |input, cx| {
                input.set_value(formatted, cx);
            });
            if let Some(ref cb) = self.on_change {
                cb(self.value, window, cx);
            }
            cx.notify();
        }
    }

    fn increment(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if !self.disabled && self.value < self.max {
            self.set_value(self.value + self.step, window, cx);
        }
    }

    fn decrement(&mut self, window: &mut Window, cx: &mut Context<Self>) {
        if !self.disabled && self.value > self.min {
            self.set_value(self.value - self.step, window, cx);
        }
    }
}

impl Focusable for InputNumber {
    fn focus_handle(&self, _cx: &App) -> FocusHandle {
        self.focus_handle.clone()
    }
}

impl Render for InputNumber {
    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let theme = cx.global::<Config>().theme.clone();

        match self.controls_position {
            InputNumberControlsPosition::Horizontal => {
                self.render_horizontal(&theme, cx).into_any_element()
            }
            InputNumberControlsPosition::Right => self.render_right(&theme, cx).into_any_element(),
        }
    }
}

impl InputNumber {
    fn render_horizontal(
        &self,
        theme: &liora_theme::Theme,
        cx: &mut Context<Self>,
    ) -> impl IntoElement {
        let icon_sz = 12.0;
        let can_inc = !self.disabled && self.value < self.max;
        let can_dec = !self.disabled && self.value > self.min;

        let mut row = gpui::div()
            .flex()
            .flex_row()
            .items_center()
            .h(px(34.0))
            .rounded(px(theme.radius.md))
            .border_1()
            .border_color(theme.neutral.border)
            .bg(theme.neutral.card)
            .overflow_hidden();

        let mut dec_btn = gpui::div()
            .flex()
            .items_center()
            .justify_center()
            .w(px(32.0))
            .h_full()
            .bg(theme.neutral.hover)
            .border_color(theme.neutral.border)
            .border_r_1();

        if can_dec {
            dec_btn = dec_btn
                .cursor_pointer()
                .hover(|s| s.bg(theme.neutral.border))
                .on_mouse_down(
                    MouseButton::Left,
                    cx.listener(|this, _, window, cx| {
                        this.decrement(window, cx);
                    }),
                );
        } else {
            dec_btn = dec_btn.cursor_not_allowed().opacity(0.5);
        }

        row = row.child(
            dec_btn.child(
                Icon::new(IconName::Minus)
                    .size(px(icon_sz))
                    .color(if can_dec {
                        theme.neutral.text_1
                    } else {
                        theme.neutral.text_disabled
                    }),
            ),
        );
        row = row.child(gpui::div().flex_1().child(self.input.clone()));

        let mut inc_btn = gpui::div()
            .flex()
            .items_center()
            .justify_center()
            .w(px(32.0))
            .h_full()
            .bg(theme.neutral.hover)
            .border_color(theme.neutral.border)
            .border_l_1();

        if can_inc {
            inc_btn = inc_btn
                .cursor_pointer()
                .hover(|s| s.bg(theme.neutral.border))
                .on_mouse_down(
                    MouseButton::Left,
                    cx.listener(|this, _, window, cx| {
                        this.increment(window, cx);
                    }),
                );
        } else {
            inc_btn = inc_btn.cursor_not_allowed().opacity(0.5);
        }

        row = row.child(
            inc_btn.child(
                Icon::new(IconName::Plus)
                    .size(px(icon_sz))
                    .color(if can_inc {
                        theme.neutral.text_1
                    } else {
                        theme.neutral.text_disabled
                    }),
            ),
        );

        row
    }

    fn render_right(&self, theme: &liora_theme::Theme, cx: &mut Context<Self>) -> impl IntoElement {
        let icon_sz = 10.0;
        let can_inc = !self.disabled && self.value < self.max;
        let can_dec = !self.disabled && self.value > self.min;

        let mut row = gpui::div()
            .flex()
            .flex_row()
            .items_center()
            .h(px(34.0))
            .rounded(px(theme.radius.md))
            .border_1()
            .border_color(theme.neutral.border)
            .bg(theme.neutral.card)
            .overflow_hidden();

        row = row.child(gpui::div().flex_1().child(self.input.clone()));

        let mut controls = gpui::div()
            .flex()
            .flex_col()
            .w(px(32.0))
            .h_full()
            .border_color(theme.neutral.border)
            .border_l_1();

        let mut inc_btn = gpui::div()
            .flex_1()
            .flex()
            .items_center()
            .justify_center()
            .bg(theme.neutral.hover)
            .border_color(theme.neutral.border)
            .border_b_1();

        if can_inc {
            inc_btn = inc_btn
                .cursor_pointer()
                .hover(|s| s.bg(theme.neutral.border))
                .on_mouse_down(
                    MouseButton::Left,
                    cx.listener(|this, _, window, cx| {
                        this.increment(window, cx);
                    }),
                );
        } else {
            inc_btn = inc_btn.cursor_not_allowed().opacity(0.5);
        }

        let mut dec_btn = gpui::div()
            .flex_1()
            .flex()
            .items_center()
            .justify_center()
            .bg(theme.neutral.hover);

        if can_dec {
            dec_btn = dec_btn
                .cursor_pointer()
                .hover(|s| s.bg(theme.neutral.border))
                .on_mouse_down(
                    MouseButton::Left,
                    cx.listener(|this, _, window, cx| {
                        this.decrement(window, cx);
                    }),
                );
        } else {
            dec_btn = dec_btn.cursor_not_allowed().opacity(0.5);
        }

        controls = controls.child(
            inc_btn.child(
                Icon::new(IconName::ChevronUp)
                    .size(px(icon_sz))
                    .color(if can_inc {
                        theme.neutral.text_1
                    } else {
                        theme.neutral.text_disabled
                    }),
            ),
        );
        controls =
            controls.child(
                dec_btn.child(Icon::new(IconName::ChevronDown).size(px(icon_sz)).color(
                    if can_dec {
                        theme.neutral.text_1
                    } else {
                        theme.neutral.text_disabled
                    },
                )),
            );

        row = row.child(controls);

        row
    }
}