From e5c755a536a1c2a5ec144b4789284173b3ff1148 Mon Sep 17 00:00:00 2001 From: lars-berger Date: Fri, 15 Sep 2023 04:11:52 +0800 Subject: [PATCH] feat: add intermediary window display states (#383) --- GlazeWM.Domain/Common/Enums/DisplayState.cs | 12 ++++++++ .../RedrawContainersHandler.cs | 20 +++++++++++-- GlazeWM.Domain/Containers/Container.cs | 2 +- .../CommandHandlers/UnmanageWindowHandler.cs | 4 --- .../EventHandlers/WindowFocusedHandler.cs | 27 ++++++++++++++++-- .../EventHandlers/WindowHiddenHandler.cs | 27 ++++++++++++------ .../EventHandlers/WindowShownHandler.cs | 28 +++++++++++++++---- GlazeWM.Domain/Windows/Window.cs | 13 +++++---- .../CommandHandlers/FocusWorkspaceHandler.cs | 10 +++---- .../MoveWindowToWorkspaceHandler.cs | 8 ++++++ 10 files changed, 116 insertions(+), 35 deletions(-) create mode 100644 GlazeWM.Domain/Common/Enums/DisplayState.cs diff --git a/GlazeWM.Domain/Common/Enums/DisplayState.cs b/GlazeWM.Domain/Common/Enums/DisplayState.cs new file mode 100644 index 000000000..09f077fc7 --- /dev/null +++ b/GlazeWM.Domain/Common/Enums/DisplayState.cs @@ -0,0 +1,12 @@ +using System; + +namespace GlazeWM.Domain.Common.Enums +{ + public enum DisplayState + { + Shown, + Showing, + Hidden, + Hiding, + } +} diff --git a/GlazeWM.Domain/Containers/CommandHandlers/RedrawContainersHandler.cs b/GlazeWM.Domain/Containers/CommandHandlers/RedrawContainersHandler.cs index a62edfda3..fe476c65b 100644 --- a/GlazeWM.Domain/Containers/CommandHandlers/RedrawContainersHandler.cs +++ b/GlazeWM.Domain/Containers/CommandHandlers/RedrawContainersHandler.cs @@ -1,8 +1,10 @@ using System; using System.Linq; +using GlazeWM.Domain.Common.Enums; using GlazeWM.Domain.Containers.Commands; using GlazeWM.Domain.UserConfigs; using GlazeWM.Domain.Windows; +using GlazeWM.Domain.Workspaces; using GlazeWM.Infrastructure.Bussing; using static GlazeWM.Infrastructure.WindowsApi.WindowsApiService; @@ -71,14 +73,28 @@ private void SetWindowPosition(Window window) SetWindowPosFlags.FrameChanged | SetWindowPosFlags.NoActivate | SetWindowPosFlags.NoCopyBits | - SetWindowPosFlags.NoSendChanging; + SetWindowPosFlags.NoSendChanging | + SetWindowPosFlags.AsyncWindowPos; + + var workspace = window.Ancestors.OfType().First(); + var isWorkspaceDisplayed = workspace.IsDisplayed; // Show or hide the window depending on whether the workspace is displayed. - if (window.IsDisplayed) + if (isWorkspaceDisplayed) defaultFlags |= SetWindowPosFlags.ShowWindow; else defaultFlags |= SetWindowPosFlags.HideWindow; + // Transition display state depending on whether window will be shown/hidden. + window.DisplayState = window.DisplayState switch + { + DisplayState.Hidden or + DisplayState.Hiding when isWorkspaceDisplayed => DisplayState.Showing, + DisplayState.Shown or + DisplayState.Showing when !isWorkspaceDisplayed => DisplayState.Hiding, + _ => window.DisplayState + }; + if (window is TilingWindow) { SetWindowPos( diff --git a/GlazeWM.Domain/Containers/Container.cs b/GlazeWM.Domain/Containers/Container.cs index 91943de5b..cad31ea87 100644 --- a/GlazeWM.Domain/Containers/Container.cs +++ b/GlazeWM.Domain/Containers/Container.cs @@ -150,7 +150,7 @@ public IEnumerable DescendantFocusOrder public bool IsDetached() { - return Parent is null; + return Parent is null || Index == -1; } public bool HasChildren() diff --git a/GlazeWM.Domain/Windows/CommandHandlers/UnmanageWindowHandler.cs b/GlazeWM.Domain/Windows/CommandHandlers/UnmanageWindowHandler.cs index a78b0e0a0..e71a4d0f2 100644 --- a/GlazeWM.Domain/Windows/CommandHandlers/UnmanageWindowHandler.cs +++ b/GlazeWM.Domain/Windows/CommandHandlers/UnmanageWindowHandler.cs @@ -42,10 +42,6 @@ public CommandResponse Handle(UnmanageWindowCommand command) if (focusTarget is null) return CommandResponse.Ok; - // The OS automatically switches focus to a different window after closing. If - // there are focusable windows, then set focus *after* the OS sets focus. This will - // cause focus to briefly flicker to the OS focus target and then to the WM's focus - // target. _bus.Invoke(new SetFocusedDescendantCommand(focusTarget)); _containerService.HasPendingFocusSync = true; _windowService.UnmanagedOrMinimizedStopwatch.Restart(); diff --git a/GlazeWM.Domain/Windows/EventHandlers/WindowFocusedHandler.cs b/GlazeWM.Domain/Windows/EventHandlers/WindowFocusedHandler.cs index a15306d5f..6c778d81f 100644 --- a/GlazeWM.Domain/Windows/EventHandlers/WindowFocusedHandler.cs +++ b/GlazeWM.Domain/Windows/EventHandlers/WindowFocusedHandler.cs @@ -1,9 +1,11 @@ using System.Linq; +using GlazeWM.Domain.Common.Enums; using GlazeWM.Domain.Common.Utils; using GlazeWM.Domain.Containers; using GlazeWM.Domain.Containers.Commands; using GlazeWM.Domain.Containers.Events; using GlazeWM.Domain.Workspaces; +using GlazeWM.Domain.Workspaces.Commands; using GlazeWM.Infrastructure.Bussing; using GlazeWM.Infrastructure.Common.Events; using Microsoft.Extensions.Logging; @@ -36,7 +38,8 @@ public void Handle(WindowFocusedEvent @event) var window = _windowService.GetWindows() .FirstOrDefault(window => window.Handle == @event.WindowHandle); - if (window is null || window?.IsDisplayed == false) + // Ignore event if window is unmanaged or being hidden by the WM. + if (window is null || window.DisplayState is DisplayState.Hiding) return; _logger.LogWindowEvent("Native focus event", window); @@ -49,13 +52,33 @@ public void Handle(WindowFocusedEvent @event) var unmanagedStopwatch = _windowService.UnmanagedOrMinimizedStopwatch; - if (unmanagedStopwatch?.ElapsedMilliseconds < 100) + // Handle overriding focus on close/minimize. After a window is closed or minimized, + // the OS or the closed application might automatically switch focus to a different + // window. To force focus to go to the WM's target focus container, we reassign any + // focus events 100ms after close/minimize. This will cause focus to briefly flicker + // to the OS focus target and then to the WM's focus target. + if (unmanagedStopwatch.IsRunning && unmanagedStopwatch.ElapsedMilliseconds < 100) { _logger.LogDebug("Overriding native focus."); _bus.Invoke(new SyncNativeFocusCommand()); return; } + // Handle focus events from windows on hidden workspaces. For example, if Discord + // is forcefully shown by the OS when it's on a hidden workspace, switch focus to + // Discord's workspace. + if (window.DisplayState is DisplayState.Hidden) + { + _logger.LogWindowEvent("Focusing off-screen window", window); + + var workspace = WorkspaceService.GetWorkspaceFromChildContainer(window); + _bus.Invoke(new FocusWorkspaceCommand(workspace.Name)); + _bus.Invoke(new SetFocusedDescendantCommand(window)); + _bus.Invoke(new RedrawContainersCommand()); + _bus.Emit(new FocusChangedEvent(window)); + return; + } + // Update the WM's focus state. _bus.Invoke(new SetFocusedDescendantCommand(window)); _bus.Emit(new FocusChangedEvent(window)); diff --git a/GlazeWM.Domain/Windows/EventHandlers/WindowHiddenHandler.cs b/GlazeWM.Domain/Windows/EventHandlers/WindowHiddenHandler.cs index e7db591a0..36911c1c6 100644 --- a/GlazeWM.Domain/Windows/EventHandlers/WindowHiddenHandler.cs +++ b/GlazeWM.Domain/Windows/EventHandlers/WindowHiddenHandler.cs @@ -1,4 +1,5 @@ using System.Linq; +using GlazeWM.Domain.Common.Enums; using GlazeWM.Domain.Common.Utils; using GlazeWM.Domain.Containers.Commands; using GlazeWM.Domain.Monitors.Commands; @@ -39,18 +40,28 @@ public void Handle(WindowHiddenEvent @event) var window = _windowService.GetWindows() .FirstOrDefault(window => window.Handle == windowHandle); - // Ignore events where the window isn't managed or is actually supposed to be hidden. Since - // window events are processed in a sequence, also handle case where the window is not - // actually hidden anymore when the event is processed. - if (window?.IsDisplayed != true || WindowService.IsHandleVisible(window.Handle)) + // Ignore event if window is unmanaged. + if (window is null) return; _logger.LogWindowEvent("Window hidden", window); - // Detach the hidden window from its parent. - _bus.Invoke(new UnmanageWindowCommand(window)); - _bus.Invoke(new RedrawContainersCommand()); - _bus.Invoke(new SyncNativeFocusCommand()); + // Update the display state. + if (window.DisplayState is DisplayState.Hiding) + { + window.DisplayState = DisplayState.Hidden; + return; + } + + // Unmanage the window if it's not in a display state transition. Also, since window + // events are not 100% guaranteed to be in correct order, we need to ignore events + // where the window is not actually hidden. + if (window.DisplayState is DisplayState.Shown && !WindowService.IsHandleVisible(window.Handle)) + { + _bus.Invoke(new UnmanageWindowCommand(window)); + _bus.Invoke(new RedrawContainersCommand()); + _bus.Invoke(new SyncNativeFocusCommand()); + } } } } diff --git a/GlazeWM.Domain/Windows/EventHandlers/WindowShownHandler.cs b/GlazeWM.Domain/Windows/EventHandlers/WindowShownHandler.cs index 410b1e9cb..a64c2e673 100644 --- a/GlazeWM.Domain/Windows/EventHandlers/WindowShownHandler.cs +++ b/GlazeWM.Domain/Windows/EventHandlers/WindowShownHandler.cs @@ -1,20 +1,28 @@ using System.Linq; +using GlazeWM.Domain.Common.Enums; +using GlazeWM.Domain.Common.Utils; using GlazeWM.Domain.Containers.Commands; using GlazeWM.Domain.Monitors.Commands; using GlazeWM.Domain.Windows.Commands; using GlazeWM.Infrastructure.Bussing; using GlazeWM.Infrastructure.Common.Events; +using Microsoft.Extensions.Logging; namespace GlazeWM.Domain.Windows.EventHandlers { internal sealed class WindowShownHandler : IEventHandler { private readonly Bus _bus; + private readonly ILogger _logger; private readonly WindowService _windowService; - public WindowShownHandler(Bus bus, WindowService windowService) + public WindowShownHandler( + Bus bus, + ILogger logger, + WindowService windowService) { _bus = bus; + _logger = logger; _windowService = windowService; } @@ -32,13 +40,21 @@ public void Handle(WindowShownEvent @event) var window = _windowService.GetWindows() .FirstOrDefault(window => window.Handle == windowHandle); - // Ignore cases where window is already managed. - if (window is not null || !WindowService.IsHandleManageable(windowHandle)) + // Manage the window if it's manageable. + if (window is null && WindowService.IsHandleManageable(windowHandle)) + { + _bus.Invoke(new ManageWindowCommand(windowHandle)); + _bus.Invoke(new RedrawContainersCommand()); + _bus.Invoke(new SyncNativeFocusCommand()); return; + } + + if (window is not null) + _logger.LogWindowEvent("Showing window", window); - _bus.Invoke(new ManageWindowCommand(@event.WindowHandle)); - _bus.Invoke(new RedrawContainersCommand()); - _bus.Invoke(new SyncNativeFocusCommand()); + // Update display state if window is already managed. + if (window?.DisplayState == DisplayState.Showing) + window.DisplayState = DisplayState.Shown; } } } diff --git a/GlazeWM.Domain/Windows/Window.cs b/GlazeWM.Domain/Windows/Window.cs index 7f605fc6c..8a4792d84 100644 --- a/GlazeWM.Domain/Windows/Window.cs +++ b/GlazeWM.Domain/Windows/Window.cs @@ -1,7 +1,8 @@ using System; +using System.Diagnostics; using GlazeWM.Domain.Common; +using GlazeWM.Domain.Common.Enums; using GlazeWM.Domain.Containers; -using GlazeWM.Domain.Workspaces; using GlazeWM.Infrastructure.WindowsApi; using static GlazeWM.Infrastructure.WindowsApi.WindowsApiService; @@ -14,6 +15,11 @@ public abstract class Window : Container public IntPtr Handle { get; } + /// + /// Whether window is shown, hidden, or in an intermediary state. + /// + public DisplayState DisplayState { get; set; } = DisplayState.Shown; + /// /// The placement of the window when floating. Initialized with window's placement on launch /// and updated on resize/move whilst floating. @@ -39,11 +45,6 @@ protected Window(IntPtr handle, Rect floatingPlacement, RectDelta borderDelta) BorderDelta = borderDelta; } - /// - /// Windows are displayed if their parent workspace is displayed. - /// - public bool IsDisplayed => WorkspaceService.GetWorkspaceFromChildContainer(this).IsDisplayed; - public string ProcessName => WindowService.GetProcessOfHandle(Handle)?.ProcessName ?? string.Empty; diff --git a/GlazeWM.Domain/Workspaces/CommandHandlers/FocusWorkspaceHandler.cs b/GlazeWM.Domain/Workspaces/CommandHandlers/FocusWorkspaceHandler.cs index 680708800..acf03fb95 100644 --- a/GlazeWM.Domain/Workspaces/CommandHandlers/FocusWorkspaceHandler.cs +++ b/GlazeWM.Domain/Workspaces/CommandHandlers/FocusWorkspaceHandler.cs @@ -59,9 +59,10 @@ public CommandResponse Handle(FocusWorkspaceCommand command) // Save currently focused workspace as recent for command "recent" _workspaceService.MostRecentWorkspace = focusedWorkspace; + _logger.LogDebug("WorkspaceToFocus: {WorkspaceToFocusName}", workspaceToFocus.Name); + // Set focus to the last focused window in workspace. If the workspace has no descendant // windows, then set focus to the workspace itself. - _logger.LogDebug("WorkspaceToFocus: {WorkspaceToFocusName}", workspaceToFocus.Name); var containerToFocus = workspaceToFocus.HasChildren() ? workspaceToFocus.LastFocusedDescendant : workspaceToFocus; @@ -70,11 +71,8 @@ public CommandResponse Handle(FocusWorkspaceCommand command) _containerService.HasPendingFocusSync = true; // Display the workspace to switch focus to. - if (focusedWorkspace.Parent == workspaceToFocus.Parent) - { - _containerService.ContainersToRedraw.Add(displayedWorkspace); - _containerService.ContainersToRedraw.Add(workspaceToFocus); - } + _containerService.ContainersToRedraw.Add(displayedWorkspace); + _containerService.ContainersToRedraw.Add(workspaceToFocus); // Get empty workspace to destroy (if any are found). Cannot destroy empty workspaces if // they're the only workspace on the monitor or are pending focus. diff --git a/GlazeWM.Domain/Workspaces/CommandHandlers/MoveWindowToWorkspaceHandler.cs b/GlazeWM.Domain/Workspaces/CommandHandlers/MoveWindowToWorkspaceHandler.cs index a4ab961d0..f4c7acc00 100644 --- a/GlazeWM.Domain/Workspaces/CommandHandlers/MoveWindowToWorkspaceHandler.cs +++ b/GlazeWM.Domain/Workspaces/CommandHandlers/MoveWindowToWorkspaceHandler.cs @@ -54,11 +54,19 @@ public CommandResponse Handle(MoveWindowToWorkspaceCommand command) var focusTarget = WindowService.GetFocusTargetAfterRemoval(windowToMove); + // Since the workspace that gets displayed is the last focused child, focus needs to be + // reassigned to the displayed workspace. + var targetMonitor = targetWorkspace.Parent as Monitor; + var focusResetTarget = targetWorkspace.IsDisplayed ? null : targetMonitor.LastFocusedDescendant; + if (windowToMove is TilingWindow) MoveTilingWindowToWorkspace(windowToMove as TilingWindow, targetWorkspace); else _bus.Invoke(new MoveContainerWithinTreeCommand(windowToMove, targetWorkspace, false)); + if (focusResetTarget is not null) + _bus.Invoke(new SetFocusedDescendantCommand(focusResetTarget)); + // Reassign focus to descendant within the current workspace. Need to call // `SetFocusedDescendantCommand` for when commands like `FocusWorkspaceCommand` are called // immediately afterwards and they should behave as if `focusTarget` is the focused