UI.8: preserve application docking chrome and group gestures

This commit is contained in:
skylar taylor-barrick
2026-10-10 10:47:52 -06:00
parent fc77f4cc7f
commit f23c9f9c4a
5 changed files with 424 additions and 28 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
embedded SVG catalogs; `buttons` paints accessible icon buttons; `tabs` handles tab fitting and
interaction; `layout` provides toolbar fitting, split geometry and resize handles; `theme` shares widget-state styling.
`docking` supplies transactional panel moves and an optional docking surface.
Adapters cover all twelve Rust Craft apps; the [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
The current delivery covers nine creative apps (DesignCraft, VectorCraft, PhotoCraft, FilmCraft, EffectCraft, CADCraft, PDFCraft, LightCraft and SoundCraft); prior basic and office adapters are separate. The [suite audit](../../docs/ui-components-audit.md#adoption-and-acceptance)
records their scope and acceptance limits. Assets, complete themes, localization and command handling
remain in each application. No icons, fonts or application engines are bundled.
+1 -1
View File
@@ -10,7 +10,7 @@ mod surface;
mod targets;
pub use model::{Action, DockError, Floating, Layout, Location, MAX_DEPTH, MAX_PANELS, Node, Permissions, Placement, StackEntry, Zone};
pub use surface::{DockArea, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
pub use surface::{DockArea, DockContent, DockOutput, DockStyle, PanelLimits, begin_drag, cancel_drag};
pub use targets::{DropProposal, DropTarget, drop_zone, resolve_drop, zone_rect};
#[cfg(test)]
+184 -26
View File
@@ -68,11 +68,15 @@ pub struct DockOutput<P> {
pub actions: Vec<Action<P>>,
pub error: Option<DockError>,
pub focused: Option<P>,
/// Whole-floating-group gesture identity; host may display its own group drop preview.
pub group_drag: Option<P>,
/// Whole-group release for host-owned atomic docking policy.
pub group_drop: Option<(P, Pos2)>,
}
impl<P> Default for DockOutput<P> {
fn default() -> Self {
Self { actions: Vec::new(), error: None, focused: None }
Self { actions: Vec::new(), error: None, focused: None, group_drag: None, group_drop: None }
}
}
@@ -111,6 +115,12 @@ struct Drag<P> {
grab: Vec2,
}
#[derive(Clone)]
struct GroupDrag<P> {
panel: P,
original: [f32; 4],
}
/// Start a docking drag from an application's existing tab/header. Use the same area ID when
/// switching from a legacy layout to [`DockArea`]. The model is untouched until a valid release.
pub fn begin_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id, panel: P, source_rect: Rect) -> bool {
@@ -127,21 +137,57 @@ pub fn cancel_drag<P: Clone + Send + Sync + 'static>(ctx: &Context, area_id: Id)
ctx.data_mut(|d| d.remove::<Drag<P>>(area_id.with("drag")));
}
/// Optional renderer for applications without an existing docking surface. Apps with custom
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
pub struct DockArea {
id: Id,
/// One mutable application adapter owns bodies, menus and shaped text, avoiding competing
/// mutable closures. Logical labels remain separate and are used for tab accessibility.
pub trait DockContent<P> {
fn body(&mut self, ui: &mut Ui, panel: &P);
fn label(&mut self, _ui: &Ui, _panel: &P, logical: &str, _font: &FontId, _color: Color32) -> egui::WidgetText {
logical.into()
}
fn panel_menu(&mut self, _ui: &mut Ui, _panel: &P) {}
fn group_header_width(&self, _panels: &[P], _active: &P, _floating: bool) -> f32 {
0.0
}
fn group_header(&mut self, _ui: &mut Ui, _panels: &[P], _active: &P, _floating: bool) {}
}
impl DockArea {
struct BodyContent<F>(F);
impl<P, F: FnMut(&mut Ui, &P)> DockContent<P> for BodyContent<F> {
fn body(&mut self, ui: &mut Ui, panel: &P) {
(self.0)(ui, panel);
}
}
/// Optional renderer for applications without an existing docking surface. Apps with custom
/// chrome can instead use the model, target resolver and resize/tab primitives independently.
pub struct DockArea<P = ()> {
id: Id,
activation: Option<P>,
blockers: Vec<Rect>,
}
impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
pub fn new(id: Id) -> Self {
Self { id }
Self { id, activation: None, blockers: Vec::new() }
}
/// Raise and focus a panel's owning floating area on this frame. Apply Activate through
/// the application's command path as well to select its tab.
pub fn raise_panel(mut self, panel: P) -> Self {
self.activation = Some(panel);
self
}
/// Host overlays that must reject docking gestures, including floating fallback.
pub fn blocked_rects(mut self, rects: &[Rect]) -> Self {
self.blockers = rects.to_vec();
self
}
/// Render a validated snapshot and return requested actions for the app's command handler.
/// No action is applied here. Panel contents get a global stable egui ID across moves.
/// Floating groups are egui areas inside this viewport; this does not create native windows.
pub fn show<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
pub fn show(
self,
ui: &mut Ui,
layout: &Layout<P>,
@@ -155,7 +201,7 @@ impl DockArea {
/// Like [`Self::show`], with per-panel size policies supplied by the application.
#[allow(clippy::too_many_arguments)]
pub fn show_with_limits<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(
pub fn show_with_limits(
self,
ui: &mut Ui,
layout: &Layout<P>,
@@ -163,7 +209,23 @@ impl DockArea {
label: impl Fn(&P) -> String,
permissions: impl Fn(&P) -> Permissions,
limits: impl Fn(&P) -> PanelLimits,
mut body: impl FnMut(&mut Ui, &P),
body: impl FnMut(&mut Ui, &P),
) -> DockOutput<P> {
self.show_customized(ui, layout, style, label, permissions, limits, &mut BodyContent(body))
}
/// Render with application-owned chrome and shaped labels. Header callbacks queue app
/// commands; structural changes are applied after rendering, like DockOutput actions.
#[allow(clippy::too_many_arguments)]
pub fn show_customized(
self,
ui: &mut Ui,
layout: &Layout<P>,
style: &DockStyle,
label: impl Fn(&P) -> String,
permissions: impl Fn(&P) -> Permissions,
limits: impl Fn(&P) -> PanelLimits,
content: &mut impl DockContent<P>,
) -> DockOutput<P> {
let rect = ui.available_rect_before_wrap();
if let Err(error) = layout.validate() {
@@ -180,7 +242,9 @@ impl DockArea {
label: &label,
permissions: &permissions,
limits: &limits,
body: &mut body,
content,
activation: self.activation,
blockers: self.blockers,
layout,
targets: Vec::new(),
output: DockOutput::default(),
@@ -190,6 +254,7 @@ impl DockArea {
ui.allocate_rect(rect, Sense::hover());
}
renderer.floating(ui.ctx());
renderer.finish_group_drag(ui.ctx());
renderer.finish_drag(ui.ctx());
renderer.output
}
@@ -201,7 +266,9 @@ struct Renderer<'a, P> {
label: &'a dyn Fn(&P) -> String,
permissions: &'a dyn Fn(&P) -> Permissions,
limits: &'a dyn Fn(&P) -> PanelLimits,
body: &'a mut dyn FnMut(&mut Ui, &P),
content: &'a mut dyn DockContent<P>,
activation: Option<P>,
blockers: Vec<Rect>,
layout: &'a Layout<P>,
targets: Vec<DropTarget<P>>,
output: DockOutput<P>,
@@ -381,7 +448,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let mut child =
ui.new_child(UiBuilder::new().id(self.id.with(("body", panel))).max_rect(rect).layout(egui::Layout::top_down(egui::Align::Min)));
child.set_clip_rect(rect.intersect(ui.clip_rect()));
(self.body)(&mut child, panel);
self.content.body(&mut child, panel);
if child.rect_contains_pointer(rect) && child.input(|i| i.pointer.primary_pressed()) {
self.output.focused = Some(panel.clone());
}
@@ -403,11 +470,58 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
});
return;
}
let strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
let mut strip = Rect::from_min_max(rect.min, pos2(rect.right(), (rect.top() + self.style.tab_height).min(rect.bottom())));
ui.painter().rect_filled(strip, 0.0, self.style.tab_background);
let widths: Vec<f32> =
panels.iter().map(|p| ui.painter().layout_no_wrap((self.label)(p), self.style.font.clone(), self.style.text).size().x + 24.0).collect();
let fit = tabs::fit(&widths, active, strip.width(), 40.0, 22.0);
if floating {
let grip = Rect::from_min_max(strip.min, pos2((strip.left() + 12.0).min(strip.right()), strip.bottom()));
let response = ui.interact(grip, self.id.with(("move-group", panels.first())), Sense::drag()).on_hover_cursor(egui::CursorIcon::Grab);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &self.style.move_label));
if panels.iter().all(|p| (self.permissions)(p).movable)
&& response.drag_started()
&& let Some(first) = panels.first()
{
ui.ctx().data_mut(|d| {
d.insert_temp(
self.id.with("group-drag"),
GroupDrag { panel: first.clone(), original: [rect.left(), rect.top(), rect.width(), rect.height()] },
)
});
}
if panels.iter().all(|p| (self.permissions)(p).movable)
&& response.dragged()
&& ui.ctx().data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))).is_some()
&& !ui.input(|i| i.key_pressed(Key::Escape) || !i.focused)
{
let moved = clamp_float(rect.translate(ui.input(|i| i.pointer.delta())), ui.ctx().content_rect());
if let Some(first) = panels.first() {
self.output
.actions
.push(Action::MoveFloating { panel: first.clone(), rect: [moved.left(), moved.top(), moved.width(), moved.height()] });
}
}
ui.painter().line_segment([grip.center() - vec2(0.0, 5.0), grip.center() + vec2(0.0, 5.0)], self.style.border);
strip.min.x = grip.right();
}
let requested = self.content.group_header_width(panels, selected, floating);
let header_width = if requested.is_finite() { requested.clamp(0.0, strip.width()) } else { 0.0 };
if header_width > 0.0 {
let header = Rect::from_min_max(pos2(strip.right() - header_width, strip.top()), strip.max);
let mut child = ui.new_child(UiBuilder::new().id(self.id.with(("header", selected))).max_rect(header));
child.set_clip_rect(header.intersect(ui.clip_rect()));
self.content.group_header(&mut child, panels, selected, floating);
}
let widths: Vec<f32> = panels
.iter()
.map(|p| {
self.content
.label(ui, p, &(self.label)(p), &self.style.font, self.style.text)
.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, self.style.font.clone())
.size()
.x
+ 24.0
})
.collect();
let fit = tabs::fit(&widths, active, (strip.width() - header_width).max(0.0), 40.0, 22.0);
let mut x = strip.left();
let mut targets = Vec::new();
for (index, width) in &fit.shown {
@@ -426,7 +540,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for index in &fit.overflow {
if let Some(panel) = panels.get(*index)
&& ui.button((self.label)(panel)).clicked()
&& self.menu_label(ui, panel).clicked()
{
self.output.actions.push(Action::Activate { panel: panel.clone() });
self.output.focused = Some(panel.clone());
@@ -454,22 +568,31 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.painter().rect_stroke(rect, 0.0, self.style.border, StrokeKind::Inside);
}
fn menu_label(&mut self, ui: &mut Ui, panel: &P) -> egui::Response {
let logical = (self.label)(panel);
let text = self.content.label(ui, panel, &logical, &self.style.font, self.style.text);
let response = ui.button(text);
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), &logical));
response
}
fn tab(&mut self, ui: &mut Ui, panel: &P, rect: Rect, active: bool, group: Rect) {
let policy = (self.permissions)(panel);
let label = (self.label)(panel);
let color = if active { self.style.text } else { self.style.inactive_text };
let galley = self.content.label(ui, panel, &label, &self.style.font, color).into_galley(
ui,
Some(egui::TextWrapMode::Extend),
f32::INFINITY,
self.style.font.clone(),
);
let response = tabs::Tab::new(self.id.with(("tab", panel)), &label, active)
.sense(if policy.movable { Sense::click_and_drag() } else { Sense::click() })
.focus_stroke(Stroke::new(1.0, self.style.accent))
.show_at(ui, rect, |ui, response| {
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
painter.rect_filled(rect, 0.0, if active || response.hovered() { self.style.active_background } else { self.style.tab_background });
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
&label,
self.style.font.clone(),
if active { self.style.text } else { self.style.inactive_text },
);
painter.galley(rect.center() - galley.size() * 0.5, galley.clone(), color);
});
if response.clicked() {
self.output.actions.push(Action::Activate { panel: panel.clone() });
@@ -479,6 +602,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
begin_drag(ui.ctx(), self.id, panel.clone(), group);
}
response.context_menu(|ui| {
self.content.panel_menu(ui, panel);
if policy.movable && policy.floatable && ui.button(&self.style.float_label).clicked() {
let rect = clamp_float(group.translate(vec2(24.0, 24.0)), ui.ctx().content_rect());
self.output.actions.push(Action::Float { panel: panel.clone(), rect: [rect.left(), rect.top(), rect.width(), rect.height()] });
@@ -488,7 +612,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ui.menu_button(&self.style.move_label, |ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
for target in self.layout.panels() {
if target != panel && (self.permissions)(target).accepts_tabs && ui.button((self.label)(target)).clicked() {
if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() {
self.output.actions.push(Action::Move {
panel: panel.clone(),
anchor: target.clone(),
@@ -525,6 +649,11 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let size = vec2(width, height).max(limits.min).min(limits.max);
let rect = clamp_float(Rect::from_min_size(pos2(x, y), size), screen);
let id = self.id.with(("floating", first));
if self.activation.as_ref().is_some_and(|panel| group.panels.contains(panel)) {
ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
ctx.memory_mut(|memory| memory.request_focus(self.id.with(("tab", self.activation.as_ref().unwrap_or(first)))));
self.output.focused = self.activation.clone();
}
egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).movable(false).constrain_to(screen).show(ctx, |ui| {
ui.set_min_size(rect.size());
let rect = Rect::from_min_size(ui.min_rect().min, rect.size());
@@ -570,6 +699,29 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
}
fn finish_group_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<GroupDrag<P>>(self.id.with("group-drag"))) else { return };
if !self.layout.floating.iter().any(|g| g.panels.contains(&drag.panel) && g.panels.iter().all(|p| (self.permissions)(p).movable)) {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
return;
}
let (escape, focused, down, released, pointer) =
ctx.input(|i| (i.key_pressed(Key::Escape), i.focused, i.pointer.primary_down(), i.pointer.primary_released(), i.pointer.interact_pos()));
if escape || !focused || !down {
ctx.data_mut(|d| d.remove::<GroupDrag<P>>(self.id.with("group-drag")));
if escape || !focused {
self.output.actions.push(Action::MoveFloating { panel: drag.panel, rect: drag.original });
} else if released
&& let Some(pointer) = pointer.filter(|p| p.is_finite())
&& !self.blockers.iter().any(|r| r.contains(pointer))
{
self.output.group_drop = Some((drag.panel, pointer));
}
} else if pointer.is_some_and(|p| !self.blockers.iter().any(|r| r.contains(p))) {
self.output.group_drag = Some(drag.panel);
}
}
fn finish_drag(&mut self, ctx: &Context) {
let Some(drag) = ctx.data(|d| d.get_temp::<Drag<P>>(self.id.with("drag"))) else { return };
let (pointer, down, released, focused) =
@@ -585,6 +737,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
}
return;
};
if self.blockers.iter().any(|rect| rect.contains(pointer)) {
if released {
cancel_drag::<P>(ctx, self.id);
}
return;
}
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
if released {
cancel_drag::<P>(ctx, self.id);
+210
View File
@@ -613,3 +613,213 @@ fn floating_only_surface_does_not_allocate_the_callers_document_region() {
.textures_delta
.clear();
}
#[test]
fn floating_group_grip_moves_every_member_while_tab_drag_detaches_one() {
let ctx = Context::default();
let mut layout = sample();
let original = layout.floating[0].clone();
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let start = pos2(86.0, 82.0);
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
let actions = step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]);
assert!(actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })));
assert_eq!(layout.floating[0].panels, original.panels);
assert_eq!(layout.floating[0].active, original.active);
assert_ne!(layout.floating[0].rect, original.rect);
step(&ctx, &mut layout, vec![pointer_button(start + vec2(50.0, 40.0), false)]);
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let tab = ctx.read_response(Id::new("test-area").with(("tab", &5u32))).unwrap().rect.center();
step(&ctx, &mut layout, vec![Event::PointerMoved(tab), pointer_button(tab, true)]);
step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(8.0, 0.0))]);
step(&ctx, &mut layout, vec![Event::PointerMoved(tab + vec2(100.0, 100.0))]);
let detached = step(&ctx, &mut layout, vec![pointer_button(tab + vec2(100.0, 100.0), false)]);
assert!(detached.iter().any(|a| matches!(a, Action::Float { panel: 5, .. })));
assert_eq!(layout.floating.len(), 2);
assert_eq!(layout.floating[0].panels, vec![6]);
}
struct CustomContent {
labels: Vec<String>,
headers: usize,
menus: usize,
bodies: usize,
}
impl DockContent<u32> for CustomContent {
fn body(&mut self, ui: &mut egui::Ui, _: &u32) {
self.bodies += 1;
ui.label("Editable content");
}
fn label(&mut self, ui: &egui::Ui, _: &u32, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText {
self.labels.push(logical.to_owned());
ui.painter().layout_no_wrap("Custom visual label".into(), font.clone(), color).into()
}
fn panel_menu(&mut self, ui: &mut egui::Ui, _: &u32) {
self.menus += 1;
ui.button("App menu").clicked();
}
fn group_header_width(&self, _: &[u32], _: &u32, _: bool) -> f32 {
40.0
}
fn group_header(&mut self, ui: &mut egui::Ui, _: &[u32], _: &u32, _: bool) {
self.headers += 1;
assert!(ui.max_rect().width() <= 40.0);
ui.label("⋮");
}
}
fn custom_frame(
ctx: &Context,
layout: &Layout<u32>,
content: &mut CustomContent,
events: Vec<Event>,
blockers: &[Rect],
raise: bool,
) -> DockOutput<u32> {
let mut out = DockOutput::default();
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| {
let style = DockStyle::from_ui(ui);
let mut area = DockArea::new(Id::new("test-area")).blocked_rects(blockers);
if raise {
area = area.raise_panel(5);
}
out = area.show_customized(ui, layout, &style, |p| format!("Logical {p}"), |_| Permissions::default(), |_| PanelLimits::default(), content);
})
.textures_delta
.clear();
out
}
#[test]
fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
let ctx = Context::default();
let layout = sample();
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
for _ in 0..2 {
custom_frame(&ctx, &layout, &mut content, vec![], &[], false);
}
let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], true);
assert_eq!(out.focused, Some(5));
assert!(out.actions.is_empty());
assert!(content.headers >= 3 && content.bodies >= 3);
assert!(content.labels.iter().any(|s| s == "Logical 5"));
let at = pos2(130.0, 82.0);
custom_frame(
&ctx,
&layout,
&mut content,
vec![Event::PointerMoved(at), Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }],
&[],
false,
);
custom_frame(
&ctx,
&layout,
&mut content,
vec![Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }],
&[],
false,
);
assert!(content.menus > 0);
}
#[test]
fn overlay_appearing_mid_drag_blocks_release_and_float_fallback() {
let ctx = Context::default();
let layout = sample();
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
let source = Rect::from_min_size(pos2(80.0, 70.0), vec2(300.0, 220.0));
custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(pos2(130.0, 82.0)), pointer_button(pos2(130.0, 82.0), true)], &[], false);
assert!(begin_drag(&ctx, Id::new("test-area"), 5, source));
let at = pos2(700.0, 400.0);
let blocker = Rect::from_min_size(pos2(650.0, 350.0), vec2(100.0, 100.0));
let out = custom_frame(&ctx, &layout, &mut content, vec![Event::PointerMoved(at), pointer_button(at, false)], &[blocker], false);
assert!(out.actions.is_empty());
let out = custom_frame(&ctx, &layout, &mut content, vec![], &[], false);
assert!(out.actions.is_empty());
}
#[test]
fn group_move_escape_restores_original_geometry() {
let ctx = Context::default();
let mut layout = sample();
let original = layout.floating[0].rect;
for _ in 0..2 {
step(&ctx, &mut layout, vec![]);
}
let start = pos2(86.0, 82.0);
step(&ctx, &mut layout, vec![Event::PointerMoved(start), pointer_button(start, true)]);
step(&ctx, &mut layout, vec![Event::PointerMoved(start + vec2(50.0, 40.0))]);
assert_ne!(layout.floating[0].rect, original);
step(
&ctx,
&mut layout,
vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
);
assert_eq!(layout.floating[0].rect, original);
}
#[test]
fn custom_headers_and_labels_render_at_small_and_large_scales() {
for scale in [0.75, 1.0, 1.5, 2.0] {
let ctx = Context::default();
ctx.set_pixels_per_point(scale);
let mut content = CustomContent { labels: vec![], headers: 0, menus: 0, bodies: 0 };
let layout = Layout { root: Some(tabs(&[1, 2, 3, 4, 5, 6], 5)), floating: vec![] };
let mut out = DockOutput::default();
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(120.0 * scale, 160.0))), ..Default::default() }, |ui| {
let style = DockStyle::from_ui(ui);
out = DockArea::new(Id::new("scaled")).show_customized(
ui,
&layout,
&style,
|p| format!("Logical {p}"),
|_| Permissions::default(),
|_| PanelLimits::default(),
&mut content,
);
})
.textures_delta
.clear();
assert!(out.error.is_none());
assert!(content.headers > 0 && content.bodies > 0);
assert!(content.labels.iter().any(|s| s == "Logical 6"));
}
}
#[test]
fn protected_group_member_blocks_whole_group_gesture_and_host_drop() {
let ctx = Context::default();
let layout = sample();
let start = pos2(86.0, 82.0);
for events in [
vec![],
vec![],
vec![Event::PointerMoved(start), pointer_button(start, true)],
vec![Event::PointerMoved(pos2(300.0, 300.0))],
vec![pointer_button(pos2(300.0, 300.0), false)],
] {
let mut out = DockOutput::default();
ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), events, ..Default::default() }, |ui| {
let style = DockStyle::from_ui(ui);
out = DockArea::new(Id::new("test-area")).show(
ui,
&layout,
&style,
|p| format!("Panel {p}"),
|p| if *p == 6 { Permissions::PROTECTED } else { Permissions::default() },
|ui, _| {
ui.label("Body");
},
);
})
.textures_delta
.clear();
assert!(out.group_drag.is_none() && out.group_drop.is_none());
assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveFloating { .. })));
}
}
+28
View File
@@ -109,3 +109,31 @@ CRAFT_UI_DOCK_VISUAL_DIR=/absolute/output/path cargo test -p craft-ui --test doc
These are offscreen egui checks. An adopting application still needs command, content, workspace
reload, theme and input tests using its own renderer and persistence adapter.
## Application chrome and activation
`DockArea::show_customized` accepts one mutable `DockContent<P>` adapter, with body,
panel-menu, group-header and label methods. Apps queue commands in these callbacks and
apply them after rendering the readonly snapshot. `group_header_width` reserves a bounded
trailing portion of the existing tab strip; its child UI is clipped to that region.
Collapsed accordion entries still have panel menus; collapsing tab groups is host policy.
`label` returns `WidgetText`, including an application-shaped galley, used for tab width,
painting and menu labels. The separate logical label callback supplies tab accessibility
text. Original `show` and `show_with_limits` call sites remain supported.
Apply `Action::Activate` in the host, then use `DockArea::raise_panel(panel)` on the next
frame to raise the owning in-viewport floating layer and focus the panel tab. This request
does not mutate the serialized layout or persist z-order. Floating area identity still
derives from the first panel; native-window identity remains host-owned.
Floating groups have a distinct 12-point grip in the existing tab strip. Moving it emits
`MoveFloating` for the whole group; dragging an individual tab detaches just that panel.
`DockOutput::group_drag` and `group_drop` expose the group identity and release position
for app-owned atomic group docking policies. Escape/lost focus restores the group's
starting rectangle. No generic group docking or app command registry is inferred.
Hosts supply overlay rectangles via `blocked_rects` before showing the surface. These
block drop previews, release actions and float fallback at the pointer, including overlays
appearing mid-drag. Hosts must provide current overlay geometry or cancel docking when
modal state prevents interaction; later-drawn overlays cannot be discovered retroactively.