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

luna/apps/luna-demo/src/bin/simulate.rs main

365 lines Raw ← Back
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
//! Headlessowy „driver" demo: przepuszcza **prawdziwe zdarzenia** przez tę samą logikę
//! co okno (`ui::handle_events` + `ui::build_scene`) i renderuje kolejne klatki.
//!
//! To dowód, że demo działa interaktywnie, bez potrzeby żywego kompozytora Wayland.
//!
//! Uruchom: `cargo run -p luna-demo --bin simulate`
//! Efekt: `docs/images/demo-step-*.png` oraz `docs/images/demo-montage.png`.

use std::error::Error;

use libluna_core::{
    Accent, ButtonState, Event, KeyEvent, Modifiers, Point, PointerButton, PointerEvent, Size,
    ThemePreference, ThemeSettings,
};
use libluna_i18n::{set_locale, Locale};
use libluna_render::{Scene, SoftwareRenderer, TextEngine};
use libluna_widgets::ViewSwitcher;
use luna_demo::ui::{self, make_nav, Demo, Popover};
use tiny_skia::{FilterQuality, IntSize, Pixmap, PixmapPaint, Transform};

const W: u32 = 900;
const H: u32 = 1028;

fn press(p: Point) -> Event {
    Event::PointerButton(PointerEvent {
        position: p,
        button: PointerButton::Left,
        state: ButtonState::Pressed,
        modifiers: Modifiers::NONE,
    })
}

fn render_pixels(settings: &ThemeSettings, demo: &Demo, text: &TextEngine, size: Size) -> Vec<u8> {
    // Headless: domknij trwające animacje, by klatka pokazywała stan ustalony.
    let mut demo = demo.clone();
    demo.tab_stack.advance(1.0, 1.0);
    demo.toast.advance(1.0, 1.0);
    demo.sheet.advance(1.0, 1.0);
    demo.pager.advance(1.0, 1.0);
    let theme = settings.resolve_themed(false);
    let mut scene = Scene::new();
    ui::build_scene(
        &mut scene,
        &theme,
        text,
        size,
        &demo,
        settings.preference(),
        settings.accent(),
    );
    let mut r = SoftwareRenderer::new(size.width as u32, size.height as u32);
    r.draw(&scene, 1.0, theme.colors.window_bg);
    r.data().to_vec()
}

fn save_png(path: &str, pixels: &[u8]) -> Result<(), Box<dyn Error>> {
    let pixmap = Pixmap::from_vec(pixels.to_vec(), IntSize::from_wh(W, H).unwrap()).unwrap();
    pixmap.save_png(path)?;
    Ok(())
}

