From da5bc8df94c789e2943e53f1fe41e539b770cf0b Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:18:10 -0400 Subject: [PATCH] docking: native palettes stay shown while the app is inactive (#326) * docking: keep native palettes shown while the app is inactive Palettes no longer hide when none of the application's windows has focus. They drop to WindowLevel::Normal while the application is inactive, so they do not cover other applications, and go back to AlwaysOnTop when it is active again. Files dragged from another application reach them, OS sheets attached to them stay open, and they stay shown, dimmed, while an app modal blocks input. * docking: on Windows an inactive app's palettes go behind other windows * docking: the viewer level test expects the inactive palette level --- crates/ui/src/docking/native_tests.rs | 85 ++++++++++++----------- crates/ui/src/docking/render/mod.rs | 2 +- crates/ui/src/docking/render/native.rs | 96 +++++++++++++------------- docs/ui-docking.md | 17 +++-- 4 files changed, 104 insertions(+), 96 deletions(-) diff --git a/crates/ui/src/docking/native_tests.rs b/crates/ui/src/docking/native_tests.rs index 380c8d45..7f473881 100644 --- a/crates/ui/src/docking/native_tests.rs +++ b/crates/ui/src/docking/native_tests.rs @@ -4,7 +4,7 @@ use egui::{Color32, Event, Key, Modifiers, PointerButton, Pos2, Rect, Vec2, ViewportCommand, epaint::ClippedShape, pos2, vec2}; use super::render::{ - APPKIT_CORNER, DragPointer, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, wayland, + APPKIT_CORNER, DragPointer, INACTIVE_PALETTE, MACOS_CLOSE, WindowDrag, appkit_resize_areas, close_rects, drag_opacity, resize_bands, wayland, }; use super::testing::DockDriver; use super::{Action, DockContent, Floating, Hit, Layout, Monitor, Node, Permissions, Placement, Target, WindowKind, Workspace}; @@ -457,37 +457,40 @@ fn id_of(d: &DockDriver) -> u64 { } #[test] -fn floating_windows_hide_while_the_app_is_inactive() { - assert!(floats_shown(true, 10.0, 10.0)); - assert!(floats_shown(false, 10.2, 10.0)); - assert!(!floats_shown(false, 10.3, 10.0)); - +fn palettes_stay_shown_at_the_normal_level_while_the_app_is_inactive() { + use egui::{ViewportId, WindowLevel}; let mut d = driver(layout(true)); - let viewport = d.viewport(window(&d, 5).id); - let hides = - |d: &DockDriver| d.host().commands.iter().filter(|(v, c)| *v == viewport && *c == ViewportCommand::Visible(false)).count(); - // Focus moving to the floating window keeps it. - d.host().focus = None; - d.step(vec![]); - d.host().focus = Some(viewport); - for _ in 0..30 { - d.step(vec![]); - } - assert_eq!(hides(&d), 0); - // Another application: hidden after the delay, shown again on return. - d.host().focus = None; - for _ in 0..10 { - d.step(vec![]); - } - assert_eq!(hides(&d), 0, "not yet"); - for _ in 0..10 { - d.step(vec![]); - } - assert_eq!(hides(&d), 1); - assert!(!d.host().windows[&viewport].visible); - d.host().focus = Some(egui::ViewportId::ROOT); - d.step(vec![]); - assert!(d.host().windows[&viewport].visible); + let palette = d.viewport(window(&d, 5).id); + let focus = |d: &mut DockDriver, window: Option, frames: usize| { + d.host().focus = window; + for _ in 0..frames { + d.step(vec![]); + } + }; + let level = |d: &DockDriver| d.host().windows[&palette].level; + let told = |d: &DockDriver| d.host().commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Focus moving to the palette, with a frame of none between, keeps it on top. + focus(&mut d, None, 1); + focus(&mut d, Some(palette), 30); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Another application: the palette drops behind it after the delay, and shows on, so + // files dragged from that application can reach it. + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop), "not yet"); + focus(&mut d, None, 10); + assert_eq!(level(&d), Some(INACTIVE_PALETTE)); + // Back: on top again. + focus(&mut d, Some(ViewportId::ROOT), 1); + assert_eq!(level(&d), Some(WindowLevel::AlwaysOnTop)); + // Blocked by a modal, it stays at the normal level, away and back. + d.input_blocked = true; + focus(&mut d, None, 20); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + focus(&mut d, Some(ViewportId::ROOT), 5); + assert_eq!(level(&d), Some(WindowLevel::Normal)); + assert!(d.host().windows[&palette].visible); + assert!(!told(&d), "{:?}", d.host().commands); } #[test] @@ -507,18 +510,20 @@ fn viewer_windows_stay_shown_at_normal_level() { assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); assert_eq!(level(&d, mixed), Some(egui::WindowLevel::Normal)); - // Another application: only the palette hides, and the others get no visibility command. + // Another application: the palette drops behind it; the viewers stay at the normal level. d.host().focus = None; for _ in 0..20 { d.step(vec![]); } - let shown = |d: &DockDriver| [viewer, palette, mixed].map(|v| d.host().windows[&v].visible); - assert_eq!(shown(&d), [true, false, true]); - let told = |d: &DockDriver, v| d.host().commands.iter().any(|(w, c)| *w == v && matches!(c, ViewportCommand::Visible(_))); - assert!(!told(&d, viewer) && !told(&d, mixed)); + assert_eq!( + [viewer, palette, mixed].map(|v| level(&d, v)), + [Some(egui::WindowLevel::Normal), Some(INACTIVE_PALETTE), Some(egui::WindowLevel::Normal)] + ); d.host().focus = Some(egui::ViewportId::ROOT); d.step(vec![]); - assert_eq!(shown(&d), [true, true, true]); + assert_eq!(level(&d, palette), Some(egui::WindowLevel::AlwaysOnTop)); + assert_eq!(level(&d, viewer), Some(egui::WindowLevel::Normal)); + assert!(!d.host().commands.iter().any(|(_, c)| matches!(c, ViewportCommand::Visible(_))), "no window hides"); } #[test] @@ -568,7 +573,7 @@ fn the_app_comes_forward_with_its_viewers_when_it_is_activated() { 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. + // front and the main window last, without focus; the palette only goes back on top. focus(&mut d, None, 20); focus(&mut d, Some(MAIN), 10); assert_eq!(levels(&mut d), raised(&[back, front, MAIN])); @@ -1815,11 +1820,13 @@ fn a_blocked_palette_stays_shown_and_behind_the_main_window_when_the_app_returns d.step(vec![]); } }; - // Showing it again would raise it over the modal and, on macOS, give it focus. + // Away and back, it stays shown at the normal level: showing it or putting it on top could + // raise it over the modal, and showing it would, on macOS, give it focus. focus(&mut d, None); assert!(d.host().windows[&palette].visible); focus(&mut d, Some(palette)); let host = d.host(); + assert_eq!(host.windows[&palette].level, Some(WindowLevel::Normal)); assert!(!host.commands.iter().any(|(v, c)| *v == palette && matches!(c, ViewportCommand::Visible(_))), "{:?}", host.commands); // Activated through the palette, the main window comes in front of it, with no viewers. let levels: Vec<_> = host.commands.iter().filter(|(_, c)| matches!(c, ViewportCommand::WindowLevel(_))).cloned().collect(); diff --git a/crates/ui/src/docking/render/mod.rs b/crates/ui/src/docking/render/mod.rs index f0495e3a..a67f2274 100644 --- a/crates/ui/src/docking/render/mod.rs +++ b/crates/ui/src/docking/render/mod.rs @@ -21,7 +21,7 @@ pub(in crate::docking) use native::WindowDrag; #[cfg(all(test, feature = "native-windows"))] pub(in crate::docking) use { cross::DragPointer, - native::{APPKIT_CORNER, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, floats_shown, resize_bands, session, wayland}, + native::{APPKIT_CORNER, INACTIVE_PALETTE, MACOS_CLOSE, Session, appkit_resize_areas, close_rects, drag_opacity, resize_bands, session, wayland}, }; #[cfg(all(test, feature = "native-windows"))] diff --git a/crates/ui/src/docking/render/native.rs b/crates/ui/src/docking/render/native.rs index d42b1a8a..8b477797 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`. 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 +//! `show_viewport_immediate`. Palettes stay on top of the application's other windows, and +//! drop to the normal level while none of its windows has focus, so they stay shown without +//! covering other applications; 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 @@ -25,13 +26,19 @@ use crate::docking::{Action, Drag, Floating, GroupDrag, GroupGesture, Node, Wind /// Smallest native window. 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; +/// How long none of the application's windows has focus before the application counts as +/// inactive, so focus moving between our own windows does not. +const AWAY_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"); +/// The level of a palette while the application is inactive, behind other applications' +/// windows. On Windows `WindowLevel::Normal` is `HWND_NOTOPMOST`, which puts the window in +/// front of every window that is not topmost, the activated application's too; `HWND_BOTTOM` +/// sends it behind them without activating it. +pub(in crate::docking) const INACTIVE_PALETTE: WindowLevel = + if cfg!(target_os = "windows") { WindowLevel::AlwaysOnBottom } else { WindowLevel::Normal }; /// 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,8 +211,6 @@ struct Place { pending: Option, /// The window system put the frame, not the content, where it opened: corrected once. corrected: bool, - /// Shown, as it opened or was last told. - shown: bool, /// The height of its bar while minimized, as it was last told. `known` keeps the expanded /// size meanwhile. collapsed: Option, @@ -232,12 +237,6 @@ pub(in crate::docking) fn viewport_id(area: Id, window: u64, n: usize) -> Viewpo } } -/// Whether floating windows show: while one of the application's windows has focus, and until -/// [`HIDE_DELAY`] after `last_focused`, when one last had it. -pub(in crate::docking) fn floats_shown(focused: bool, now: f64, last_focused: f64) -> bool { - focused || now - last_focused < HIDE_DELAY -} - /// The desktop rect of a native viewport's content. None when the backend reports no /// positions. fn placed(info: &ViewportInfo) -> Option<[f32; 4]> { @@ -268,7 +267,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, activated) = if native { self.float_visibility(ctx) } else { (true, false) }; + let (active, activated) = if native { self.app_activity(ctx) } else { (true, false) }; self.cross.on = native; let mut shown = Vec::new(); let mut in_window = Vec::new(); @@ -299,7 +298,7 @@ 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, activated); + self.map_desktop(ctx, &natives, activated); self.raise_next(ctx, &natives); // The main window owns drags of the panels it draws. let id = self.id; @@ -314,7 +313,7 @@ impl Renderer<'_, P> { } self.map_body_route(ctx, native); for (window, rect, viewport) in natives { - if let Some(key) = self.native_window(ctx, window, rect, viewport, visible) { + if let Some(key) = self.native_window(ctx, window, rect, viewport, active) { shown.push(key); } } @@ -352,10 +351,9 @@ 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. `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) { + /// unknown is left out. `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)], 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(); @@ -385,7 +383,7 @@ impl Renderer<'_, P> { Some(info) => placed(&info).map(|[x, y, w, h]| Rect::from_min_size(pos2(x, y), vec2(w, h))), }; let normal = self.normal_window(window); - if let Some(placed) = placed.filter(|r| r.is_finite() && (visible || normal)) { + if let Some(placed) = placed.filter(|r| r.is_finite()) { windows.push((window.id, placed, normal)); } } @@ -446,10 +444,11 @@ impl Renderer<'_, P> { } } - /// 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) { + /// Whether the application is active this frame: one of its windows has focus, or one had it + /// less than [`AWAY_DELAY`] ago. And whether it was just activated: one of its windows has + /// focus after none had it for that long. Losing focus again stops the raise of normal + /// windows that activation started. + fn app_activity(&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. @@ -457,35 +456,34 @@ impl Renderer<'_, P> { (focused, i.time) }); self.cross.focus_lost = crate::focus::turned_false(ctx, self.id.with("app-focus"), focused); - let key = self.id.with("native-visibility"); + let key = self.id.with("native-last-focused"); let last = if focused { now } else { ctx.data(|d| d.get_temp::(key)).unwrap_or(now) }; - let visible = floats_shown(focused, now, last); - if visible && !focused { - ctx.request_repaint_after(std::time::Duration::from_secs_f64((HIDE_DELAY - (now - last)).max(0.0))); + let active = focused || now - last < AWAY_DELAY; + if active && !focused { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((AWAY_DELAY - (now - last)).max(0.0))); } ctx.data_mut(|d| d.insert_temp(key, last)); 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)); + d.insert_temp(away_key, !focused && (away || !active)); if !focused { d.remove::>>(self.id.with("native-raise")); } }); - (visible, focused && away) + (active, focused && away) } - /// A window holding a viewer is a normal window, so it neither vanishes nor sits over other - /// applications; the rest are palettes. + /// A window holding a viewer is a normal window, so it stacks with the main window; the rest + /// are palettes. fn normal_window(&self, window: &Floating

) -> bool { window.panels().any(|p| self.content.window_kind(p) == WindowKind::Normal) } - /// One floating window in its own viewport. `visible`: palettes show. Returns the key of - /// its [`Place`]. - fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, visible: bool) -> Option { + /// One floating window in its own viewport. `active`: the application is active (see + /// [`Self::app_activity`]). Returns the key of its [`Place`]. + fn native_window(&mut self, ctx: &Context, window: &Floating

