Skip to content

Commit

Permalink
Change focus indicator to polling (#959)
Browse files Browse the repository at this point in the history
Previously, the focus indicator plugin would show and hide based on events from Whim's core. However, this had the downside that the focus indicator could suffer from flickering. This was a result of the multi-threading work for #859, as all events were being processed (as opposed to relying on the STA always providing the latest event).

The focus indicator has now switched to a polling model running on a long-running task (i.e., a different thread). There is a hardcoded sleep for 16ms between each poll (roughly corresponding to 60fps). Polling avoids the continual handling of each event Windows emits and instead relies on the **current state of Whim**. This should resolve #944.

The polling has the downside of significantly increasing the logs which are generated. Some of the more common logs have been moved to `Verbose` to ameliorate this.

The polling has the additional benefit of having the indicator follow the window as it moves around the screen. This was not feasible with the event-based model. The focus indicator also now uses the _actual_ window size, rather than the size which Whim stores. This is good for naughty windows (like Logi Options+) which prevent Whim from resizing them.

This PR also changes the focus indicator to only apply the indicator color to the border of the indicator window, resolving #669. This does not use an actual border for each window - it still uses the prior approach of using a Whim-managed window. From the brief research I did, it did not seem that Windows really supports custom borders without potentially messing with the window itself. This does not resolve #908.
  • Loading branch information
dalyIsaac authored Aug 1, 2024
1 parent b777779 commit 5432198
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 89 deletions.
138 changes: 73 additions & 65 deletions src/Whim.FocusIndicator/FocusIndicatorPlugin.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
using System;
using System.Text.Json;
using Microsoft.UI.Xaml;
using System.Threading;
using System.Threading.Tasks;
using Windows.Win32.Foundation;

namespace Whim.FocusIndicator;

Expand All @@ -10,8 +12,10 @@ public class FocusIndicatorPlugin : IFocusIndicatorPlugin
private bool _isEnabled = true;
private readonly IContext _context;
private readonly FocusIndicatorConfig _focusIndicatorConfig;
private readonly CancellationTokenSource _cancellationTokenSource;
private readonly CancellationToken _cancellationToken;
private FocusIndicatorWindow? _focusIndicatorWindow;
private DispatcherTimer? _dispatcherTimer;
private int _lastFocusStartTime;
private bool _disposedValue;

/// <summary>
Expand All @@ -31,17 +35,23 @@ public FocusIndicatorPlugin(IContext context, FocusIndicatorConfig focusIndicato
{
_context = context;
_focusIndicatorConfig = focusIndicatorConfig;

_cancellationTokenSource = new CancellationTokenSource();
_cancellationToken = _cancellationTokenSource.Token;
}

/// <inheritdoc/>
public void PreInitialize()
{
_context.FilterManager.AddTitleMatchFilter(FocusIndicatorConfig.Title);

_context.WindowManager.WindowMoveStart += WindowManager_WindowMoveStart;
_context.WindowManager.WindowFocused += WindowManager_WindowFocused;
}

private void WindowManager_WindowFocused(object? sender, WindowFocusedEventArgs e)
{
_lastFocusStartTime = Environment.TickCount;
}

/// <inheritdoc/>
public void PostInitialize()
{
Expand All @@ -52,97 +62,84 @@ public void PostInitialize()
_focusIndicatorWindow.Activate();
_focusIndicatorWindow.Hide(_context);

// Only subscribe to workspace changes once the indicator window has been created - we shouldn't
// show a window which doesn't yet exist (it'll just crash Whim).
_context.WorkspaceManager.WorkspaceLayoutStarted += WorkspaceManager_WorkspaceLayoutStarted;
_context.WorkspaceManager.WorkspaceLayoutCompleted += WorkspaceManager_WorkspaceLayoutCompleted;
Task.Factory.StartNew(
ContinuousPolling,
_cancellationToken,
TaskCreationOptions.LongRunning,
TaskScheduler.Default
);
}

private void DispatcherTimer_Tick(object? sender, object e)
private void ContinuousPolling()
{
Logger.Debug("Focus indicator timer ticked");
Hide();
while (true)
{
Poll();
Thread.Sleep(16);
}
}

private void WindowManager_WindowMoveStart(object? sender, WindowMoveStartedEventArgs e) => Hide();

private void WindowManager_WindowFocused(object? sender, WindowFocusedEventArgs e)
private void Poll()
{
if (!_isEnabled)
if (_isEnabled)
{
Logger.Debug("Focus indicator is disabled");
return;
}
if (_focusIndicatorConfig.FadeEnabled)
{
int now = Environment.TickCount;
if (now - _lastFocusStartTime >= _focusIndicatorConfig.FadeTimeout.TotalMilliseconds)
{
Hide();
return;
}
}

if (e.Window == null)
// If the fade is not enabled, or the fade is not over, show the focus indicator.
Show();
}
else if (IsVisible)
{
Hide();
return;
}

Show();
}

private void WorkspaceManager_WorkspaceLayoutStarted(object? sender, WorkspaceEventArgs e) => Hide();

private void WorkspaceManager_WorkspaceLayoutCompleted(object? sender, WorkspaceEventArgs e) => Show();

/// <inheritdoc/>
public void Show(IWindow? window = null)
{
Logger.Debug("Showing focus indicator");
IWorkspace activeWorkspace = _context.WorkspaceManager.ActiveWorkspace;
window ??= activeWorkspace.LastFocusedWindow;
if (window == null)
{
Logger.Debug("No window to show focus indicator for");
Hide();
return;
}
Logger.Verbose("Showing focus indicator");

// Get the window rectangle.
IWindowState? windowRect = activeWorkspace.TryGetWindowState(window);
if (windowRect == null)
HWND handle = window?.Handle ?? default;
if (handle == default)
{
Logger.Error($"Could not find window rectangle for window {window}");
Hide();
return;
if (_context.Store.Pick(Pickers.PickLastFocusedWindowHandle()).TryGet(out HWND hwnd))
{
handle = hwnd;
}
else
{
Logger.Verbose("No last focused window to show focus indicator for");
Hide();
return;
}
}

if (windowRect.WindowSize == WindowSize.Minimized)
IRectangle<int>? rect = _context.NativeManager.DwmGetWindowRectangle(handle);
if (rect == null)
{
Logger.Debug($"Window {window} is minimized");
Logger.Error($"Could not find window rectangle for window {handle}");
Hide();
return;
}

IsVisible = true;
_focusIndicatorWindow?.Activate(windowRect);

// If the fade is enabled, start the timer.
if (_focusIndicatorConfig.FadeEnabled)
{
_dispatcherTimer?.Stop();

_dispatcherTimer = new DispatcherTimer();
_dispatcherTimer.Tick += DispatcherTimer_Tick;
_dispatcherTimer.Interval = _focusIndicatorConfig.FadeTimeout;
_dispatcherTimer.Start();
}
_focusIndicatorWindow?.Activate(handle, rect);
}

/// <inheritdoc/>
private void Hide()
{
Logger.Debug("Hiding focus indicator");
Logger.Verbose("Hiding focus indicator");
_focusIndicatorWindow?.Hide(_context);
IsVisible = false;

if (_dispatcherTimer != null)
{
_dispatcherTimer.Stop();
_dispatcherTimer.Tick -= DispatcherTimer_Tick;
}
}

/// <inheritdoc/>
Expand All @@ -154,6 +151,12 @@ public void Toggle()
}
else
{
// Reset the last focus start time so the fade timer starts over.
if (_focusIndicatorConfig.FadeEnabled)
{
_lastFocusStartTime = Environment.TickCount;
}

Show();
}
}
Expand All @@ -167,6 +170,12 @@ public void ToggleEnabled()
_isEnabled = !_isEnabled;
if (_isEnabled)
{
// Reset the last focus start time so the fade timer starts over.
if (_focusIndicatorConfig.FadeEnabled)
{
_lastFocusStartTime = Environment.TickCount;
}

Show();
}
else
Expand All @@ -183,11 +192,10 @@ protected virtual void Dispose(bool disposing)
if (disposing)
{
// dispose managed state (managed objects)
_context.WindowManager.WindowFocused -= WindowManager_WindowFocused;
_context.WorkspaceManager.WorkspaceLayoutStarted -= WorkspaceManager_WorkspaceLayoutStarted;
_context.WorkspaceManager.WorkspaceLayoutCompleted -= WorkspaceManager_WorkspaceLayoutCompleted;
_cancellationTokenSource.Dispose();
_focusIndicatorWindow?.Dispose();
_focusIndicatorWindow?.Close();
_context.WindowManager.WindowFocused -= WindowManager_WindowFocused;
}