fn main() -> Result<(), Box<dyn Error>> {
    set_locale(Locale::new("pl"));
    // Katalog ze stylami (przykłady w repozytorium) — dla kroku wyboru stylu.
    let themes =
        std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../examples/luna/themes");
    std::env::set_var("LUNA_THEMES_DIR", themes);

    let size = Size::new(W as f64, H as f64);
    let text = TextEngine::system();
    let mut demo = Demo::default();
    let mut settings = ThemeSettings::default();

    let mut frames: Vec<Vec<u8>> = Vec::new();
    let mut labels: Vec<String> = Vec::new();

    // 1. Start.
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("1. Start (light, PL)".into());

    // Pomocniczo: aktualny layout i wysłanie kliknięcia.
    macro_rules! layout {
        () => {
            ui::layout(size, &settings.resolve_themed(false), &demo)
        };
    }
    macro_rules! click {
        ($pos:expr) => {{
            let theme = settings.resolve(false);
            ui::handle_events(
                &mut settings,
                &[press($pos)],
                size,
                &theme,
                &text,
                &mut demo,
            );
        }};
    }

    // 2. Klik „Ciemny" w przełączniku motywu.
    let c = ViewSwitcher::new(["a", "b", "c"]).segments(layout!().theme_switcher)[1].center();
    click!(c);
    assert_eq!(
        settings.preference(),
        ThemePreference::Dark,
        "theme did not switch to Dark"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("2. Theme: Dark".into());

    // 3. Klik kropki akcentu (Purple = indeks 7).
    let c = layout!().accents[7].center();
    click!(c);
    assert_eq!(
        settings.accent(),
        Accent::Purple,
        "accent did not switch to Purple"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("3. Accent: Purple".into());

    // Zamknięcie powiadomienia klikiem w jego treść (akcent zostaje).
    let theme = settings.resolve(false);
    let toast = demo.toast.rect(size, &theme, &text);
    click!(Point::new(toast.x() + 8.0, toast.center().y));
    assert!(!demo.toast.is_open(), "toast did not dismiss");

    // 4. Klik pozycji „Sieć" w menu nawigacji (indeks elementu 5).
    let theme = settings.resolve(false);
    let c = make_nav().rows(layout!().sidebar, &theme)[5].rect.center();
    click!(c);
    assert_eq!(
        demo.nav_selected,
        Some(5),
        "nav item 'Network' not selected"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("4. Navigation: Network".into());

    // 5. Klik „Siatka" w przełączniku widoku.
    let c = ViewSwitcher::new(["a", "b"]).segments(layout!().view_switcher)[1].center();
    click!(c);
    assert_eq!(demo.view_mode, 1, "view did not switch to Grid");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("5. View: Grid".into());

    // 6. Przełączenie zakładki (TabView + TabBar).
    let theme = settings.resolve(false);
    let c = ui::tab_bar(&demo).segments(layout!().tabs, &text, &theme)[2].center();
    click!(c);
    assert_eq!(demo.tabs.selected(), 2, "tab did not switch");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("6. Tab: About".into());

    // 7. Klik ikony ◧ w HeaderBarze — zwinięcie paska bocznego.
    let c = layout!().sidebar_button.center();
    click!(c);
    assert!(!demo.sidebar_visible, "sidebar did not collapse");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("7. Sidebar collapsed \u{25e7}".into());

    // 8. Klik ikony „☰” — otwarcie popoveru (menu nawigacji).
    let c = layout!().burger.center();
    click!(c);
    assert_eq!(demo.popover, Some(Popover::Menu), "menu did not open");
    // Podświetlenie drugiej pozycji, żeby pokazać hover.
    let theme = settings.resolve(false);
    if let Some((panel, menu)) = ui::popover_menu(size, &theme, &text, &layout!(), &demo) {
        let hover = menu.rows(panel, &theme)[1].rect.center();
        ui::handle_events(
            &mut settings,
            &[Event::PointerMotion {
                position: hover,
                modifiers: Modifiers::NONE,
            }],
            size,
            &theme,
            &text,
            &mut demo,
        );
    }
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("8. Menu \u{2630} open".into());

    // 9. Klik „O aplikacji” w otwartym menu → dolny panel (BottomSheet + Pager).
    let theme = settings.resolve(false);
    let about = ui::popover_menu(size, &theme, &text, &layout!(), &demo)
        .map(|(panel, menu)| menu.rows(panel, &theme)[5].rect.center())
        .expect("menu is open");
    click!(about);
    assert!(demo.sheet.is_open(), "bottom sheet did not open");

    // Pole `Entry` w panelu: fokus i wpisanie tekstu.
    let entry = ui::sheet_entry_rect(size, &settings.resolve(false));
    click!(entry.center());
    assert!(demo.sheet_entry.is_focused(), "entry not focused");
    for ch in ['A', 'd', 'a'] {
        let key = Event::Key(KeyEvent {
            keysym: ch as u32,
            keycode: 0,
            text: Some(ch.to_string()),
            state: ButtonState::Pressed,
            modifiers: Modifiers::NONE,
            repeat: false,
        });
        let theme = settings.resolve(false);
        ui::handle_events(&mut settings, &[key], size, &theme, &text, &mut demo);
    }
    assert_eq!(demo.sheet_entry.text(), "Ada", "entry text mismatch");

    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("9. Bottom sheet \u{201c}About\u{201d} + Entry".into());

    // 10. Zamknięcie panelu (klik w tło) i rozwinięcie listy języków (DropDown + Menu).
    click!(Point::new(120.0, 300.0));
    assert!(!demo.sheet.is_open(), "bottom sheet did not close");
    let c = layout!().language.center();
    click!(c);
    assert_eq!(
        demo.popover,
        Some(Popover::Language),
        "language list did not open"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("10. Language selection (DropDown)".into());

    // 11. Popover „Opcje” (rozmiar ikon / sortowanie / ukryte pliki).
    click!(Point::new(450.0, 300.0)); // zamknij popover języka
    assert!(demo.popover.is_none(), "popover did not close");
    let c = layout!().options_button.center();
    click!(c);
    assert_eq!(
        demo.popover,
        Some(Popover::Options),
        "options popover did not open"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("11. Options popover".into());

    // 12. Klik popovera (zamknięcie) i zmiana akcentu → powiadomienie (Toast) z „Cofnij”.
    click!(Point::new(450.0, 300.0));
    let c = layout!().accents[1].center();
    click!(c);
    assert_eq!(
        settings.accent(),
        Accent::Teal,
        "accent did not switch to Teal"
    );
    assert!(demo.toast.is_open(), "toast did not appear");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("12. Toast".into());

    // 13. Klik „Cofnij” w powiadomieniu → powrót akcentu, powiadomienie znika.
    let theme = settings.resolve(false);
    let undo = demo
        .toast
        .action_rect(size, &theme, &text)
        .expect("toast action")
        .center();
    click!(undo);
    assert_eq!(
        settings.accent(),
        Accent::Purple,
        "accent did not revert after Undo"
    );
    assert!(!demo.toast.is_open(), "toast did not disappear");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("13. Accent reverted".into());

    // 14. Filtr bez wyników → ekran stanu (StatusPage) z akcją „Wyczyść”.
    let c = layout!().search.center();
    click!(c);
    assert!(demo.search.is_focused(), "search field is not focused");
    for ch in ['x', 'y', 'z'] {
        let key = Event::Key(KeyEvent {
            keysym: ch as u32,
            keycode: 0,
            text: Some(ch.to_string()),
            state: ButtonState::Pressed,
            modifiers: Modifiers::NONE,
            repeat: false,
        });
        let theme = settings.resolve(false);
        ui::handle_events(&mut settings, &[key], size, &theme, &text, &mut demo);
    }
    assert!(
        ui::empty_state(&demo).is_some(),
        "status page missing for empty search"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("14. No results (StatusPage)".into());

    // 15. Klik „Wyczyść” w ekranie stanu → lista wraca.
    let theme = settings.resolve(false);
    let clear = ui::empty_state(&demo)
        .expect("status page")
        .rects(layout!().items, &theme)
        .action
        .expect("status page action")
        .center();
    click!(clear);
    assert!(demo.search.is_empty(), "search filter was not cleared");
    assert!(
        ui::empty_state(&demo).is_none(),
        "status page did not disappear"
    );
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("15. Filter cleared".into());

    // 16. Wybór stylu: ☰ → Styl → Nord (styl z pliku, steruje kolorem i geometrią).
    let c = layout!().burger.center();
    click!(c);
    assert_eq!(demo.popover, Some(Popover::Menu), "menu did not open");
    let theme = settings.resolve_themed(false);
    let style_item = ui::popover_menu(size, &theme, &text, &layout!(), &demo)
        .map(|(panel, menu)| menu.rows(panel, &theme)[4].rect.center())
        .expect("style entry");
    click!(style_item);
    assert_eq!(
        demo.popover,
        Some(Popover::Styles),
        "styles popover did not open"
    );
    let theme = settings.resolve_themed(false);
    let nord = ui::popover_menu(size, &theme, &text, &layout!(), &demo)
        .map(|(panel, menu)| menu.rows(panel, &theme)[1].rect.center())
        .expect("style list");
    click!(nord);
    assert_eq!(settings.style(), Some("nord"), "style not applied");
    frames.push(render_pixels(&settings, &demo, &text, size));
    labels.push("16. Style: Nord".into());

    // Zapis klatek.
    for (i, pixels) in frames.iter().enumerate() {
        let path = format!("docs/images/demo-step-{}.png", i + 1);
        save_png(&path, pixels)?;
        println!("saved {path}  — {}", labels[i]);
    }

    // Montaż (siatka `cols` x `rows`) na zmienną liczbę klatek.
    let cols = 3usize;
    let rows = frames.len().div_ceil(cols);
    let cell_w = W / cols as u32;
    let cell_h = (H as f64 * cell_w as f64 / W as f64).round() as u32;
    let mut montage = Pixmap::new(cell_w * cols as u32, cell_h * rows as u32).unwrap();
    let paint = PixmapPaint {
        quality: FilterQuality::Bilinear,
        ..Default::default()
    };
    for (i, pixels) in frames.iter().enumerate() {
        let src = Pixmap::from_vec(pixels.clone(), IntSize::from_wh(W, H).unwrap()).unwrap();
        let cx = (i % cols) as u32 * cell_w;
        let cy = (i / cols) as u32 * cell_h;
        // Skalowanie i przesunięcie w jednej transformacji (inaczej komórki lądują w rogu).
        let t = Transform::from_scale(cell_w as f32 / W as f32, cell_h as f32 / H as f32)
            .post_translate(cx as f32, cy as f32);
        montage.draw_pixmap(0, 0, src.as_ref(), &paint, t, None);
    }
    montage.save_png("docs/images/demo-montage.png")?;
    println!("saved docs/images/demo-montage.png");

    println!("\nAll interactions passed ({} frames).", frames.len());
    Ok(())
}