From acb7290804afb3de9061f0133f7d74eec6bdf94a Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:12:06 -0400 Subject: [PATCH] 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 --- crates/ui/src/docking/native_tests.rs | 75 ++++++++++++++++++++++ crates/ui/src/docking/render/native.rs | 87 +++++++++++++++++++++++--- crates/ui/src/docking/surface.rs | 4 +- crates/ui/src/docking/testing.rs | 5 +- docs/ui-docking.md | 16 +++++ 5 files changed, 172 insertions(+), 15 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index c72a0711..39a2ecf9 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -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, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let levels = |d: &mut DockDriver| -> 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)); diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index 11d98117..4d47c04a 100644 --- a/crates/ui/src/docking/render/native.rs +++ b/crates/ui/src/docking/render/native.rs @@ -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 Renderer<'_, P> { pub(super) fn native_windows<'w>(&mut self, ctx: &Context, windows: Vec<&'w Floating

>) -> Vec<&'w Floating

> { 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 Renderer<'_, P> { if ctx.data(|d| d.get_temp::(key)).is_some_and(|d| !natives.iter().any(|(w, ..)| w.id == d.window)) { ctx.data_mut(|d| d.remove::(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 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

, [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

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

, [f32; 4], ViewportId)]) { + let key = self.id.with("native-raise"); + let Some(mut queue) = ctx.data_mut(|d| d.remove_temp::>>(key)) else { return }; + // A window that became a palette since activation keeps its `AlwaysOnTop` level. + let viewport = |id: Option| 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 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::(away_key)).unwrap_or(false); + ctx.data_mut(|d| { + d.insert_temp(away_key, !focused && (away || !visible)); + if !focused { + d.remove::>>(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 diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs index 32e1756c..f2f473d7 100644 --- a/crates/ui/src/docking/surface.rs +++ b/crates/ui/src/docking/surface.rs @@ -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, } diff --git a/crates/ui/src/docking/testing.rs b/crates/ui/src/docking/testing.rs index 49fe34d6..dd3dbe6e 100644 --- a/crates/ui/src/docking/testing.rs +++ b/crates/ui/src/docking/testing.rs @@ -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, - /// 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 }; diff --git a/docs/ui-docking.md b/docs/ui-docking.md index a58d55df..34f41695 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -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