LR-BEHAV-HEADLESS: keep CPU snapshots isolated from saved preferences

Co-authored-by: Codex <[email protected]>
This commit is contained in:
sanyamsingh001andCodex committed 2026-10-09 13:19:47 +10:00
1 parent b1883231bd
commit 56754e5703
9 files changed
+431 -19

No files matched your search

+8 -4
View File
@@ -728,9 +728,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
if script.is_none() && output.is_none() {
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
}
if !(scale > 0.0 && size[0] >= 1.0 && size[1] >= 1.0) {
return Err("snapshot: bad --size/--scale".into());
}
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
@@ -741,6 +739,8 @@ fn snapshot(args: &[String]) -> Result<(), String> {
None if files.is_empty() => Session::with_demo().with_fs(),
None => Session::new().with_fs(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
if !files.is_empty() {
let ti = Instant::now();
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
@@ -755,7 +755,11 @@ fn snapshot(args: &[String]) -> Result<(), String> {
})),
..Default::default()
};
let app = lightcraft_ui_egui::LightcraftApp::new(session, services);
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
// before the first frame can start background adapter discovery.
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
app.ui.settings.gpu = false;
let mut h = Headless::new(app, size, scale);
let timeout = Duration::from_secs(60);
let mut shots = 0usize;
+128
View File
@@ -233,6 +233,58 @@ fn snapshot_script_failure_exits_non_zero() {
assert_eq!((d.width, d.height), (320, 200));
}
#[test]
fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() {
let script = tmp("snap-ui-zoom.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "ui.key", "params": {"key": "Plus", "cmd": true}}), json!({"method": "ui.inspect"})),
)
.unwrap();
for (size, scale, expected) in [("400x240", "1", (400, 240)), ("345x200", "0.9", (311, 180))] {
let out = tmp(&format!("snap-ui-zoom-{scale}.png"));
let o = Command::new(BIN)
.args(["snapshot", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", size, "--scale", scale])
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&o.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
assert!(replies[1]["result"]["pixelsPerPoint"].as_f64().unwrap() > scale.parse::<f64>().unwrap());
let decoded = lightcraft_codecs::decode(&std::fs::read(out).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), expected);
}
}
/// Headless snapshots default to CPU photo rendering as well as CPU UI rasterization.
#[test]
fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
let script = tmp("default-cpu.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.screenshot"})),
)
.unwrap();
let image = tmp("default-cpu.png");
let output = Command::new(BIN)
.env_remove("LIGHTCRAFT_GPU")
.env_remove("LIGHTCRAFT_GPU_BACKEND")
.env_remove("WGPU_BACKEND")
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
.output()
.unwrap();
assert!(output.status.success(), "status {}: {}", output.status, String::from_utf8_lossy(&output.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
let gpu = &replies[0]["result"];
assert_eq!(gpu["enabled"], false, "{gpu}");
assert_eq!(gpu["available"], false, "{gpu}");
assert_eq!(gpu["adapter"], Value::Null, "no device is created: {gpu}");
assert!(gpu["reason"].as_str().is_some_and(|reason| reason.contains("preference")), "{gpu}");
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (480, 320));
}
/// Issue #136: with the GPU switched off from the environment the UI starts and renders on the CPU,
/// without creating a GPU device (no driver is loaded), and says why.
#[test]
@@ -412,3 +464,79 @@ fn warnings_are_logged_on_stderr() {
let off = run("off");
assert!(!off.contains("names no known backend"), "{off}");
}
#[test]
fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
for (index, (size, scale)) in
[("NaNx100", "1"), ("100x100", "inf"), ("9000x9000", "1"), ("9000x100", "2"), ("100x100", "0.001")].into_iter().enumerate()
{
let image = tmp(&format!("invalid-viewport-{index}.png"));
let output = Command::new(BIN).args(["snapshot", "--size", size, "--scale", scale, "-o"]).arg(&image).output().unwrap();
assert!(!output.status.success());
assert!(String::from_utf8_lossy(&output.stderr).contains("snapshot: bad --size/--scale"), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!image.exists());
}
}
#[test]
fn snapshot_preserves_fractional_scale_rounding() {
let image = tmp("fractional-scale-rounding.png");
let output =
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let decoded = lightcraft_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
assert_eq!((decoded.width, decoded.height), (311, 180));
}
/// Snapshot's CPU setting must never replace the desktop preference, including with --library.
#[cfg(target_os = "linux")]
#[test]
fn snapshot_leaves_desktop_gpu_preferences_untouched() {
let dir = tmp("snapshot-desktop-preferences");
let library = dir.join("library");
let mut session = lightcraft_engine::Session::new().with_fs();
session.open_library(&library, true).unwrap();
drop(session);
let config = dir.join("config");
let app_config = config.join("lightcraft");
std::fs::create_dir_all(&app_config).unwrap();
let ui_path = app_config.join("ui.json");
let mut ui = lightcraft_ui_egui::UiState::default();
ui.settings.gpu = true;
let saved = serde_json::to_vec_pretty(&ui).unwrap();
std::fs::write(&ui_path, &saved).unwrap();
let script = dir.join("preferences.jsonl");
std::fs::write(
&script,
format!("{}\n{}\n", json!({"method": "engine.execute", "params": {"command": "app.gpu"}}), json!({"method": "ui.inspect"})),
)
.unwrap();
for gpu_environment in [None, Some("1")] {
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
let mut cmd = Command::new(BIN);
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
if let Some(value) = gpu_environment {
cmd.env("LIGHTCRAFT_GPU", value);
}
let output = cmd
.args(["snapshot", "--library"])
.arg(&library)
.args(["--script"])
.arg(&script)
.args(["-o"])
.arg(&image)
.args(["--size", "480x320"])
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
let replies: Vec<Value> = String::from_utf8_lossy(&output.stdout).lines().map(|line| serde_json::from_str(line).unwrap()).collect();
assert!(replies.iter().all(|reply| reply["ok"] == true), "{replies:?}");
assert_eq!(replies[0]["result"]["enabled"], false);
assert_eq!(replies[0]["result"]["available"], false);
assert_eq!(replies[0]["result"]["adapter"], Value::Null);
assert_eq!(replies[1]["result"]["ui"]["settings"]["gpu"], false);
assert_eq!(std::fs::read(&ui_path).unwrap(), saved, "snapshot changed the desktop preferences");
assert!(!app_config.join("ui.json.tmp").exists());
assert!(image.exists());
}
}
+3
View File
@@ -21,3 +21,6 @@
real_name = "Brandon Thomas"
display_name = "Brandon Thomas"
sync_github_name = false
[people.sanyamsingh001]
display_name = "sanyamsingh001"
+10
View File
@@ -323,8 +323,18 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest
ok(Value::Null)
}
"ui.resize" => {
for name in ["width", "height"] {
if p.get(name).is_some() && f(name).is_none() {
return err(format!("ui.resize: {name} must be a number"));
}
}
let w = f("width").unwrap_or(1600.0) as f32;
let h = f("height").unwrap_or(1000.0) as f32;
let zoom = ctx.zoom_factor();
let native_scale = ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or_else(|| ctx.pixels_per_point() / zoom);
if let Err(e) = crate::headless::resized_viewport([w, h], native_scale, zoom) {
return err(e);
}
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
ok(Value::Null)
}
+227 -8
View File
@@ -28,13 +28,56 @@ const CLEAR: Color32 = Color32::from_rgb(12, 12, 12);
/// Simulated frame interval (deterministic animation time).
const FRAME_DT: f64 = 1.0 / 60.0;
/// Validate the scaled screenshot dimensions before layout or pixel allocation.
pub fn viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
if size.iter().any(|edge| *edge < 1.0) {
return Err("viewport dimensions must be at least one logical point".into());
}
native_viewport_pixels(size, pixels_per_point)
}
fn native_viewport_pixels(size: [f32; 2], pixels_per_point: f32) -> Result<[usize; 2], String> {
let [width, height] = size;
if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 || !pixels_per_point.is_finite() || pixels_per_point <= 0.0 {
return Err("viewport dimensions and scale must be finite and positive".into());
}
let axis = |value: f32| {
// Match egui/scissor rounding and the existing screenshot dimension contract.
let pixels = (value * pixels_per_point).round();
if !(1.0..=softpaint::MAX_IMAGE_EDGE as f32).contains(&pixels) {
return Err(format!("scaled viewport edges must be between 1 and {} pixels", softpaint::MAX_IMAGE_EDGE));
}
Ok(pixels as usize)
};
let (w, h) = (axis(width)?, axis(height)?);
if w.checked_mul(h).is_none_or(|pixels| pixels > softpaint::MAX_IMAGE_PIXELS) {
return Err(format!("scaled viewport exceeds {} pixels", softpaint::MAX_IMAGE_PIXELS));
}
Ok([w, h])
}
/// Convert an effective-point resize to the native viewport before validating pixel bounds.
/// Sub-point native edges after zooming out are valid when they round to at least one pixel.
pub fn resized_viewport(size: [f32; 2], native_pixels_per_point: f32, zoom: f32) -> Result<egui::Vec2, String> {
if size.iter().any(|edge| !edge.is_finite() || *edge < 1.0) || !zoom.is_finite() || zoom <= 0.0 {
return Err("resize dimensions must be at least one finite logical point with a positive zoom".into());
}
let native_size = egui::Vec2::from(size) * zoom;
native_viewport_pixels([native_size.x, native_size.y], native_pixels_per_point)?;
Ok(native_size)
}
fn bounded_viewport(size: egui::Vec2, scale: f32) -> (egui::Vec2, f32) {
if native_viewport_pixels([size.x, size.y], scale).is_ok() { (size, scale) } else { (egui::vec2(1600.0, 1000.0), 1.0) }
}
/// An offscreen egui context with our fonts and theme, and a CPU mirror of its textures.
pub struct HeadlessView {
pub ctx: egui::Context,
pub textures: TextureStore,
pub(crate) shapes: Vec<egui::epaint::ClippedShape>,
pixels_per_point: f32,
size: egui::Vec2,
size: [usize; 2],
frames: u64,
}
@@ -49,11 +92,12 @@ impl HeadlessView {
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
crate::theme::apply(&ctx);
HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: egui::vec2(1600.0, 1000.0), frames: 0 }
HeadlessView { ctx, textures: TextureStore::default(), shapes: vec![], pixels_per_point: 1.0, size: [1600, 1000], frames: 0 }
}
/// Input for one frame of a `size` (points) viewport at `pixels_per_point`.
pub fn raw_input(size: egui::Vec2, pixels_per_point: f32, time: f64, events: Vec<egui::Event>) -> RawInput {
let (size, pixels_per_point) = bounded_viewport(size, pixels_per_point);
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, size);
let mut raw = RawInput {
screen_rect: Some(rect),
@@ -73,9 +117,20 @@ impl HeadlessView {
}
/// Run one frame; keeps the shapes for [`Self::paint`]. Returns the root viewport's commands.
pub fn run(&mut self, raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec<ViewportCommand> {
if let Some(r) = raw.screen_rect {
self.size = r.size();
pub fn run(&mut self, mut raw: RawInput, run_ui: impl FnMut(&mut egui::Ui)) -> Vec<ViewportCommand> {
if let Some(rect) = raw.screen_rect {
let native_scale = raw.viewports.get(&ViewportId::ROOT).and_then(|info| info.native_pixels_per_point).unwrap_or(1.0);
self.size = native_viewport_pixels([rect.width(), rect.height()], native_scale).unwrap_or([0, 0]);
// The host viewport is measured before UI zoom. Egui lays out in zoomed points;
// retain the host's physical size while allowing text and controls to grow.
let zoom = self.ctx.zoom_factor();
if zoom.is_finite() && zoom > 0.0 {
raw.screen_rect = Some(rect / zoom);
if let Some(info) = raw.viewports.get_mut(&ViewportId::ROOT) {
info.inner_rect = info.inner_rect.map(|rect| rect / zoom);
info.outer_rect = info.outer_rect.map(|rect| rect / zoom);
}
}
}
let mut out = self.ctx.run_ui(raw, run_ui);
self.frames += 1;
@@ -92,11 +147,17 @@ impl HeadlessView {
/// Size in pixels of the last frame.
pub fn size_px(&self) -> [usize; 2] {
[(self.size.x * self.pixels_per_point).round() as usize, (self.size.y * self.pixels_per_point).round() as usize]
self.size
}
/// Rasterize the last frame. `extra` textures (by id) take precedence over the context's own.
pub fn paint(&self, extra: &HashMap<TextureId, CpuTexture>) -> ColorImage {
if viewport_pixels([self.size[0] as f32, self.size[1] as f32], 1.0).is_err()
|| !self.pixels_per_point.is_finite()
|| self.pixels_per_point <= 0.0
{
return ColorImage::new([0, 0], Vec::new());
}
let prims = self.ctx.tessellate(self.shapes.clone(), self.pixels_per_point);
softpaint::paint(&prims, &Layered { over: extra, base: &self.textures }, self.size_px(), self.pixels_per_point, CLEAR)
}
@@ -125,7 +186,14 @@ pub struct Headless {
impl Headless {
/// Wrap `app` (its control channel is replaced by the driver's).
/// Invalid dimensions fall back to 1600×1000 at scale 1; external callers can use
/// [`viewport_pixels`] to reject them before constructing the session.
pub fn new(app: LightcraftApp, size: [f32; 2], pixels_per_point: f32) -> Self {
let (size, pixels_per_point) = if viewport_pixels(size, pixels_per_point).is_ok() {
(egui::Vec2::from(size), pixels_per_point)
} else {
(egui::vec2(1600.0, 1000.0), 1.0)
};
let (tx, rx) = std::sync::mpsc::channel();
let mut app = app.with_control(rx);
app.headless_host = true;
@@ -133,7 +201,7 @@ impl Headless {
app,
view: HeadlessView::new(),
max_texture_side: 16384,
size: egui::vec2(size[0], size[1]),
size,
pixels_per_point,
time: 0.0,
frames: 0,
@@ -156,6 +224,7 @@ impl Headless {
/// Run one frame.
pub fn step(&mut self) {
(self.size, self.pixels_per_point) = bounded_viewport(self.size, self.pixels_per_point);
let mut raw = HeadlessView::raw_input(self.size, self.pixels_per_point, self.time, std::mem::take(&mut self.events));
raw.viewports.entry(ViewportId::ROOT).or_default().maximized = Some(self.window_maximized);
raw.max_texture_side = Some(self.max_texture_side);
@@ -173,7 +242,12 @@ impl Headless {
let image = Arc::new(self.paint());
self.events.push(egui::Event::Screenshot { viewport_id: ViewportId::ROOT, user_data, image });
}
ViewportCommand::InnerSize(s) if s.x >= 1.0 && s.y >= 1.0 => self.size = s,
ViewportCommand::InnerSize(size) => {
// Viewport commands use current egui points, which include UI zoom.
if let Ok(native_size) = resized_viewport([size.x, size.y], self.pixels_per_point, self.view.ctx.zoom_factor()) {
self.size = native_size;
}
}
ViewportCommand::Close => self.quit = true,
ViewportCommand::Maximized(on) => {
self.window_maximized = on;
@@ -293,6 +367,151 @@ impl Headless {
mod tests {
use super::*;
#[test]
fn screenshot_dimensions_are_checked_before_layout() {
assert_eq!(viewport_pixels([1600.0, 1000.0], 2.0).unwrap(), [3200, 2000]);
assert_eq!(viewport_pixels([345.0, 200.0], 0.9).unwrap(), [311, 180]);
assert_eq!(viewport_pixels([8000.0, 8000.0], 1.0).unwrap(), [8000, 8000]);
for (size, scale) in [
([f32::NAN, 10.0], 1.0),
([10.0, f32::INFINITY], 1.0),
([10.0, 10.0], f32::INFINITY),
([10.0, 10.0], f32::NAN),
([10.0, 10.0], 0.0),
([0.0, 10.0], 1.0),
([10.0, 10.0], 0.01),
([9000.0, 9000.0], 1.0),
([9000.0, 100.0], 2.0),
] {
assert!(viewport_pixels(size, scale).is_err(), "{size:?} at {scale}");
}
let raw = HeadlessView::raw_input(egui::vec2(f32::INFINITY, 10.0), 1.0, 0.0, vec![]);
assert_eq!(raw.screen_rect.unwrap().size(), egui::vec2(1600.0, 1000.0));
}
#[test]
fn ui_zoom_preserves_host_pixels_and_scales_layout() {
for (size, scale, expected) in
[(egui::vec2(400.0, 240.0), 1.0, [400, 240]), (egui::vec2(345.0, 200.0), 0.9, [311, 180]), (egui::vec2(400.0, 240.0), 2.0, [800, 480])]
{
let mut view = HeadlessView::new();
for frame in 0..9 {
let key = match frame {
1 => Some(egui::Key::Plus),
5 => Some(egui::Key::Minus),
_ => None,
};
let mut raw = HeadlessView::raw_input(size, scale, frame as f64 / 60.0, vec![]);
if let Some(key) = key {
let modifiers = egui::Modifiers { command: true, ..Default::default() };
raw.events.push(egui::Event::ModifiersChanged(modifiers));
raw.events.extend(vec![
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers },
egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers },
]);
}
view.run(raw, |ui| {
ui.label("UI zoom changes layout, keeping the host viewport size");
});
assert_eq!(view.size_px(), expected, "frame {frame} at scale {scale}");
assert_eq!(view.paint(&HashMap::new()).size, expected);
if frame == 4 {
assert!(view.ctx.zoom_factor() > 1.0);
assert!(view.ctx.content_rect().width() < size.x);
}
if frame == 8 {
assert!(view.ctx.zoom_factor() < 1.01);
}
}
}
}
#[test]
fn shadow_screenshot_keeps_the_zoomed_hosts_size_and_scale() {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.settings.gpu = false;
let mut h = Headless::new(app, [480.0, 320.0], 2.0);
let t = Duration::from_secs(5);
assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true);
assert!(h.view.ctx.zoom_factor() > 1.0);
let main = h.view.ctx.clone();
let screenshot = h.app.headless_screenshot(&main, true).unwrap();
assert_eq!(screenshot.size, [960, 640]);
let shadow = h.app.shadow.as_ref().unwrap();
assert!((shadow.ctx.pixels_per_point() - main.pixels_per_point()).abs() < 0.001);
assert!((shadow.ctx.content_rect().width() - main.content_rect().width()).abs() < 0.1);
}
#[test]
fn control_resize_uses_effective_points_after_ui_zoom() {
for scale in [0.9, 1.0, 2.0] {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.settings.gpu = false;
let mut h = Headless::new(app, [480.0, 320.0], scale);
let t = Duration::from_secs(5);
assert_eq!(h.request("ui.key", json!({"key": "Plus", "cmd": true}), t)["ok"], true);
let native_size = resized_viewport([320.0, 240.0], scale, h.view.ctx.zoom_factor()).unwrap();
let expected = native_viewport_pixels([native_size.x, native_size.y], scale).unwrap();
assert_eq!(h.request("ui.resize", json!({"width": 320, "height": 240}), t)["ok"], true);
assert_eq!(h.view.size_px(), expected);
assert!((h.view.ctx.content_rect().width() - 320.0).abs() < 0.1);
let main = h.view.ctx.clone();
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, expected);
}
}
#[test]
fn control_resize_keeps_a_minimum_width_after_zooming_out() {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.settings.gpu = false;
let mut h = Headless::new(app, [480.0, 320.0], 1.0);
h.view.ctx.set_zoom_factor(0.5);
let t = Duration::from_secs(5);
assert_eq!(h.request("ui.resize", json!({"width": 1, "height": 120}), t)["ok"], true);
assert_eq!(h.size, egui::vec2(0.5, 60.0));
assert_eq!(h.view.size_px(), [1, 60]);
assert!((h.view.ctx.content_rect().width() - 1.0).abs() < 0.01);
let main = h.view.ctx.clone();
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [1, 60]);
}
#[test]
fn control_resize_rounds_in_native_points_after_a_fractional_zoom_out() {
let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
app.ui.settings.gpu = false;
let mut h = Headless::new(app, [480.0, 320.0], 0.9);
let t = Duration::from_secs(5);
assert_eq!(h.request("ui.key", json!({"key": "Minus", "cmd": true}), t)["ok"], true);
assert!((h.view.ctx.zoom_factor() - 0.9).abs() < 0.001);
assert_eq!(h.request("ui.resize", json!({"width": 150, "height": 150}), t)["ok"], true);
assert_eq!(h.size, egui::vec2(135.0, 135.0));
assert_eq!(h.view.size_px(), [122, 122]);
assert!((h.view.ctx.content_rect().width() - 150.0).abs() < 0.01);
let main = h.view.ctx.clone();
assert_eq!(h.app.headless_screenshot(&main, true).unwrap().size, [122, 122]);
}
#[test]
fn invalid_control_resize_preserves_the_headless_viewport() {
let app = LightcraftApp::new(lightcraft_engine::Session::new(), crate::Services::default());
let mut h = Headless::new(app, [480.0, 320.0], 2.0);
h.app.ui.settings.gpu = false;
h.step();
let size = h.size;
let rejected = h.request("ui.resize", json!({"width": 9000, "height": 8000}), Duration::from_secs(5));
assert_eq!(rejected["ok"], false, "{rejected}");
assert_eq!(h.size, size);
for bad in [json!({"width": "320", "height": 240}), json!({"width": 320, "height": null}), json!({"width": true})] {
let rejected = h.request("ui.resize", bad, Duration::from_secs(5));
assert_eq!(rejected["ok"], false, "{rejected}");
assert_eq!(h.size, size);
}
let accepted = h.request("ui.resize", json!({"width": 320, "height": 240}), Duration::from_secs(5));
assert_eq!(accepted["ok"], true, "{accepted}");
assert_eq!(h.size, egui::vec2(320.0, 240.0));
assert_eq!(h.view.size_px(), [640, 480]);
}
/// Generous: renders are slow when the machine is loaded (parallel builds), and a timed-out
/// settle would show a half-rendered UI.
const SETTLE: Duration = Duration::from_secs(120);
+4 -1
View File
@@ -606,7 +606,10 @@ impl LightcraftApp {
let mut view = self.shadow.take().unwrap_or_default();
let size = main.input(|i| i.content_rect()).size();
let size = if size.x >= 1.0 && size.y >= 1.0 { size } else { egui::vec2(1600.0, 1000.0) };
let ppp = main.pixels_per_point();
// Feed native viewport points; the shadow context applies the copied UI zoom.
let zoom = main.zoom_factor();
let size = size * zoom;
let ppp = main.pixels_per_point() / zoom;
let time = main.input(|i| i.time);
if view.frames() == 0 {
// warm-up pass: activates our fonts (pending font definitions live in `Memory`, which
+32 -1
View File
@@ -130,10 +130,24 @@ struct Prepared<'a> {
}
const BAND: usize = 32;
/// Limits for CPU screenshots, shared with headless input validation.
pub const MAX_IMAGE_EDGE: usize = 16_384;
pub const MAX_IMAGE_PIXELS: usize = 64_000_000;
fn image_len(size: [usize; 2]) -> Option<usize> {
let [w, h] = size;
if w == 0 || h == 0 || w > MAX_IMAGE_EDGE || h > MAX_IMAGE_EDGE {
return None;
}
w.checked_mul(h).filter(|n| *n <= MAX_IMAGE_PIXELS)
}
/// Rasterize `primitives` into a `size` (pixels) image cleared to `clear`. The result is opaque if
/// `clear` is.
pub fn paint(primitives: &[ClippedPrimitive], textures: &dyn TextureSource, size: [usize; 2], pixels_per_point: f32, clear: Color32) -> ColorImage {
let Some(len) = image_len(size).filter(|_| pixels_per_point.is_finite() && pixels_per_point > 0.0) else {
return ColorImage::new([0, 0], Vec::new());
};
let [w, h] = size;
let ppp = pixels_per_point as f64;
let prepared: Vec<Prepared<'_>> = primitives
@@ -167,7 +181,7 @@ pub fn paint(primitives: &[ClippedPrimitive], textures: &dyn TextureSource, size
})
.collect();
let mut pixels = vec![clear; w * h];
let mut pixels = vec![clear; len];
let bands: Vec<(usize, &mut [Color32])> = pixels.chunks_mut((BAND * w).max(1)).enumerate().map(|(i, c)| (i * BAND, c)).collect();
let threads = thread_count().min(bands.len()).max(1);
if threads <= 1 {
@@ -469,3 +483,20 @@ mod tests {
assert_eq!(t.image.pixels[2 * 4 + 3], Color32::BLACK);
}
}
#[cfg(test)]
mod dimension_tests {
use super::*;
#[test]
fn rejected_dimensions_allocate_no_pixels() {
for size in [[usize::MAX, 2], [9000, 9000], [MAX_IMAGE_EDGE + 1, 1], [0, 1]] {
let image = paint(&[], &TextureStore::default(), size, 1.0, Color32::BLACK);
assert_eq!(image.size, [0, 0]);
assert!(image.pixels.is_empty());
}
let image = paint(&[], &TextureStore::default(), [2, 3], 1.0, Color32::BLACK);
assert_eq!(image.size, [2, 3]);
assert_eq!(image.pixels.len(), 6);
}
}
+15 -2
View File
@@ -85,14 +85,27 @@ scissor clipping as egui's GPU backends, so the image matches the window (minus
- **In the running app:** `{"method": "ui.screenshot", "params": {"path": "a.png", "headless": true}}`.
The UI is drawn into an offscreen context from the app's logic tick, which keeps running when the
window is occluded or the display sleeps/locks. Windowed screenshots fall back to this after 2 s.
- **Without any app window:** `lightcraft-cli snapshot` runs a whole app session headlessly and
answers the same control requests (same handler) from a JSON-lines script:
- **Without any app window:** `lightcraft-cli snapshot` runs a whole app session headlessly.
Photo development also defaults to the CPU, so the initial frames do not discover a GPU adapter
or load a graphics driver. `LIGHTCRAFT_GPU=1` does not opt snapshots back into GPU rendering.
This applies only to the snapshot session: even `--library DIR` leaves the desktop app's saved
`ui.json` preferences untouched. It answers the same control requests (same handler) from a JSON-lines script:
```text
lightcraft-cli snapshot --demo -o grid.png --size 1600x1000 [--scale 2]
lightcraft-cli snapshot --library DIR --script tour.jsonl -o shot.png
```
Headless screenshot dimensions must be finite and at least one logical point, with a finite,
positive scale. Rounded output must be at least one pixel per edge, at most 16,384 pixels
per edge and 64 million pixels in total. `snapshot` and `ui.resize` reject requests outside
these limits before layout or rasterization; supplied resize dimensions must be numbers.
UI zoom changes text and control sizes while preserving the snapshot viewport's requested
physical pixel dimensions. Later `ui.resize` requests use current egui points; validation
and rasterization both convert them to native viewport points before rounding the output
pixels. A native edge below one point after zooming out is valid if it rounds to at least
one pixel.
`tour.jsonl` holds one request per line (`#` comments allowed), e.g.
`{"method": "ui.set", "params": {"view": "detail", "right": "edit", "openSections": ["optics"]}}`
then `{"method": "ui.screenshot"}`. Replies are printed to stdout. A failed request
+4 -3
View File
@@ -44,13 +44,13 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table
| U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) |
| V. Preferences (PREF) | 6 | 0 | 3 | 2 | 1/1 (100%) | 4/4 (100%) |
| W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |
| X. Cross-cutting behaviours (BEHAV) | 17 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| X. Cross-cutting behaviours (BEHAV) | 18 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) |
| Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) |
| Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) |
| Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) |
| **Total** | 393 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
| **Total** | 394 | 36 | 86 | 36 | 193/200 (97%) | 142/152 (93%) |
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 515 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.0% of 162.
Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **79.8%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.3% of 163.
<!-- /parity:summary -->
## Top gaps
@@ -519,6 +519,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed |
| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | |
| LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | |
| LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported |
| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/grid.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control and value, buttons / icon buttons / dropdowns / section headers / sources their labels and state, grid thumbnails file name, rating, flag and label; the canvas tools (crop, masks) are pointer-only; not audited with a screen reader |
| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) |
| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German and Russian UI with a persisted language preference and craft-fonts regular/bold faces per script; menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language is a table entry plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the German catalog covers every existing catalog key, command/control label and rule-editor label; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German; Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds only the Japanese face. |