🔒 Repository is read-only – file editing is disabled.

luna/crates/libluna-widgets/src/spin_button.rs main

295 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
//! `SpinButton` — pole liczbowe z edycją tekstową i przyciskami `▲`/`▼` (GTK4, w duchu libLuny).
//!
//! Standalone wariant liczbowy: tekst edytuje [`Entry`], a strzałki po prawej zwiększają/
//! zmniejszają wartość o krok. Wartość zmienia się dopiero po zatwierdzeniu edycji
//! ([`SpinButton::commit`], np. na `Enter` albo utratę fokusu).

use libluna_core::{Point, Rect, Theme};

use crate::ctx::PaintCtx;
use crate::entry::{Entry, TextInput};
use crate::icon;

/// Strzałka pola liczbowego.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpinArrow {
    Up,
    Down,
}

/// Pole liczbowe.
#[derive(Debug, Clone, Default)]
pub struct SpinButton {
    entry: Entry,
    value: f64,
    min: f64,
    max: f64,
    step: f64,
    digits: usize,
    arrows_hover: Option<SpinArrow>,
}

impl SpinButton {
    /// Pole z wartością w zakresie `min..max`, krokiem 1 i bez miejsc po przecinku.
    pub fn new(value: f64, min: f64, max: f64) -> Self {
        let value = value.clamp(min, max);
        let mut entry = Entry::new("");
        entry.set_text(format_number(value, 0));
        Self {
            entry,
            value,
            min,
            max,
            step: 1.0,
            digits: 0,
            arrows_hover: None,
        }
    }

    pub fn step(mut self, step: f64) -> Self {
        self.step = step.abs().max(f64::EPSILON);
        self
    }

    /// Liczba miejsc po przecinku (formatowanie wartości).
    pub fn digits(mut self, digits: usize) -> Self {
        self.digits = digits;
        self.entry.set_text(format_number(self.value, digits));
        self
    }

    pub fn value(&self) -> f64 {
        self.value
    }

    pub fn min(&self) -> f64 {
        self.min
    }

    pub fn max(&self) -> f64 {
        self.max
    }

    pub fn is_focused(&self) -> bool {
        self.entry.is_focused()
    }

    pub fn set_focused(&mut self, focused: bool) {
        self.entry.set_focused(focused);
    }

    /// Ustawia wartość (przyciętą do zakresu) i Odświeża tekst.
    pub fn set_value(&mut self, value: f64) {
        self.value = value.clamp(self.min, self.max);
        self.entry.set_text(format_number(self.value, self.digits));
    }

    pub fn inc(&mut self) {
        self.set_value(self.value + self.step);
    }

    pub fn dec(&mut self) {
        self.set_value(self.value - self.step);
    }

    /// Zatwierdza edycję tekstową: parsuje wpis i przycina do zakresu.
    pub fn commit(&mut self) {
        match self.entry.text().trim().parse::<f64>() {
            Ok(v) => self.set_value(v),
            Err(_) => self.entry.set_text(format_number(self.value, self.digits)),
        }
    }

    pub fn height(&self, theme: &Theme) -> f64 {
        theme.metrics.entry.height
    }

    /// Szerokość kolumny strzałek po prawej.
    pub fn arrows_width(&self, theme: &Theme, rect: Rect) -> f64 {
        theme
            .metrics
            .stepper
            .button
            .clamp(0.0, (rect.width() * 0.5).max(0.0))
    }

    /// Prostokąt strzałki `Up`/`Down`.
    pub fn arrow_rect(&self, rect: Rect, theme: &Theme, arrow: SpinArrow) -> Rect {
        let w = self.arrows_width(theme, rect);
        let half = rect.height() / 2.0;
        let y = match arrow {
            SpinArrow::Up => rect.y(),
            SpinArrow::Down => rect.y() + half,
        };
        Rect::from_xywh(rect.right() - w, y, w, half)
    }

    pub fn arrow_at(&self, rect: Rect, theme: &Theme, p: Point) -> Option<SpinArrow> {
        [SpinArrow::Up, SpinArrow::Down]
            .into_iter()
            .find(|&a| self.arrow_rect(rect, theme, a).contains(p))
    }

    pub fn set_arrows_hover(&mut self, hover: Option<SpinArrow>) {
        self.arrows_hover = hover;
    }

