🔒 Repository is read-only – file editing is disabled.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
//! Wyjście: nested (okno na nadrzędnym kompozytorze) oraz **realne DRM/KMS** (scanout).
//!
//! Wątek nested pełni dwie role: rysuje scenę przez `libluna-render` i odbiera wejście
//! od nadrzędnego kompozytora, przekazując je do magistrali (`crate::input`).
//! Wątek DRM renderuje scenę wprost do buforów „dumb” i skanuje je na monitorach
//! (page-flip). Wejście w trybie DRM (evdev) jest kolejnym krokiem.
use std::os::unix::net::UnixStream;
use std::time::Duration;
use libluna_core::{Color, Point, Rect, Size, Theme, ThemeSettings};
use libluna_platform::{run, AppConfig, Frame};
use libluna_render::{ImageData, Scene, SoftwareRenderer, TextEngine, TextRun};
use libluna_widgets::{ControlsSide, HeaderBar, PaintCtx};
use crate::drm::{DrmBackend, DrmError};
use crate::input::{self, Events};
use crate::monitors::{layout_horizontal, OutputInfo, Outputs};
use crate::state::{
CursorModel, Overview, OverviewModel, ScreenshotModel, Window, WindowModel, WorkspaceModel,
};
use crate::sync::LockExt;
/// Uruchamia wątek wyjścia (nested Wayland) o zadanym rozmiarze.
pub fn spawn(
model: WindowModel,
outputs: Outputs,
events: Events,
wake: UnixStream,
ws: WorkspaceModel,
overview: OverviewModel,
screenshot: ScreenshotModel,
ws_count: usize,
width: u32,
height: u32,
) {
std::thread::spawn(move || {
// Tryb nested nie ma sprzętowej tablicy gamma — chowamy kafelek night lightu.
write_gamma_flag(false);
let mut wake = wake;
let config = AppConfig::new("Selene")
.app_id("eu.paganlinux.Luna.Selene")
.initial_size(width, height)
.settings(ThemeSettings::load());
let theme = user_theme();
let text = TextEngine::system();
let result = run(config, move |frame: &mut Frame| {
// Zgłoś bieżący rozmiar monitora (okno może być zmieniane przez rodzica).
{
let mut outs = outputs.lock_or_recover();
if let Some(out) = outs.first_mut() {
out.width = frame.size.width as i32;
out.height = frame.size.height as i32;
}
}
// Przekaż wejście do magistrali.
input::push(&events, &mut wake, frame.events);
// Narysuj scenę.
let size = frame.size;
let cur_ws = *ws.lock_or_recover();
let ov = *overview.lock_or_recover();
build_scene(
frame.scene,
size,
0.0,
0.0,
&model,
cur_ws,
ws_count,
ov,
true,
None,
true,
&theme,
&text,
);
// Zrzut ekranu także w trybie nested: kompozytor nie ma tu skanouta,
// więc kodujemy bieżącą scenę bezpośrednio do PNG.
if screenshot.swap(false, std::sync::atomic::Ordering::Relaxed) {
let mut renderer = SoftwareRenderer::new(size.width as u32, size.height as u32);
renderer.draw(frame.scene, frame.scale, Color::TRANSPARENT);
if let Some(png) = renderer.encode_png() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let path = screenshot_dir().join(format!("luna-{stamp}-nested.png"));
match std::fs::write(&path, png) {
Ok(()) => eprintln!("selene: zrzut ekranu: {}", path.display()),
Err(e) => eprintln!("selene: nie udało się zapisać zrzutu: {e}"),
}
}
}
// Utrzymuj klatki — model okien zmienia się poza tą pętlą.
frame.request_frame();
});
// Nested rysuje do okna na nadrzędnym kompozytorze Wayland. Gdy go nie ma
// (np. uruchomienie na X11), nie połykamy błędu — inaczej wygląda to jak
// „kompozytor działa, ale nic nie widać”.
if let Err(e) = result {
eprintln!(
"{}: {e}",
libluna_i18n::tr("nested output unavailable (no parent compositor)")
);
}
});
}
/// Otwiera backend DRM, czekając chwilę, gdy mastera trzyma jeszcze greeter LightDM.
///
/// LightDM potrafi wystartować sesję Wayland, **zanim ubije greeter** (Xorg), który
/// trzyma master DRM. Bez ponowień Selene od razu widzi `MasterBusy` i zostawia czarny
/// ekran.
///
/// Greeter oddaje master zwykle w ułamku sekundy, dlatego pierwsze próby są **gęste**
/// (20 ms) — wcześniej każde opóźnienie było płacone w krokach po 100 ms, co dawało
/// odczucie „panel wyskakuje po ~5 sekundach”. Po pół sekundy przechodzimy na 100 ms,
/// żeby nie palić procesora bez potrzeby.
fn open_drm_waiting() -> Result<DrmBackend, DrmError> {
let stats = std::env::var("SELENE_STATS").is_ok();
let started = std::time::Instant::now();
let deadline = started + Duration::from_secs(15);
let mut attempt = 0u32;
loop {
let err = match DrmBackend::open() {
Ok(backend) => {
if stats && attempt > 0 {
eprintln!(
"selene/drm: przejęto ekran po {:.0} ms",
started.elapsed().as_secs_f64() * 1000.0
);
}
return Ok(backend);
}
Err(e) => e,
};
if std::time::Instant::now() >= deadline {
return Err(err);
}
if attempt == 0 && stats {
eprintln!("selene/drm: init nieudany ({err:?}) — czekam na zwolnienie ekranu…");
}
let wait_ms = if attempt < 25 { 20 } else { 100 };
attempt += 1;
std::thread::sleep(Duration::from_millis(wait_ms));
}
}
/// Informuje powłokę (plikiem w `$XDG_RUNTIME_DIR`), czy night light jest dostępny.
fn write_gamma_flag(supported: bool) {
let Some(rt) = std::env::var_os("XDG_RUNTIME_DIR") else {
return;
};
let path = std::path::PathBuf::from(rt).join("luna-gamma");
let _ = std::fs::write(path, if supported { "1" } else { "0" });
}
/// Opóźnienie do limitu ~30 FPS w trybie oszczędzania (`None` = bez limitu).
fn throttle_delay(power_saver: bool, elapsed: Duration) -> Option<Duration> {
if !power_saver {
return None;
}
const MIN: Duration = Duration::from_millis(33);
(elapsed < MIN).then(|| MIN - elapsed)
}
/// Uruchamia wątek wyjścia DRM/KMS — renderuje scenę do buforów i skanuje na monitorach.
///
/// Wymaga bycia DRM masterem; jeśli inną sesję trzyma master, kończy bez efektu
/// (wywołujący powinien wtedy użyć wyjścia nested).
pub fn spawn_drm(
model: WindowModel,
cursor: CursorModel,
ws: WorkspaceModel,
overview: OverviewModel,
screenshot: crate::state::ScreenshotModel,
night_light: crate::state::NightLightModel,
power_saver: crate::state::PowerSaverModel,
outputs: Outputs,
ws_count: usize,
) {
std::thread::spawn(move || {
let mut backend = match open_drm_waiting() {
Ok(backend) => backend,
Err(reason) => {
// Rozróżniamy powód — inaczej „brak mastera” maskuje błędy inicjalizacji.
let msg = match reason {
DrmError::MasterBusy => {
libluna_i18n::tr("DRM backend unavailable (master held by another session)")
}
DrmError::Resources => {
libluna_i18n::tr("DRM backend unavailable (cannot read card resources)")
}
DrmError::NoOutputs => {
libluna_i18n::tr("DRM backend unavailable (no monitor could be set up)")
}
};
eprintln!("{msg}");
return;
}
};
let infos = backend.outputs();
// Night light: sprawdzamy **realne** wsparcie CRTC dla tablicy gamma i od razu
// wgrywamy neutralną rampę (to zarazem test ioctla). Flaga w katalogu runtime
// mówi powłoce, czy kafelek „Tryb nocny” jest w ogóle dostępny.
let gamma_ok = backend.supports_night_light() && backend.set_night_light(0);
write_gamma_flag(gamma_ok);
if std::env::var("SELENE_STATS").is_ok() {
eprintln!(
"selene/drm: night light {}",
if gamma_ok {
"dostępny (gamma LUT)"
} else {
"niedostępny"
}
);
}
// **Faktyczne** tryby z modesetu trafiają do wspólnego modelu monitorów.
// Wcześniej model zostawał przy wyniku wczesnego `discover()`; gdy kernel
// zgłosił inny tryb (albo inne złącze), panel i okna miały złą geometrię,
// a drugi monitor potrafił dostać nie-natywną rozdzielczość.
{
let mut list: Vec<OutputInfo> = infos
.iter()
.map(|(name, w, h, hz)| OutputInfo {
name: name.clone(),
width: *w as i32,
height: *h as i32,
refresh_hz: *hz as i32,
scale: 1,
x: 0,
y: 0,
})
.collect();
layout_horizontal(&mut list);
*outputs.lock_or_recover() = list;
}
let sizes: Vec<(u32, u32)> = infos.iter().map(|(_, w, h, _)| (*w, *h)).collect();
let origins: Vec<(f64, f64)> = {
// Wyjścia ułożone poziomo (spójne z modelem monitorów).
let mut x = 0.0;
sizes
.iter()
.map(|(w, _)| {
let o = (x, 0.0);
x += *w as f64;
o
})
.collect()
};
for (name, w, h, hz) in &infos {
println!(
"{}: {} {}x{}@{}Hz",
libluna_i18n::tr("DRM output"),
name,
w,
h,
hz
);
}
let mut renderers: Vec<SoftwareRenderer> = sizes
.iter()
.map(|(w, h)| SoftwareRenderer::new(*w, *h))
.collect();
let bg = Color::from_rgba8(0x1e, 0x20, 0x28, 0xff);
// Motyw i silnik tekstu dla dekoracji (widget `HeaderBar` z libLuny).
// Bierzemy **motyw użytkownika** (jasny/ciemny, akcent, styl z pliku), a nie
// twarde `Theme::dark()` — inaczej pasek okna X11 wyglądał inaczej niż okno
// aplikacji libLuny. Co 2 s sprawdzamy plik ustawień, żeby zmiana motywu
// w demo odświeżyła też dekoracje bez wylogowania.
let mut theme = user_theme();
let mut theme_checked = std::time::Instant::now();
let text_engine = TextEngine::system();
// Sprzętowy kursor: renderujemy raz obraz strzałki (ARGB) i odtąd poruszamy
// tylko płaszczyzną kursora — ruch myszy nie przemalowuje sceny.
let (cursor_rgba, cw, ch) = cursor_image();
let mut hw_cursor = true;
for i in 0..sizes.len() {
// Bezpieczna próba na (0, 0) i natychmiastowe ukrycie — uodparnia na
// sterowniki DRM odrzucające ujemne współrzędne w `MODE_CURSOR`.
if !backend.set_cursor_image(i, &cursor_rgba, cw, ch, 0, 0) || !backend.hide_cursor(i) {
hw_cursor = false;
}
}
if !hw_cursor {
eprintln!(
"{}",
libluna_i18n::tr("DRM hardware cursor unavailable; using software cursor")
);
}
let mut cursor_shown = vec![false; sizes.len()];
// Diagnostyka (SELENE_STATS=1): fps i rozbicie czasu na render/present/wait_flip.
let stats = std::env::var("SELENE_STATS").is_ok();
let mut flip_error_logged = false;
let mut frames = 0u64;
let (mut ms_render, mut ms_present, mut ms_wait) = (0.0f64, 0.0f64, 0.0f64);
let mut report = std::time::Instant::now();
let mut last_sigs: Vec<Option<u64>> = vec![None; sizes.len()];
let mut last_cur: Option<Point> = None;
let mut applied_night: Option<u8> = None;
let mut applied_power = false;
let mut last_present = std::time::Instant::now();
loop {
// Motyw użytkownika mógł się zmienić (demo/Ustawienia) — odśwież, gdy inny.
if theme_checked.elapsed() >= Duration::from_secs(2) {
theme_checked = std::time::Instant::now();
let next = user_theme();
if next != theme {
theme = next;
// Wymuś pełny render (sygnatury nie zawierają kolorów motywu).
for sig in last_sigs.iter_mut() {
*sig = None;
}
}
}
// Night light i oszczędzanie energii — zmiany z gniazda sterowania.
if gamma_ok {
let requested = night_light.load(std::sync::atomic::Ordering::Relaxed);
if applied_night != Some(requested) {
applied_night = Some(requested);
backend.set_night_light(requested);
if stats {
eprintln!("selene/drm: night light {requested}%");
}
}
}
let power = power_saver.load(std::sync::atomic::Ordering::Relaxed);
if power != applied_power {
applied_power = power;
if stats {
eprintln!(
"selene/drm: power saver {}",
if power { "on" } else { "off" }
);
}
}
// Zrzut ekranu (Super+F12): kompozytor trzyma gotowe klatki każdego monitora,
// więc wystarczy je zapisać.
if screenshot.swap(false, std::sync::atomic::Ordering::Relaxed) {
save_screenshots(&renderers, &infos);
}
let cur = *cursor.lock_or_recover();
let cur_ws = *ws.lock_or_recover();
let ov = *overview.lock_or_recover();
let cur_changed = last_cur != cur;
let mut any_changed = false;
let mut changed = vec![false; sizes.len()];
for i in 0..sizes.len() {
let (w, h) = sizes[i];
let (ox, oy) = origins[i];
let sig = output_signature(
&model,
cur_ws,
ov,
if hw_cursor { None } else { cur },
ox,
oy,
w as f64,
h as f64,
);
if last_sigs[i] != Some(sig) {
last_sigs[i] = Some(sig);
changed[i] = true;
any_changed = true;
}
}
if !any_changed && !cur_changed {
std::thread::sleep(Duration::from_millis(2));
continue;
}
last_cur = cur;
// Kursor sprzętowy: ustaw/przesuń płaszczyznę kursora (bez dotykania sceny).
if hw_cursor && cur_changed {
for i in 0..sizes.len() {
let (w, h) = sizes[i];
let (ox, oy) = origins[i];
let local = cur.map(|c| ((c.x - ox) as i32, (c.y - oy) as i32));
let inside = cur
.map(|c| {
let (lx, ly) = (c.x - ox, c.y - oy);
lx >= 0.0 && ly >= 0.0 && lx < w as f64 && ly < h as f64
})
.unwrap_or(false);
if inside {
let (x, y) = local.unwrap();
if cursor_shown[i] {
backend.move_cursor(i, x, y);
} else {
backend.set_cursor_image(i, &cursor_rgba, cw, ch, x, y);
cursor_shown[i] = true;
}
} else if cursor_shown[i] {
backend.hide_cursor(i);
cursor_shown[i] = false;
}
}
}
if any_changed {
// Tryb oszczędzania: maksymalnie ~30 klatek/s (oszczędza CPU/GPU
// podczas animacji i przewijania; kursor sprzętowy pozostaje płynny).
if let Some(delay) = throttle_delay(power, last_present.elapsed()) {
std::thread::sleep(delay);
}
last_present = std::time::Instant::now();
// Renderowanie monitorów **równolegle**: każdy ma własny bufor i scenę,
// więc na maszynie wielordzeniowej dwie klatki 1080p powstają w czasie
// jednej. Sekwencyjny render dwóch monitorów zjadał cały budżet klatki
// (7–10 ms każdy) i powłoka „zwalniała” do ~20 FPS.
let t_render = std::time::Instant::now();
let theme = &theme;
let text_engine = &text_engine;
std::thread::scope(|scope| {
for (i, renderer) in renderers.iter_mut().enumerate() {
if !changed[i] {
continue;
}
let (w, h) = sizes[i];
let (ox, oy) = origins[i];
let model = &model;
scope.spawn(move || {
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(w as f64, h as f64),
ox,
oy,
model,
cur_ws,
ws_count,
ov,
i == 0,
cur,
!hw_cursor,
&theme,
&text_engine,
);
renderer.draw(&scene, 1.0, bg);
});
}
});
ms_render += t_render.elapsed().as_secs_f64() * 1000.0;
let mut num_flips = 0;
for i in 0..sizes.len() {
if !changed[i] {
continue;
}
let t = std::time::Instant::now();
let flipped = backend.present(i, renderers[i].data());
if !flipped && !flip_error_logged {
eprintln!(
"{}",
libluna_i18n::tr("DRM page-flip failed (screen may stay blank)")
);
flip_error_logged = true;
}
ms_present += t.elapsed().as_secs_f64() * 1000.0;
if flipped {
num_flips += 1;
}
}
// Czekamy równolegle na flipy zakolejkowane na monitorach
for _ in 0..num_flips {
let t = std::time::Instant::now();
backend.wait_flip();
ms_wait += t.elapsed().as_secs_f64() * 1000.0;
}
frames += 1;
if stats && report.elapsed().as_secs_f64() >= 2.0 {
let secs = report.elapsed().as_secs_f64();
let frames_f = frames.max(1) as f64;
let per_monitor = frames_f * sizes.len() as f64;
eprintln!(
"selene/drm: {:.1} klatek/s | render {:.2} ms/klatke, present {:.2} ms, wait_flip {:.2} ms",
frames as f64 / secs,
ms_render / frames_f,
ms_present / per_monitor.max(1.0),
ms_wait / per_monitor.max(1.0)
);
frames = 0;
ms_render = 0.0;
ms_present = 0.0;
ms_wait = 0.0;
report = std::time::Instant::now();
}
}
}
});
}
/// Zapisuje bieżące klatki (po jednej na monitor) jako PNG — obsługa `Super+F12`.
pub fn save_screenshots(renderers: &[SoftwareRenderer], infos: &[(String, u32, u32, u32)]) {
let dir = screenshot_dir();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
for (i, renderer) in renderers.iter().enumerate() {
let name = infos
.get(i)
.map(|(n, _, _, _)| n.as_str())
.unwrap_or("ekran");
let path = dir.join(format!("luna-{stamp}-{name}.png"));
match renderer.encode_png() {
Some(png) => match std::fs::write(&path, png) {
Ok(()) => eprintln!("selene: zrzut ekranu: {}", path.display()),
Err(e) => eprintln!("selene: nie udało się zapisać zrzutu: {e}"),
},
None => eprintln!("selene: nie udało się zakodować zrzutu"),
}
}
}
/// Katalog na zrzuty: `XDG_PICTURES_DIR` → `~/.config/user-dirs.dirs` → `$HOME/Pictures`
/// (oraz polskie `Obrazy`) → `/tmp`.
fn screenshot_dir() -> std::path::PathBuf {
let mut pictures = std::env::var("XDG_PICTURES_DIR")
.ok()
.filter(|d| !d.is_empty());
// Wiele sesji nie eksportuje `XDG_PICTURES_DIR`; użytkownik ma je w `user-dirs.dirs`.
if pictures.is_none() {
if let Some(home) = std::env::var_os("HOME") {
let cfg = std::path::PathBuf::from(&home)
.join(".config")
.join("user-dirs.dirs");
if let Ok(text) = std::fs::read_to_string(cfg) {
for line in text.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("XDG_PICTURES_DIR=") {
let value = rest
.trim()
.trim_matches('"')
.replace("$HOME", &home.to_string_lossy());
if !value.is_empty() {
pictures = Some(value);
}
break;
}
}
}
}
}
let mut candidates: Vec<String> = Vec::new();
if let Some(p) = pictures {
candidates.push(p);
}
if let Some(home) = std::env::var_os("HOME") {
let home = std::path::PathBuf::from(home);
candidates.push(home.join("Pictures").to_string_lossy().into_owned());
candidates.push(home.join("Obrazy").to_string_lossy().into_owned());
}
for dir in candidates {
let p = std::path::PathBuf::from(dir).join("Screenshots");
if std::fs::create_dir_all(&p).is_ok() {
return p;
}
}
std::path::PathBuf::from("/tmp")
}
/// Obraz kursora (strzałka) w RGBA8 — do płaszczyzny kursora DRM.
fn cursor_image() -> (Vec<u8>, u32, u32) {
let (w, h) = (64u32, 64u32);
let mut renderer = SoftwareRenderer::new(w, h);
let mut scene = Scene::new();
scene.clear();
draw_cursor(&mut scene, Point::new(2.0, 2.0));
renderer.draw(&scene, 1.0, Color::rgba(0.0, 0.0, 0.0, 0.0));
(renderer.data().to_vec(), w, h)
}
/// Sygnatura sceny dla konkretnego monitora — pozwala pominąć render monitora, na którym nic się nie zmieniło.
fn output_signature(
model: &WindowModel,
current_ws: usize,
overview: Overview,
cursor: Option<Point>,
ox: f64,
oy: f64,
ow: f64,
oh: f64,
) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
h ^= v;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
};
mix(current_ws as u64);
mix(u64::from(overview.on));
if let Some((slot, (px, py))) = overview.drag {
mix(slot as u64);
mix(px.to_bits());
mix(py.to_bits());
}
let mon_rect = Rect::from_xywh(ox, oy, ow, oh);
{
let windows = model.lock_or_recover();
for w in windows.iter() {
if !w.mapped || w.minimized || !w.in_workspace(current_ws) {
continue;
}
if !w.outer().intersects(&mon_rect) {
continue;
}
mix(w.x.to_bits());
mix(w.y.to_bits());
mix(w.z as u64);
mix(w.width as u64);
mix(w.height as u64);
mix(u64::from(w.mapped) | (u64::from(w.minimized) << 1) | (u64::from(w.layer) << 2));
// Pasek tytułu, pełny ekran, popup i tytuł zmieniają wygląd bez zmiany geometrii.
mix(u64::from(w.ssd) | (u64::from(w.fullscreen) << 1) | (u64::from(w.popup) << 2));
mix(w.title.as_bytes().iter().fold(0u64, |acc, b| {
acc.wrapping_mul(31).wrapping_add(u64::from(*b))
}));
mix(std::sync::Arc::as_ptr(&w.data) as u64);
}
}
if let Some(c) = cursor {
if mon_rect.contains(c) {
mix(c.x.to_bits());
mix(c.y.to_bits());
}
}
h
}
#[allow(dead_code)]
/// Sygnatura sceny (okna + kursor + przestrzeń + podgląd) — do wykrywania zmian.
fn scene_signature(
model: &WindowModel,
current_ws: usize,
overview: Overview,
cursor: Option<Point>,
) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
h ^= v;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
};
mix(current_ws as u64);
mix(u64::from(overview.on));
if let Some((slot, (px, py))) = overview.drag {
mix(slot as u64);
mix(px.to_bits());
mix(py.to_bits());
}
{
let windows = model.lock_or_recover();
mix(windows.len() as u64);
for w in windows.iter() {
mix(w.x.to_bits());
mix(w.y.to_bits());
mix(w.z as u64);
mix(w.width as u64);
mix(w.height as u64);
mix(w.image_offset.0.to_bits());
mix(w.image_offset.1.to_bits());
mix(u64::from(w.mapped) | (u64::from(w.minimized) << 1) | (u64::from(w.layer) << 2));
mix(u64::from(w.ssd) | (u64::from(w.fullscreen) << 1) | (u64::from(w.popup) << 2));
mix(w.title.as_bytes().iter().fold(0u64, |acc, b| {
acc.wrapping_mul(31).wrapping_add(u64::from(*b))
}));
mix(std::sync::Arc::as_ptr(&w.data) as u64);
}
}
if let Some(c) = cursor {
mix(c.x.to_bits());
mix(c.y.to_bits());
}
h
}
/// Motyw użytkownika dla dekoracji okien (`~/.config/luna/theme.conf` + styl z pliku).
///
/// Dzięki temu pasek SSD (okna X11 i klienci negocjujący `server_side`) ma dokładnie
/// ten sam wygląd co `HeaderBar` w aplikacjach libLuny — jasny/ciemny, akcent i styl.
fn user_theme() -> Theme {
let system_dark = libluna_core::appearance::system_dark();
ThemeSettings::load().resolve_themed(system_dark)
}
/// Buduje scenę okien w rozmiarze wyjścia, przesuniętą o `(ox, oy)` (origin monitora).
#[allow(clippy::too_many_arguments)]
fn build_scene(
scene: &mut Scene,
size: Size,
ox: f64,
oy: f64,
model: &WindowModel,
current_ws: usize,
ws_count: usize,
overview: Overview,
_is_primary: bool,
cursor: Option<Point>,
show_cursor: bool,
theme: &Theme,
text: &TextEngine,
) {
let sh = |r: Rect| r.offset(-ox, -oy);
scene.clear();
// Tło pulpitu: tapeta z `assets/branding` (gradient zostaje jako zapas).
match wallpaper_for(size.width.max(1.0) as u32, size.height.max(1.0) as u32) {
Some(img) => {
scene.background(img);
}
None => fill_vgradient(
scene,
Rect::from_size(size),
Color::from_rgba8(0x23, 0x28, 0x3a, 0xff),
Color::from_rgba8(0x0f, 0x11, 0x18, 0xff),
),
}
{
let windows = model.lock_or_recover();
let mut order: Vec<&Window> = windows.iter().collect();
order.sort_by_key(|w| w.z);
for w in order {
if !w.mapped || w.minimized || !w.in_workspace(current_ws) {
continue;
}
// Tło treści: **nie malujemy czerni** pod buforem klienta. Bufory bywają
// częściowo przezroczyste (cienie i zaokrąglenia CSD w GTK), a czarne tło
// przeświecało przez nie jako ramka wokół okna.
// `buffer_rect()` uwzględnia cienie CSD (`set_window_geometry`) — bufor
// jest większy od widocznego okna i musi być rysowany z przesunięciem.
// Zaokrąglone narożniki: wymuszamy dla wszystkich zwykłych okien (także CSD),
// promieniem z motywu. Maksymalizowane/fullscreen zostają ostre.
let radius = if !w.popup && !w.layer && !w.maximized && !w.fullscreen {
theme.radius.large
} else {
0.0
};
if radius > 0.0 {
scene.push_clip_rounded(sh(w.outer()), radius);
}
scene.image(sh(w.buffer_rect()), w.data.clone());
// Pasek tytułu rysujemy **tylko** oknom z naszym SSD (`w.ssd`). Klienty
// z własnymi dekoracjami (GTK/libadwaita, CSD) nie mają `ssd`, a wcześniej
// rysowaliśmy im mimo to tytuł i przyciski okna — lewitowały nad treścią.
if w.ssd {
// Dokładnie ten sam widget, którego używają aplikacje libLuny
// (`libluna_widgets::HeaderBar`) — dzięki temu dekoracje kompozytora
// i aplikacji są identyczne (tło, typografia, kontrolki, odstępy).
let bar = sh(w.titlebar());
let mut ctx = PaintCtx::new(scene, theme, text);
// Podświetlenie kontrolki pod kursorem — bez tego pasek SSD zachowywał
// się „martwo” w porównaniu z oknami libLuny (tam hover działa).
let hovered = cursor.and_then(|c| {
let p = Point::new(c.x - ox, c.y - oy);
if bar.contains(p) {
HeaderBar::new().control_at(bar, theme, p)
} else {
None
}
});
let mut header = HeaderBar::new()
.title(w.title.clone())
.controls(ControlsSide::Right);
// Dopasuj kolor paska do górnej części okna (jeśli bufor ma kryjące piksele).
if let Some((bg, fg)) = crate::decoration::tint_from_image(&w.data) {
header = header.tinted(bg, fg);
}
header.paint(&mut ctx, bar, hovered);
// **Bez** własnej ramki wokół całego okna — libLuna rysuje tylko pasek
// (z własną linią pod nim). Dodatkowy `stroke` wyglądał jak obca ramka
// od innego środowiska, zwłaszcza na oknach X11.
}
if radius > 0.0 {
scene.pop_clip();
}
}
}
// Podgląd okien i przestrzeni (Super+Tab) — na każdym monitorze (jego okna).
if overview.on {
draw_overview(
scene,
size,
ox,
oy,
model,
current_ws,
ws_count,
overview.drag,
);
}
// Kursor rysuje ten monitor, na którym leży (tryb DRM — nikt go za nas nie narysuje).
if let Some(c) = cursor.filter(|_| show_cursor) {
let (cx, cy) = (c.x - ox, c.y - oy);
if cx >= 0.0 && cy >= 0.0 && cx < size.width && cy < size.height {
draw_cursor(scene, Point::new(cx, cy));
}
}
}
/// Wypełnia prostokąt pionowym gradientem (pasami — tanie, a tło nie jest płaskie).
/// Tapeta pulpitu (z `assets/branding`), przeskalowana „cover” do rozmiaru wyjścia.
///
/// Skalowanie robimy **raz** na rozmiar i trzymamy w cache — w pętli renderowania
/// idzie wtedy tani blit 1:1, a nie skalowanie pełnego ekranu co klatkę.
fn wallpaper_for(w: u32, h: u32) -> Option<std::sync::Arc<ImageData>> {
let (w, h) = (w.max(1), h.max(1));
static CACHE: std::sync::OnceLock<
std::sync::Mutex<Vec<((u32, u32), std::sync::Arc<ImageData>)>>,
> = std::sync::OnceLock::new();
let cache = CACHE.get_or_init(|| std::sync::Mutex::new(Vec::new()));
let mut guard = cache.lock_or_recover();
if let Some((_, img)) = guard.iter().find(|((cw, ch), _)| *cw == w && *ch == h) {
return Some(std::sync::Arc::clone(img));
}
let src = wallpaper_source()?;
let img = std::sync::Arc::new(libluna_render::wallpaper::cover(src, w, h));
guard.push(((w, h), std::sync::Arc::clone(&img)));
Some(img)
}
/// Dekoduje tapetę raz na proces. Podmiana: `SELENE_WALLPAPER=/ścieżka.png`.
/// Wyłączenie (powrót do gradientu): `SELENE_NO_WALLPAPER=1`.
fn wallpaper_source() -> Option<&'static ImageData> {
static SRC: std::sync::OnceLock<Option<ImageData>> = std::sync::OnceLock::new();
SRC.get_or_init(|| {
if std::env::var_os("SELENE_NO_WALLPAPER").is_some() {
return None;
}
if let Ok(path) = std::env::var("SELENE_WALLPAPER") {
if let Ok(bytes) = std::fs::read(&path) {
if let Some(img) = libluna_render::wallpaper::decode_png(&bytes) {
return Some(img);
}
}
}
libluna_render::wallpaper::decode_png(include_bytes!(
"../../../assets/branding/paganlinux.png"
))
})
.as_ref()
}
fn fill_vgradient(scene: &mut Scene, rect: Rect, top: Color, bottom: Color) {
const BANDS: usize = 64;
let h = rect.height() / BANDS as f64;
for i in 0..BANDS {
let t = i as f32 / (BANDS - 1) as f32;
let c = top.lerp(bottom, t);
scene.fill_rect(
Rect::from_xywh(rect.x(), rect.y() + i as f64 * h, rect.width(), h + 1.0),
c,
);
}
}
/// Rysuje podgląd okien i przestrzeni — kafelki z podglądem treści + pasek przestrzeni.
#[allow(clippy::too_many_arguments)]
fn draw_overview(
scene: &mut Scene,
size: Size,
ox: f64,
oy: f64,
model: &WindowModel,
current_ws: usize,
ws_count: usize,
drag: Option<(usize, (f64, f64))>,
) {
// Przygaszenie tła.
scene.fill_rect(
Rect::from_size(size),
Color::from_rgba8(0x0b, 0x0d, 0x13, 0xd6),
);
let slots = {
let w = model.lock_or_recover();
crate::state::overview_slots_on(&w, current_ws, ox, size.width)
};
let tiles = crate::state::overview_tiles(slots.len(), (size.width, size.height));
let windows = model.lock_or_recover();
for (k, &slot) in slots.iter().enumerate() {
let (Some(r), Some(w)) = (tiles.get(k), windows.get(slot)) else {
continue;
};
let title_h = crate::state::OVERVIEW_TITLE;
scene.fill_rounded_rect(*r, 8.0, Color::from_rgba8(0x1a, 0x1d, 0x25, 0xff));
let bar = Rect::from_xywh(r.x(), r.y(), r.width(), title_h);
scene.fill_rounded_rect(bar, 8.0, Color::from_rgba8(0x2c, 0x2f, 0x38, 0xff));
let img = Rect::from_xywh(
r.x(),
r.y() + title_h,
r.width(),
(r.height() - title_h).max(1.0),
);
scene.image(img, w.data.clone());
scene.stroke_rounded_rect(*r, 8.0, 1.5, Color::from_rgba8(0x6c, 0x74, 0x86, 0xff));
scene.text(
TextRun::new(Point::new(r.x() + 10.0, r.y() + 18.0), &w.title, 13.0)
.with_color(Color::from_rgba8(0xee, 0xee, 0xee, 0xff)),
);
}
// Pasek przestrzeni roboczych.
let chips = crate::state::overview_ws_chips(ws_count, (size.width, size.height));
for (i, c) in chips.iter().enumerate() {
let bg = if i == current_ws {
Color::from_rgba8(0x5b, 0x8d, 0xef, 0xff)
} else {
Color::from_rgba8(0x2c, 0x2f, 0x38, 0xff)
};
scene.fill_rounded_rect(*c, c.height() / 2.0, bg);
scene.text(
TextRun::new(
Point::new(c.center().x - 6.0, c.center().y + 6.0),
&format!("{}", i + 1),
16.0,
)
.with_color(Color::from_rgba8(0xf5, 0xf5, 0xf5, 0xff)),
);
}
// „Duch” przeciąganego okna pod kursorem.
if let Some((slot, (px, py))) = drag {
let local = Point::new(px - ox, py - oy);
let g = Rect::from_xywh(local.x - 90.0, local.y - 60.0, 180.0, 120.0);
scene.fill_rounded_rect(g, 8.0, Color::from_rgba8(0x5b, 0x8d, 0xef, 0xcc));
scene.stroke_rounded_rect(g, 8.0, 2.0, Color::from_rgba8(0xff, 0xff, 0xff, 0xdd));
if let Some(w) = windows.get(slot) {
scene.text(
TextRun::new(Point::new(g.x() + 10.0, g.y() + 20.0), &w.title, 13.0)
.with_color(Color::from_rgba8(0xff, 0xff, 0xff, 0xff)),
);
}
}
}
/// Klasyczna strzałka kursora (biała z ciemnym obrysem).
fn draw_cursor(scene: &mut Scene, p: Point) {
let arrow = [
Point::new(p.x, p.y),
Point::new(p.x, p.y + 17.0),
Point::new(p.x + 4.2, p.y + 12.6),
Point::new(p.x + 7.0, p.y + 18.5),
Point::new(p.x + 9.7, p.y + 17.2),
Point::new(p.x + 6.9, p.y + 11.4),
Point::new(p.x + 11.5, p.y + 11.4),
];
let white = Color::from_rgba8(0xf5, 0xf5, 0xf5, 0xff);
let outline = Color::from_rgba8(0x10, 0x10, 0x10, 0xff);
scene.fill_polygon(&arrow, white);
let mut closed = arrow.to_vec();
closed.push(arrow[0]);
scene.stroke_polyline(&closed, 1.5, outline);
}
#[cfg(test)]
mod tests {
use super::*;
use libluna_render::SoftwareRenderer;
#[test]
fn draw_cursor_paints_pixels() {
let mut scene = Scene::new();
draw_cursor(&mut scene, Point::new(8.0, 8.0));
let mut renderer = SoftwareRenderer::new(48, 48);
renderer.draw(&scene, 1.0, Color::from_rgba8(0, 0, 0, 255));
let painted = renderer
.data()
.chunks_exact(4)
.any(|px| px[0] > 0 || px[1] > 0 || px[2] > 0);
assert!(painted, "kursor powinien zostawić niezerowe piksele");
}
#[test]
#[ignore = "generuje podglad do docs/images (uruchom recznie)"]
fn render_desktop_preview() {
use std::sync::{Arc, Mutex};
let model: WindowModel = Arc::new(Mutex::new(Vec::new()));
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(1920.0, 1080.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
None,
true,
&Theme::dark(),
&TextEngine::none(),
);
let mut renderer = SoftwareRenderer::new(1920, 1080);
renderer.draw(&scene, 1.0, Color::from_rgba8(0x1e, 0x20, 0x28, 0xff));
let png = renderer.encode_png().expect("PNG");
let path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../docs/images/luna-desktop.png"
);
std::fs::write(path, png).expect("zapis PNG");
println!("saved {path}");
}
#[test]
fn power_saver_caps_presentation_at_30_fps() {
assert_eq!(throttle_delay(false, Duration::from_millis(5)), None);
assert_eq!(
throttle_delay(true, Duration::from_millis(10)),
Some(Duration::from_millis(23))
);
assert_eq!(throttle_delay(true, Duration::from_millis(40)), None);
}
#[test]
fn desktop_background_is_the_wallpaper() {
use std::sync::{Arc, Mutex};
let model: WindowModel = Arc::new(Mutex::new(Vec::new()));
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(320.0, 180.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
None,
true,
&Theme::dark(),
&TextEngine::none(),
);
// Pierwsze polecenie to tło pulpitu — tapeta przeskalowana dokładnie do rozmiaru
// wyjścia (renderer kopiuje ją wprost), a nie gradient z 64 pasów.
match &scene.commands()[0] {
libluna_render::DrawCommand::Background { data } => {
assert_eq!((data.width, data.height), (320, 180));
}
other => panic!("oczekiwano tapety, a jest {other:?}"),
}
}
#[test]
fn ssd_header_highlights_hovered_close_like_libluna() {
use libluna_render::DrawCommand;
use std::sync::{Arc, Mutex};
let data = Arc::new(ImageData::new(400, 300, vec![0u8; 400 * 300 * 4]));
let windows = vec![Window {
x: 0.0,
y: 0.0,
width: 400,
height: 300,
image_offset: (0.0, 0.0),
z: 1,
title: "Test".to_owned(),
popup: false,
layer: false,
mapped: true,
workspace: 0,
input: None,
input_capture: false,
minimized: false,
maximized: false,
fullscreen: false,
ssd: true,
saved: None,
data,
}];
let model: WindowModel = Arc::new(Mutex::new(windows));
let theme = Theme::light();
let has_error_fill = |scene: &Scene| {
scene.commands().iter().any(|c| {
matches!(
c,
DrawCommand::FillRoundedRect { color, .. } if *color == theme.colors.error
)
})
};
// Środek kontrolki „zamknij”: szerokość 400, pad 6, bok 30.
let close = Point::new(400.0 - 6.0 - 30.0 + 15.0, 23.0);
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(800.0, 600.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
Some(close),
false,
&theme,
&TextEngine::none(),
);
assert!(
has_error_fill(&scene),
"hover na „zamknij” powinien dać czerwone tło jak HeaderBar libLuny"
);
// Poza kontrolkami — brak podświetlenia.
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(800.0, 600.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
Some(Point::new(100.0, 23.0)),
false,
&theme,
&TextEngine::none(),
);
assert!(!has_error_fill(&scene));
}
#[test]
fn ssd_header_takes_color_from_window_content() {
use libluna_render::DrawCommand;
use std::sync::{Arc, Mutex};
// Ciemny, kryjący pasek narzędzi w górnych 28 px → pasek SSD w tym samym kolorze.
let (w, h) = (400u32, 300u32);
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..28u32 {
for x in 0..w {
let i = ((y * w + x) * 4) as usize;
rgba[i..i + 4].copy_from_slice(&[0x11, 0x22, 0x33, 0xff]);
}
}
let model: WindowModel = Arc::new(Mutex::new(vec![Window {
x: 0.0,
y: 0.0,
width: 400,
height: 300,
image_offset: (0.0, 0.0),
z: 1,
title: "Test".to_owned(),
popup: false,
layer: false,
mapped: true,
workspace: 0,
input: None,
input_capture: false,
minimized: false,
maximized: false,
fullscreen: false,
ssd: true,
saved: None,
data: Arc::new(ImageData::new(w, h, rgba)),
}]));
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(800.0, 600.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
None,
false,
&Theme::dark(),
&TextEngine::none(),
);
let tint = Color::from_rgba8(0x11, 0x22, 0x33, 0xff);
assert!(
scene.commands().iter().any(|c| matches!(
c,
DrawCommand::FillRect { rect, color }
if *color == tint && rect.y() == 0.0 && rect.height() == crate::state::TITLEBAR
)),
"pasek SSD powinien przyjąć kolor górnej części treści okna"
);
}
#[test]
#[ignore = "benchmark reczny"]
fn bench_1080p_frame() {
use std::sync::{Arc, Mutex};
use std::time::Instant;
let model: WindowModel = Arc::new(Mutex::new(Vec::new()));
let bg = Color::from_rgba8(0x1e, 0x20, 0x28, 0xff);
let mut renderer = SoftwareRenderer::new(1920, 1080);
let n = 30;
let t = Instant::now();
for _ in 0..n {
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(1920.0, 1080.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
Some(Point::new(100.0, 100.0)),
true,
&Theme::dark(),
&TextEngine::none(),
);
renderer.draw(&scene, 1.0, bg);
}
println!(
"render 1920x1080: {:.2} ms/klatke",
t.elapsed().as_secs_f64() * 1000.0 / n as f64
);
}
#[test]
#[ignore = "benchmark reczny"]
fn bench_1080p_with_panel() {
use std::sync::{Arc, Mutex};
use std::time::Instant;
let data = Arc::new(libluna_render::ImageData::new(
1920,
680,
vec![128u8; 1920 * 680 * 4],
));
let windows = vec![Window {
x: 0.0,
y: 0.0,
width: 1920,
height: 680,
image_offset: (0.0, 0.0),
z: 1_000_000,
title: String::new(),
popup: false,
layer: true,
mapped: true,
workspace: 0,
input: None,
input_capture: false,
minimized: false,
maximized: false,
fullscreen: false,
ssd: true,
saved: None,
data,
}];
let model: WindowModel = Arc::new(Mutex::new(windows));
let bg = Color::from_rgba8(0x1e, 0x20, 0x28, 0xff);
let mut renderer = SoftwareRenderer::new(1920, 1080);
let n = 20;
// Sam render sceny:
let t = Instant::now();
for _ in 0..n {
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(1920.0, 1080.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
Some(Point::new(100.0, 100.0)),
true,
&Theme::dark(),
&TextEngine::none(),
);
renderer.draw(&scene, 1.0, bg);
}
println!(
"render 1080p + panel 1920x680: {:.2} ms/klatke",
t.elapsed().as_secs_f64() * 1000.0 / n as f64
);
}
#[test]
#[ignore = "benchmark reczny"]
fn bench_sparse_panel() {
use std::sync::{Arc, Mutex};
use std::time::Instant;
let (w, h) = (1920u32, 680u32);
let mut rgba = vec![0u8; (w * h * 4) as usize];
let mut fill = |x0: u32, y0: u32, x1: u32, y1: u32| {
for y in y0..y1 {
for x in x0..x1 {
let i = ((y * w + x) * 4) as usize;
rgba[i..i + 4].copy_from_slice(&[30, 30, 30, 255]);
}
}
};
// Pasek na całą szerokość + launcher 720×600 (reszta przezroczysta).
fill(0, 0, w, 48);
fill(600, 72, 1320, 672);
let data = Arc::new(ImageData::new(w, h, rgba).with_sparse(true));
let windows = vec![Window {
x: 0.0,
y: 0.0,
width: w as i32,
height: h as i32,
image_offset: (0.0, 0.0),
z: 1_000_000,
title: String::new(),
popup: false,
layer: true,
mapped: true,
workspace: 0,
input: None,
input_capture: false,
minimized: false,
maximized: false,
fullscreen: false,
ssd: true,
saved: None,
data,
}];
let model: WindowModel = Arc::new(Mutex::new(windows));
let bg = Color::from_rgba8(0x1e, 0x20, 0x28, 0xff);
let mut renderer = SoftwareRenderer::new(1920, 1080);
let n = 20;
let t = Instant::now();
for _ in 0..n {
let mut scene = Scene::new();
build_scene(
&mut scene,
Size::new(1920.0, 1080.0),
0.0,
0.0,
&model,
0,
1,
Overview::default(),
true,
Some(Point::new(100.0, 100.0)),
true,
&Theme::dark(),
&TextEngine::none(),
);
renderer.draw(&scene, 1.0, bg);
}
println!(
"render 1080p + sparse panel: {:.2} ms/klatke",
t.elapsed().as_secs_f64() * 1000.0 / n as f64
);
}
}