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