docking: normal floating windows come forward when the app is activated (#301)

* docking: normal floating windows come forward when the app is activated

* docking: drop windows that became palettes from the raise queue

* docking: the main window comes forward with the normal windows, in focus order
This commit is contained in:
aiongg authored and GitHub committed 2026-10-24 18:12:06 -04:00
1 parent f9181cc7b9
commit acb7290804
5 files changed
+172 -15

No files matched your search

+75
View File
@@ -391,6 +391,81 @@ fn viewer_windows_stay_shown_at_normal_level() {
assert_eq!(shown(&d), [true, true, true]);
}
#[test]
fn the_app_comes_forward_with_its_viewers_when_it_is_activated() {
use egui::{ViewportId, WindowLevel};
let start = || {
let mut layout = layout(false);
layout.floating = vec![
Floating::single(vec![5], 0, [300.0, 80.0, 240.0, 300.0]),
Floating::single(vec![6], 0, [560.0, 80.0, 240.0, 300.0]),
Floating::single(vec![7], 0, [820.0, 80.0, 240.0, 300.0]),
];
let mut d = driver(layout);
d.content.normal = vec![5, 7];
d.step(vec![]);
let [back, palette, front] = [5, 6, 7].map(|p| d.viewport(window(&d, p).id));
(d, back, palette, front)
};
let focus = |d: &mut DockDriver<u32, Content>, window: Option<ViewportId>, frames: usize| {
d.host().focus = window;
for _ in 0..frames {
d.step(vec![]);
}
};
let levels = |d: &mut DockDriver<u32, Content>| -> Vec<(ViewportId, WindowLevel)> {
let mut host = d.host();
assert!(!host.commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Focus)), "no window takes focus");
let levels = host.commands.iter().filter_map(|(v, c)| if let ViewportCommand::WindowLevel(l) = c { Some((*v, *l)) } else { None }).collect();
host.commands.clear();
levels
};
// Each window once, in this order: the main window's commands are the root's.
let raised = |order: &[ViewportId]| -> Vec<(ViewportId, WindowLevel)> {
if cfg!(target_os = "macos") {
return Vec::new();
}
order.iter().flat_map(|v| [(*v, WindowLevel::AlwaysOnTop), (*v, WindowLevel::Normal)]).collect()
};
const MAIN: ViewportId = ViewportId::ROOT;
let (mut d, back, palette, front) = start();
// Focus moving between the app's own windows, with a frame of none between two, raises
// nothing.
focus(&mut d, Some(back), 2);
focus(&mut d, None, 1);
focus(&mut d, Some(front), 2);
focus(&mut d, Some(MAIN), 2);
assert_eq!(levels(&mut d), []);
// Another application, then back through the main window: the viewers are raised back to
// front and the main window last, without focus; the palette only shows again.
focus(&mut d, None, 20);
focus(&mut d, Some(MAIN), 10);
assert_eq!(levels(&mut d), raised(&[back, front, MAIN]));
assert!(d.host().windows[&palette].visible);
// Back through the back viewer: it is raised last, so it stays in front, and the main
// window comes forward behind it.
focus(&mut d, None, 20);
focus(&mut d, Some(back), 10);
assert_eq!(levels(&mut d), raised(&[front, MAIN, back]));
// A queued viewer that turns into a palette before its turn is not raised: the raise
// would leave it at the normal level.
focus(&mut d, None, 20);
focus(&mut d, Some(MAIN), 1);
d.content.normal = vec![7];
focus(&mut d, Some(MAIN), 10);
assert_eq!(levels(&mut d), raised(&[front, MAIN]));
// A press in the main window brings it in front of the viewers that opened over it, though
// it kept focus.
let (mut d, back, _, front) = start();
d.click(pos2(100.0, 300.0));
levels(&mut d);
focus(&mut d, None, 20);
focus(&mut d, Some(back), 10);
assert_eq!(levels(&mut d), raised(&[front, MAIN, back]));
}
#[test]
fn unused_keys_in_a_native_window_reach_the_app() {
let mut d = driver(layout(true));
+77 -10
View File
@@ -1,7 +1,8 @@
//! Floating windows in their own OS windows (feature `native-windows`). Where the backend has
//! viewports, every floating window whose panels allow it is drawn with
//! `show_viewport_immediate`, always on top of other windows and hidden while none of the
//! application's windows has focus. Where egui embeds viewports (wasm, tests) windows stay
//! `show_viewport_immediate`. Palettes stay on top of other windows and hide while none of the
//! application's windows has focus; viewers are normal windows that come forward when the
//! application is activated. Where egui embeds viewports (wasm, tests) windows stay
//! in-window at their in-window rect, and so do they on Wayland, unless the app turns native
//! windows on ([`crate::docking::DockArea::native_windows`]). Tabs, groups and whole windows
//! drag between OS windows (see `cross.rs`).
@@ -25,6 +26,10 @@ const MIN_SIZE: [f32; 2] = [160.0, 100.0];
/// How long floating windows stay after the application's last window lost focus, so focus
/// moving between our own windows does not hide them.
const HIDE_DELAY: f64 = 0.25;
/// Whether normal windows are raised when the application is activated. macOS brings an
/// application's windows forward itself: all of them on activation from the Dock or the
/// application switcher, the clicked one alone on a click, as its own windows do.
const RAISE_ON_ACTIVATION: bool = !cfg!(target_os = "macos");
/// How long reports of a native window's place may lag a move the dock area commanded.
const MOVE_WAIT: f64 = 0.5;
/// How far the resize bands reach into a native window from its edges, where the window has
@@ -204,7 +209,7 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating<P>>) -> Vec<&'w Floating<P>> {
let native = !ctx.embed_viewports() && self.native_windows.unwrap_or_else(|| !wayland(ctx, env_session()));
let origin = ctx.input(|i| i.viewport().inner_rect.map(|r| r.min)).filter(|p| p.is_finite()).unwrap_or(Pos2::ZERO);
let visible = !native || self.float_visibility(ctx);
let (visible, activated) = if native { self.float_visibility(ctx) } else { (true, false) };
self.cross.on = native;
let mut shown = Vec::new();
let mut in_window = Vec::new();
@@ -235,7 +240,8 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
if ctx.data(|d| d.get_temp::<WindowDrag>(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) {
ctx.data_mut(|d| d.remove::<WindowDrag>(key));
}
self.map_desktop(ctx, &natives, visible);
self.map_desktop(ctx, &natives, visible, activated);
self.raise_next(ctx, &natives);
// The main window owns drags of the panels it draws.
let id = self.id;
let dragged = ctx.data(|d| {
@@ -285,13 +291,21 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
/// The main window and the shown native windows on the desktop, front first: palettes
/// above the rest, each the window focused most recently first. A window whose position is
/// unknown is left out. `visible`: palettes show.
fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating<P>, [f32; 4], ViewportId)], visible: bool) {
/// unknown is left out. `visible`: palettes show. `activated`: the application was just
/// activated, so the main window and its normal windows are raised (see
/// [`Self::raise_next`]).
fn map_desktop(&mut self, ctx: &Context, natives: &[(&Floating<P>, [f32; 4], ViewportId)], visible: bool, activated: bool) {
let key = self.id.with("native-order");
// Focus order, front first; `None` is the main window.
let mut order = ctx.data(|d| d.get_temp::<Vec<Option<u64>>>(key)).unwrap_or_default();
order.retain(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id)));
if ctx.input(|i| i.viewport().focused == Some(true)) || !order.contains(&None) {
// The main window comes to the front when it gains focus, and on a press in it: the
// window system raises it then, though it may have kept focus.
let main_key = self.id.with("native-main-focused");
let (main_focused, pressed) = ctx.input(|i| (i.viewport().focused == Some(true), i.pointer.any_pressed()));
let gained = main_focused && !ctx.data(|d| d.get_temp::<bool>(main_key)).unwrap_or(false);
ctx.data_mut(|d| d.insert_temp(main_key, main_focused));
if gained || pressed || !order.contains(&None) {
let front = order.contains(&None);
order.retain(Option::is_some);
if front { order.insert(0, None) } else { order.push(None) }
@@ -314,6 +328,19 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
windows.push((window.id, placed, normal));
}
}
if activated && RAISE_ON_ACTIVATION {
// The back one first, so each lands in front of those raised before it, and the
// window just focused, at the front of the order, last.
let queue: Vec<Option<u64>> = order
.iter()
.rev()
.filter(|id| id.is_none_or(|id| natives.iter().any(|(w, ..)| w.id == id && self.normal_window(w))))
.copied()
.collect();
if queue.iter().any(Option::is_some) {
ctx.data_mut(|d| d.insert_temp(self.id.with("native-raise"), queue));
}
}
let rank = |id: Option<u64>| order.iter().position(|o| *o == id);
windows.sort_by_key(|(id, _, normal)| (*normal, rank(Some(*id))));
self.cross.front = windows.iter().filter(|(id, _, normal)| !normal || rank(Some(*id)) < rank(None)).count();
@@ -322,8 +349,40 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
self.cross.main = ctx.input(|i| i.viewport().outer_rect.and(i.viewport().inner_rect)).filter(|r| r.is_finite());
}
/// Whether palettes show this frame.
fn float_visibility(&mut self, ctx: &Context) -> bool {
/// Raise the next window [`Self::map_desktop`] queued when the application was activated,
/// the main window (`None`) or a normal window: one per pass, since egui sends the commands
/// of different viewports in no fixed order. Toggling the window level raises a window without focusing it: on Windows
/// `HWND_NOTOPMOST` puts the window in front of every window that is not topmost, and on
/// X11 the window manager raises a window as it takes `_NET_WM_STATE_ABOVE`. winit has no
/// plain raise. `ViewportCommand::Focus` would take focus from the window the user
/// activated, and egui-winit ignores it for a window that has focus, so focus could not be
/// handed back. `Visible(true)` does nothing to a shown window.
fn raise_next(&self, ctx: &Context, natives: &[(&Floating<P>, [f32; 4], ViewportId)]) {
let key = self.id.with("native-raise");
let Some(mut queue) = ctx.data_mut(|d| d.remove_temp::<Vec<Option<u64>>>(key)) else { return };
// A window that became a palette since activation keeps its `AlwaysOnTop` level.
let viewport = |id: Option<u64>| match id {
None => Some(ViewportId::ROOT),
Some(id) => natives.iter().find(|(w, ..)| w.id == id && self.normal_window(w)).map(|(.., v)| *v),
};
queue.retain(|id| viewport(*id).is_some());
if queue.is_empty() {
return;
}
if let Some(viewport) = viewport(queue.remove(0)) {
ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::AlwaysOnTop));
ctx.send_viewport_cmd_to(viewport, ViewportCommand::WindowLevel(WindowLevel::Normal));
}
if !queue.is_empty() {
ctx.data_mut(|d| d.insert_temp(key, queue));
ctx.request_repaint();
}
}
/// Whether palettes show this frame, and whether the application was just activated: one
/// of its windows has focus after none had it for long enough to hide the palettes.
/// Losing focus again stops the raise of normal windows that activation started.
fn float_visibility(&mut self, ctx: &Context) -> (bool, bool) {
let (focused, now) = ctx.input(|i| {
let infos = || i.raw.viewports.values();
// A backend that reports no focus at all keeps the windows.
@@ -338,7 +397,15 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {
ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0)));
}
ctx.data_mut(|d| d.insert_temp(key, last));
visible
let away_key = self.id.with("native-away");
let away = ctx.data(|d| d.get_temp::<bool>(away_key)).unwrap_or(false);
ctx.data_mut(|d| {
d.insert_temp(away_key, !focused && (away || !visible));
if !focused {
d.remove::<Vec<Option<u64>>>(self.id.with("native-raise"));
}
});
(visible, focused && away)
}
/// A window holding a viewer is a normal window, so it neither vanishes nor sits over other
+3 -1
View File
@@ -640,7 +640,9 @@ pub enum WindowKind {
#[default]
Palette,
/// A window of its own, such as a viewer on a second display: normal window level, and it
/// stays shown while the app is in the background.
/// stays shown while the app is in the background. When the app is activated, these
/// windows and the main window come forward, and the main window is left at the normal
/// level.
Normal,
}
+1 -4
View File
@@ -389,7 +389,7 @@ pub struct NativeHost {
/// frame still reports the old place, and gets pointer events relative to it.
pub lag: bool,
pub windows: std::collections::HashMap<egui::ViewportId, HostWindow>,
/// Every command sent to a native window, in order.
/// Every command sent to a native window or the main window, in order.
pub commands: Vec<(egui::ViewportId, egui::ViewportCommand)>,
/// Moves waiting to land under `lag`: window, content position, frame sent.
late: Vec<(egui::ViewportId, Pos2, usize)>,
@@ -527,9 +527,6 @@ impl NativeHost {
/// Carry out the commands a frame sent to native windows.
fn apply(&mut self, full: &egui::FullOutput) {
for (id, output) in &full.viewport_output {
if *id == egui::ViewportId::ROOT {
continue;
}
for command in &output.commands {
self.commands.push((*id, command.clone()));
let Some(window) = self.windows.get_mut(id) else { continue };
+16
View File
@@ -393,6 +393,22 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho
background. They stack with the main window by focus, below every palette; a drag over a
normal window that lies behind the main window targets the main window where the two
overlap, and the normal window outside the main window.
- Like palettes, normal windows have no taskbar entry; they come forward with the app
instead. When one of the app's windows gains focus after none had it for the hide delay,
the main window and each normal window get `WindowLevel::AlwaysOnTop` and then
`WindowLevel::Normal`, one window per frame, in their focus order from back to front.
They keep their order, nothing takes focus, and the window the user activated is raised
last, so it stays in front. The focus order counts a press in the main window as focusing
it, since the window system raises it then. The main window is left at the normal level,
so an app that keeps its main window always on top should not use normal windows. On
Windows the toggle puts the window in front of every window that is not topmost, without
activating it; on X11 the window manager raises a window as it takes
`_NET_WM_STATE_ABOVE`. egui 0.36 cannot make the main window a native window's owner,
which on Windows would bring the window forward with the main window, and
`ViewportCommand::Focus` would take focus. On
macOS nothing is raised: activation from the Dock or the application switcher brings all
of the app's windows forward, and a click brings the clicked window alone, as with the
app's other windows.
- **Bar.** Each window has a bar at the top, 18 points high, in the floating title bar's style:
the title (the shown panel of its first group) and ×. Dragging the bar moves the window. On
Wayland, where a window cannot be placed, the bar hands the move to the compositor. On