From f23c9f9c4abbc4c84397e236a4a0a6115cb59ea6 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 10:47:52 -0600 Subject: [PATCH] UI.8: preserve application docking chrome and group gestures --- crates/ui/README.md | 2 +- crates/ui/src/docking/mod.rs | 2 +- crates/ui/src/docking/surface.rs | 210 +++++++++++++++++++++++++++---- crates/ui/src/docking/tests.rs | 210 +++++++++++++++++++++++++++++++ docs/ui-docking.md | 28 +++++ 5 files changed, 424 insertions(+), 28 deletions(-) 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.