Implement stable serialization and restoration for dock layouts, including auto-hidden and floating panels

- Add `persistence.rs` for stable serialization of dock layouts without runtime identifiers.
- Introduce `preview.rs` for geometry handling of dock drop previews.
- Create `sizing.rs` to manage content-aware sizing policies and constraint solving for dock panels.
- Implement `welcome.rs` to provide a welcome surface when no documents are open, featuring New and Open actions.
This commit is contained in:
2026-07-16 22:10:22 +03:00
parent 1240d25011
commit b09795ccff
72 changed files with 11898 additions and 3115 deletions
+33 -10
View File
@@ -1,11 +1,13 @@
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test]
fn bitmap_brush_stamp_shape() {
let mut engine = Engine::new(64, 64);
// 4x4 solid white square bitmap stamp
let mut pixels = vec![0u8; 16];
for i in 0..16 { pixels[i] = 255; }
for i in 0..16 {
pixels[i] = 255;
}
let brush = BrushTip {
style: BrushStyle::Bitmap,
size: 8.0,
@@ -25,25 +27,46 @@ fn bitmap_brush_stamp_shape() {
// Check that the painted area is roughly 4x4, not a circular blob.
// Count alpha > 0 pixels and bounding box.
let mut min_x = 63; let mut max_x = 0; let mut min_y = 63; let mut max_y = 0;
let mut min_x = 63;
let mut max_x = 0;
let mut min_y = 63;
let mut max_y = 0;
let mut count = 0;
for y in 0..64 {
for x in 0..64 {
let a = output[(y*64+x)*4+3];
let a = output[(y * 64 + x) * 4 + 3];
if a > 0 {
count += 1;
if x < min_x { min_x = x; }
if x > max_x { max_x = x; }
if y < min_y { min_y = y; }
if y > max_y { max_y = y; }
if x < min_x {
min_x = x;
}
if x > max_x {
max_x = x;
}
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
}
}
}
let w = (max_x - min_x + 1) as i32;
let h = (max_y - min_y + 1) as i32;
// Should fit inside ~8x8 because size=8 and stamp 4x4 scaled up
assert!(w <= 8 && h <= 8, "bitmap dab bounding box {}x{} too large for 4x4 stamp", w, h);
assert!(
w <= 8 && h <= 8,
"bitmap dab bounding box {}x{} too large for 4x4 stamp",
w,
h
);
assert!(count >= 8, "bitmap dab should paint some pixels");
// Square-ish: aspect ratio not too far from 1 (was very low for circle vs square?)
assert!(w.abs_diff(h) <= 2, "bitmap dab bounding box should be roughly square, got {}x{}", w, h);
assert!(
w.abs_diff(h) <= 2,
"bitmap dab bounding box should be roughly square, got {}x{}",
w,
h
);
}
+8 -3
View File
@@ -4,7 +4,7 @@
//! public engine API and saves the result as a PNG. Used for manual inspection
//! and regression detection.
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test]
#[ignore = "manual visual check: run with --ignored --test leaves_check and inspect target/leaves_check.png"]
@@ -44,11 +44,16 @@ fn leaves_brush_visual_check() {
let pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone();
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target");
std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("leaves_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
let has_color = pixels
.chunks_exact(4)
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
assert!(has_color, "Leaves check produced no colored pixels");
}
+8 -3
View File
@@ -11,7 +11,7 @@
//! 3. Draw multiple short strokes at different positions.
//! 4. Save the composited pixels to `target/meadow_check.png`.
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test]
#[ignore = "manual visual check: run with --ignored --test meadow_check and inspect target/meadow_check.png"]
@@ -60,12 +60,17 @@ fn meadow_brush_visual_check() {
let pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone();
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target");
std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("meadow_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
// Basic sanity: output should contain non-white, non-transparent pixels.
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
let has_color = pixels
.chunks_exact(4)
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
assert!(has_color, "Meadow check produced no colored pixels");
}
+16 -5
View File
@@ -25,7 +25,7 @@
//! - Allocates ~400MB of pixel/tile/mask buffers.
//! - Uses `std::time::Instant` for measurement; results depend on the host CPU.
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
const W: u32 = 3840;
const H: u32 = 2160;
@@ -112,18 +112,29 @@ fn benchmark_4k_stroke_on_multilayer_document() {
let total_us = total_start.elapsed().as_micros() as f64;
let avg_segment_us = segment_times.iter().sum::<f64>() / segment_times.len().max(1) as f64;
let avg_composite_us = composite_times.iter().sum::<f64>() / composite_times.len().max(1) as f64;
let avg_composite_us =
composite_times.iter().sum::<f64>() / composite_times.len().max(1) as f64;
let max_segment_us = segment_times.iter().copied().fold(0.0, f64::max);
let max_composite_us = composite_times.iter().copied().fold(0.0, f64::max);
println!("\n=== 4K Multi-Layer Stroke Benchmark ===");
println!("Canvas: {}x{}, Layers: {}, Segments: {}", W, H, LAYERS, SEGMENTS);
println!(
"Canvas: {}x{}, Layers: {}, Segments: {}",
W, H, LAYERS, SEGMENTS
);
println!("Average stroke_to: {:>8.1} us", avg_segment_us);
println!("Max stroke_to: {:>8.1} us", max_segment_us);
println!("Average composite: {:>8.1} us", avg_composite_us);
println!("Max composite: {:>8.1} us", max_composite_us);
println!("Total wall time: {:>8.1} us ({:.2} s)", total_us, total_us / 1_000_000.0);
println!("Segments per second: {:>8.1}", SEGMENTS as f64 / (total_us / 1_000_000.0));
println!(
"Total wall time: {:>8.1} us ({:.2} s)",
total_us,
total_us / 1_000_000.0
);
println!(
"Segments per second: {:>8.1}",
SEGMENTS as f64 / (total_us / 1_000_000.0)
);
// Soft sanity check: the engine must still report a valid top layer and the
// document must be marked modified after painting.
+8 -3
View File
@@ -4,7 +4,7 @@
//! using the public engine API and saves the result as a PNG. Used for manual
//! inspection and regression detection.
use hcie_engine_api::{Engine, BrushTip, BrushStyle};
use hcie_engine_api::{BrushStyle, BrushTip, Engine};
#[test]
#[ignore = "manual visual check: run with --ignored --test stamp_floor_check and inspect target/stamp_floor_check.png"]
@@ -46,11 +46,16 @@ fn stamp_floor_brush_visual_check() {
let pixels = engine.get_composite_pixels();
let mut output_layer = hcie_protocol::Layer::new_transparent("", 512, 512);
output_layer.pixels = pixels.clone();
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().unwrap().join("target");
let out_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("target");
std::fs::create_dir_all(&out_dir).unwrap();
let path = out_dir.join("stamp_floor_check.png");
hcie_engine_api::dynamic_loader::save_image(&output_layer, &path, "png").expect("save png");
let has_color = pixels.chunks_exact(4).any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
let has_color = pixels
.chunks_exact(4)
.any(|p| p[3] > 0 && (p[0] != 255 || p[1] != 255 || p[2] != 255));
assert!(has_color, "Stamp Floor check produced no colored pixels");
}
+59 -16
View File
@@ -23,7 +23,7 @@
//! - undo_after_rect_golden: validates history snapshot and restoration
use hcie_engine_api::Engine;
use hcie_engine_api::{BrushTip, BrushStyle, VectorShape};
use hcie_engine_api::{BrushStyle, BrushTip, VectorShape};
use sha2::{Digest, Sha256};
/// Compute SHA-256 hash of pixel buffer for golden comparison.
@@ -46,7 +46,10 @@ fn white_canvas_empty_document() {
for i in (0..pixels.len()).step_by(4) {
assert_eq!(pixels[i + 3], 0, "Alpha channel should be 0 (transparent)");
}
assert_eq!(hash_pixels(&pixels), "5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1");
assert_eq!(
hash_pixels(&pixels),
"5341e6b2646979a70e57653007a1f310169421ec9bdd9f1a5648f75ade005af1"
);
}
/// Test: Green rectangle draw and composite.
@@ -64,7 +67,10 @@ fn green_rect_draw_and_composite() {
assert_eq!(pixels[center_idx + 1], 255);
assert_eq!(pixels[center_idx + 2], 0);
assert_eq!(pixels[center_idx + 3], 255);
assert_eq!(hash_pixels(&pixels), "01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a");
assert_eq!(
hash_pixels(&pixels),
"01b9681b08e6d9bafd568f110f0656613c7447c667fda4af9350d705dadc758a"
);
}
/// Test: Red rectangle over green rectangle.
@@ -91,7 +97,10 @@ fn red_rect_over_green_rect() {
assert_eq!(pixels[center_idx], 255);
assert_eq!(pixels[center_idx + 1], 0);
assert_eq!(pixels[center_idx + 2], 0);
assert_eq!(hash_pixels(&pixels), "c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750");
assert_eq!(
hash_pixels(&pixels),
"c893ae44fb82ff080e286a6625389fef843ac60e82cdb4d7dde8c7975bbae750"
);
}
/// Test: Vector shape rendering.
@@ -100,7 +109,10 @@ fn red_rect_over_green_rect() {
fn vector_rect_golden() {
let mut engine = Engine::new(16, 16);
let shape = VectorShape::Rect {
x1: 0.0, y1: 0.0, x2: 16.0, y2: 16.0,
x1: 0.0,
y1: 0.0,
x2: 16.0,
y2: 16.0,
stroke: 1.0,
color: [255, 0, 0, 255],
fill_color: [255, 0, 0, 255],
@@ -116,9 +128,14 @@ fn vector_rect_golden() {
assert_eq!(pixels.len(), 16 * 16 * 4);
// Verify at least one red pixel (vector rect was drawn)
let has_red = pixels.chunks(4).any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
let has_red = pixels
.chunks(4)
.any(|p| p[0] == 255 && p[1] == 0 && p[2] == 0);
assert!(has_red, "Vector rect should contain red pixels");
assert_eq!(hash_pixels(&pixels), "71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d");
assert_eq!(
hash_pixels(&pixels),
"71205eb7a329a3ead670c77eee185c0fbeb612f7a2b3d6aadbe2af4f9276b60d"
);
}
/// Test: Brush stroke rendering.
@@ -144,8 +161,14 @@ fn brush_stroke_golden() {
// Brush stroke should produce non-white pixels
let has_non_white = pixels.iter().any(|&p| p < 255);
assert!(has_non_white, "Brush stroke should produce non-white pixels");
assert_eq!(hash_pixels(&pixels), "e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087");
assert!(
has_non_white,
"Brush stroke should produce non-white pixels"
);
assert_eq!(
hash_pixels(&pixels),
"e97f0dd89644a40cd4d16b7440576265761849c45180fd4dece66b4f709fa087"
);
}
/// Test: Invert filter.
@@ -164,7 +187,10 @@ fn invert_filter_golden() {
assert_eq!(pixels[idx], 155); // 255 - 100
assert_eq!(pixels[idx + 1], 105); // 255 - 150
assert_eq!(pixels[idx + 2], 55); // 255 - 200
assert_eq!(hash_pixels(&pixels), "35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f");
assert_eq!(
hash_pixels(&pixels),
"35490247d911e119aeae86afa584459b47b853262afde75f832521a738bb069f"
);
}
/// Test: Undo after rectangle.
@@ -196,7 +222,10 @@ fn vector_fill_toggle_and_delete() {
// Create a rect with fill=false
let shape = VectorShape::Rect {
x1: 2.0, y1: 2.0, x2: 14.0, y2: 14.0,
x1: 2.0,
y1: 2.0,
x2: 14.0,
y2: 14.0,
stroke: 1.0,
color: [255, 0, 0, 255],
fill_color: [0, 0, 255, 255],
@@ -211,25 +240,39 @@ fn vector_fill_toggle_and_delete() {
// Verify fill is initially false
let shapes = engine.active_vector_shapes().unwrap();
assert_eq!(shapes[0].fill(), Some(false), "fill should be false initially");
assert_eq!(
shapes[0].fill(),
Some(false),
"fill should be false initially"
);
// Toggle fill ON
engine.set_vector_shape_fill(layer_id, 0, true);
let shapes = engine.active_vector_shapes().unwrap();
assert_eq!(shapes[0].fill(), Some(true), "fill should be true after toggle");
assert_eq!(
shapes[0].fill(),
Some(true),
"fill should be true after toggle"
);
// Verify composite pixels contain blue fill
let pixels = engine.get_composite_pixels();
let has_blue = pixels.chunks(4).any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
let has_blue = pixels
.chunks(4)
.any(|p| p[2] == 255 && p[0] == 0 && p[1] == 0 && p[3] > 0);
assert!(has_blue, "should have blue fill pixels after enabling fill");
// Toggle fill OFF
engine.set_vector_shape_fill(layer_id, 0, false);
let shapes = engine.active_vector_shapes().unwrap();
assert_eq!(shapes[0].fill(), Some(false), "fill should be false after toggling off");
assert_eq!(
shapes[0].fill(),
Some(false),
"fill should be false after toggling off"
);
// Delete the shape
engine.delete_vector_shape(layer_id, 0);
let shapes = engine.active_vector_shapes().unwrap();
assert!(shapes.is_empty(), "shapes should be empty after delete");
}
}