// free unmanaged resources (unmanaged objects) and override finalizer
Expand Down
15 changes: 14 additions & 1 deletion src/Whim.FocusIndicator/FocusIndicatorWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,18 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Background="{x:Bind Path=FocusIndicatorConfig.Color, Mode=OneWay}" />

<RelativePanel>
<!-- Windows 11 uses 8px radii -->
<Rectangle
RadiusX="8"
RadiusY="8"
RelativePanel.AlignBottomWithPanel="True"
RelativePanel.AlignLeftWithPanel="True"
RelativePanel.AlignRightWithPanel="True"
RelativePanel.AlignTopWithPanel="True"
Stroke="{x:Bind Path=FocusIndicatorConfig.Color, Mode=OneWay}"
StrokeThickness="{x:Bind Path=FocusIndicatorConfig.BorderSize, Mode=OneWay}" />

</RelativePanel>
</Window>
21 changes: 11 additions & 10 deletions src/Whim.FocusIndicator/FocusIndicatorWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Windows.Win32.UI.WindowsAndMessaging;
using Windows.Win32.Foundation;
using Windows.Win32.UI.WindowsAndMessaging;

namespace Whim.FocusIndicator;

Expand Down Expand Up @@ -27,19 +28,19 @@ public FocusIndicatorWindow(IContext context, FocusIndicatorConfig focusIndicato
/// <summary>
/// Activates the window behind the given window.
/// </summary>
/// <param name="targetWindowState">The window to show the indicator for.</param>
public void Activate(IWindowState targetWindowState)
/// <param name="handle">The handle of the window to activate behind.</param>
/// <param name="windowRectangle">The rectangle of the window to activate behind.</param>
public void Activate(HWND handle, IRectangle<int> windowRectangle)
{
Logger.Debug("Activating focus indicator window");
IRectangle<int> focusedWindowRect = targetWindowState.Rectangle;
Logger.Verbose("Activating focus indicator window");
int borderSize = FocusIndicatorConfig.BorderSize;

IRectangle<int> borderRect = new Rectangle<int>()
{
X = focusedWindowRect.X - borderSize,
Y = focusedWindowRect.Y - borderSize,
Height = focusedWindowRect.Height + (borderSize * 2),
Width = focusedWindowRect.Width + (borderSize * 2)
X = windowRectangle.X - borderSize,
Y = windowRectangle.Y - borderSize,
Height = windowRectangle.Height + (borderSize * 2),
Width = windowRectangle.Width + (borderSize * 2)
};

// Prevent the window from being activated.
Expand All @@ -53,7 +54,7 @@ public void Activate(IWindowState targetWindowState)
_window.Handle,
WindowSize.Normal,
borderRect,
targetWindowState.Window.Handle,
handle,
SET_WINDOW_POS_FLAGS.SWP_NOREDRAW | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE
)
);
Expand Down
63 changes: 63 additions & 0 deletions src/Whim.Tests/Store/WorkspaceSector/WorkspacePickersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,69 @@ internal void PickLastFocusedWindow_NoLastFocusedWindow(IContext ctx, MutableRoo
Assert.False(result.IsSuccessful);
}

