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

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

535 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535
//! Widgety prezentacji (Display): `Label`, `Separator`, `ProgressBar`, `LevelBar`, `Frame`,
//! `Revealer`, `Overlay`, `AspectFrame`.

use libluna_core::style::weight;
use libluna_core::{Animation, Easing, Rect, Size, Theme};
use libluna_render::TextEngine;

use crate::bin::BinAlign;
use crate::ctx::PaintCtx;

/// Orientacja (pozioma/pionowa).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Orientation {
    #[default]
    Horizontal,
    Vertical,
}

/// Wyrównanie napisu w poziomie.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LabelAlign {
    Start,
    #[default]
    Center,
    End,
}

/// Etykieta tekstowa (z ucinaniem w wielokropek).
#[derive(Debug, Clone, Default)]
pub struct Label {
    text: String,
    align: LabelAlign,
    dim: bool,
    bold: bool,
}

impl Label {
    pub fn new(text: impl Into<String>) -> Self {
        Self {
            text: text.into(),
            ..Self::default()
        }
    }

    pub fn align(mut self, align: LabelAlign) -> Self {
        self.align = align;
        self
    }

    pub fn dim(mut self, dim: bool) -> Self {
        self.dim = dim;
        self
    }

    pub fn bold(mut self, bold: bool) -> Self {
        self.bold = bold;
        self
    }

    pub fn set_text(&mut self, text: impl Into<String>) {
        self.text = text.into();
    }

    pub fn text(&self) -> &str {
        &self.text
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let size = theme.typography.body.size;
        let w = if self.bold {
            weight::BOLD
        } else {
            weight::NORMAL
        };
        let color = if self.dim {
            theme.colors.dim_text
        } else {
            theme.colors.text
        };
        let cy = rect.center().y;
        ctx.scene.push_clip(rect);
        match self.align {
            LabelAlign::Start => ctx.label_left(rect.x(), cy, &self.text, size, w, color),
            LabelAlign::Center => ctx.label_center(rect.center().x, cy, &self.text, size, w, color),
            LabelAlign::End => ctx.label_right(rect.right(), cy, &self.text, size, w, color),
        }
        ctx.scene.pop_clip();
    }
}

/// Separator (linia).
#[derive(Debug, Clone, Copy)]
pub struct Separator {
    orientation: Orientation,
}

impl Separator {
    pub fn new(orientation: Orientation) -> Self {
        Self { orientation }
    }

    pub fn horizontal() -> Self {
        Self::new(Orientation::Horizontal)
    }

    pub fn vertical() -> Self {
        Self::new(Orientation::Vertical)
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let line = match self.orientation {
            Orientation::Horizontal => {
                Rect::from_xywh(rect.x(), rect.center().y - 0.5, rect.width(), 1.0)
            }
            Orientation::Vertical => {
                Rect::from_xywh(rect.center().x - 0.5, rect.y(), 1.0, rect.height())
            }
        };
        ctx.scene.fill_rect(line, theme.colors.border);
    }
}

/// Pasek postępu (0..1) z opcjonalnym napisem.
#[derive(Debug, Clone, Default)]
pub struct ProgressBar {
    fraction: f64,
    text: Option<String>,
    show_text: bool,
}

impl ProgressBar {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn fraction(mut self, fraction: f64) -> Self {
        self.fraction = fraction.clamp(0.0, 1.0);
        self
    }

    pub fn set_fraction(&mut self, fraction: f64) {
        self.fraction = fraction.clamp(0.0, 1.0);
    }

    pub fn text(mut self, text: impl Into<String>) -> Self {
        self.text = Some(text.into());
        self.show_text = true;
        self
    }

    pub fn show_text(mut self, show: bool) -> Self {
        self.show_text = show;
        self
    }

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

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let m = theme.metrics.bar;
        let h = rect.height().min(m.thickness);
        let track = Rect::from_xywh(rect.x(), rect.center().y - h / 2.0, rect.width(), h);
        ctx.scene
            .fill_rounded_rect(track, m.radius, theme.colors.shade);
        let fill = Rect::from_xywh(
            track.x(),
            track.y(),
            track.width() * self.fraction,
            track.height(),
        );
        if self.fraction > 0.0 {
            ctx.scene
                .fill_rounded_rect(fill, m.radius, theme.colors.accent);
        }
        if self.show_text {
            if let Some(text) = &self.text {
                ctx.label_center(
                    rect.center().x,
                    rect.center().y,
                    text,
                    theme.typography.caption.size,
                    weight::NORMAL,
                    theme.colors.text,
                );
            } else {
                let label = format!("{}%", (self.fraction * 100.0).round() as i32);
                ctx.label_center(
                    rect.center().x,
                    rect.center().y,
                    &label,
                    theme.typography.caption.size,
                    weight::NORMAL,
                    theme.colors.text,
                );
            }
        }
    }
}