, rect: [f32; 4], viewport: ViewportId, active: bool) -> Option { let normal = self.normal_window(window); - let visible = visible || normal; let first = window.panels().next()?.clone(); let key = self.id.with(("native-place", viewport)); let now = ctx.input(|i| i.time); @@ -522,7 +520,7 @@ impl Renderer<'_, P> { }; seen.insert(window.id); ctx.data_mut(|d| d.insert_temp(seen_key, seen)); - Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, shown: true, collapsed: None } + Place { spawn, known: spawn, pending: Some(now + MOVE_WAIT), corrected: false, collapsed: None } } }; // Minimized or expanded: the window keeps its top-left corner and width, and its @@ -546,8 +544,14 @@ impl Renderer<'_, P> { .with_inner_size([spawn[2], spawn[3]]) .with_min_inner_size(MIN_SIZE) // A blocked palette could cover the modal, and its bar moves nothing: the main - // window can come in front of it at the normal level. - .with_window_level(if normal || self.input_blocked { WindowLevel::Normal } else { WindowLevel::AlwaysOnTop }) + // window can come in front of it at the normal level. While the application is + // inactive, its palettes stay shown behind other applications' windows + // (`INACTIVE_PALETTE`). egui sends the level only when it changes. + .with_window_level(match (normal || self.input_blocked, active) { + (true, _) => WindowLevel::Normal, + (false, true) => WindowLevel::AlwaysOnTop, + (false, false) => INACTIVE_PALETTE, + }) .with_taskbar(false); // The bar replaces the OS title bar. macOS keeps the frame, with its title bar and // buttons hidden: AppKit resizes a movable window from its edges and corners with its @@ -561,15 +565,9 @@ impl Renderer<'_, P> { .with_resizable(!self.input_blocked && window.panels().all(|p| self.permissions(p).movable)); #[cfg(not(target_os = "macos"))] let builder = builder.with_decorations(false); - // A blocked palette keeps showing while the app is away: showing it on the app's return - // would raise it over the modal and, on macOS, give it focus. - let visible = visible || (self.input_blocked && place.shown); - // Only on a change: showing a shown window raises it and, on macOS, takes focus. - if place.shown != visible { - ctx.send_viewport_cmd_to(viewport, ViewportCommand::Visible(visible)); - place.shown = visible; - } - if visible && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { + // While the application is inactive, raising a palette leaves focus with the application + // in use. + if (active || normal) && !self.input_blocked && self.activation.as_ref().is_some_and(|panel| window.contains(panel)) { ctx.send_viewport_cmd_to(viewport, ViewportCommand::Focus); } let (targets, docks, windows) = (self.targets.len(), self.dock_targets.len(), self.frame.windows.len()); diff --git a/docs/ui-docking.md b/docs/ui-docking.md index 5dbfba79..aff09198 100644 --- a/docs/ui-docking.md +++ b/docs/ui-docking.md @@ -475,15 +475,19 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho monitor on a second display. A window holding any `Normal` panel is a normal window, whichever of its tabs shows; the rest are palettes. - Palettes stay above other windows (`WindowLevel::AlwaysOnTop`), so clicking the document - does not bury them. While none of the app's windows has focus they hide - (`ViewportCommand::Visible(false)`), and they come back when one has it again. The hide - waits 250 ms, so focus moving between the app's own windows does not flicker. + does not bury them. They stay shown while the app is in the background, so files dragged + from another app reach them and an OS sheet attached to one stays open. While none of the + app's windows has focus they take `WindowLevel::Normal`, so they do not cover other apps + (on Windows `WindowLevel::AlwaysOnBottom`: there `Normal` would put them in front of the + app the user switched to), and they go back to `AlwaysOnTop` when one has it again. The app counts as in the + background after 250 ms without focus, so focus moving between its own windows changes + nothing. The level is sent only when it changes. - Normal windows have `WindowLevel::Normal` and stay shown while the app is in the 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, + instead. When one of the app's windows gains focus after none had it for 250 ms, 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 @@ -573,9 +577,8 @@ windows from the first frame. `DockArea::native_windows(bool)` overrides the cho answers the OS close request with `ViewportCommand::CancelClose`. On macOS it is not resizable meanwhile. - *Stacking.* A palette drops to `WindowLevel::Normal` while blocked, so a click on the main - window brings it in front of a palette that covers the modal. A shown palette stays shown - while the app is in the background, since showing it again on the app's return would raise - it over the modal and, on macOS, give it focus. When the app is activated meanwhile, the + window brings it in front of a palette that covers the modal. It stays shown and at that + level when the app goes to the background and returns. When the app is activated meanwhile, the main window is raised last, in front of the normal windows and of a palette the user clicked. - *Edges.* Turning blocking on cancels a drag in progress, and a widget of a native window