🔒 Repository is read-only – file editing is disabled.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692
//! Scena demo Ustawień środowiska Luna — budowana z widgetów `libluna-widgets`
//! (`ViewSwitcher`, `NavList`, `Menu`, `Button`, `Switch`, `Slider`, `DropDown`, `TabBar`)
//! plus obsługa zdarzeń (klik, przeciąganie, skróty). Logika nie zależy od Waylanda.
use libluna_core::event::ButtonState;
use libluna_core::style::{weight, Appearance};
use libluna_core::{
Accent, Color, Easing, Event, Insets, KeyEvent, Point, PointerButton, Rect, Size, Theme,
ThemePreference, ThemeSettings,
};
use libluna_i18n::{tr, Locale};
use libluna_render::TextEngine;
use libluna_widgets::layout::{Layout as Tree, LayoutCtx, Node};
use libluna_widgets::{
ActionRow, BottomSheet, Button, Card, CheckButton, ControlsLayout, ControlsSide, DropDown,
Entry, HeaderBar, Item, ItemView, Menu, MenuEntry, NavEntry, NavList, Pager, PaintCtx, PathBar,
RadioGroup, ScrollView, SearchEntry, Slider, Stack, StackTransition, StatusPage, Stepper,
StepperSide, Switch, Tab, TabBar, TabView, TextInput, Toast, ViewMode, ViewSwitcher,
WindowControl,
};
const STATUS_H: f64 = 28.0;
const PAD: f64 = 12.0;
/// Wysokość wiersza ustawień w demo.
const ROW_H: f64 = 56.0;
/// Indeks pozycji „O aplikacji” w menu „☰” (otwiera dolny panel).
const ABOUT_ENTRY: usize = 5;
/// Indeks pozycji „Styl” w menu „☰” (otwiera wybór stylu).
const STYLE_ENTRY: usize = 4;
/// Pozycja „Widgety” w sidebarze — pokazuje pełną galerię komponentów.
pub const WIDGETS_NAV: usize = 7;
/// Domyślna szerokość paska bocznego.
pub const DEFAULT_SIDEBAR_W: f64 = 220.0;
/// Dolny/górny limit szerokości paska bocznego przy przeciąganiu.
const MIN_SIDEBAR_W: f64 = 150.0;
const MAX_SIDEBAR_W: f64 = 460.0;
/// Szerokość strefy chwytu do przeciągania krawędzi paska bocznego.
const HANDLE_W: f64 = 6.0;
/// Który popover jest otwarty.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Popover {
/// Menu „☰” pod ikoną w HeaderBarze.
Menu,
/// Lista wyboru języka pod kontrolką języka.
Language,
/// Popover opcji widoku pod przyciskiem „Opcje ▾”.
Options,
/// Lista wyboru stylu (instalowanego z pliku).
Styles,
}
/// Stan aplikacji demo, który nie należy do warstwy platformy ani do widgetów.
#[derive(Clone)]
pub struct Demo {
pub option_a: bool,
pub volume: f64,
pub volume_dragging: bool,
pub show_bottom_bar: bool,
pub sidebar_visible: bool,
pub sidebar_width: f64,
pub dragging_sidebar: bool,
/// Wybrany widok (0 = Lista, 1 = Siatka).
pub view_mode: usize,
/// Zaznaczona pozycja menu nawigacji (indeks elementu) oraz podświetlenia.
pub nav_selected: Option<usize>,
pub nav_hover: Option<usize>,
pub theme_hover: Option<usize>,
pub view_hover: Option<usize>,
pub options_hover: bool,
pub switch_hover: bool,
/// Podświetlona kontrolka okna i akcje do wykonania przez platformę.
pub controls_hover: Option<WindowControl>,
pub pending_close: bool,
pub pending_minimize: bool,
pub pending_maximize: bool,
/// Przeciąganie okna za pasek tytułu (CSD).
pub pending_move: bool,
/// Otwarty popover i podświetlona pozycja.
pub popover: Option<Popover>,
pub popover_hover: Option<usize>,
/// Strony zakładek (tytuły to klucze i18n) oraz podświetlona zakładka.
pub tabs: TabView,
pub tabs_hover: Option<usize>,
/// Animowane przejście aktywnej strony zakładek (`Stack`: slide w poziomie).
pub tab_stack: Stack,
/// Tytuł strony wychodzącej w trakcie animacji przejścia.
pub tab_prev_title: Option<String>,
/// Dolny panel (modalny) i karuzela stron w jego zawartości.
pub sheet: BottomSheet,
pub pager: Pager,
pub pager_dragging: bool,
/// Pole tekstowe w dolny panelu (demonstracja `Entry`).
pub sheet_entry: Entry,
/// Pasek ścieżki (składniki to klucze i18n) i pole wyszukiwania.
pub path: PathBar,
pub path_hover: Option<usize>,
pub search: SearchEntry,
/// Widok elementów (pliki/katalogi) i podświetlony element.
pub items: ItemView,
pub items_hover: Option<usize>,
/// Przewijanie listy plików i stan przeciągania paska.
pub files_scroll: ScrollView,
pub scroll_dragging: bool,
/// Stan popovera „Opcje” (rozmiar ikon, sortowanie, ukryte pliki) i podświetlenia.
pub icon_size: i64,
pub sort: usize,
pub show_hidden: bool,
pub options_stepper_hover: Option<StepperSide>,
pub options_radio_hover: Option<usize>,
pub options_check_hover: bool,
/// Powiadomienie (nakładka) oraz akcent sprzed ostatniej zmiany (do „Cofnij”).
pub toast: Toast,
pub prev_accent: Accent,
}
impl Default for Demo {
fn default() -> Self {
Self {
option_a: true,
volume: 0.65,
volume_dragging: false,
show_bottom_bar: true,
sidebar_visible: true,
sidebar_width: DEFAULT_SIDEBAR_W,
dragging_sidebar: false,
view_mode: 0,
// Domyślnie zaznaczony „Wygląd" (indeks elementu w liście nawigacji).
nav_selected: Some(2),
nav_hover: None,
theme_hover: None,
view_hover: None,
options_hover: false,
switch_hover: false,
controls_hover: None,
pending_close: false,
pending_minimize: false,
pending_maximize: false,
pending_move: false,
popover: None,
popover_hover: None,
tabs: {
let mut tabs = TabView::new();
tabs.add_titled("General");
tabs.add_titled("Advanced");
tabs.add_titled("About");
tabs
},
tabs_hover: None,
tab_stack: {
let mut s = Stack::with_count(3);
s.set_transition(StackTransition::SlideHorizontal);
s.set_transition_easing(Easing::EaseOut);
s
},
tab_prev_title: None,
sheet: BottomSheet::new(),
pager: Pager::new(3),
pager_dragging: false,
sheet_entry: Entry::new(""),
path: PathBar::new(["Home", "Documents", "Projects"]),
path_hover: None,
search: SearchEntry::new("Search"),
items: ItemView::new(vec![
Item::folder("Projects"),
Item::new("Report").detail("PDF"),
Item::new("Notes").detail("TXT"),
Item::new("Budget").detail("XLSX"),
Item::new("IMG_0001.JPG").detail("JPG"),
Item::new("IMG_0002.JPG").detail("JPG"),
Item::new("screenshot.png").detail("PNG"),
Item::new("TODO.txt").detail("TXT"),
Item::new("changelog.md").detail("MD"),
Item::new("backup.tar").detail("TAR"),
]),
items_hover: None,
files_scroll: ScrollView::new(),
scroll_dragging: false,
icon_size: 32,
sort: 0,
show_hidden: false,
options_stepper_hover: None,
options_radio_hover: None,
options_check_hover: false,
toast: Toast::new(""),
prev_accent: Accent::default(),
}
}
}
/// Prostokąty interaktywnych elementów — wspólne dla rysowania i hit-testingu.
pub struct Layout {
pub sidebar: Rect,
pub sidebar_handle: Rect,
pub area: Rect,
/// Karty obszaru treści (ułożone silnikiem układu `measure`/`allocate`).
pub card: Rect,
pub card2: Rect,
pub card3: Rect,
pub card4: Rect,
pub sidebar_button: Rect,
pub sidebar_visible: bool,
pub options_button: Rect,
/// Rozkład paska tytułu (kontrolki okna + obszar treści).
pub header: ControlsLayout,
pub theme_switcher: Rect,
pub accents: [Rect; 9],
pub language: Rect,
pub view_switcher: Rect,
pub option_a: Rect,
pub volume_track: Rect,
pub bottom_bar: Rect,
pub sidebar_toggle: Rect,
/// Pasek zakładek (karta „Zakładki”) i wiersz z aktywną stroną.
pub tabs: Rect,
pub tabs_value: Rect,
/// Pasek ścieżki i pole wyszukiwania (karta „Pliki”).
pub path: Rect,
pub search: Rect,
/// Obszar elementów (siatka/lista) w karcie „Pliki”.
pub items: Rect,
/// Kotwica popoveru „☰” w HeaderBarze.
pub burger: Rect,
}
fn segment_labels() -> [&'static str; 3] {
["Light", "Dark", "Auto"]
}
/// Pasek tytułu z tytułem i kontrolkami okna po prawej.
fn header_bar() -> HeaderBar {
HeaderBar::new()
.title(tr("Application"))
.controls(ControlsSide::Right)
}
/// Lista nawigacji z aktualnie przetłumaczonymi etykietami (kolejność stała).
pub fn make_nav() -> NavList {
NavList::new(vec![
NavEntry::Item(tr("Overview")),
NavEntry::Item(tr("Account")),
NavEntry::Item(tr("Appearance")),
NavEntry::Item(tr("Notifications")),
NavEntry::Section(tr("Advanced")),
NavEntry::Item(tr("Network")),
NavEntry::Item(tr("System")),
NavEntry::Item(tr("Widgets")),
])
}
/// Czy w sidebarze wybrana jest strona „Widgety” (galeria komponentów).
pub fn is_gallery(demo: &Demo) -> bool {
demo.nav_selected == Some(WIDGETS_NAV)
}
/// Pozycje menu „☰" (aktualnie przetłumaczone).
pub fn menu_entries() -> Menu {
Menu::new(vec![
MenuEntry::action(tr("New Window")).shortcut("Ctrl+N"),
MenuEntry::action(tr("Keyboard Shortcuts")),
MenuEntry::separator(),
MenuEntry::action(tr("Settings")),
MenuEntry::action(tr("Style")),
MenuEntry::action(tr("About")),
])
}
/// Lista dostępnych stylów (z plików) do popoveru wyboru.
pub fn style_list() -> Vec<libluna_core::StyledTheme> {
libluna_core::available_themes()
}
/// Pasek zakładek demo z przetłumaczonymi etykietami (tytuły w `TabView` to klucze).
pub fn tab_bar(demo: &Demo) -> TabBar {
let tabs: Vec<Tab> = demo
.tabs
.tabs()
.iter()
.map(|t| Tab::new(tr(&t.title)).closable(t.closable))
.collect();
let mut bar = TabBar::with_tabs(tabs);
bar.set_selected(demo.tabs.selected());
bar.set_hovered(demo.tabs_hover);
bar
}
/// Pasek ścieżki demo z przetłumaczonymi etykietami (składniki to klucze i18n).
pub fn path_bar(demo: &Demo) -> PathBar {
let mut bar = PathBar::new(demo.path.components().iter().map(|k| tr(k)));
bar.set_hovered(demo.path_hover);
bar
}
/// Indeksy elementów z `demo.items`, które przechodzą obecny filtr wyszukiwania.
pub fn visible_indices(demo: &Demo) -> Vec<usize> {
let query = demo.search.text().trim().to_lowercase();
let all = demo.items.items();
if query.is_empty() {
return (0..all.len()).collect();
}
all.iter()
.enumerate()
.filter(|(_, it)| tr(&it.label).to_lowercase().contains(&query))
.map(|(i, _)| i)
.collect()
}
/// Widok elementów demo z przetłumaczonymi etykietami (etykiety to klucze i18n).
///
/// Wykorzystuje filtr wyszukiwania, a zaznaczenie (indeks w pełnej liście) mapuje
/// na widoczną pozycję.
pub fn item_view(demo: &Demo) -> ItemView {
let visible = visible_indices(demo);
let items: Vec<Item> = visible
.iter()
.map(|&i| {
let it = &demo.items.items()[i];
let mut item = Item::new(tr(&it.label)).kind(it.kind);
if let Some(detail) = &it.detail {
item = item.detail(detail.clone());
}
item
})
.collect();
let mut view = ItemView::new(items);
view.set_mode(demo.items.mode());
view.set_selected(
demo.items
.selected()
.and_then(|s| visible.iter().position(|&i| i == s)),
);
view.set_hovered(demo.items_hover);
view.set_scroll(demo.items.scroll());
view
}
/// Ekran stanu dla listy plików: brak wyników wyszukiwania albo pusty katalog.
///
/// W demo obszar listy jest niski, więc używamy wariantu kompaktowego (tytuł + akcja).
pub fn empty_state(demo: &Demo) -> Option<StatusPage> {
let query = demo.search.text().trim();
if !query.is_empty() {
if visible_indices(demo).is_empty() {
return Some(StatusPage::new(tr("No results")).action(tr("Clear")));
}
None
} else if demo.items.is_empty() {
Some(StatusPage::new(tr("Nothing here")))
} else {
None
}
}
/// Kody języków i kontrolka wyboru (nazwy natywne).
pub fn language_dropdown(codes: &[String]) -> DropDown {
let names: Vec<String> = codes
.iter()
.map(|c| libluna_i18n::language_name(c).unwrap_or_else(|| c.to_uppercase()))
.collect();
let mut dd = DropDown::new(names);
let current = libluna_i18n::locale().language().to_owned();
if let Some(i) = codes.iter().position(|c| *c == current) {
dd.set_selected(i);
}
dd
}
fn theme_switcher(preference: ThemePreference, hovered: Option<usize>) -> ViewSwitcher {
let mut vs = ViewSwitcher::new(segment_labels().map(tr));
let index = ThemePreference::ALL
.iter()
.position(|p| *p == preference)
.unwrap_or(0);
vs.set_selected(index);
vs.set_hovered(hovered);
vs
}
fn view_switcher(view_mode: usize, hovered: Option<usize>) -> ViewSwitcher {
let mut vs = ViewSwitcher::new([tr("List"), tr("Grid")]);
vs.set_selected(view_mode);
vs.set_hovered(hovered);
vs
}
/// Panel otwartego popoveru wraz z jego widgetem.
pub fn popover_menu(
size: Size,
theme: &Theme,
text: &TextEngine,
lay: &Layout,
demo: &Demo,
) -> Option<(Rect, Menu)> {
let viewport = Rect::from_xywh(0.0, 0.0, size.width, size.height);
match demo.popover? {
Popover::Menu => {
let menu = menu_entries();
let w = menu.width(text, theme);
Some((menu.place(lay.burger, w, viewport, theme), menu))
}
Popover::Language => {
let codes = libluna_i18n::languages();
let menu = language_dropdown(&codes).menu();
let w = menu.width(text, theme);
Some((menu.place(lay.language, w, viewport, theme), menu))
}
// Popover „Opcje” rysowany osobno (własny układ).
Popover::Options => None,
// Wybór stylu: „Domyślny” + style z plików.
Popover::Styles => {
let mut entries = vec![MenuEntry::action(tr("Default"))];
for style in style_list() {
entries.push(MenuEntry::action(style.name));
}
let menu = Menu::new(entries);
let w = menu.width(text, theme);
Some((menu.place(lay.burger, w, viewport, theme), menu))
}
}
}
// --- Układ ---
pub fn layout(size: Size, theme: &Theme, demo: &Demo) -> Layout {
let lg = theme.spacing.lg;
let md = theme.spacing.md;
let header_h = theme.metrics.headerbar.height;
let control = theme.metrics.headerbar.control;
let switch_m = theme.metrics.switch;
let status_h = if demo.show_bottom_bar { STATUS_H } else { 0.0 };
let content_h = (size.height - header_h - status_h).max(0.0);
let sidebar_w = if demo.sidebar_visible {
demo.sidebar_width
} else {
0.0
};
let sidebar = Rect::from_xywh(0.0, header_h, sidebar_w, content_h);
let sidebar_handle = if demo.sidebar_visible {
Rect::from_xywh(sidebar_w - HANDLE_W / 2.0, header_h, HANDLE_W, content_h)
} else {
Rect::ZERO
};
let area = Rect::from_xywh(
sidebar_w,
header_h,
(size.width - sidebar_w).max(0.0),
content_h,
);
// Pasek tytułu jako widget: kontrolki okna + obszar na treść.
let header_rect = Rect::from_xywh(0.0, 0.0, size.width, header_h);
let header = header_bar().controls_layout(header_rect, theme);
let hb_cy = header_rect.center().y;
let content = header.content;
let sidebar_button = Rect::from_xywh(content.x(), hb_cy - control / 2.0, control, control);
let plus = Rect::from_xywh(
sidebar_button.right() + PAD,
hb_cy - control / 2.0,
control,
control,
);
let options_button = Rect::from_xywh(plus.right() + PAD, hb_cy - control / 2.0, 110.0, control);
let burger = Rect::from_xywh(
content.right() - control,
hb_cy - control / 2.0,
control,
control,
);
// Karty i wiersze układane przez silnik `measure`/`allocate` (retencyjne drzewo).
let ctx_text = TextEngine::none();
let ctx = LayoutCtx {
theme,
text: &ctx_text,
};
let cards = Tree::new(Node::padding(
Insets::splat(lg),
Node::vbox(vec![
Node::fixed(0.0, 208.0),
Node::fixed(0.0, 320.0),
Node::fixed(0.0, 152.0),
Node::fixed(0.0, 196.0),
])
.gap(lg),
))
.fit(area, &ctx);
let (card, card2, card3, card4) = (cards[0], cards[1], cards[2], cards[3]);
let header_h = theme.metrics.card.header;
let row_h = ROW_H;
// Wiersze karty: pionowy stos `count` pozycji (silnik).
let row_rects = |card: Rect, count: usize| -> Vec<Rect> {
let inner = Rect::from_xywh(
card.x(),
card.y() + header_h,
card.width(),
(card.height() - header_h).max(0.0),
);
let tree = Node::vbox((0..count).map(|_| Node::fixed(0.0, row_h)).collect());
Tree::new(tree).fit(inner, &ctx)
};
let rows1 = row_rects(card, 3);
// Wiersz 0: tryb (jasny/ciemny/auto).
let seg_total = 240.0;
let r0 = rows1[0];
let theme_switcher = Rect::from_xywh(
r0.right() - md - seg_total,
r0.center().y - 16.0,
seg_total,
32.0,
);
// Wiersz 1: akcent.
let dot = 22.0;
let gap = 10.0;
let dots_w = Accent::ALL.len() as f64 * dot + (Accent::ALL.len() - 1) as f64 * gap;
let r1 = rows1[1];
let dots_x = r1.right() - md - dots_w;
let accents = std::array::from_fn(|i| {
Rect::from_xywh(
dots_x + i as f64 * (dot + gap),
r1.center().y - dot / 2.0,
dot,
dot,
)
});
// Wiersz 2: język.
let r2 = rows1[2];
let dd_h = theme.metrics.dropdown.height;
let language = Rect::from_xywh(
r2.right() - md - 170.0,
r2.center().y - dd_h / 2.0,
170.0,
dd_h,
);
// Karta 2: Ustawienia (5 wierszy).
let rows2 = row_rects(card2, 5);
let switch = |r: Rect| {
Rect::from_xywh(
r.right() - md - switch_m.width,
r.center().y - switch_m.height / 2.0,
switch_m.width,
switch_m.height,
)
};
let option_a = switch(rows2[0]);
let srow = rows2[1];
let track_h = theme.metrics.slider.track_height;
let volume_track = Rect::from_xywh(
srow.right() - md - 200.0,
srow.center().y - track_h / 2.0,
200.0,
track_h,
);
let vrow = rows2[2];
let view_switcher = Rect::from_xywh(
vrow.right() - md - 160.0,
vrow.center().y - 16.0,
160.0,
32.0,
);
let bottom_bar = switch(rows2[3]);
let sidebar_toggle = switch(rows2[4]);
// Karta 3: Zakładki (pasek + wiersz z aktywną stroną).
let rows3 = row_rects(card3, 2);
let tabs_h = theme.metrics.tabs.height;
let tabs_w = 420.0;
let trow = rows3[0];
let tabs = Rect::from_xywh(
trow.right() - md - tabs_w,
trow.center().y - tabs_h / 2.0,
tabs_w,
tabs_h,
);
let tabs_value = rows3[1];
// Karta 4: Pliki (PathBar + SearchEntry + ItemView).
let row4 = row_rects(card4, 1)[0];
let path_h = theme.metrics.pathbar.height;
let path = Rect::from_xywh(
card4.x() + md,
row4.center().y - path_h / 2.0,
360.0,
path_h,
);
let entry_h = theme.metrics.entry.height;
let search = Rect::from_xywh(
row4.right() - md - 240.0,
row4.center().y - entry_h / 2.0,
240.0,
entry_h,
);
let items = Rect::from_xywh(
card4.x() + md,
card4.y() + header_h + row_h,
(card4.width() - 2.0 * md).max(0.0),
(card4.height() - header_h - row_h - md).max(0.0),
);
Layout {
sidebar,
sidebar_handle,
area,
card,
card2,
card3,
card4,
sidebar_button,
sidebar_visible: demo.sidebar_visible,
options_button,
header,
theme_switcher,
accents,
language,
view_switcher,
option_a,
volume_track,
bottom_bar,
sidebar_toggle,
tabs,
tabs_value,
path,
search,
items,
burger,
}
}
// --- Obsługa zdarzeń ---
/// Reaguje na zdarzenia: kliknięcia, przeciąganie (pasek boczny, suwak) i skróty.
///
/// Nie zależy od Waylanda — bierze zdarzenia i ustawienia, więc da się ją wywołać
/// także w testach i w headlessowym „driverze” (bez okna).
pub fn handle_events(
settings: &mut ThemeSettings,
events: &[Event],
size: Size,
theme: &Theme,
text: &TextEngine,
demo: &mut Demo,
) {
let lay = layout(size, theme, demo);
let theme_vs = ViewSwitcher::new(segment_labels().map(tr));
let view_vs = ViewSwitcher::new([tr("List"), tr("Grid")]);
let tabs_bar = tab_bar(demo);
let path = path_bar(demo);
let items = item_view(demo);
let visible = visible_indices(demo);
let nav = make_nav();
let slider = Slider::new(demo.volume);
let popover = popover_menu(size, theme, text, &lay, demo);
let header_rect = Rect::from_xywh(0.0, 0.0, size.width, theme.metrics.headerbar.height);
let header = header_bar();
demo.files_scroll
.set_content_height(items.content_height(lay.items, theme));
for event in events {
if std::env::var("LUNA_DEBUG_INPUT").is_ok() {
match event {
Event::PointerButton(b) => eprintln!(
"demo: button {:?} {:?} at ({:.1},{:.1}) control={:?}",
b.button,
b.state,
b.position.x,
b.position.y,
header.control_at(header_rect, theme, b.position)
),
Event::Window(w) => eprintln!("demo: window {w:?}"),
_ => {}
}
}
match event {
Event::PointerButton(pe) if pe.button == PointerButton::Left => {
let p = pe.position;
match pe.state {
ButtonState::Pressed => {
// Dolny panel (modalny) ma pierwszeństwo nad resztą UI.
if demo.sheet.is_open() {
let area = demo.sheet.content_rect(size, theme);
if demo.sheet.close_at(size, theme, p) {
demo.sheet.set_open_animated(false);
demo.sheet_entry.set_focused(false);
} else if demo.sheet.drag_zone_at(size, theme, p) {
demo.sheet.begin_drag(p.y);
} else if demo.sheet.scrim_at(size, theme, p) {
demo.sheet.set_open_animated(false);
demo.sheet_entry.set_focused(false);
} else if area.contains(p) {
if sheet_entry_rect(size, theme).contains(p) {
demo.sheet_entry.set_focused(true);
} else if let Some(i) =
demo.pager
.indicator_at(sheet_pages_rect(size, theme), theme, p)
{
demo.pager.set_active_animated(i);
demo.sheet_entry.set_focused(false);
} else {
demo.sheet_entry.set_focused(false);
demo.pager_dragging = true;
demo.pager.begin_drag(p.x);
}
}
continue;
}
// Powiadomienie (nakładka) ma pierwszeństwo nad treścią.
if demo.toast.is_open() {
if demo.toast.action_at(size, theme, text, p) {
settings.set_accent(demo.prev_accent);
demo.toast.set_open_animated(false);
continue;
}
// Klik w treść powiadomienia je zamyka (akcent zostaje).
if demo.toast.dismiss_at(size, theme, text, p) {
demo.toast.set_open_animated(false);
continue;
}
}
// Popover „Opcje” obsługuje własne kontrolki.
if demo.popover == Some(Popover::Options) {
let ol = options_layout(size, theme, &lay);
let mut stepper =
Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX).step(8);
let mut radios = sort_options();
radios.set_selected(demo.sort);
let mut check = CheckButton::new(tr("Show hidden files"));
check.set_checked(demo.show_hidden);
if stepper.click_at(ol.stepper, theme, p) {
demo.icon_size = stepper.value();
} else if radios.click_at(ol.radios, p, theme) {
demo.sort = radios.selected();
} else if check.click_at(ol.check, p) {
demo.show_hidden = check.checked();
} else if !ol.panel.contains(p) {
demo.popover = None;
}
continue;
}
// Gdy popover otwarty, klik wybiera pozycję albo zamyka.
if demo.popover.is_some() {
if let (Some((panel, menu)), Some(kind)) = (&popover, demo.popover) {
if let Some(entry) = menu.index_at(*panel, p, theme) {
match kind {
Popover::Language => {
let codes = libluna_i18n::languages();
if let Some(code) = codes.get(entry) {
libluna_i18n::set_locale(Locale::new(code));
}
}
Popover::Menu if entry == STYLE_ENTRY => {
// „Styl” otwiera wybór stylu.
demo.popover = Some(Popover::Styles);
demo.popover_hover = None;
continue;
}
Popover::Menu if entry == ABOUT_ENTRY => {
// „O aplikacji” otwiera dolny panel.
demo.sheet.set_open_animated(true);
}
Popover::Styles => {
// 0 = „Domyślny”, dalej style z plików.
if entry == 0 {
settings.set_style(None);
} else if let Some(style) = style_list().get(entry - 1)
{
settings.set_style(Some(style.id.clone()));
}
}
_ => {}
}
}
}
demo.popover = None;
demo.popover_hover = None;
continue;
}
// Klik poza polem wyszukiwania odbiera mu fokus.
if !demo.search.contains(lay.search, p) {
demo.search.set_focused(false);
}
if let Some(control) = header.control_at(header_rect, theme, p) {
match control {
WindowControl::Minimize => demo.pending_minimize = true,
WindowControl::Maximize => demo.pending_maximize = true,
WindowControl::Close => demo.pending_close = true,
}
} else if lay.burger.contains(p) {
demo.popover = Some(Popover::Menu);
} else if lay.options_button.contains(p) {
demo.popover = Some(Popover::Options);
} else if lay.language.contains(p) {
demo.popover = Some(Popover::Language);
} else if lay.sidebar_handle.contains(p) {
demo.dragging_sidebar = true;
} else if lay.sidebar_button.contains(p) {
// Przycisk „pokaż/ukryj sidebar” leży w pasku tytułu —
// musi mieć pierwszeństwo przed obszarem przeciągania okna.
demo.sidebar_visible = !demo.sidebar_visible;
} else if header_rect.contains(p) {
// Pasek tytułu poza kontrolkami i przyciskami — przeciąganie okna.
demo.pending_move = true;
} else if is_gallery(demo) {
// Na stronie galerii jedynymi aktywnymi elementami są
// sidebar (zmiana strony) i pasek tytułu.
if let Some(i) = nav.index_at(lay.sidebar, p, theme) {
demo.nav_selected = Some(i);
}
} else if let Some(i) = theme_vs.index_at(lay.theme_switcher, p) {
settings.set_preference(ThemePreference::ALL[i]);
} else if let Some(i) = view_vs.index_at(lay.view_switcher, p) {
demo.view_mode = i;
demo.items.set_mode(if i == 1 {
ViewMode::Grid
} else {
ViewMode::List
});
let mut view = item_view(demo);
view.set_scroll(demo.items.scroll());
view.clamp_scroll(lay.items, theme);
demo.items.set_scroll(view.scroll());
demo.files_scroll
.set_content_height(view.content_height(lay.items, theme));
demo.files_scroll.set_offset(view.scroll(), lay.items);
} else if let Some(i) = tabs_bar.index_at(lay.tabs, text, theme, p) {
if i != demo.tabs.selected() {
demo.tab_prev_title =
demo.tabs.selected_title().map(|s| s.to_owned());
demo.tab_stack.set_visible_index(i);
}
demo.tabs.select(i);
} else if demo.search.contains(lay.search, p) {
if demo.search.clear_at(lay.search, theme, p) {
demo.search.clear();
}
demo.search.set_focused(true);
} else if let Some(i) = path.index_at(lay.path, text, theme, p) {
demo.path.truncate(i + 1);
} else if let Some(page) = empty_state(demo) {
// Akcja pustego stanu (np. „Wyczyść” w braku wyników).
if page.action_at(lay.items, theme, p) {
demo.search.clear();
}
} else if demo.files_scroll.begin_drag(p, lay.items, theme) {
demo.scroll_dragging = true;
} else if let Some(i) = items.index_at(lay.items, p, theme) {
demo.items.set_selected(visible.get(i).copied());
} else if let Some(i) = nav.index_at(lay.sidebar, p, theme) {
demo.nav_selected = Some(i);
} else if lay.option_a.contains(p) {
demo.option_a = !demo.option_a;
} else if slider.hit(lay.volume_track, p, theme) {
demo.volume_dragging = true;
demo.volume = slider.value_at(lay.volume_track, p);
} else if lay.bottom_bar.contains(p) {
demo.show_bottom_bar = !demo.show_bottom_bar;
} else if lay.sidebar_toggle.contains(p) {
demo.sidebar_visible = !demo.sidebar_visible;
} else {
for (i, r) in lay.accents.iter().enumerate() {
if r.contains(p) {
apply_accent(settings, demo, Accent::ALL[i]);
}
}
}
}
ButtonState::Released => {
demo.dragging_sidebar = false;
demo.volume_dragging = false;
if demo.pager_dragging {
demo.pager.end_drag_animated();
demo.pager_dragging = false;
}
if demo.scroll_dragging {
demo.files_scroll.end_drag();
demo.scroll_dragging = false;
}
if demo.sheet.is_open() {
demo.sheet.end_drag(theme);
}
}
}
}
Event::PointerMotion { position, .. } => {
let position = *position;
if demo.sheet.is_open() {
let close = demo.sheet.close_at(size, theme, position);
demo.sheet.set_hover_close(close);
demo.sheet.update_drag(position.y);
if demo.pager_dragging {
let area = demo.sheet.content_rect(size, theme);
demo.pager.update_drag(position.x, area);
}
continue;
}
if demo.popover == Some(Popover::Options) {
let ol = options_layout(size, theme, &lay);
demo.options_stepper_hover = Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX)
.side_at(ol.stepper, theme, position);
demo.options_radio_hover = sort_options().index_at(ol.radios, position, theme);
demo.options_check_hover = CheckButton::new("").hit(ol.check, position);
continue;
}
if demo.dragging_sidebar && demo.sidebar_visible {
demo.sidebar_width = position.x.clamp(MIN_SIDEBAR_W, MAX_SIDEBAR_W);
}
if demo.volume_dragging {
demo.volume = slider.value_at(lay.volume_track, position);
}
if demo.popover.is_some() {
demo.popover_hover = popover
.as_ref()
.and_then(|(panel, menu)| menu.index_at(*panel, position, theme));
} else {
demo.nav_hover = nav.index_at(lay.sidebar, position, theme);
demo.theme_hover = theme_vs.index_at(lay.theme_switcher, position);
demo.view_hover = view_vs.index_at(lay.view_switcher, position);
demo.options_hover = lay.options_button.contains(position);
demo.switch_hover = lay.option_a.contains(position);
demo.tabs_hover = tabs_bar.index_at(lay.tabs, text, theme, position);
demo.path_hover = path.index_at(lay.path, text, theme, position);
demo.items_hover = items.index_at(lay.items, position, theme);
if demo.scroll_dragging {
demo.files_scroll.drag_to(position, lay.items, theme);
demo.items.set_scroll(demo.files_scroll.offset());
}
demo.search
.set_hover_clear(demo.search.clear_at(lay.search, theme, position));
demo.controls_hover = header.control_at(header_rect, theme, position);
}
}
Event::PointerLeave => {
demo.dragging_sidebar = false;
demo.volume_dragging = false;
demo.pager_dragging = false;
demo.nav_hover = None;
demo.theme_hover = None;
demo.view_hover = None;
demo.options_hover = false;
demo.switch_hover = false;
demo.tabs_hover = None;
demo.path_hover = None;
demo.items_hover = None;
demo.files_scroll.end_drag();
demo.scroll_dragging = false;
demo.search.set_hover_clear(false);
demo.sheet.set_hover_close(false);
demo.controls_hover = None;
}
Event::Scroll(se) => {
if !demo.sheet.is_open() && lay.items.contains(se.position) {
let step = theme.metrics.itemview.row_h;
if demo.files_scroll.on_scroll(se.dy * step, lay.items) {
demo.items.set_scroll(demo.files_scroll.offset());
}
}
}
Event::Key(ke) if ke.state == ButtonState::Pressed => {
// Gdy pole wyszukiwania ma fokus, klawisze idą do niego.
if demo.search.is_focused() && handle_text_key(&mut demo.search, ke) {
continue;
}
// Pole `Entry` w dolnym panelu ma własny fokus.
if demo.sheet.is_open()
&& demo.sheet_entry.is_focused()
&& handle_text_key(&mut demo.sheet_entry, ke)
{
continue;
}
match ke.keysym {
// `l` — przełącz jasny/ciemny
0x6c => {
let target = if theme.appearance == Appearance::Dark {
ThemePreference::Light
} else {
ThemePreference::Dark
};
settings.set_preference(target);
}
// `a` — następny akcent
0x61 => {
let idx = Accent::ALL
.iter()
.position(|a| *a == settings.accent())
.unwrap_or(0);
apply_accent(settings, demo, Accent::ALL[(idx + 1) % Accent::ALL.len()]);
}
// `g` — następny język
0x67 => cycle_language(),
// `b` — pokaż/ukryj pasek dolny
0x62 => demo.show_bottom_bar = !demo.show_bottom_bar,
// `s` — pokaż/ukryj pasek boczny
0x73 => demo.sidebar_visible = !demo.sidebar_visible,
// `v` — przełącz widok (Lista/Siatka)
0x76 => demo.view_mode = (demo.view_mode + 1) % 2,
// `o` — pokaż/ukryj dolny panel („O aplikacji”)
0x6f => {
let open = !demo.sheet.is_open();
demo.sheet.set_open_animated(open);
}
_ => {}
}
}
_ => {}
}
}
}
/// Zmienia akcent (o ile inny), zapamiętuje poprzedni i pokazuje powiadomienie z „Cofnij”.
fn apply_accent(settings: &mut ThemeSettings, demo: &mut Demo, accent: Accent) {
if settings.accent() == accent {
return;
}
demo.prev_accent = settings.accent();
settings.set_accent(accent);
demo.toast = Toast::new(tr("Accent changed")).action(tr("Undo"));
demo.toast.show_animated();
}
fn cycle_language() {
let languages = libluna_i18n::languages();
if languages.is_empty() {
return;
}
let current = libluna_i18n::locale().language().to_owned();
let idx = languages.iter().position(|l| *l == current).unwrap_or(0);
let next = languages[(idx + 1) % languages.len()].clone();
libluna_i18n::set_locale(Locale::new(&next));
}
/// Kieruje pojedyncze zdarzenie klawiatury do pola tekstowego.
///
/// Zwraca `true`, jeśli klawisz został zużyty (wtedy skróty demo są pomijane).
fn handle_text_key<T: TextInput>(entry: &mut T, ke: &KeyEvent) -> bool {
let shift = ke.modifiers.shift;
let ctrl = ke.modifiers.ctrl;
// Ctrl+A — zaznacz cały tekst.
if ctrl && ke.keysym == 0x61 {
entry.select_all();
return true;
}
// Tekst złożony przez platformę (obsługuje też znaki spoza ASCII).
if !ctrl {
if let Some(t) = &ke.text {
if !t.is_empty() && !t.chars().all(char::is_control) {
entry.insert_str(t);
return true;
}
}
}
match ke.keysym {
0xff08 => entry.backspace(), // BackSpace
0xffff => entry.delete_forward(), // Delete
0xff51 => {
if shift {
entry.move_left_selecting(true)
} else {
entry.move_left()
}
}
0xff53 => {
if shift {
entry.move_right_selecting(true)
} else {
entry.move_right()
}
}
0xff50 => {
if shift {
entry.home_selecting(true)
} else {
entry.home()
}
}
0xff57 => {
if shift {
entry.end_selecting(true)
} else {
entry.end()
}
}
0x20..=0x7e if !ctrl => {
if let Some(c) = char::from_u32(ke.keysym) {
entry.insert_char(c);
}
}
_ => return false,
}
true
}
// --- Budowa sceny ---
/// Buduje scenę makiety Ustawień o zadanym rozmiarze (piksele logiczne).
pub fn build_scene(
scene: &mut libluna_render::Scene,
theme: &Theme,
text: &TextEngine,
size: Size,
demo: &Demo,
preference: ThemePreference,
accent: Accent,
) {
let lay = layout(size, theme, demo);
let mut paint = PaintCtx { scene, theme, text };
let (w, h) = (size.width, size.height);
let bg = theme.colors.window_bg;
paint.scene.fill_rect(Rect::from_xywh(0.0, 0.0, w, h), bg);
draw_headerbar(
&mut paint,
Rect::from_xywh(0.0, 0.0, w, theme.metrics.headerbar.height),
&lay,
demo,
);
if lay.sidebar.width() > 0.0 {
draw_sidebar(&mut paint, &lay, demo);
}
if is_gallery(demo) {
crate::gallery_page::paint(&mut paint, lay.area);
} else {
draw_content(&mut paint, lay.area, &lay, demo, preference, accent);
}
if demo.show_bottom_bar {
draw_statusbar(&mut paint, Rect::from_xywh(0.0, h - STATUS_H, w, STATUS_H));
}
// Popover („☰" lub wybór języka) rysowany na wierzchu.
if let Some((panel, mut menu)) = popover_menu(size, theme, text, &lay, demo) {
menu.set_hovered(demo.popover_hover);
menu.paint(&mut paint, panel);
}
// Popover „Opcje” (rozmiar ikon / sortowanie / ukryte pliki).
if demo.popover == Some(Popover::Options) {
draw_options(&mut paint, size, &lay, demo);
}
// Dolny panel (modalny) rysowany na samym wierzchu (także w trakcie wygaszania).
if demo.sheet.progress() > 0.001 {
let mut sheet = demo.sheet.clone();
sheet.set_title(Some(tr("About")));
sheet.paint(&mut paint, size);
draw_sheet_content(&mut paint, demo, size);
}
// Powiadomienie („toast”) nad wszystkim (także w trakcie wygaszania).
if demo.toast.progress() > 0.001 {
demo.toast.paint(&mut paint, size);
}
}
/// Obszar zawartości dolnego panelu (poniżej jego nagłówka).
fn sheet_content_rect(size: Size, theme: &Theme) -> Rect {
let m = theme.metrics.sheet;
Rect::from_xywh(
0.0,
size.height - m.height + m.header,
size.width,
(m.height - m.header).max(0.0),
)
}
/// Prostokąt pola tekstowego w dolnym panelu (demonstracja `Entry`).
pub fn sheet_entry_rect(size: Size, theme: &Theme) -> Rect {
let content = sheet_content_rect(size, theme);
let pad = theme.spacing.md;
Rect::from_xywh(
content.x() + pad,
content.y() + pad,
(content.width() - 2.0 * pad).max(0.0),
theme.metrics.entry.height,
)
}
/// Obszar stron karuzeli w dolnym panelu (poniżej pola tekstowego).
fn sheet_pages_rect(size: Size, theme: &Theme) -> Rect {
let content = sheet_content_rect(size, theme);
let pad = theme.spacing.md;
let top = content.y() + pad + theme.metrics.entry.height + pad;
Rect::from_xywh(
content.x(),
top,
content.width(),
(content.bottom() - top).max(0.0),
)
}
/// Zawartość dolnego panelu: pole `Entry` (demonstracja) + karuzela stron (`Pager`).
fn draw_sheet_content(paint: &mut PaintCtx, demo: &Demo, size: Size) {
let theme = *paint.theme;
// Pole tekstowe (`Entry`) na górze panelu.
let mut entry = demo.sheet_entry.clone();
entry.set_placeholder(tr("Your name"));
entry.paint(paint, sheet_entry_rect(size, &theme));
// Strony karuzeli poniżej.
let pages = sheet_pages_rect(size, &theme);
let pager = &demo.pager;
paint.scene.push_clip(pages);
for i in 0..pager.count() {
draw_about_page(paint, pager.page_rect(pages, i), i);
}
paint.scene.pop_clip();
pager.paint(paint, pages);
}
/// Pojedyncza strona karuzeli w panelu „O aplikacji”.
fn draw_about_page(paint: &mut PaintCtx, rect: Rect, index: usize) {
let theme = *paint.theme;
let (heading, body) = match index {
0 => (tr("About"), "libLuna 0.0.1".to_owned()),
1 => (tr("Keyboard Shortcuts"), "l · a · g · b · s · v".to_owned()),
_ => (tr("License"), "MPL-2.0".to_owned()),
};
let cx = rect.center().x;
let cy = rect.center().y;
paint.label_center(
cx,
cy - 14.0,
&heading,
theme.typography.heading.size,
weight::BOLD,
theme.colors.text,
);
paint.label_center(
cx,
cy + 14.0,
&body,
theme.typography.body.size,
weight::NORMAL,
theme.colors.dim_text,
);
}
fn draw_headerbar(paint: &mut PaintCtx, bar: Rect, lay: &Layout, demo: &Demo) {
let theme = *paint.theme;
// Tło, tytuł i kontrolki okna rysuje widget.
header_bar().paint(paint, bar, demo.controls_hover);
let cy = bar.center().y;
// Ikona-przełącznik paska bocznego (◧ / ▭).
let sidebar_button = lay.sidebar_button;
paint
.scene
.fill_rounded_rect(sidebar_button, theme.radius.small, theme.colors.shade);
draw_sidebar_icon(
paint,
theme.colors.text,
sidebar_button,
lay.sidebar_visible,
);
let control = theme.metrics.headerbar.control;
let plus = Rect::from_xywh(
sidebar_button.right() + PAD,
cy - control / 2.0,
control,
control,
);
paint
.scene
.fill_rounded_rect(plus, theme.radius.small, theme.colors.shade);
draw_plus(paint, theme.colors.text, plus, 5.0);
// Przycisk „Opcje ▾” jako widget.
let mut options = Button::new(tr("Options")).dropdown(true);
options.set_hovered(demo.options_hover);
options.paint(paint, lay.options_button);
// Menu „☰”.
let burger = lay.burger;
paint
.scene
.fill_rounded_rect(burger, theme.radius.small, theme.colors.shade);
draw_burger(paint, theme.colors.text, burger, 8.0);
}
fn draw_sidebar(paint: &mut PaintCtx, lay: &Layout, demo: &Demo) {
let sb = lay.sidebar;
paint.scene.fill_rect(sb, paint.theme.colors.sidebar_bg);
paint.scene.fill_rect(
Rect::from_xywh(sb.right() - 1.0, sb.y(), 1.0, sb.height()),
paint.theme.colors.border,
);
let mut nav = make_nav();
nav.set_selected(demo.nav_selected);
nav.set_hovered(demo.nav_hover);
nav.paint(paint, sb);
}
/// Pojedyncza „strona” zakładki rysowana jako kapsuła (z kryciem dla animacji przejścia).
fn draw_tab_page(paint: &mut PaintCtx, rect: Rect, title: &str, alpha: f64) {
let theme = *paint.theme;
let a = alpha.clamp(0.0, 1.0) as f32;
paint
.scene
.fill_rounded_rect(rect, theme.radius.small, theme.colors.shade.scale_alpha(a));
paint.scene.stroke_rounded_rect(
rect,
theme.radius.small,
1.0,
theme.colors.accent.scale_alpha(a),
);
paint.label_center(
rect.center().x,
rect.center().y,
title,
theme.typography.body.size,
weight::MEDIUM,
theme.colors.text.scale_alpha(a),
);
}
fn draw_content(
paint: &mut PaintCtx,
area: Rect,
lay: &Layout,
demo: &Demo,
preference: ThemePreference,
accent: Accent,
) {
let theme = *paint.theme;
paint.scene.fill_rect(area, theme.colors.view_bg);
let md = theme.spacing.md;
let body = theme.typography.body.size;
// --- Karta 1: Wygląd (3 wiersze) ---
// Karty pochodzą z silnika układu (`lay`).
let card = lay.card;
Card::new(tr("Appearance")).paint(paint, card);
draw_row_label(paint, card, lay.theme_switcher, &tr("Theme"));
theme_switcher(preference, demo.theme_hover).paint(paint, lay.theme_switcher);
draw_row_label(paint, card, lay.accents[0], &tr("Accent color"));
draw_accent_dots(paint, &lay.accents, accent);
draw_row_label(paint, card, lay.language, &tr("Language"));
let codes = libluna_i18n::languages();
language_dropdown(&codes).paint(paint, lay.language);
// --- Karta 2: Ustawienia ---
let card2 = lay.card2;
Card::new(tr("Settings")).paint(paint, card2);
draw_row_label(paint, card2, lay.option_a, &tr("Option A"));
let mut sw = Switch::new(demo.option_a);
sw.set_hovered(demo.switch_hover);
sw.paint(paint, lay.option_a);
draw_row_label(paint, card2, lay.volume_track, &tr("Volume level"));
Slider::new(demo.volume).paint(paint, lay.volume_track);
paint.label_right(
lay.volume_track.x() - 12.0,
lay.volume_track.center().y,
&format!("{}%", (demo.volume * 100.0).round() as i32),
body,
weight::NORMAL,
theme.colors.text,
);
draw_row_label(paint, card2, lay.view_switcher, &tr("View"));
view_switcher(demo.view_mode, demo.view_hover).paint(paint, lay.view_switcher);
draw_row_label(paint, card2, lay.bottom_bar, &tr("Bottom bar"));
Switch::new(demo.show_bottom_bar).paint(paint, lay.bottom_bar);
draw_row_label(paint, card2, lay.sidebar_toggle, &tr("Sidebar"));
Switch::new(demo.sidebar_visible).paint(paint, lay.sidebar_toggle);
// --- Karta 3: Zakładki ---
let card3 = lay.card3;
Card::new(tr("Tabs")).paint(paint, card3);
// Wiersz 0: pasek zakładek (widget TabBar).
tab_bar(demo).paint(paint, lay.tabs);
// Wiersz 1: aktywna strona (model TabView) z animowanym przejściem (`Stack`).
paint.label_left(
card3.x() + md,
lay.tabs_value.center().y,
&tr("Active tab"),
body,
weight::NORMAL,
theme.colors.text,
);
let cur_title = demo.tabs.selected_title().map(tr).unwrap_or_default();
let pill = Rect::from_xywh(
lay.tabs_value.right() - md - 150.0,
lay.tabs_value.y() + 2.0,
150.0,
(lay.tabs_value.height() - 4.0).max(0.0),
);
if demo.tab_stack.previous_index().is_some() {
let (out, inc) = demo.tab_stack.frames(pill);
let prev_title = demo.tab_prev_title.as_deref().map(tr).unwrap_or_default();
paint.scene.push_clip(lay.tabs_value);
draw_tab_page(paint, out.rect, &prev_title, out.alpha);
draw_tab_page(paint, inc.rect, &cur_title, inc.alpha);
paint.scene.pop_clip();
} else {
draw_tab_page(paint, pill, &cur_title, 1.0);
}
// --- Karta 4: Pliki (PathBar + SearchEntry + ItemView) ---
let card4 = lay.card4;
Card::new(tr("Files")).paint(paint, card4);
path_bar(demo).paint(paint, lay.path);
let mut search = demo.search.clone();
search.set_placeholder(tr("Search"));
search.paint(paint, lay.search);
item_view(demo).paint(paint, lay.items);
// Pusty stan (brak wyników / pusty katalog) zamiast listy.
if let Some(page) = empty_state(demo) {
page.paint(paint, lay.items);
}
let mut scrollbar = demo.files_scroll;
scrollbar.set_content_height(item_view(demo).content_height(lay.items, &theme));
scrollbar.paint_scrollbar(paint, lay.items);
}
/// Rysuje etykietę wiersza ustawień (`ActionRow`) z miejscem na kontrolkę po prawej.
fn draw_row_label(paint: &mut PaintCtx, container: Rect, control: Rect, title: &str) {
let theme = *paint.theme;
let md = theme.spacing.md;
let row = Rect::from_xywh(
container.x(),
control.center().y - ROW_H / 2.0,
container.width(),
ROW_H,
);
let suffix = (container.width() - (control.x() - container.x()) - md).max(0.0);
ActionRow::new(title).paint(paint, row, suffix);
}
fn draw_accent_dots(paint: &mut PaintCtx, dots: &[Rect; 9], accent: Accent) {
let theme = *paint.theme;
for (i, r) in dots.iter().enumerate() {
let a = Accent::ALL[i];
paint
.scene
.fill_rounded_rect(*r, r.width() / 2.0, a.color());
if a == accent {
let ring = r.inflate(Insets::splat(3.0));
paint
.scene
.stroke_rounded_rect(ring, ring.width() / 2.0, 2.0, theme.colors.text);
}
}
}
fn draw_statusbar(paint: &mut PaintCtx, bar: Rect) {
let theme = *paint.theme;
let body = theme.typography.body.size;
paint.scene.fill_rect(bar, theme.colors.headerbar_bg);
paint.scene.fill_rect(
Rect::from_xywh(bar.x(), bar.y(), bar.width(), 1.0),
theme.colors.border,
);
paint.label_left(
bar.x() + PAD,
bar.center().y,
&tr("Status: Ready"),
body,
weight::NORMAL,
theme.colors.dim_text,
);
let version = format!("{} 1.0.0", tr("Version"));
paint.label_right(
bar.right() - PAD,
bar.center().y,
&version,
body,
weight::NORMAL,
theme.colors.dim_text,
);
}
// --- Ikony rysowane wektorowo (bez zależności od fontu) ---
fn draw_plus(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
let c = box_.center();
paint.scene.stroke_line(
Point::new(c.x - arm, c.y),
Point::new(c.x + arm, c.y),
2.0,
color,
);
paint.scene.stroke_line(
Point::new(c.x, c.y - arm),
Point::new(c.x, c.y + arm),
2.0,
color,
);
}
fn draw_burger(paint: &mut PaintCtx, color: Color, box_: Rect, arm: f64) {
let c = box_.center();
for dy in [-6.0, 0.0, 6.0] {
paint.scene.stroke_line(
Point::new(c.x - arm, c.y + dy),
Point::new(c.x + arm, c.y + dy),
2.0,
color,
);
}
}
/// Ikona paska bocznego: `◧` gdy widoczny, `▭` (sam kontur) gdy ukryty.
fn draw_sidebar_icon(paint: &mut PaintCtx, color: Color, box_: Rect, filled: bool) {
let c = box_.center();
let w = 18.0;
let h = 14.0;
let outer = Rect::from_xywh(c.x - w / 2.0, c.y - h / 2.0, w, h);
paint.scene.stroke_rounded_rect(outer, 3.0, 1.5, color);
if filled {
let inner = Rect::from_xywh(outer.x() + 2.0, outer.y() + 2.0, w * 0.36, h - 4.0);
paint.scene.fill_rounded_rect(inner, 2.0, color);
}
}
// --- Popover „Opcje” (rozmiar ikon, sortowanie, ukryte pliki) ---
const ICON_MIN: i64 = 16;
const ICON_MAX: i64 = 64;
const OPTIONS_W: f64 = 260.0;
/// Rozkład popovera opcji.
pub struct OptionsLayout {
pub panel: Rect,
pub stepper: Rect,
pub radios: Rect,
pub check: Rect,
pub icon_label_cy: f64,
pub sort_label_cy: f64,
pub sep1_y: f64,
pub sep2_y: f64,
}
fn sort_options() -> RadioGroup {
RadioGroup::new([tr("Name"), tr("Date modified"), tr("Size")])
}
/// Liczy rozkład popovera opcji (pod przyciskiem „Opcje ▾”).
pub fn options_layout(size: Size, theme: &Theme, lay: &Layout) -> OptionsLayout {
let md = theme.spacing.md;
let stepper_h = theme.metrics.stepper.height;
let stepper_w = theme.metrics.stepper.width;
let radio_h = 3.0 * theme.metrics.radio.row_h;
let check_h = theme.metrics.check.size.max(24.0);
let row = stepper_h;
let cap = 20.0;
let sep = 1.0;
let h = md + row + md + sep + md + cap + radio_h + md + sep + md + check_h + md;
let anchor = lay.options_button;
let mut x = anchor.x();
if x + OPTIONS_W > size.width - 6.0 {
x = size.width - 6.0 - OPTIONS_W;
}
x = x.max(6.0);
let mut y = anchor.bottom() + 6.0;
if y + h > size.height - 6.0 {
y = anchor.y() - h - 6.0;
}
y = y.max(6.0);
let panel = Rect::from_xywh(x, y, OPTIONS_W, h);
let icon_label_cy = panel.y() + md + row / 2.0;
let stepper = Rect::from_xywh(
panel.right() - md - stepper_w,
panel.y() + md + (row - stepper_h) / 2.0,
stepper_w,
stepper_h,
);
let sep1_y = panel.y() + md + row + md;
let sort_label_cy = sep1_y + md + cap / 2.0;
let radios = Rect::from_xywh(
panel.x() + md,
sep1_y + md + cap,
panel.width() - 2.0 * md,
radio_h,
);
let sep2_y = sep1_y + md + cap + radio_h + md;
let check = Rect::from_xywh(
panel.x() + md,
sep2_y + md,
panel.width() - 2.0 * md,
check_h,
);
OptionsLayout {
panel,
stepper,
radios,
check,
icon_label_cy,
sort_label_cy,
sep1_y,
sep2_y,
}
}
fn draw_options(paint: &mut PaintCtx, size: Size, lay: &Layout, demo: &Demo) {
let theme = *paint.theme;
let md = theme.spacing.md;
let body = theme.typography.body.size;
let ol = options_layout(size, &theme, lay);
paint
.scene
.fill_rounded_rect(ol.panel, theme.radius.medium, theme.colors.card_bg);
paint
.scene
.stroke_rounded_rect(ol.panel, theme.radius.medium, 1.0, theme.colors.border);
paint.label_left(
ol.panel.x() + md,
ol.icon_label_cy,
&tr("Icon size"),
body,
weight::NORMAL,
theme.colors.text,
);
let mut stepper = Stepper::new(demo.icon_size, ICON_MIN, ICON_MAX).step(8);
stepper.set_hovered(demo.options_stepper_hover);
stepper.paint(paint, ol.stepper);
paint.scene.fill_rect(
Rect::from_xywh(
ol.panel.x() + md,
ol.sep1_y,
(ol.panel.width() - 2.0 * md).max(0.0),
1.0,
),
theme.colors.border,
);
paint.label_left(
ol.panel.x() + md,
ol.sort_label_cy,
&tr("Sort by"),
body,
weight::NORMAL,
theme.colors.text,
);
let mut radios = sort_options();
radios.set_selected(demo.sort);
radios.set_hovered(demo.options_radio_hover);
radios.paint(paint, ol.radios);
paint.scene.fill_rect(
Rect::from_xywh(
ol.panel.x() + md,
ol.sep2_y,
(ol.panel.width() - 2.0 * md).max(0.0),
1.0,
),
theme.colors.border,
);
let mut check = CheckButton::new(tr("Show hidden files"));
check.set_checked(demo.show_hidden);
check.set_hovered(demo.options_check_hover);
check.paint(paint, ol.check);
}