/// Wskaźnik poziomu (segmented).
#[derive(Debug, Clone, Copy)]
pub struct LevelBar {
    value: f64,
    segments: usize,
}

impl Default for LevelBar {
    fn default() -> Self {
        Self {
            value: 0.0,
            segments: 8,
        }
    }
}

impl LevelBar {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn value(mut self, value: f64) -> Self {
        self.value = value.clamp(0.0, 1.0);
        self
    }

    pub fn segments(mut self, segments: usize) -> Self {
        self.segments = segments.max(1);
        self
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
        let theme = *ctx.theme;
        let n = self.segments;
        let gap = 2.0;
        let seg_w = (rect.width() - gap * (n as f64 - 1.0)).max(0.0) / n as f64;
        let filled = (self.value * n as f64).round() as usize;
        for i in 0..n {
            let x = rect.x() + i as f64 * (seg_w + gap);
            let seg = Rect::from_xywh(x, rect.y(), seg_w, rect.height());
            let color = if i < filled {
                theme.colors.accent
            } else {
                theme.colors.shade
            };
            ctx.scene.fill_rounded_rect(seg, 2.0, color);
        }
    }
}

/// Ramka dekoracyjna z opcjonalną etykietą.
#[derive(Debug, Clone, Default)]
pub struct Frame {
    label: Option<String>,
}

impl Frame {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn label(mut self, label: impl Into<String>) -> Self {
        self.label = Some(label.into());
        self
    }

    /// Prostokąt zawartości (wewnątrz ramki).
    pub fn content_rect(&self, rect: Rect, theme: &Theme) -> Rect {
        rect.deflate(libluna_core::Insets::splat(theme.spacing.md))
    }

    pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) -> Rect {
        let theme = *ctx.theme;
        ctx.scene
            .stroke_rounded_rect(rect, theme.radius.medium, 1.0, theme.colors.border);
        if let Some(label) = &self.label {
            ctx.label_left(
                rect.x() + theme.spacing.md,
                rect.y(),
                label,
                theme.typography.caption.size,
                weight::BOLD,
                theme.colors.dim_text,
            );
        }
        self.content_rect(rect, &theme)
    }
}

/// Odkrywanie/ukrywanie zawartości; ułamek można zadać wprost albo animować [`Revealer::advance`].
#[derive(Debug, Clone, Copy)]
pub struct Revealer {
    open: bool,
    orientation: Orientation,
    animation: Animation,
}

impl Default for Revealer {
    fn default() -> Self {
        Self {
            open: false,
            orientation: Orientation::Vertical,
            animation: Animation::default(),
        }
    }
}

impl Revealer {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn orientation(mut self, orientation: Orientation) -> Self {
        self.orientation = orientation;
        self
    }

    /// Ustawia stan natychmiast (bez animacji).
    pub fn set_open(&mut self, open: bool) {
        self.open = open;
        self.animation.jump_to(if open { 1.0 } else { 0.0 });
    }

    /// Ustawia ułamek odsłonięcia natychmiast.
    pub fn set_fraction(&mut self, fraction: f64) {
        let fraction = fraction.clamp(0.0, 1.0);
        self.animation.jump_to(fraction);
        self.open = fraction > 0.0;
    }

    pub fn is_open(&self) -> bool {
        self.open
    }

    /// Bieżący ułamek odsłonięcia (0..1).
    pub fn fraction(&self) -> f64 {
        self.animation.value()
    }

    /// Rozpoczyna płynne odsłanianie/ukrywanie z zadaną krzywą.
    pub fn animate_open(&mut self, open: bool, easing: Easing) {
        self.open = open;
        self.animation.set_easing(easing);
        self.animation.animate_to(if open { 1.0 } else { 0.0 });
    }

    /// Krok animacji (`dt` i czas `duration` w sekundach); zwraca `true`, gdy trwa dalej.
    pub fn advance(&mut self, dt: f64, duration: f64) -> bool {
        self.animation.advance(dt, duration)
    }

    pub fn is_animating(&self) -> bool {
        self.animation.is_animating()
    }

    /// Widoczny prostokąt zawartości (przycięty wg ułamka).
    pub fn reveal_rect(&self, rect: Rect) -> Rect {
        let f = self.fraction();
        match self.orientation {
            Orientation::Vertical => {
                Rect::from_xywh(rect.x(), rect.y(), rect.width(), rect.height() * f)
            }
            Orientation::Horizontal => {
                Rect::from_xywh(rect.x(), rect.y(), rect.width() * f, rect.height())
            }
        }
    }
}

/// Nakładka: prostokąt główny + pozycjonowanie dzieci „overlay”.
#[derive(Debug, Clone, Copy, Default)]
pub struct Overlay;

impl Overlay {
    pub fn new() -> Self {
        Self
    }

