diff --git a/crates/ui/README.md b/crates/ui/README.md
index 96ddf5d6..0f3758a4 100644
--- a/crates/ui/README.md
+++ b/crates/ui/README.md
@@ -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.
diff --git a/crates/ui/src/docking/mod.rs b/crates/ui/src/docking/mod.rs
index b0defc50..a7bf3f98 100644
--- a/crates/ui/src/docking/mod.rs
+++ b/crates/ui/src/docking/mod.rs
@@ -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)]
diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs
index d993ffa5..2f827b75 100644
--- a/crates/ui/src/docking/surface.rs
+++ b/crates/ui/src/docking/surface.rs
@@ -68,11 +68,15 @@ pub struct DockOutput
{
pub actions: Vec>,
pub error: Option,
pub focused: Option,
+ /// Whole-floating-group gesture identity; host may display its own group drop preview.
+ pub group_drag: Option
,
+ /// Whole-group release for host-owned atomic docking policy.
+ pub group_drop: Option<(P, Pos2)>,
}
impl
Default for DockOutput
{
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
{
grab: Vec2,
}
+#[derive(Clone)]
+struct GroupDrag
{
+ 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(ctx: &Context, area_id: Id, panel: P, source_rect: Rect) -> bool {
@@ -127,21 +137,57 @@ pub fn cancel_drag(ctx: &Context, area_id: Id)
ctx.data_mut(|d| d.remove::>(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 {
+ 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);
+impl DockContent
for BodyContent {
+ 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 {
+ id: Id,
+ activation: Option
,
+ blockers: Vec,
+}
+
+impl DockArea {
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(
+ pub fn show(
self,
ui: &mut Ui,
layout: &Layout,
@@ -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(
+ pub fn show_with_limits(
self,
ui: &mut Ui,
layout: &Layout,
@@ -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
{
+ 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
,
+ style: &DockStyle,
+ label: impl Fn(&P) -> String,
+ permissions: impl Fn(&P) -> Permissions,
+ limits: impl Fn(&P) -> PanelLimits,
+ content: &mut impl DockContent
,
) -> DockOutput
{
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
,
+ activation: Option
,
+ blockers: Vec,
layout: &'a Layout,
targets: Vec>,
output: DockOutput,
@@ -381,7 +448,7 @@ impl 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 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 =
- 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::>(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 = 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 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 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 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 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 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 Renderer<'_, P> {
}
}
+ fn finish_group_drag(&mut self, ctx: &Context) {
+ let Some(drag) = ctx.data(|d| d.get_temp::>(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::>(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::>(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::>(self.id.with("drag"))) else { return };
let (pointer, down, released, focused) =
@@ -585,6 +737,12 @@ impl Renderer<'_, P> {
}
return;
};
+ if self.blockers.iter().any(|rect| rect.contains(pointer)) {
+ if released {
+ cancel_drag::(ctx, self.id);
+ }
+ return;
+ }
let proposal = resolve_drop(&drag.panel, pointer, &self.targets);
if released {
cancel_drag::
(ctx, self.id);
diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs
index 91e374d5..49f89792 100644
--- a/crates/ui/src/docking/tests.rs
+++ b/crates/ui/src/docking/tests.rs
@@ -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,
+ headers: usize,
+ menus: usize,
+ bodies: usize,
+}
+impl DockContent 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,
+ content: &mut CustomContent,
+ events: Vec,
+ blockers: &[Rect],
+ raise: bool,
+) -> DockOutput {
+ 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 { .. })));
+ }
+}
diff --git a/docs/ui-docking.md b/docs/ui-docking.md
index b8d82941..440c64a2 100644
--- a/docs/ui-docking.md
+++ b/docs/ui-docking.md
@@ -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` 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.