docking: focus only follows the user's input

This commit is contained in:
aiongg committed 2026-10-19 14:06:37 +00:00
1 parent a014337eb2
commit 13be52fbaa
6 files changed
+85 -12

No files matched your search

+37
View File
@@ -588,3 +588,40 @@ fn a_short_docked_panel_floats_at_its_content_height_up_to_its_floating_size() {
let Some(Target::Float { rect: Some([_, _, _, height]) }) = moved_group(&out.actions) else { panic!("{:?}", out.actions) };
assert_eq!(*height, title + strip + 150.0);
}
#[test]
fn tab_focus_follows_only_the_users_input() {
let mut d = Driver::new(docked());
let tab = Id::new(AREA).with(("tab", &4u32));
// Keyboard focus as Tab gives it.
d.ctx.memory_mut(|m| m.request_focus(tab));
d.step(vec![]);
assert!(d.ctx.memory(|m| m.has_focus(tab)));
// A reset that moves the panel takes its focus away and reports no new focus.
let mut reset = docked();
let Some(Node::Split { first, .. }) = &mut reset.root else { panic!() };
let Node::Dock { columns, .. } = first.as_mut() else { panic!() };
columns[0].groups.reverse();
d.layout = reset;
let out = d.step(vec![]);
assert_eq!(out.focused, None);
d.step(vec![]);
assert_eq!(d.ctx.memory(|m| m.focused()), None, "the moved tab gives up keyboard focus");
// A user drag keeps the dragged tab's focus where the user put it.
let mut d = Driver::new(docked());
d.ctx.memory_mut(|m| m.request_focus(tab));
d.drag(d.tab(4).center(), d.tab(2).center());
d.step(vec![]);
assert!(d.layout.place(&4) != docked().place(&4));
assert!(d.ctx.memory(|m| m.has_focus(tab)), "a tab moved by the user keeps focus");
// A closed panel's tab gives focus up too.
let out = d.step(vec![]);
assert!(out.actions.is_empty());
d.layout.apply(Action::Close { panel: 4 }).unwrap();
d.step(vec![]);
d.step(vec![]);
assert_eq!(d.ctx.memory(|m| m.focused()), None);
}
-2
View File
@@ -66,8 +66,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
let id = self.id.with(("floating", first));
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.move_to_top(FloatingFrame::layer(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();
}
let movable = window.panels().all(|panel| self.permissions(panel).movable);
let style = ChromeStyle::from_dock(self.style);
-1
View File
@@ -79,7 +79,6 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
.with_min_inner_size(MIN_SIZE);
if self.activation.as_ref().is_some_and(|panel| window.contains(panel)) {
ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus);
self.output.focused = self.activation.clone();
}
let (targets, stacks) = (self.targets.len(), self.frame.stacks.len());
let (closed, placed) = ctx.show_viewport_immediate(viewport, builder, |ui, class| {
+39 -4
View File
@@ -201,8 +201,9 @@ impl DockStyle {
pub struct DockOutput<P> {
pub actions: Vec<Action<P>>,
pub error: Option<DockError>,
/// The panel the user focused this frame: a clicked tab or menu entry, a drop, a raised
/// window, or a press of any pointer button in a panel body, stripless panels included.
/// The panel the user focused this frame: a clicked tab or menu entry, a drop, or a press
/// of any pointer button in a panel body, stripless panels included. Only user input sets
/// it; [`DockArea::raise_panel`] and host commands do not.
pub focused: Option<P>,
/// Whole-floating-group gesture identity; host may display its own group drop preview.
pub group_drag: Option<P>,
@@ -309,6 +310,37 @@ fn float_space_key(area: Id) -> Id {
area.with("float-space")
}
/// The panel whose tab held keyboard focus at the end of the last frame, where it was, and
/// whether that frame's own actions may have moved it.
type TabFocus<P> = (P, super::dock_model::Place, bool);
fn tab_focus_key(area: Id) -> Id {
area.with("tab-focus")
}
/// Keyboard focus stays on a tab only while its panel stays where the user put it: a panel
/// hidden, closed or moved by the host (a command or a workspace reset) gives focus up.
fn release_moved_tab_focus<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>) {
let Some((panel, place, moved_by_area)) = ctx.data(|d| d.get_temp::<TabFocus<P>>(tab_focus_key(area))) else { return };
let tab = area.with(("tab", &panel));
let place_now = layout.place(&panel);
if ctx.memory(|m| m.has_focus(tab)) && (place_now.is_none() || (place_now != Some(place) && !moved_by_area)) {
ctx.memory_mut(|m| m.surrender_focus(tab));
}
}
fn record_tab_focus<P: Clone + Eq + Hash + Debug + Send + Sync + 'static>(ctx: &Context, area: Id, layout: &Layout<P>, acted: bool) {
let focused = ctx.memory(|m| m.focused());
let tab = focused.and_then(|id| layout.panels().into_iter().find(|panel| area.with(("tab", *panel)) == id));
let record = tab.and_then(|panel| Some((panel.clone(), layout.place(panel)?, acted)));
ctx.data_mut(|d| match record {
Some(record) => {
d.insert_temp(tab_focus_key(area), record);
}
None => d.remove::<TabFocus<P>>(tab_focus_key(area)),
});
}
/// A request for host state outside the layout.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
@@ -692,8 +724,9 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
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.
/// Raise a panel's floating window (or open its flyout) on this frame. Apply Activate
/// through the application's command path as well to select its tab. A raise is not user
/// input: it moves no keyboard focus and is not reported as [`DockOutput::focused`].
pub fn raise_panel(mut self, panel: P) -> Self {
self.activation = Some(panel);
self
@@ -764,6 +797,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
cancel_drag::<P>(ui.ctx(), self.id);
return DockOutput { error: Some(DockError::InvalidGeometry), ..Default::default() };
}
release_moved_tab_focus(ui.ctx(), self.id, layout);
let (view, frame) = super::render::view(layout, self.maximized.as_ref(), self.hide, &*content);
let mut renderer = Renderer {
id: self.id,
@@ -797,6 +831,7 @@ impl<'a, P: Clone + Eq + Hash + Debug + Send + Sync + 'static> DockArea<'a, P> {
renderer.finish_drag(ui.ctx());
renderer.report_flyout(ui.ctx());
super::hits::record(ui.ctx(), self.id, &view, &renderer.owned_interactions);
record_tab_focus(ui.ctx(), self.id, layout, !renderer.output.actions.is_empty());
ui.ctx().data_mut(|d| d.insert_temp(float_space_key(self.id), renderer.float_space));
ui.ctx().data_mut(|d| d.insert_temp(self.id.with("owned-interactions"), renderer.owned_interactions));
renderer.output
+2 -1
View File
@@ -912,7 +912,8 @@ fn custom_chrome_shaped_labels_and_programmatic_raise_are_available() {
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_eq!(out.focused, None, "a programmatic raise is not user focus");
assert_eq!(ctx.memory(|m| m.focused()), None, "a raise draws no keyboard-focus ring");
assert!(out.actions.is_empty());
assert!(content.headers >= 3 && content.bodies >= 3);
assert!(content.labels.iter().any(|s| s == "Logical 5"));
+7 -4
View File
@@ -110,8 +110,10 @@ survives.
`DockOutput` also has:
- `focused`: the panel the user focused this frame: a clicked tab, a drop, a raised window, or a
press of any pointer button in a panel body, bare and protected panels included.
- `focused`: the panel the user focused this frame: a clicked tab, a drop, or a press of any
pointer button in a panel body, bare and protected panels included. Commands, `raise_panel` and
workspace resets never set it; a host that tracks the last focused panel clears it when a reset
or command hides, closes or moves that panel.
- `events`: requests outside the layout, such as `DockEvent::ToggleMaximize` (Frame) and
`DockEvent::ToggleAutoCollapse` (Dock), and `DockEvent::FlyoutChanged { panel }` (Dock): the
flyout opened, switched or closed (`None`). It comes once per change, whatever made it: an
@@ -125,7 +127,7 @@ Builder options on `DockArea`:
| `maximized(panel)` | Maximize Frame: that frame fills the area. |
| `hide_panels(HidePanels)` | Hide panels for Tab and Shift+Tab. Protected panels stay. |
| `auto_collapse_iconic(on)` | Close a flyout on Escape or a click outside (default on). |
| `raise_panel(panel)` | Raise and focus a panel's floating window this frame. |
| `raise_panel(panel)` | Raise a panel's floating window, or open its flyout, this frame. It moves no keyboard focus and does not set `DockOutput::focused`. |
| `blocked_rects(rects)` | App overlays that docking gestures must ignore. |
`DockStyle::close_menu_items` and `DockStyle::close_button` hide the built-in Close menu items
@@ -133,7 +135,8 @@ and the floating window ×. They hide controls only: a panel that stays `closabl
by the app's own Close items and commands.
`DockArea::chrome_has_focus` tells whether keyboard focus is on docking chrome rather than on a
panel body. `begin_drag` hands a drag that started in the app's own chrome to the area.
panel body. Focus follows only the user's input: a tab keeps keyboard focus until a command or a
workspace reset hides, closes or moves its panel, and then gives it up. `begin_drag` hands a drag that started in the app's own chrome to the area.
`docking::chrome` has the floating frame, the title bar and `drop_preview` (the drop preview
docking and `craft_ui::panes` both draw) for apps that draw their own windows.