🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249
//! `NavList` — menu nawigacji (sekcje + wybieralne pozycje) do panelu bocznego.
//!
//! Widget immediate. Pozycje są wybieralne; nagłówki sekcji pełnią rolę podpisów.
//! Etykiety podaje aplikacja (już przetłumaczone) — widget nie zna i18n.
use libluna_core::style::weight;
use libluna_core::{Point, Rect, Theme};
use crate::ctx::PaintCtx;
/// Element menu nawigacji.
#[derive(Debug, Clone)]
pub enum NavEntry {
/// Nagłówek sekcji (nieklikalny).
Section(String),
/// Pozycja wybieralna.
Item(String),
}
/// Wiersz rozłożony w prostokącie listy (wynik [`NavList::rows`]).
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NavRow {
/// Indeks elementu w [`NavList::entries`].
pub entry: usize,
/// Prostokąt wiersza.
pub rect: Rect,
/// Czy pozycja jest wybieralna (a więc klikalna).
pub selectable: bool,
}
/// Menu nawigacji.
#[derive(Debug, Clone, Default)]
pub struct NavList {
entries: Vec<NavEntry>,
selected: Option<usize>,
hovered: Option<usize>,
}
impl NavList {
pub fn new(entries: Vec<NavEntry>) -> Self {
Self {
entries,
selected: None,
hovered: None,
}
}
pub fn entries(&self) -> &[NavEntry] {
&self.entries
}
/// Indeks wybranej pozycji (albo `None`).
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn selected_label(&self) -> Option<&str> {
match self.selected.and_then(|i| self.entries.get(i)) {
Some(NavEntry::Item(label)) => Some(label),
_ => None,
}
}
/// Ustawia wybór; ignoruje indeksy wskazujące na nagłówki sekcji.
pub fn set_selected(&mut self, index: Option<usize>) {
self.selected = match index {
Some(i) if matches!(self.entries.get(i), Some(NavEntry::Item(_))) => Some(i),
_ => None,
};
}
pub fn set_hovered(&mut self, hovered: Option<usize>) {
self.hovered = hovered;
}
/// Wysokość potrzebna na całą listę (piksele logiczne).
pub fn height(&self, theme: &Theme) -> f64 {
let m = theme.metrics.nav;
let body: f64 = self
.entries
.iter()
.map(|e| match e {
NavEntry::Section(_) => m.section_h,
NavEntry::Item(_) => m.item_h,
})
.sum();
m.pad_v + body + m.pad_v
}
/// Rozkłada wiersze od góry `rect`.
pub fn rows(&self, rect: Rect, theme: &Theme) -> Vec<NavRow> {
let m = theme.metrics.nav;
let mut rows = Vec::with_capacity(self.entries.len());
let mut y = rect.y() + m.pad_v;
for (i, entry) in self.entries.iter().enumerate() {
let (h, selectable) = match entry {
NavEntry::Section(_) => (m.section_h, false),
NavEntry::Item(_) => (m.item_h, true),
};
rows.push(NavRow {
entry: i,
rect: Rect::from_xywh(rect.x(), y, rect.width(), h),
selectable,
});
y += h;
}
rows
}
/// Indeks wybieralnej pozycji pod punktem `p` (nagłówki zwracają `None`).
pub fn index_at(&self, rect: Rect, p: Point, theme: &Theme) -> Option<usize> {
self.rows(rect, theme)
.into_iter()
.find(|row| row.selectable && row.rect.contains(p))
.map(|row| row.entry)
}
/// Obsługuje klik: ustawia wybór. Zwraca `true`, jeśli wybór się zmienił.
pub fn click_at(&mut self, rect: Rect, p: Point, theme: &Theme) -> bool {
match self.index_at(rect, p, theme) {
Some(i) if self.selected != Some(i) => {
self.selected = Some(i);
true
}
_ => false,
}
}
/// Rysuje listę w prostokącie `rect`.
pub fn paint(&self, ctx: &mut PaintCtx, rect: Rect) {
let theme = *ctx.theme;
let radius = theme.radius;
let colors = theme.colors;
let caption = theme.typography.caption.size;
let body = theme.typography.body.size;
let pad_x = theme.metrics.nav.pad_x;
for row in self.rows(rect, ctx.theme) {
match &self.entries[row.entry] {
NavEntry::Section(title) => {
ctx.label_left(
row.rect.x() + pad_x + 8.0,
row.rect.center().y,
title,
caption,
weight::BOLD,
colors.dim_text,
);
}
NavEntry::Item(label) => {
let item = Rect::from_xywh(
row.rect.x() + pad_x,
row.rect.y(),
(row.rect.width() - 2.0 * pad_x).max(0.0),
row.rect.height(),
);
let selected = self.selected == Some(row.entry);
if selected || self.hovered == Some(row.entry) {
ctx.scene
.fill_rounded_rect(item, radius.small, colors.shade);
}
let dot = Rect::from_xywh(item.x() + 8.0, item.center().y - 4.0, 8.0, 8.0);
let dot_color = if selected {
colors.accent
} else {
colors.dim_text
};
ctx.scene.fill_rounded_rect(dot, 4.0, dot_color);
let fg = colors.text;
let weight = if selected {
weight::MEDIUM
} else {
weight::NORMAL
};
ctx.label_left(item.x() + 26.0, item.center().y, label, body, weight, fg);
}
}
}
}
/// Ustawia podświetlenie na podstawie pozycji wskaźnika (wygodne w `PointerMotion`).
pub fn hover_at(&mut self, rect: Rect, p: Option<Point>, theme: &Theme) {
self.hovered = p.and_then(|p| self.index_at(rect, p, theme));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn list() -> NavList {
NavList::new(vec![
NavEntry::Item("Overview".into()),
NavEntry::Item("Account".into()),
NavEntry::Section("Advanced".into()),
NavEntry::Item("Network".into()),
])
}
fn area() -> Rect {
Rect::from_xywh(0.0, 0.0, 200.0, 400.0)
}
#[test]
fn rows_have_expected_heights() {
let theme = Theme::light();
let m = theme.metrics.nav;
let l = list();
let rows = l.rows(area(), &theme);
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].rect.height(), m.item_h);
assert!(!rows[2].selectable);
assert_eq!(
l.height(&theme),
m.pad_v * 2.0 + 3.0 * m.item_h + m.section_h
);
}
#[test]
fn index_at_skips_sections() {
let theme = Theme::light();
let l = list();
let rows = l.rows(area(), &theme);
// Środek nagłówka sekcji → brak trafienia.
let section_p = rows[2].rect.center();
assert_eq!(l.index_at(area(), section_p, &theme), None);
// Środek pierwszej pozycji → trafienie w indeks 0.
assert_eq!(l.index_at(area(), rows[0].rect.center(), &theme), Some(0));
}
#[test]
fn click_sets_selection_and_reports_change() {
let theme = Theme::light();
let mut l = list();
let rows = l.rows(area(), &theme);
assert!(l.click_at(area(), rows[3].rect.center(), &theme));
assert_eq!(l.selected(), Some(3));
assert_eq!(l.selected_label(), Some("Network"));
assert!(!l.click_at(area(), rows[3].rect.center(), &theme));
}
#[test]
fn cannot_select_a_section() {
let mut l = list();
l.set_selected(Some(2));
assert_eq!(l.selected(), None);
}
}