mirror of
https://github.com/storytold/craft-libs.git
synced 2026-10-31 15:49:54 +00:00
447 lines
21 KiB
Rust
447 lines
21 KiB
Rust
//! Content-bearing offscreen acceptance of the shared docking surface. No OS windows are opened.
|
|
//! Set CRAFT_UI_DOCK_VISUAL_DIR to retain every stage of the pointer-driven workflow.
|
|
|
|
use std::{collections::BTreeMap, path::PathBuf};
|
|
|
|
use craft_ui::{
|
|
docking::{Action, DockArea, DockStyle, Floating, Layout, Node, Permissions},
|
|
layout::{SplitAxis, SplitSize},
|
|
};
|
|
use egui::{Color32, Context, Event, FullOutput, Id, Modifiers, PointerButton, Pos2, RawInput, Rect, Stroke, pos2, vec2};
|
|
use egui_kittest::{TestRenderer, wgpu::WgpuTestRenderer};
|
|
|
|
const CANVAS: u32 = 0;
|
|
const LAYERS: u32 = 1;
|
|
const PROPERTIES: u32 = 2;
|
|
const BRUSHES: u32 = 3;
|
|
|
|
fn label(panel: &u32) -> String {
|
|
match *panel {
|
|
CANVAS => "Document",
|
|
LAYERS => "Layers",
|
|
PROPERTIES => "Properties",
|
|
BRUSHES => "Brushes",
|
|
_ => "Unknown",
|
|
}
|
|
.into()
|
|
}
|
|
|
|
struct Harness {
|
|
ctx: Context,
|
|
renderer: WgpuTestRenderer,
|
|
layout: Layout<u32>,
|
|
output: FullOutput,
|
|
width: f32,
|
|
frame: usize,
|
|
values: BTreeMap<u32, String>,
|
|
body_ids: BTreeMap<u32, Id>,
|
|
edit_ids: BTreeMap<u32, Id>,
|
|
applied: Vec<Action<u32>>,
|
|
}
|
|
|
|
impl Harness {
|
|
/// Create a workflow harness with the requested theme, width and pixel scale,
|
|
/// zero animation and no cursor blink, and a canvas plus side tabs and floating
|
|
/// brushes layout, then settle it.
|
|
fn new(dark: bool, width: f32, scale: f32) -> Self {
|
|
let ctx = Context::default();
|
|
ctx.set_theme(if dark { egui::Theme::Dark } else { egui::Theme::Light });
|
|
ctx.set_pixels_per_point(scale);
|
|
ctx.all_styles_mut(|style| {
|
|
style.animation_time = 0.0;
|
|
style.visuals.text_cursor.blink = false;
|
|
});
|
|
let layout = Layout {
|
|
root: Some(Node::Split {
|
|
axis: SplitAxis::Horizontal,
|
|
size: SplitSize::FixedSecond(240.0),
|
|
first: Box::new(Node::Tabs { panels: vec![CANVAS], active: 0 }),
|
|
second: Box::new(Node::Tabs { panels: vec![LAYERS, PROPERTIES], active: 0 }),
|
|
}),
|
|
floating: vec![Floating::single(vec![BRUSHES], 0, [38.0, 76.0, 210.0, 250.0])],
|
|
};
|
|
let mut harness = Self {
|
|
ctx,
|
|
renderer: WgpuTestRenderer::new(),
|
|
layout,
|
|
output: FullOutput::default(),
|
|
width,
|
|
frame: 0,
|
|
values: BTreeMap::from([
|
|
(BRUSHES, "Soft round 24 px".into()),
|
|
(PROPERTIES, "72% opacity".into()),
|
|
(LAYERS, "Foreground selected".into()),
|
|
]),
|
|
body_ids: BTreeMap::new(),
|
|
edit_ids: BTreeMap::new(),
|
|
applied: Vec::new(),
|
|
};
|
|
harness.settle();
|
|
harness
|
|
}
|
|
|
|
/// One 520 pt-high frame through the real wgpu test renderer. Applies returned actions with
|
|
/// canvas protection (round-tripping them through JSON when `serde` is enabled), validates the
|
|
/// layout, and records body ids and applied actions.
|
|
fn step(&mut self, events: Vec<Event>) {
|
|
self.frame += 1;
|
|
let input = RawInput {
|
|
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 520.0))),
|
|
time: Some(self.frame as f64 / 60.0),
|
|
events,
|
|
focused: true,
|
|
max_texture_side: Some(4096),
|
|
..Default::default()
|
|
};
|
|
let mut actions = Vec::new();
|
|
self.output = self.ctx.run_ui(input, |ui| {
|
|
let style = DockStyle::from_ui(ui);
|
|
let out = DockArea::new(Id::unique("workflow")).show(
|
|
ui,
|
|
&self.layout,
|
|
&style,
|
|
label,
|
|
|panel| if *panel == CANVAS { Permissions::PROTECTED } else { Permissions::default() },
|
|
|ui, panel| {
|
|
self.body_ids.insert(*panel, ui.scope_id());
|
|
if *panel == CANVAS {
|
|
let rect = ui.available_rect_before_wrap();
|
|
ui.painter().rect_filled(rect, 0.0, Color32::from_rgb(44, 53, 66));
|
|
let page = rect.shrink(24.0);
|
|
if page.is_positive() {
|
|
ui.painter().rect_filled(page, 1.0, Color32::from_rgb(243, 239, 225));
|
|
ui.painter().text(
|
|
page.min + vec2(12.0, 28.0),
|
|
egui::Align2::LEFT_CENTER,
|
|
"SYNTHETIC LAYOUT",
|
|
egui::FontId::proportional(18.0),
|
|
Color32::from_rgb(26, 37, 57),
|
|
);
|
|
for row in 0..5 {
|
|
let y = page.top() + 68.0 + row as f32 * 24.0;
|
|
ui.painter().line_segment(
|
|
[pos2(page.left() + 12.0, y), pos2(page.right() - 12.0, y)],
|
|
Stroke::new(4.0, Color32::from_rgb(122, 138, 151)),
|
|
);
|
|
}
|
|
let art = Rect::from_min_max(page.min + vec2(12.0, 220.0), page.max - vec2(12.0, 20.0));
|
|
if art.is_positive() {
|
|
ui.painter().rect_filled(art, 6.0, Color32::from_rgb(94, 143, 139));
|
|
}
|
|
}
|
|
} else {
|
|
egui::ScrollArea::vertical().id_salt(("content-scroll", panel)).show(ui, |ui| {
|
|
ui.add_space(8.0);
|
|
ui.heading(label(panel));
|
|
if let Some(value) = self.values.get_mut(panel) {
|
|
let response = ui.text_edit_singleline(value);
|
|
self.edit_ids.insert(*panel, response.id);
|
|
}
|
|
ui.separator();
|
|
if *panel == BRUSHES {
|
|
for row in 1..=6 {
|
|
let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), egui::Sense::hover());
|
|
ui.painter().circle_filled(rect.left_center() + vec2(14.0, 0.0), row as f32 + 2.0, ui.visuals().text_color());
|
|
ui.painter().text(
|
|
rect.left_center() + vec2(32.0, 0.0),
|
|
egui::Align2::LEFT_CENTER,
|
|
format!("Brush preset {row}"),
|
|
egui::FontId::proportional(12.0),
|
|
ui.visuals().text_color(),
|
|
);
|
|
}
|
|
} else if *panel == LAYERS {
|
|
for text in ["Foreground", "Title", "Shapes", "Background"] {
|
|
ui.label(text);
|
|
}
|
|
} else {
|
|
ui.label("Width: 320 px");
|
|
ui.label("Height: 180 px");
|
|
ui.label("Blend: Normal");
|
|
ui.add(egui::ProgressBar::new(0.72).text("Opacity 72%"));
|
|
}
|
|
});
|
|
}
|
|
},
|
|
);
|
|
assert!(out.error.is_none(), "{:?}", out.error);
|
|
actions = out.actions;
|
|
});
|
|
self.renderer.handle_delta(&mut self.output.textures_delta);
|
|
for action in actions {
|
|
// Apps send renderer actions through their JSON command boundary before applying.
|
|
#[cfg(feature = "serde")]
|
|
let action: Action<u32> = serde_json::from_value(serde_json::to_value(&action).unwrap()).unwrap();
|
|
self.layout
|
|
.apply_with_permissions(action.clone(), |panel| if *panel == CANVAS { Permissions::PROTECTED } else { Permissions::default() })
|
|
.unwrap();
|
|
self.applied.push(action);
|
|
}
|
|
self.layout.validate().unwrap();
|
|
}
|
|
|
|
/// Run four empty workflow frames to settle sizing.
|
|
fn settle(&mut self) {
|
|
for _ in 0..4 {
|
|
self.step(Vec::new());
|
|
}
|
|
}
|
|
|
|
/// The response rect of `panel`'s tab in the workflow dock.
|
|
fn tab(&self, panel: u32) -> Rect {
|
|
self.ctx.read_response(Id::unique("workflow").with(("tab", &panel))).unwrap().rect
|
|
}
|
|
|
|
fn drag(&mut self, from: Pos2, to: Pos2) {
|
|
let pointer = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
|
self.step(vec![Event::PointerMoved(from), pointer(from, true)]);
|
|
self.step(vec![Event::PointerMoved(from + vec2(8.0, 0.0))]);
|
|
self.step(vec![Event::PointerMoved(to)]);
|
|
self.step(vec![pointer(to, false)]);
|
|
self.settle();
|
|
}
|
|
|
|
fn edit_brush_value(&mut self) {
|
|
let point = self.ctx.read_response(self.edit_ids[&BRUSHES]).unwrap().rect.center();
|
|
let pointer = |pressed| Event::PointerButton { pos: point, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
|
|
self.step(vec![Event::PointerMoved(point), pointer(true)]);
|
|
self.step(vec![pointer(false)]);
|
|
self.step(vec![
|
|
Event::Key { key: egui::Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND },
|
|
Event::Text("Edited soft round 36 px".into()),
|
|
]);
|
|
self.step(vec![Event::Key { key: egui::Key::A, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::COMMAND }]);
|
|
assert_eq!(self.values[&BRUSHES], "Edited soft round 36 px");
|
|
}
|
|
|
|
fn capture(&mut self, directory: &Option<PathBuf>, name: &str) {
|
|
if let Some(directory) = directory {
|
|
std::fs::create_dir_all(directory).unwrap();
|
|
let image = self.renderer.render(&self.ctx, &self.output).unwrap();
|
|
image.save(directory.join(format!("{name}.png"))).unwrap();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn floating_brushes_merge_detach_and_redock_through_real_pointer_input() {
|
|
let output = std::env::var_os("CRAFT_UI_DOCK_VISUAL_DIR").map(PathBuf::from);
|
|
for dark in [true, false] {
|
|
for width in [640.0, 1000.0] {
|
|
for scale in [1.0, 2.0] {
|
|
let name = format!("{}-{}-{}x", if dark { "dark" } else { "light" }, width as u32, scale as u32);
|
|
let directory = output.as_ref().map(|p| p.join(&name));
|
|
let mut h = Harness::new(dark, width, scale);
|
|
h.edit_brush_value();
|
|
h.settle();
|
|
h.capture(&directory, "01-floating-brushes");
|
|
let original_values = h.values.clone();
|
|
let brush_id = h.body_ids[&BRUSHES];
|
|
h.drag(h.tab(BRUSHES).center(), h.tab(PROPERTIES).left_center() + vec2(4.0, 0.0));
|
|
assert!(h.applied.iter().any(|a| matches!(a, Action::Move { panel: BRUSHES, .. })), "{name}: {:?}", h.applied);
|
|
assert!(h.layout.floating.is_empty(), "{name}");
|
|
assert_eq!(h.body_ids[&BRUSHES], brush_id);
|
|
h.capture(&directory, "02-merged-main-tabs");
|
|
h.drag(h.tab(BRUSHES).center(), pos2((width - 240.0) * 0.5, 270.0));
|
|
assert!(h.layout.floating.iter().any(|g| g.contains(&BRUSHES)), "{name}: {:?}", h.applied);
|
|
h.capture(&directory, "03-detached-again");
|
|
h.drag(h.tab(BRUSHES).center(), h.tab(LAYERS).left_center() + vec2(4.0, 0.0));
|
|
assert!(h.layout.floating.is_empty(), "{name}");
|
|
assert_eq!(h.body_ids[&BRUSHES], brush_id);
|
|
assert_eq!(h.values, original_values);
|
|
assert_eq!(h.layout.panels().len(), 4);
|
|
h.capture(&directory, "04-redocked");
|
|
if let Some(directory) = &directory {
|
|
std::fs::write(directory.join("actions.txt"), format!("{:#?}\n", h.applied)).unwrap();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Each app's saved v1 layout goes through a docking session driven by real pointer input and a
|
|
/// command, then writes and reads back unchanged.
|
|
#[cfg(all(feature = "serde", feature = "testing"))]
|
|
mod v1_fixtures {
|
|
use craft_ui::docking::{DockCommand, DockContent, Hit, Layout, Node, Permissions, ResizeEdge, Side, testing::DockDriver};
|
|
use egui::{Pos2, Rect, pos2};
|
|
use serde_json::{Value, json};
|
|
|
|
struct Content;
|
|
|
|
impl DockContent<String> for Content {
|
|
fn body(&mut self, ui: &mut egui::Ui, panel: &String) {
|
|
ui.label(panel.as_str());
|
|
}
|
|
fn logical_label(&self, panel: &String) -> String {
|
|
panel.clone()
|
|
}
|
|
fn permissions(&self, panel: &String) -> Permissions {
|
|
if panel == "canvas" { Permissions::PROTECTED } else { Permissions::default() }
|
|
}
|
|
}
|
|
|
|
type Driver = DockDriver<String, Content>;
|
|
type Columns = Vec<Vec<Vec<String>>>;
|
|
|
|
/// App, file contents and the JSON pointer to a saved layout.
|
|
const FIXTURES: [(&str, &str, &str); 10] = [
|
|
("designcraft", include_str!("fixtures/v1/designcraft.json"), "/docking"),
|
|
("designcraft", include_str!("fixtures/v1/designcraft.json"), "/customWorkspaces/0/docking"),
|
|
("vectorcraft", include_str!("fixtures/v1/vectorcraft.json"), "/docking"),
|
|
("lightcraft", include_str!("fixtures/v1/lightcraft.json"), "/docking/layout"),
|
|
("pdfcraft", include_str!("fixtures/v1/pdfcraft.json"), "/docking/layout"),
|
|
("cadcraft", include_str!("fixtures/v1/cadcraft.json"), "/workspace/docking/layout"),
|
|
("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/docking/layout"),
|
|
("soundcraft", include_str!("fixtures/v1/soundcraft.json"), "/mix_docking/layout"),
|
|
("photocraft", include_str!("fixtures/v1/photocraft.json"), "/docking"),
|
|
("photocraft", include_str!("fixtures/v1/photocraft.json"), "/panel_layout/docking"),
|
|
];
|
|
|
|
/// The start dock's columns, each a list of groups.
|
|
fn start_columns(layout: &Layout<String>) -> Columns {
|
|
fn find(node: &Node<String>) -> Option<Columns> {
|
|
match node {
|
|
Node::Dock { side: Side::Start, columns } => {
|
|
Some(columns.iter().map(|column| column.groups.iter().map(|group| group.panels.clone()).collect()).collect())
|
|
}
|
|
Node::Split { first, second, .. } => find(first).or_else(|| find(second)),
|
|
_ => None,
|
|
}
|
|
}
|
|
layout.root.as_ref().and_then(find).unwrap_or_default()
|
|
}
|
|
|
|
fn iconic(layout: &Layout<String>) -> bool {
|
|
fn find(node: &Node<String>) -> Option<bool> {
|
|
match node {
|
|
Node::Dock { side: Side::Start, columns } => columns.first().map(|column| column.iconic),
|
|
Node::Split { first, second, .. } => find(first).or_else(|| find(second)),
|
|
_ => None,
|
|
}
|
|
}
|
|
layout.root.as_ref().and_then(find).unwrap_or(false)
|
|
}
|
|
|
|
fn hit(d: &Driver, hit: Hit<String>) -> Rect {
|
|
d.hit(&hit).unwrap_or_else(|| panic!("{hit:?} in {:?}", d.hits()))
|
|
}
|
|
|
|
/// A point of `from` that no floating window above it covers, as drawn last frame.
|
|
fn grab(d: &Driver, from: &Hit<String>, panel: &String) -> Option<Pos2> {
|
|
let rect = d.hit(from)?;
|
|
let own = d.layout.floating.iter().position(|w| w.contains(panel));
|
|
let windows: Vec<Rect> = d
|
|
.layout
|
|
.floating
|
|
.iter()
|
|
.filter_map(|w| {
|
|
let first = w.panels().next()?.clone();
|
|
Some(Rect::from_min_max(d.hit(&Hit::Title(first.clone()))?.min, d.hit(&Hit::Resize(first, ResizeEdge::BottomRight))?.max))
|
|
})
|
|
.collect();
|
|
let covered = |p: Pos2| windows.iter().enumerate().any(|(i, w)| own.is_none_or(|own| i > own) && w.contains(p));
|
|
let mut x = rect.left() + 4.0;
|
|
while x < rect.right() {
|
|
let p = pos2(x, rect.center().y);
|
|
if !covered(p) {
|
|
return Some(p);
|
|
}
|
|
x += 4.0;
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Drag from an uncovered point of `from` to `to`; something must change.
|
|
fn drag(d: &mut Driver, from: Hit<String>, to: Pos2, what: &str) {
|
|
let panel = match &from {
|
|
Hit::DragBar(p) | Hit::Title(p) => p.clone(),
|
|
other => panic!("{other:?}"),
|
|
};
|
|
let at = grab(d, &from, &panel).unwrap_or_else(|| panic!("{what}: {from:?} is covered"));
|
|
let out = d.drag(at, to);
|
|
assert!(out.error.is_none() && !out.actions.is_empty(), "{what}: {out:?}\n{:?}\n{:?}", d.layout, d.hits());
|
|
d.settle();
|
|
}
|
|
|
|
fn sorted(layout: &Layout<String>) -> Vec<String> {
|
|
let mut panels: Vec<String> = layout.panels().into_iter().cloned().collect();
|
|
panels.sort();
|
|
panels
|
|
}
|
|
|
|
#[test]
|
|
fn v1_layouts_dock_collapse_float_redock_and_reload() {
|
|
let mut seams = Vec::new();
|
|
for (app, text, pointer) in FIXTURES {
|
|
let at = format!("{app}{pointer}");
|
|
let file: Value = serde_json::from_str(text).unwrap();
|
|
let layout: Layout<String> = serde_json::from_value(file.pointer(pointer).unwrap().clone()).unwrap_or_else(|e| panic!("{at}: {e}"));
|
|
let panels = sorted(&layout);
|
|
let mut d = Driver::new(layout, Content);
|
|
let mut bars: Vec<String> = Vec::new();
|
|
for (h, _) in d.hits() {
|
|
if let Hit::DragBar(panel) = h
|
|
&& !bars.contains(&panel)
|
|
{
|
|
bars.push(panel);
|
|
}
|
|
}
|
|
assert!(bars.len() >= 2, "{at}: two movable groups in {bars:?}");
|
|
|
|
// The first group's bar dropped on the window's start edge makes a dock.
|
|
drag(&mut d, Hit::DragBar(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: new dock"));
|
|
let columns = start_columns(&d.layout);
|
|
assert!(columns.len() == 1 && columns[0].len() == 1 && columns[0][0][0] == bars[0], "{at}: {columns:?}");
|
|
|
|
// The second group stacks below it, and a third goes into the seam between them.
|
|
let first = hit(&d, Hit::Tab(bars[0].clone()));
|
|
let bottom = d.area.bottom();
|
|
drag(&mut d, Hit::DragBar(bars[1].clone()), pos2(first.center().x, bottom - 10.0), &format!("{at}: stack below"));
|
|
let third = bars[2..].iter().find(|p| grab(&d, &Hit::DragBar((*p).clone()), p).is_some()).cloned();
|
|
if let Some(third) = &third {
|
|
seams.push(at.clone());
|
|
let second = hit(&d, Hit::Tab(bars[1].clone()));
|
|
drag(&mut d, Hit::DragBar(third.clone()), pos2(second.center().x, second.top() + 2.0), &format!("{at}: seam"));
|
|
}
|
|
let columns = start_columns(&d.layout);
|
|
let heads: Vec<&String> = columns[0].iter().map(|group| &group[0]).collect();
|
|
let expected: Vec<&String> = match &third {
|
|
Some(third) => vec![&bars[0], third, &bars[1]],
|
|
None => vec![&bars[0], &bars[1]],
|
|
};
|
|
assert_eq!((columns.len(), heads), (1, expected), "{at}");
|
|
|
|
// Collapse the column to icons and expand it again.
|
|
d.click(hit(&d, Hit::Collapse(bars[0].clone())).center());
|
|
d.settle();
|
|
assert!(iconic(&d.layout), "{at}: collapsed");
|
|
assert!(d.hit(&Hit::Icon(bars[0].clone())).is_some(), "{at}: icons");
|
|
d.click(hit(&d, Hit::Collapse(bars[0].clone())).center());
|
|
d.settle();
|
|
assert!(!iconic(&d.layout), "{at}: expanded");
|
|
|
|
// Float the column by command, then drag its window back to the start edge.
|
|
let float: DockCommand<String> =
|
|
serde_json::from_value(json!({"command": "window.panel.float", "panel": bars[0], "column": true})).unwrap();
|
|
let space = craft_ui::docking::float_space(&d.ctx, d.id).unwrap();
|
|
let actions =
|
|
float.to_actions_with(&craft_ui::docking::Workspace::new(d.layout.clone()), false, &space, |p| d.content.permissions(p)).unwrap();
|
|
d.layout.apply_all(actions, |p| d.content.permissions(p)).unwrap_or_else(|e| panic!("{at}: {e}"));
|
|
d.settle();
|
|
assert!(start_columns(&d.layout).is_empty(), "{at}: floated");
|
|
drag(&mut d, Hit::Title(bars[0].clone()), pos2(2.0, 300.0), &format!("{at}: dock back"));
|
|
assert_eq!(start_columns(&d.layout), columns, "{at}: docked back");
|
|
|
|
// Nothing was lost, and the layout reads back as written.
|
|
assert_eq!(sorted(&d.layout), panels, "{at}");
|
|
let written = serde_json::to_string(&d.layout).unwrap();
|
|
let read: Layout<String> = serde_json::from_str(&written).unwrap();
|
|
read.validate().unwrap_or_else(|e| panic!("{at}: {e}"));
|
|
assert_eq!(read, d.layout, "{at}");
|
|
}
|
|
// The fixtures with a third movable group, uncovered, try the seam.
|
|
assert_eq!(seams.len(), 6, "{seams:?}");
|
|
}
|
|
}
|