diff --git a/Cargo.lock b/Cargo.lock index ec9405e..ee50f1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3589,6 +3589,16 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "shturman-render" +version = "0.0.0" +dependencies = [ + "anyhow", + "png 0.17.16", + "slint", + "tempfile", +] + [[package]] name = "shturman-sdk" version = "0.0.0" @@ -3624,6 +3634,7 @@ dependencies = [ "anyhow", "png 0.17.16", "shturman-common", + "shturman-render", "shturman-sdk", "slint", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 1ad6d1a..934cb86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/core/shturman-firstboot", "crates/core/shturman-settings", "crates/core/shturman-power", + "crates/apps/shturman-render", "crates/apps/shturman-shell", "crates/tools/shturman-manifest-validator", ] diff --git a/crates/apps/shturman-render/Cargo.toml b/crates/apps/shturman-render/Cargo.toml new file mode 100644 index 0000000..6c74dba --- /dev/null +++ b/crates/apps/shturman-render/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "shturman-render" +version = "0.0.0" +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +slint.workspace = true +png = "0.17" + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/apps/shturman-render/src/lib.rs b/crates/apps/shturman-render/src/lib.rs new file mode 100644 index 0000000..97f2508 --- /dev/null +++ b/crates/apps/shturman-render/src/lib.rs @@ -0,0 +1,68 @@ +//! Headless software-render Slint-компонента в PNG (без дисплея/композитора). +//! Общий для shturman-shell (первый кадр) и shturman-splash (Stage 0). Спека v0.2 §4.1. + +use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; +use slint::platform::{Platform, PlatformError, WindowAdapter}; +use slint::ComponentHandle; +use std::path::Path; +use std::rc::Rc; +use std::sync::Once; + +thread_local! { + static WINDOW: Rc = + MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); +} + +struct SwPlatform; +impl Platform for SwPlatform { + fn create_window_adapter(&self) -> Result, PlatformError> { + Ok(WINDOW.with(|w| w.clone())) + } +} + +/// Software-platform ставится один раз на процесс (Slint — глобально). В мультитестовом процессе +/// повтор терпим (`Once` + терпимый результат); рендер в проде зовётся раз на процесс (oneshot-сервис). +fn ensure_platform() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let _ = slint::platform::set_platform(Box::new(SwPlatform)); + }); +} + +/// Построить компонент (ВНУТРИ — после `set_platform`) и отрендерить его кадр в PNG. +/// `build` зовётся после установки software-platform (порядок обязателен для Slint). +pub fn render_to_png( + build: impl FnOnce() -> anyhow::Result, + w: u32, + h: u32, + path: &Path, +) -> anyhow::Result<()> { + ensure_platform(); + let ui = build()?; + let window = WINDOW.with(|x| x.clone()); + window.set_size(slint::PhysicalSize::new(w, h)); + ui.show()?; + ui.window().request_redraw(); // форсим перерисовку (повторный рендер в том же потоке) + let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (w * h) as usize]; + let drawn = window.draw_if_needed(|r| { + r.render(buf.as_mut_slice(), w as usize); + }); + ui.hide()?; // освободить окно для следующего рендера в том же потоке + if !drawn { + anyhow::bail!("software-renderer не отрисовал кадр"); + } + write_png(path, w, h, &buf) +} + +fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> { + let mut enc = png::Encoder::new(std::io::BufWriter::new(std::fs::File::create(path)?), w, h); + enc.set_color(png::ColorType::Rgb); + enc.set_depth(png::BitDepth::Eight); + let mut writer = enc.write_header()?; + let mut data = Vec::with_capacity((w * h * 3) as usize); + for px in buf { + data.extend_from_slice(&[px.r, px.g, px.b]); + } + writer.write_image_data(&data)?; + Ok(()) +} diff --git a/crates/apps/shturman-render/tests/render.rs b/crates/apps/shturman-render/tests/render.rs new file mode 100644 index 0000000..6e16e87 --- /dev/null +++ b/crates/apps/shturman-render/tests/render.rs @@ -0,0 +1,25 @@ +//! Общий headless-рендер: произвольный Slint-компонент → непустой PNG (План 6 P6.1). + +use shturman_render::render_to_png; + +slint::slint! { + export component Probe inherits Window { + width: 64px; height: 48px; background: #101418; + Rectangle { background: #ffffff; width: 20px; height: 20px; } + } +} + +#[test] +fn renders_component_to_nonempty_png() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("probe.png"); + render_to_png(|| Ok(Probe::new()?), 64, 48, &path).expect("render"); + + let dec = png::Decoder::new(std::fs::File::open(&path).unwrap()); + let mut r = dec.read_info().unwrap(); + assert_eq!((r.info().width, r.info().height), (64, 48)); + let mut buf = vec![0u8; r.output_buffer_size()]; + let info = r.next_frame(&mut buf).unwrap(); + let px = &buf[..info.buffer_size()]; + assert!(px.iter().any(|&b| b != px[0]), "кадр одноцветный"); +} diff --git a/crates/apps/shturman-shell/Cargo.toml b/crates/apps/shturman-shell/Cargo.toml index a258a13..e26f6a4 100644 --- a/crates/apps/shturman-shell/Cargo.toml +++ b/crates/apps/shturman-shell/Cargo.toml @@ -7,12 +7,13 @@ license.workspace = true [dependencies] shturman-sdk = { path = "../../shturman-sdk" } shturman-common = { path = "../../shturman-common" } +shturman-render = { path = "../shturman-render" } tokio.workspace = true anyhow.workspace = true tracing.workspace = true slint.workspace = true -# PNG-кодек для headless software-render кадра (E2E-ассерт «кадр не пустой», спека §6). -png = "0.17" [dev-dependencies] tempfile.workspace = true +# PNG-декодер для проверки кадра в tests/screenshot.rs (рендер — в shturman-render). +png = "0.17" diff --git a/crates/apps/shturman-shell/src/lib.rs b/crates/apps/shturman-shell/src/lib.rs index 793312c..ba77b55 100644 --- a/crates/apps/shturman-shell/src/lib.rs +++ b/crates/apps/shturman-shell/src/lib.rs @@ -6,12 +6,8 @@ mod theme; use std::path::Path; -use std::rc::Rc; -use std::sync::Once; use std::time::{SystemTime, UNIX_EPOCH}; -use slint::platform::software_renderer::{MinimalSoftwareWindow, RepaintBufferType}; -use slint::platform::{Platform, PlatformError, WindowAdapter}; use slint::ComponentHandle; slint::slint! { @@ -167,64 +163,13 @@ pub fn run_interactive(initial: &Initial, hour: u8, clock: &str) -> anyhow::Resu Ok(()) } -// --- headless software-render (спека §6: основной автотест кадра, композитор не нужен) --- - -thread_local! { - static SCREENSHOT_WINDOW: Rc = - MinimalSoftwareWindow::new(RepaintBufferType::ReusedBuffer); -} - -struct ScreenshotPlatform; -impl Platform for ScreenshotPlatform { - fn create_window_adapter(&self) -> Result, PlatformError> { - Ok(SCREENSHOT_WINDOW.with(|w| w.clone())) - } -} - -/// Установить software-platform один раз на процесс. `--screenshot` зовётся в свежем процессе -/// первым делом; в мультитестовом процессе повтор терпим (`Once` + терпимый результат). -fn ensure_screenshot_platform() { - static ONCE: Once = Once::new(); - ONCE.call_once(|| { - let _ = slint::platform::set_platform(Box::new(ScreenshotPlatform)); - }); -} +// --- headless software-render первого кадра (спека §6) — через общий хелпер shturman-render --- /// Headless software-render первого кадра в PNG (спека §6). Без дисплей-сервера/композитора. /// `hour` задаёт тему для `auto` (тест — детерминированно); `clock` берётся из текущего времени. pub fn render_screenshot(initial: &Initial, hour: u8, path: &Path) -> anyhow::Result<()> { - ensure_screenshot_platform(); let (_, clock) = utc_hh_mm(); - let ui = build_ui(initial, hour, &clock)?; - - let window = SCREENSHOT_WINDOW.with(|w| w.clone()); - window.set_size(slint::PhysicalSize::new(FRAME_W, FRAME_H)); - ui.show()?; - ui.window().request_redraw(); // форсим перерисовку (повторный рендер в том же потоке) - - let mut buf = vec![slint::Rgb8Pixel { r: 0, g: 0, b: 0 }; (FRAME_W * FRAME_H) as usize]; - let drawn = window.draw_if_needed(|renderer| { - renderer.render(buf.as_mut_slice(), FRAME_W as usize); - }); - ui.hide()?; // освободить окно для следующего рендера в том же потоке - if !drawn { - anyhow::bail!("software-renderer не отрисовал кадр"); - } - write_png(path, FRAME_W, FRAME_H, &buf)?; + shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?; tracing::info!(path = %path.display(), "кадр записан (software-render)"); Ok(()) } - -fn write_png(path: &Path, w: u32, h: u32, buf: &[slint::Rgb8Pixel]) -> anyhow::Result<()> { - let file = std::fs::File::create(path)?; - let mut enc = png::Encoder::new(std::io::BufWriter::new(file), w, h); - enc.set_color(png::ColorType::Rgb); - enc.set_depth(png::BitDepth::Eight); - let mut writer = enc.write_header()?; - let mut data = Vec::with_capacity((w * h * 3) as usize); - for px in buf { - data.extend_from_slice(&[px.r, px.g, px.b]); - } - writer.write_image_data(&data)?; - Ok(()) -}