798e5ba14a
P6.1: общий хелпер render_to_png<C: ComponentHandle>(build, w, h, path) поверх Slint software-renderer (thread_local окно + set_platform once + draw + png). shturman-shell.render_screenshot теперь зовёт его; плумбинг-дубль удалён. png в shell → dev-deps (рендер в render-крейте, тест декодирует). Тесты зелёные. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Alexander <akotenev2003@gmail.com>
176 lines
6.7 KiB
Rust
176 lines
6.7 KiB
Rust
//! `shturman-shell` (lib) — первый Slint-кадр (срезы C03/C04/C05/C07/C02) + headless
|
|
//! software-render кадра в PNG (спека §6). Bin (`main.rs`) — тонкая обёртка над этим API:
|
|
//! интерактивный `run` (dev: weston/нативно) либо `--screenshot <path>` (E2E/CI, без композитора).
|
|
//! v0: одноразовое чтение `ui.theme`/Power при старте (best-effort; без шины — дефолты, #4).
|
|
|
|
mod theme;
|
|
|
|
use std::path::Path;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
use slint::ComponentHandle;
|
|
|
|
slint::slint! {
|
|
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
|
|
|
|
export component AppWindow inherits Window {
|
|
in property <bool> is-night: false;
|
|
in property <string> clock: "--:--";
|
|
in property <string> network: "unknown";
|
|
in property <string> ignition: "unknown";
|
|
in property <[string]> tiles: ["Навигация", "Музыка", "Телефон", "Ассистент", "Машина", "Настройки"];
|
|
|
|
title: "Штурман";
|
|
width: 1024px;
|
|
height: 600px;
|
|
background: root.is-night ? #0e1014 : #f4f5f7;
|
|
|
|
VerticalBox {
|
|
padding: 16px;
|
|
spacing: 16px;
|
|
|
|
HorizontalBox {
|
|
height: 44px;
|
|
Text {
|
|
text: root.clock;
|
|
font-size: 22px;
|
|
color: root.is-night ? #f0f0f0 : #1a1a1a;
|
|
vertical-alignment: center;
|
|
}
|
|
Rectangle { }
|
|
Text {
|
|
text: "сеть: " + root.network;
|
|
color: root.is-night ? #9aa0a6 : #5f6368;
|
|
vertical-alignment: center;
|
|
}
|
|
Text {
|
|
text: "зажигание: " + root.ignition;
|
|
color: root.is-night ? #9aa0a6 : #5f6368;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
|
|
HorizontalBox {
|
|
spacing: 16px;
|
|
for tile in root.tiles : Rectangle {
|
|
background: root.is-night ? #1b1e24 : #ffffff;
|
|
border-radius: 16px;
|
|
Text {
|
|
text: tile;
|
|
font-size: 18px;
|
|
color: root.is-night ? #e8eaed : #202124;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Размер первого кадра (логические пиксели = физические при scale 1.0).
|
|
const FRAME_W: u32 = 1024;
|
|
const FRAME_H: u32 = 600;
|
|
|
|
/// Начальное состояние кадра, прочитанное с шины (best-effort). Без шины — дефолты (#4).
|
|
#[derive(Debug, Clone)]
|
|
pub struct Initial {
|
|
pub theme: String,
|
|
pub ignition: String,
|
|
pub network: String,
|
|
}
|
|
|
|
impl Default for Initial {
|
|
fn default() -> Self {
|
|
Self {
|
|
theme: "auto".into(),
|
|
ignition: "unknown".into(),
|
|
network: "unknown".into(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Одноразовое чтение состояния с шины (best-effort). Без шины/сервисов — дефолты (#4).
|
|
pub fn read_initial() -> Initial {
|
|
// current-thread рантайм: одно best-effort чтение на холодном старте, без пула потоков (#11).
|
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
{
|
|
Ok(rt) => rt,
|
|
Err(_) => return Initial::default(),
|
|
};
|
|
rt.block_on(async {
|
|
match connect_and_read().await {
|
|
Ok(i) => i,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "нет шины/сервисов — дефолты кадра");
|
|
Initial::default()
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn connect_and_read() -> anyhow::Result<Initial> {
|
|
let def = Initial::default(); // единый источник дефолтов (без дублей литералов)
|
|
let conn = shturman_sdk::connect().await?;
|
|
let settings = shturman_sdk::SettingsClient::new(&conn).await?;
|
|
let theme = settings
|
|
.get("ui.theme")
|
|
.await
|
|
.ok()
|
|
.and_then(|v| String::try_from(v).ok())
|
|
.unwrap_or(def.theme);
|
|
let power = shturman_sdk::PowerClient::new(&conn).await?;
|
|
let ignition = power
|
|
.ignition_state()
|
|
.await
|
|
.map(|i| i.as_str().to_string())
|
|
.unwrap_or(def.ignition);
|
|
Ok(Initial {
|
|
theme,
|
|
ignition,
|
|
network: def.network,
|
|
})
|
|
}
|
|
|
|
/// Часы UTC `HH:MM` без tz-зависимостей (локальная tz — позже, a-base §7). Возвращает (час, строка).
|
|
pub fn utc_hh_mm() -> (u8, String) {
|
|
let secs = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.map(|d| d.as_secs())
|
|
.unwrap_or(0);
|
|
let h = ((secs / 3600) % 24) as u8;
|
|
let m = ((secs / 60) % 60) as u8;
|
|
(h, format!("{h:02}:{m:02}"))
|
|
}
|
|
|
|
/// Создать и наполнить окно (общий код интерактива и скриншота).
|
|
fn build_ui(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<AppWindow> {
|
|
let ui = AppWindow::new()?;
|
|
ui.set_is_night(theme::resolve_night(&initial.theme, hour));
|
|
ui.set_clock(clock.into());
|
|
ui.set_network(initial.network.as_str().into());
|
|
ui.set_ignition(initial.ignition.as_str().into());
|
|
Ok(ui)
|
|
}
|
|
|
|
/// Интерактивный запуск (dev: weston в VM / нативно на хосте). Блокирующий event-loop.
|
|
pub fn run_interactive(initial: &Initial, hour: u8, clock: &str) -> anyhow::Result<()> {
|
|
let ui = build_ui(initial, hour, clock)?;
|
|
tracing::info!("первый Slint-кадр (интерактивно)");
|
|
ui.run()?;
|
|
Ok(())
|
|
}
|
|
|
|
// --- 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<()> {
|
|
let (_, clock) = utc_hh_mm();
|
|
shturman_render::render_to_png(|| build_ui(initial, hour, &clock), FRAME_W, FRAME_H, path)?;
|
|
tracing::info!(path = %path.display(), "кадр записан (software-render)");
|
|
Ok(())
|
|
}
|