🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
//! `StatusPage` — ekran stanu (pusty/powitalny): ikona, tytuł, opis i opcjonalna akcja.
//!
//! Widget immediate: układa wyśrodkowany blok w zadanym obszarze. Aplikacja woła
//! [`StatusPage::paint`] oraz [`StatusPage::action_at`] (klik w przycisk akcji).
use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};
use crate::ctx::PaintCtx;
use crate::icon;
/// Ikona ekranu stanu (rysowana wektorowo).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusIcon {
/// Katalog (np. „pusty folder”).
Folder,
/// Plik.
File,
/// Wyszukiwanie (np. „brak wyników”).
Search,
}
/// Prostokąty elementów ekranu stanu.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct StatusRects {
pub icon: Option<Rect>,
pub title: Rect,
pub description: Option<Rect>,
pub action: Option<Rect>,
}
/// Ekran stanu.
#[derive(Debug, Clone, Default)]
pub struct StatusPage {
icon: Option<StatusIcon>,
title: String,
description: Option<String>,
action: Option<String>,
}
impl StatusPage {
pub fn new(title: impl Into<String>) -> Self {
Self {
icon: None,
title: title.into(),
description: None,
action: None,
}
}
pub fn icon(mut self, icon: StatusIcon) -> Self {
self.icon = Some(icon);
self
}
pub fn description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn action(mut self, label: impl Into<String>) -> Self {
self.action = Some(label.into());
self
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = title.into();
}
pub fn has_action(&self) -> bool {
self.action.is_some()
}
/// Wyśrodkowany układ elementów w obszarze `area`.
pub fn rects(&self, area: Rect, theme: &Theme) -> StatusRects {
let m = theme.metrics.status;
let title_lh = theme.typography.heading.line_height;
let desc_lh = theme.typography.body.line_height;
let icon_h = if self.icon.is_some() { m.icon } else { 0.0 };
let mut total = title_lh;
if icon_h > 0.0 {
total += icon_h + m.gap;
}
if self.description.is_some() {
total += m.gap + desc_lh;
}
if self.action.is_some() {
total += m.gap + m.button_h;
}
let cx = area.center().x;
let mut y = area.center().y - total / 2.0;
let icon_rect = self.icon.map(|_| {
let r = Rect::from_xywh(cx - m.icon / 2.0, y, m.icon, m.icon);
y += m.icon + m.gap;
r
});
let title = Rect::from_xywh(area.x(), y, area.width(), title_lh);
y += title_lh;
let description = self.description.as_ref().map(|_| {
y += m.gap;
let r = Rect::from_xywh(area.x(), y, area.width(), desc_lh);
y += desc_lh;
r
});
let action = self.action.as_ref().map(|_| {
y += m.gap;
Rect::from_xywh(cx - m.button_w / 2.0, y, m.button_w, m.button_h)
});
StatusRects {
icon: icon_rect,
title,
description,
action,
}
}
/// Czy punkt trafia w przycisk akcji.
pub fn action_at(&self, area: Rect, theme: &Theme, p: Point) -> bool {
self.rects(area, theme)
.action
.is_some_and(|r| r.contains(p))
}
/// Rysuje ekran stanu w obszarze `area`.
pub fn paint(&self, ctx: &mut PaintCtx, area: Rect) {
let theme = *ctx.theme;
let colors = theme.colors;
let r = self.rects(area, &theme);
ctx.scene.push_clip(area);
if let (Some(kind), Some(rect)) = (self.icon, r.icon) {
let accent = colors.dim_text;
match kind {
StatusIcon::Folder => icon::folder(ctx, accent, rect),
StatusIcon::File => icon::file(ctx, accent, rect),
StatusIcon::Search => icon::search(ctx, accent, rect.center(), rect.width()),
}
}
ctx.label_center(
r.title.center().x,
r.title.center().y,
&self.title,
theme.typography.heading.size,
weight::BOLD,
colors.text,
);
if let (Some(description), Some(rect)) = (&self.description, r.description) {
ctx.label_center(
rect.center().x,
rect.center().y,
description,
theme.typography.body.size,
weight::NORMAL,
colors.dim_text,
);
}
if let (Some(label), Some(rect)) = (&self.action, r.action) {
let radius = theme.radius.small;
ctx.scene.fill_rounded_rect(rect, radius, colors.accent);
ctx.label_center(
rect.center().x,
rect.center().y,
label,
theme.typography.body.size,
weight::MEDIUM,
colors.accent_fg,
);
}
ctx.scene.pop_clip();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn block_is_vertically_centered() {
let theme = Theme::light();
let page = StatusPage::new("Pusto")
.icon(StatusIcon::Folder)
.description("Brak plików");
let area = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
let r = page.rects(area, &theme);
// Ikona jest najwyżej, opis najniżej, a całość wyśrodkowana pionowo.
assert!(r.icon.unwrap().y() < r.title.y());
assert!(r.title.y() < r.description.unwrap().y());
let top = r.icon.unwrap().y();
let bottom = r.description.unwrap().bottom();
assert!(((top + bottom) / 2.0 - 150.0).abs() < 1e-9);
}
#[test]
fn action_hit_only_when_present() {
let theme = Theme::light();
let area = Rect::from_xywh(0.0, 0.0, 400.0, 300.0);
let with = StatusPage::new("Pusto").action("Nowy");
let ar = with.rects(area, &theme).action.unwrap();
assert!(with.action_at(area, &theme, ar.center()));
let without = StatusPage::new("Pusto");
assert!(!without.action_at(area, &theme, area.center()));
}
#[test]
fn title_is_horizontally_centered() {
let theme = Theme::light();
let page = StatusPage::new("Pusto");
let area = Rect::from_xywh(20.0, 0.0, 400.0, 300.0);
let r = page.rects(area, &theme);
assert!((r.title.center().x - area.center().x).abs() < 1e-9);
}
}