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
+31 -13
View File
@@ -3,7 +3,7 @@
//! Compiles only when running `cargo run --example draw_tester`.
use eframe::egui;
use hcie_draw::{draw_line, draw_filled_rect, draw_filled_circle, flood_fill};
use hcie_draw::{draw_filled_circle, draw_filled_rect, draw_line, flood_fill};
use hcie_protocol::Layer;
fn main() -> eframe::Result {
@@ -86,7 +86,8 @@ impl eframe::App for DrawTesterApp {
[self.layer.width as usize, self.layer.height as usize],
&self.layer.pixels,
);
self.texture = Some(ctx.load_texture("canvas_preview", color_image, Default::default()));
self.texture =
Some(ctx.load_texture("canvas_preview", color_image, Default::default()));
}
let texture = self.texture.as_ref().unwrap().clone();
@@ -99,22 +100,29 @@ impl eframe::App for DrawTesterApp {
ui.label("Click & drag to draw on this canvas:");
ui.add_space(5.0);
let image_widget = egui::Image::new((texture.id(), texture.size_vec2())).sense(egui::Sense::click_and_drag());
let image_widget = egui::Image::new((texture.id(), texture.size_vec2()))
.sense(egui::Sense::click_and_drag());
let resp = ui.add(image_widget);
if resp.dragged() || resp.clicked() {
if let Some(hover_pos) = resp.hover_pos() {
let rect = resp.rect;
let px = ((hover_pos.x - rect.min.x) / rect.width() * self.layer.width as f32).clamp(0.0, self.layer.width as f32 - 1.0);
let py = ((hover_pos.y - rect.min.y) / rect.height() * self.layer.height as f32).clamp(0.0, self.layer.height as f32 - 1.0);
let px = ((hover_pos.x - rect.min.x) / rect.width()
* self.layer.width as f32)
.clamp(0.0, self.layer.width as f32 - 1.0);
let py = ((hover_pos.y - rect.min.y) / rect.height()
* self.layer.height as f32)
.clamp(0.0, self.layer.height as f32 - 1.0);
match self.selected_tool {
"Line" => {
if let Some(last_pos) = self.last_drag_pos {
draw_line(
&mut self.layer,
last_pos.x, last_pos.y,
px, py,
last_pos.x,
last_pos.y,
px,
py,
self.current_color,
self.thickness,
None,
@@ -127,7 +135,8 @@ impl eframe::App for DrawTesterApp {
if resp.drag_started() || resp.clicked() {
draw_filled_circle(
&mut self.layer,
px, py,
px,
py,
self.thickness * 2.0,
self.current_color,
None,
@@ -140,8 +149,10 @@ impl eframe::App for DrawTesterApp {
let size = self.thickness * 3.0;
draw_filled_rect(
&mut self.layer,
px - size, py - size,
px + size, py + size,
px - size,
py - size,
px + size,
py + size,
self.current_color,
None,
);
@@ -152,7 +163,8 @@ impl eframe::App for DrawTesterApp {
if resp.clicked() {
flood_fill(
&mut self.layer,
px as u32, py as u32,
px as u32,
py as u32,
self.current_color,
20,
None,
@@ -195,7 +207,10 @@ impl eframe::App for DrawTesterApp {
self.current_color[2] as f32 / 255.0,
self.current_color[3] as f32 / 255.0,
];
if ui.color_edit_button_rgba_unmultiplied(&mut color_f32).changed() {
if ui
.color_edit_button_rgba_unmultiplied(&mut color_f32)
.changed()
{
self.current_color = [
(color_f32[0] * 255.0).round() as u8,
(color_f32[1] * 255.0).round() as u8,
@@ -213,7 +228,10 @@ impl eframe::App for DrawTesterApp {
ui.add_space(35.0);
ui.colored_label(egui::Color32::LIGHT_GREEN, "Layer-based drawing API");
ui.colored_label(egui::Color32::LIGHT_GREEN, "Decoupled from hcie-protocol");
ui.colored_label(egui::Color32::LIGHT_GREEN, "Operating with 100% GUI neutrality");
ui.colored_label(
egui::Color32::LIGHT_GREEN,
"Operating with 100% GUI neutrality",
);
});
});
});
+125 -42
View File
@@ -1,13 +1,15 @@
#![allow(dead_code)]
pub use hcie_protocol::{Layer, BlendMode};
pub use hcie_brush_engine::{BrushTip, BrushStyle};
use hcie_blend::blend_pixels;
use hcie_brush_engine::{brush_spacing_pixels, jitter_offset};
pub use hcie_brush_engine::{BrushStyle, BrushTip};
pub use hcie_protocol::{BlendMode, Layer};
pub fn draw_brush_stamp(
layer: &mut Layer,
cx: f32, cy: f32,
stamp: &[u8], stamp_diameter: usize,
cx: f32,
cy: f32,
stamp: &[u8],
stamp_diameter: usize,
color: [u8; 4],
mask: Option<&[u8]>,
) {
@@ -27,15 +29,21 @@ pub fn draw_brush_stamp(
if let Some(m) = mask {
let idx = (py_u * layer.width + px_u) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
if m.get(idx).copied().unwrap_or(0) == 0 {
continue;
}
}
let stamp_alpha = stamp[sy * stamp_diameter + sx] as f32 / 255.0;
if stamp_alpha <= 0.0 { continue; }
if stamp_alpha <= 0.0 {
continue;
}
let dst = layer.get_pixel(px_u, py_u);
let src_color: [u8; 4] = [
color[0], color[1], color[2],
color[0],
color[1],
color[2],
(color[3] as f32 * stamp_alpha).round() as u8,
];
let out = blend_pixels(dst, src_color, hcie_protocol::BlendMode::Normal.into(), 1.0);
@@ -54,7 +62,9 @@ pub fn draw_brush_stroke(
mut stroke_mask: Option<&mut [u8]>,
bg_pixels: Option<&[u8]>,
) {
if points.is_empty() { return; }
if points.is_empty() {
return;
}
let spacing = brush_spacing_pixels(tip.size, tip.spacing);
let use_color_variant = tip.color_variant && tip.variant_amount > 0.0;
@@ -89,13 +99,26 @@ pub fn draw_brush_stroke(
color
};
let first_px_color = [
first_color[0], first_color[1], first_color[2],
first_color[0],
first_color[1],
first_color[2],
(first_color[3] as f32 * first_opacity).round() as u8,
];
hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height,
first_x, first_y, first_size, tip.hardness, first_px_color, 1.0, is_eraser, mask,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
&mut layer.pixels,
layer.width,
layer.height,
first_x,
first_y,
first_size,
tip.hardness,
first_px_color,
1.0,
is_eraser,
mask,
stroke_mask.as_deref_mut(),
bg_pixels,
tip.opacity,
stamp_ref,
);
@@ -103,7 +126,9 @@ pub fn draw_brush_stroke(
let dx = p.0 - last_p.0;
let dy = p.1 - last_p.1;
let dist = (dx * dx + dy * dy).sqrt();
if dist == 0.0 { continue; }
if dist == 0.0 {
continue;
}
let steps = ((dist / spacing).max(1.0)).ceil() as i32;
let step_x = dx / steps as f32;
@@ -127,13 +152,26 @@ pub fn draw_brush_stroke(
color
};
let px_color = [
dab_color[0], dab_color[1], dab_color[2],
dab_color[0],
dab_color[1],
dab_color[2],
(dab_color[3] as f32 * pressure_opacity).round() as u8,
];
hcie_brush_engine::draw_dab_capped_with_stamp(
&mut layer.pixels, layer.width, layer.height,
final_x, final_y, effective_size, tip.hardness, px_color, 1.0, is_eraser, mask,
stroke_mask.as_deref_mut(), bg_pixels, tip.opacity,
&mut layer.pixels,
layer.width,
layer.height,
final_x,
final_y,
effective_size,
tip.hardness,
px_color,
1.0,
is_eraser,
mask,
stroke_mask.as_deref_mut(),
bg_pixels,
tip.opacity,
stamp_ref,
);
}
@@ -144,7 +182,10 @@ pub fn draw_brush_stroke(
pub fn draw_line(
layer: &mut Layer,
x0: f32, y0: f32, x1: f32, y1: f32,
x0: f32,
y0: f32,
x1: f32,
y1: f32,
color: [u8; 4],
width: f32,
mask: Option<&[u8]>,
@@ -152,7 +193,9 @@ pub fn draw_line(
let dx = x1 - x0;
let dy = y1 - y0;
let dist = (dx * dx + dy * dy).sqrt();
if dist == 0.0 { return; }
if dist == 0.0 {
return;
}
let steps = (dist * 2.0).max(1.0).ceil() as i32;
for i in 0..=steps {
@@ -165,29 +208,44 @@ pub fn draw_line(
pub fn draw_filled_circle(
layer: &mut Layer,
cx: f32, cy: f32, r: f32,
cx: f32,
cy: f32,
r: f32,
color: [u8; 4],
mask: Option<&[u8]>,
) {
let r_i = r.ceil() as i32;
for dy in -r_i..=r_i {
let py = (cy + dy as f32).round() as i32;
if py < 0 || py >= layer.height as i32 { continue; }
if py < 0 || py >= layer.height as i32 {
continue;
}
let dx_max = (r * r - dy as f32 * dy as f32).sqrt();
let dx_i = dx_max.ceil() as i32;
for dx in -dx_i..=dx_i {
let px = (cx + dx as f32).round() as i32;
if px < 0 || px >= layer.width as i32 { continue; }
if px < 0 || px >= layer.width as i32 {
continue;
}
if let Some(m) = mask {
let idx = (py as u32 * layer.width + px as u32) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
if m.get(idx).copied().unwrap_or(0) == 0 {
continue;
}
}
let dist = ((dx as f32).powi(2) + (dy as f32).powi(2)).sqrt();
let alpha_t = 1.0 - (dist - (r - 1.0)).max(0.0).min(1.0);
if alpha_t <= 0.0 { continue; }
let src = [color[0], color[1], color[2], (color[3] as f32 * alpha_t).round() as u8];
if alpha_t <= 0.0 {
continue;
}
let src = [
color[0],
color[1],
color[2],
(color[3] as f32 * alpha_t).round() as u8,
];
let dst = layer.get_pixel(px as u32, py as u32);
let out = blend_pixels(dst, src, hcie_protocol::BlendMode::Normal.into(), 1.0);
layer.set_pixel(px as u32, py as u32, out);
@@ -197,20 +255,25 @@ pub fn draw_filled_circle(
pub fn draw_filled_rect(
layer: &mut Layer,
x1: f32, y1: f32, x2: f32, y2: f32,
x1: f32,
y1: f32,
x2: f32,
y2: f32,
color: [u8; 4],
mask: Option<&[u8]>,
) {
let x_start = x1.min(x2).max(0.0).floor() as u32;
let x_end = x1.max(x2).min(layer.width as f32).ceil() as u32;
let x_end = x1.max(x2).min(layer.width as f32).ceil() as u32;
let y_start = y1.min(y2).max(0.0).floor() as u32;
let y_end = y1.max(y2).min(layer.height as f32).ceil() as u32;
let y_end = y1.max(y2).min(layer.height as f32).ceil() as u32;
for y in y_start..y_end {
for x in x_start..x_end {
if let Some(m) = mask {
let idx = (y * layer.width + x) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
if m.get(idx).copied().unwrap_or(0) == 0 {
continue;
}
}
let dst = layer.get_pixel(x, y);
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0);
@@ -221,14 +284,17 @@ pub fn draw_filled_rect(
pub fn draw_filled_ellipse(
layer: &mut Layer,
cx: f32, cy: f32, rx: f32, ry: f32,
cx: f32,
cy: f32,
rx: f32,
ry: f32,
color: [u8; 4],
mask: Option<&[u8]>,
) {
let x_start = (cx - rx).max(0.0).floor() as u32;
let x_end = (cx + rx).min(layer.width as f32).ceil() as u32;
let x_end = (cx + rx).min(layer.width as f32).ceil() as u32;
let y_start = (cy - ry).max(0.0).floor() as u32;
let y_end = (cy + ry).min(layer.height as f32).ceil() as u32;
let y_end = (cy + ry).min(layer.height as f32).ceil() as u32;
for y in y_start..y_end {
for x in x_start..x_end {
@@ -238,7 +304,9 @@ pub fn draw_filled_ellipse(
if dist <= 1.0 {
if let Some(m) = mask {
let idx = (y * layer.width + x) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
if m.get(idx).copied().unwrap_or(0) == 0 {
continue;
}
}
let dst = layer.get_pixel(x, y);
let out = blend_pixels(dst, color, hcie_protocol::BlendMode::Normal.into(), 1.0);
@@ -250,14 +318,17 @@ pub fn draw_filled_ellipse(
pub fn flood_fill(
layer: &mut Layer,
x: u32, y: u32,
x: u32,
y: u32,
fill_color: [u8; 4],
tolerance: u8,
mask: Option<&[u8]>,
) {
if let Some(m) = mask {
let idx = (y * layer.width + x) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { return; }
if m.get(idx).copied().unwrap_or(0) == 0 {
return;
}
}
let start_color = layer.get_pixel(x, y);
@@ -271,19 +342,31 @@ pub fn flood_fill(
while let Some((px, py)) = stack.pop() {
let c = layer.get_pixel(px, py);
if !color_match(&c, &start_color, tolerance) { continue; }
if !color_match(&c, &start_color, tolerance) {
continue;
}
if let Some(m) = mask {
let idx = (py * w + px) as usize;
if m.get(idx).copied().unwrap_or(0) == 0 { continue; }
if m.get(idx).copied().unwrap_or(0) == 0 {
continue;
}
}
layer.set_pixel(px, py, fill_color);
if px > 0 { stack.push((px - 1, py)); }
if px + 1 < w { stack.push((px + 1, py)); }
if py > 0 { stack.push((px, py - 1)); }
if py + 1 < h { stack.push((px, py + 1)); }
if px > 0 {
stack.push((px - 1, py));
}
if px + 1 < w {
stack.push((px + 1, py));
}
if py > 0 {
stack.push((px, py - 1));
}
if py + 1 < h {
stack.push((px, py + 1));
}
}
}
@@ -294,4 +377,4 @@ fn color_match(a: &[u8; 4], b: &[u8; 4], tolerance: u8) -> bool {
let diff_b = (a[2] as i32 - b[2] as i32).abs();
let diff_a = (a[3] as i32 - b[3] as i32).abs();
diff_r <= tol && diff_g <= tol && diff_b <= tol && diff_a <= tol
}
}
+19 -2
View File
@@ -37,7 +37,16 @@ fn test_draw_filled_ellipse() {
#[test]
fn test_draw_line() {
let mut layer = Layer::new_transparent("test", 10, 10);
hcie_draw::draw_line(&mut layer, 1.0, 5.0, 8.0, 5.0, [255, 255, 0, 255], 1.0, None);
hcie_draw::draw_line(
&mut layer,
1.0,
5.0,
8.0,
5.0,
[255, 255, 0, 255],
1.0,
None,
);
let px = layer.get_pixel(5, 5);
assert_eq!(px[0], 255, "line pixel should be yellow");
assert_eq!(px[1], 255);
@@ -56,7 +65,15 @@ fn test_draw_with_mask_restricts_pixels() {
let mut layer = Layer::new_transparent("test", 10, 10);
let mut mask = vec![0u8; 100];
mask[0] = 255;
hcie_draw::draw_filled_rect(&mut layer, 0.0, 0.0, 9.0, 9.0, [255, 0, 0, 255], Some(&mask));
hcie_draw::draw_filled_rect(
&mut layer,
0.0,
0.0,
9.0,
9.0,
[255, 0, 0, 255],
Some(&mask),
);
let in_mask = layer.get_pixel(0, 0);
assert_eq!(in_mask[0], 255, "masked pixel should be red");
let out_mask = layer.get_pixel(5, 5);