🔒 Repository is read-only – file editing is disabled.
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());
}
}