diff --git a/crates/ui/src/docking/group_tests.rs b/crates/ui/src/docking/group_tests.rs index 71c9f113..85ff5506 100644 --- a/crates/ui/src/docking/group_tests.rs +++ b/crates/ui/src/docking/group_tests.rs @@ -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); +} diff --git a/crates/ui/src/docking/render/floating.rs b/crates/ui/src/docking/render/floating.rs index 1ac0b65f..bfcabce0 100644 --- a/crates/ui/src/docking/render/floating.rs +++ b/crates/ui/src/docking/render/floating.rs @@ -66,8 +66,6 @@ impl 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); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 05fe8196..94627bd1 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -79,7 +79,6 @@ impl 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| { diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index f53707f0..fb092503 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -201,8 +201,9 @@ impl DockStyle { pub struct DockOutput

{ pub actions: Vec>, pub error: Option, - /// 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

, /// Whole-floating-group gesture identity; host may display its own group drop preview. pub group_drag: Option

, @@ -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, 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(ctx: &Context, area: Id, layout: &Layout

) { + let Some((panel, place, moved_by_area)) = ctx.data(|d| d.get_temp::>(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(ctx: &Context, area: Id, layout: &Layout

, 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::>(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::

(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 diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs index 013bb274..f934f9d1 100644 --- a/crates/ui/src/docking/tests.rs +++ b/crates/ui/src/docking/tests.rs @@ -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")); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index c7643033..bcd62c86 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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.