[Theory, AutoSubstituteData<StoreCustomization>]
internal void PickLastFocusedWindowHandle_DefaultWorkspace(IContext ctx, MutableRootSector root)
{
// Given the workspaces and windows
Workspace workspace = CreateWorkspace(ctx);
IWindow lastFocusedWindow = Setup_LastFocusedWindow(ctx, root, workspace);

root.WorkspaceSector.Workspaces = root.WorkspaceSector.Workspaces.SetItem(
workspace.Id,
workspace with
{
LastFocusedWindowHandle = lastFocusedWindow.Handle
}
);

// When we get the last focused window handle
Result<HWND> result = ctx.Store.Pick(Pickers.PickLastFocusedWindowHandle());

// Then we get the last focused window handle
Assert.True(result.IsSuccessful);
Assert.Equal(lastFocusedWindow.Handle, result.Value);
}

[Theory, AutoSubstituteData<StoreCustomization>]
internal void PickLastFocusedWindowHandle_WorkspaceNotFound(IContext ctx, MutableRootSector root)
{
// Given the workspaces and windows exist, but the workspace to search for doesn't exist
Workspace workspace = CreateWorkspace(ctx);
IWindow lastFocusedWindow = Setup_LastFocusedWindow(ctx, root, workspace);

root.WorkspaceSector.Workspaces = root.WorkspaceSector.Workspaces.SetItem(
workspace.Id,
workspace with
{
LastFocusedWindowHandle = lastFocusedWindow.Handle
}
);

Guid workspaceToSearchFor = Guid.NewGuid();

// When we get the last focused window handle
Result<HWND> result = ctx.Store.Pick(Pickers.PickLastFocusedWindowHandle(workspaceToSearchFor));

// Then we get an error
Assert.False(result.IsSuccessful);
}

[Theory, AutoSubstituteData<StoreCustomization>]
internal void PickLastFocusedWindowHandle_NoLastFocusedWindow(IContext ctx, MutableRootSector root)
{
// Given the workspaces and windows, but the last focused window isn't set
Workspace workspace = CreateWorkspace(ctx);
IWindow lastFocusedWindow = Setup_LastFocusedWindow(ctx, root, workspace);

root.WorkspaceSector.Workspaces = root.WorkspaceSector.Workspaces.SetItem(workspace.Id, workspace);

// When we get the last focused window handle
Result<HWND> result = ctx.Store.Pick(Pickers.PickLastFocusedWindowHandle());

// Then we get an error
Assert.False(result.IsSuccessful);
}

private static IWindow Setup_WindowPosition(IContext ctx, MutableRootSector root, Workspace workspace)
{
IMonitor monitor = CreateMonitor((HMONITOR)1);
Expand Down
Loading

0 comments on commit 5432198

Please sign in to comment.