    /// Obsługuje klik w strzałkę: zmienia wartość. Zwraca `true`, gdy trafiono.
    pub fn click_at(&mut self, rect: Rect, theme: &Theme, p: Point) -> bool {
        match self.arrow_at(rect, theme, p) {
            Some(SpinArrow::Up) => {
                self.inc();
                true
            }
            Some(SpinArrow::Down) => {
                self.dec();
                true
            }
            None => false,
        }
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let w = self.arrows_width(&theme, rect);
        let mut entry = self.entry.clone();
        entry.set_trailing_space(w + 6.0);
        entry.paint(ctx, rect);

        let m = theme.metrics.entry;
        for arrow in [SpinArrow::Up, SpinArrow::Down] {
            let ar = self.arrow_rect(rect, &theme, arrow);
            if self.arrows_hover == Some(arrow) {
                ctx.scene.fill_rounded_rect(
                    ar,
                    theme.radius.small,
                    theme.colors.shade.scale_alpha(1.8),
                );
            }
            let c = ar.center();
            let size = m.icon * 0.22;
            match arrow {
                SpinArrow::Up => icon::chevron_up(ctx, theme.colors.dim_text, c, size, 1.8),
                SpinArrow::Down => icon::chevron_down(ctx, theme.colors.dim_text, c, size, 1.8),
            }
        }
        // Cienka kreska oddzielająca strzałki od pola.
        ctx.scene.fill_rect(
            Rect::from_xywh(
                rect.right() - w - 2.0,
                rect.y() + 5.0,
                1.0,
                (rect.height() - 10.0).max(0.0),
            ),
            theme.colors.border,
        );
    }
}

impl TextInput for SpinButton {
    fn insert_str(&mut self, s: &str) {
        self.entry.insert_str(s);
    }
    fn insert_char(&mut self, c: char) {
        self.entry.insert_char(c);
    }
    fn backspace(&mut self) {
        self.entry.backspace();
    }
    fn delete_forward(&mut self) {
        self.entry.delete_forward();
    }
    fn move_left(&mut self) {
        self.entry.move_left();
    }
    fn move_right(&mut self) {
        self.entry.move_right();
    }
    fn home(&mut self) {
        self.entry.home();
    }
    fn end(&mut self) {
        self.entry.end();
    }
    fn move_left_selecting(&mut self, shift: bool) {
        self.entry.move_left_selecting(shift);
    }
    fn move_right_selecting(&mut self, shift: bool) {
        self.entry.move_right_selecting(shift);
    }
    fn home_selecting(&mut self, shift: bool) {
        self.entry.home_selecting(shift);
    }
    fn end_selecting(&mut self, shift: bool) {
        self.entry.end_selecting(shift);
    }
    fn select_all(&mut self) {
        self.entry.select_all();
    }
    fn clear_selection(&mut self) {
        self.entry.clear_selection();
    }
}

fn format_number(value: f64, digits: usize) -> String {
    if digits == 0 {
        format!("{}", value.round() as i64)
    } else {
        format!("{value:.digits$}")
    }
}

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

    fn rect() -> Rect {
        Rect::from_xywh(0.0, 0.0, 120.0, 34.0)
    }

    #[test]
    fn value_is_clamped_and_formatted() {
        let mut s = SpinButton::new(5.0, 0.0, 10.0);
        assert_eq!(s.value(), 5.0);
        s.set_value(99.0);
        assert_eq!(s.value(), 10.0);
        s.set_value(-3.0);
        assert_eq!(s.value(), 0.0);
        assert_eq!(s.entry.text(), "0");
    }

    #[test]
    fn arrows_change_value() {
        let theme = Theme::light();
        let mut s = SpinButton::new(2.0, 0.0, 10.0).step(3.0);
        let up = s.arrow_rect(rect(), &theme, SpinArrow::Up).center();
        let down = s.arrow_rect(rect(), &theme, SpinArrow::Down).center();
        assert!(s.click_at(rect(), &theme, up));
        assert_eq!(s.value(), 5.0);
        assert!(s.click_at(rect(), &theme, down));
        assert_eq!(s.value(), 2.0);
        // Klik w pole (nie w strzałkę) nie zmienia wartości.
        assert!(!s.click_at(rect(), &theme, Point::new(10.0, 17.0)));
    }

    #[test]
    fn commit_parses_and_clamps() {
        let mut s = SpinButton::new(1.0, 0.0, 10.0);
        s.entry.set_text("7");
        s.commit();
        assert_eq!(s.value(), 7.0);
        s.entry.set_text("bzdury");
        s.commit();
        assert_eq!(s.value(), 7.0); // przywrócone formatowanie
        assert_eq!(s.entry.text(), "7");
        s.entry.set_text("999");
        s.commit();
        assert_eq!(s.value(), 10.0);
    }

    #[test]
    fn digits_formatting() {
        let s = SpinButton::new(2.5, 0.0, 10.0).digits(2);
        assert_eq!(s.entry.text(), "2.50");
    }
}