    /// Prostokąt dziecka nakładki o rozmiarze `size`, wyrównanego w `rect`.
    pub fn child_rect(
        &self,
        rect: Rect,
        size: Size,
        halign: BinAlign,
        valign: BinAlign,
        margin: f64,
    ) -> Rect {
        let inner = rect.deflate(libluna_core::Insets::splat(margin));
        let w = size.width.min(inner.width());
        let h = size.height.min(inner.height());
        let x = match halign {
            BinAlign::Start => inner.x(),
            BinAlign::Center => inner.x() + (inner.width() - w) / 2.0,
            BinAlign::End => inner.right() - w,
        };
        let y = match valign {
            BinAlign::Start => inner.y(),
            BinAlign::Center => inner.y() + (inner.height() - h) / 2.0,
            BinAlign::End => inner.bottom() - h,
        };
        Rect::from_xywh(x, y, w, h)
    }
}

/// Ramka utrzymująca proporcje dziecka (szerokość/wysokość).
#[derive(Debug, Clone, Copy)]
pub struct AspectFrame {
    ratio: f64,
}

impl AspectFrame {
    pub fn new(ratio: f64) -> Self {
        Self { ratio }
    }

    /// Prostokąt dziecka o zadanych proporcjach, wpisany w `rect` (wyśrodkowany).
    pub fn content_rect(&self, rect: Rect) -> Rect {
        if self.ratio <= 0.0 || rect.width() <= 0.0 || rect.height() <= 0.0 {
            return rect;
        }
        let mut w = rect.width();
        let mut h = w / self.ratio;
        if h > rect.height() {
            h = rect.height();
            w = h * self.ratio;
        }
        Rect::from_xywh(
            rect.x() + (rect.width() - w) / 2.0,
            rect.y() + (rect.height() - h) / 2.0,
            w,
            h,
        )
    }
}

/// Pomocnik: ucinanie napisu z wielokropkiem do zadanej szerokości.
pub fn ellipsize(text: &str, max_width: f64, size: f64, text_engine: &TextEngine) -> String {
    if text_engine.measure(text, size, weight::NORMAL).width <= max_width {
        return text.to_owned();
    }
    let mut out = String::new();
    for ch in text.chars() {
        let mut candidate = out.clone();
        candidate.push(ch);
        candidate.push('…');
        if text_engine.measure(&candidate, size, weight::NORMAL).width > max_width {
            break;
        }
        out.push(ch);
    }
    out.push('…');
    out
}

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

    fn rect() -> Rect {
        Rect::from_xywh(0.0, 0.0, 200.0, 40.0)
    }

    #[test]
    fn progress_fraction_clamps() {
        let mut p = ProgressBar::new().fraction(1.5);
        assert_eq!(p.fraction_value(), 1.0);
        p.set_fraction(-1.0);
        assert_eq!(p.fraction_value(), 0.0);
    }

    #[test]
    fn separator_line_is_centered() {
        let s = Separator::horizontal();
        assert_eq!(s.orientation, Orientation::Horizontal);
    }

    #[test]
    fn revealer_clips_by_fraction() {
        let mut r = Revealer::new();
        r.set_fraction(0.5);
        let rr = r.reveal_rect(rect());
        assert_eq!(rr.height(), 20.0);
        r.set_open(true);
        assert_eq!(r.reveal_rect(rect()).height(), 40.0);
        r.set_open(false);
        assert_eq!(r.reveal_rect(rect()).height(), 0.0);
    }

    #[test]
    fn revealer_animates_open() {
        let mut r = Revealer::new();
        r.animate_open(true, Easing::Linear);
        assert!(r.is_animating());
        assert!(r.advance(0.5, 1.0));
        assert!((r.fraction() - 0.5).abs() < 1e-9);
        assert!(!r.advance(0.5, 1.0));
        assert_eq!(r.fraction(), 1.0);
        assert!(!r.is_animating());
        assert_eq!(r.reveal_rect(rect()).height(), 40.0);
    }

    #[test]
    fn aspect_frame_fits_ratio() {
        let a = AspectFrame::new(2.0); // szersze niż wyższe
        let r = a.content_rect(Rect::from_xywh(0.0, 0.0, 100.0, 100.0));
        assert_eq!(r.width(), 100.0);
        assert_eq!(r.height(), 50.0);
        assert_eq!(r.y(), 25.0);
    }

    #[test]
    fn overlay_anchors_child() {
        let o = Overlay::new();
        let r = o.child_rect(
            rect(),
            Size::new(20.0, 10.0),
            BinAlign::End,
            BinAlign::Start,
            4.0,
        );
        assert_eq!(r.right(), 196.0);
        assert_eq!(r.y(), 4.0);
    }

    #[test]
    fn ellipsize_shortens() {
        let engine = TextEngine::system();
        let out = ellipsize("bardzo długi napis do ucięcia", 20.0, 14.0, &engine);
        assert!(out.ends_with('…'));
        assert!(out.chars().count() < "bardzo długi napis do ucięcia".chars().count());
    }
}