diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index cecdaa8..49b5bc3 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -176,6 +176,55 @@ impl DockArea

{ Self { id, activation: None, blockers: Vec::new() } } + /// Whether keyboard focus belongs to a rendered docking control in this layout. + /// Panel bodies and application-provided header/menu widgets are excluded. Hosts may + /// route nonconflicting global shortcuts through chrome while retaining widget ownership + /// of activation, traversal, resize and cancellation keys. Invalid layouts return false. + pub fn chrome_has_focus(&self, ctx: &egui::Context, layout: &Layout

) -> bool { + let Some(focused) = ctx.memory(|memory| memory.focused()) else { return false }; + if layout.validate().is_err() || ctx.read_response(focused).is_none() { + return false; + } + let matches = |id: Id| id == focused; + let group = |panels: &[P]| { + panels.iter().any(|panel| matches(self.id.with(("tab", panel))) || matches(self.id.with(("overflow", panel)))) + || matches(self.id.with(("move-group", panels.first()))) + }; + for floating in &layout.floating { + if group(&floating.panels) || floating.panels.first().is_some_and(|first| matches(self.id.with(("floating", first)).with("resize"))) { + return true; + } + } + let mut nodes = layout.root.as_ref().map(|root| vec![(root, Vec::::new())]).unwrap_or_default(); + while let Some((node, path)) = nodes.pop() { + match node { + Node::Tabs { panels, .. } if group(panels) => return true, + Node::Stack { entries } => { + if entries.iter().any(|entry| { + matches(self.id.with(("tab", &entry.panel))) + || matches(self.id.with(("collapse", &entry.panel))) + || matches(self.id.with(("stack-resize", &entry.panel))) + }) { + return true; + } + } + Node::Split { first, second, .. } => { + if matches(self.id.with(("split", &path))) { + return true; + } + let mut second_path = path.clone(); + second_path.push(true); + let mut first_path = path; + first_path.push(false); + nodes.push((second, second_path)); + nodes.push((first, first_path)); + } + _ => {} + } + } + false + } + /// 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 { diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 57625d8..064bc2d 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -848,3 +848,79 @@ fn protected_group_member_blocks_whole_group_gesture_and_host_drop() { assert!(!out.actions.iter().any(|a| matches!(a, Action::MoveFloating { .. }))); } } + +#[test] +fn chrome_focus_classifies_rendered_tab_but_excludes_body_text_field_and_invalid_layout() { + let ctx = Context::default(); + let layout = sample(); + let tab = Id::new("focus-area").with(("tab", 3u32)); + let text = Id::new("body-text"); + let mut value = String::new(); + let mut paint = |focus: Id| { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { + DockArea::new(Id::new("focus-area")).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| format!("Panel {p}"), + |_| Permissions::default(), + |ui, p| { + if *p == 3 { + ui.add(egui::TextEdit::singleline(&mut value).id(text)); + } + }, + ); + ui.memory_mut(|memory| memory.request_focus(focus)); + }); + output.textures_delta.clear(); + }; + paint(tab); + assert!(DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); + paint(text); + assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); + paint(tab); + let mut invalid = layout.clone(); + invalid.floating.push(Floating { panels: vec![3], active: 0, rect: [10.0, 10.0, 100.0, 100.0] }); + assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &invalid)); + assert!(!DockArea::::new(Id::new("focus-area")).chrome_has_focus(&ctx, &Layout::default())); + ctx.memory_mut(|memory| memory.request_focus(Id::new("unrendered"))); + assert!(!DockArea::new(Id::new("focus-area")).chrome_has_focus(&ctx, &layout)); +} + +#[test] +fn chrome_focus_classifies_rendered_accordion_and_split_and_floating_resizers() { + let ctx = Context::default(); + let area = Id::new("focus-chrome-controls"); + let accordion = Layout { + root: Some(Node::Stack { + entries: vec![StackEntry { panel: 1u32, open: true, height: Some(150.0) }, StackEntry { panel: 2u32, open: true, height: Some(150.0) }], + }), + floating: Vec::new(), + }; + for (layout, id) in [ + (accordion.clone(), area.with(("tab", 1u32))), + (accordion.clone(), area.with(("collapse", 1u32))), + (accordion, area.with(("stack-resize", 1u32))), + (sample(), area.with(("split", Vec::::new()))), + (sample(), area.with(("floating", 5u32)).with("resize")), + ] { + let mut output = + ctx.run_ui(RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))), ..Default::default() }, |ui| { + DockArea::new(area).show( + ui, + &layout, + &DockStyle::from_ui(ui), + |p| format!("Panel {p}"), + |_| Permissions::default(), + |ui, _| { + ui.label("Body"); + }, + ); + ui.memory_mut(|memory| memory.request_focus(id)); + }); + output.textures_delta.clear(); + assert!(ctx.read_response(id).is_some(), "the fixture must render the control"); + assert!(DockArea::new(area).chrome_has_focus(&ctx, &layout)); + } +} diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 440c64a..76586f0 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -137,3 +137,11 @@ Hosts supply overlay rectangles via `blocked_rects` before showing the surface. 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. + +`DockArea::chrome_has_focus(ctx, layout)` classifies focus on rendered dock tabs, +collapse buttons, overflow, split/stack dividers, floating resize corners and group +grips. It excludes panel content and custom header/menu widgets and rejects invalid +layouts. Hosts can permit global commands from chrome while reserving Enter, Space, +Tab, Escape and arrow keys for activation, traversal, cancellation and resizing. +Content widgets retain their usual keyboard ownership. The renderer owns the IDs; +hosts do not need to reconstruct them.