🔒 Repository is read-only – file editing is disabled.

luna/de/selene/src/output.rs main

1357 lines Raw ← Back
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
        );
    }
}