UI.8: classify renderer-owned docking keyboard focus

This commit is contained in:
skylar taylor-barrick committed 2026-10-10 11:00:02 -06:00
1 parent 7bb9c865e6
commit 615f0638c7
3 files changed
+133

No files matched your search

+49
View File
@@ -176,6 +176,55 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<P> {
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<P>) -> 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::<bool>::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 {
+76
View File
@@ -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::<u32>::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::<bool>::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));
}
}
+8
View File
@@ -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.