Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ All notable changes to Windows Edge Light are documented here.

The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project uses [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- Eliminated system-wide mouse/input lag caused by the edge-light overlay, most noticeably during color-temperature changes and while hovering the cursor near the glow.
- The overlay now adapts automatically to monitor configuration changes (resolution changes, displays connected or disconnected) instead of requiring a restart to look right again.
- Restored and widened the soft blurred edge so it displays reliably across monitors with different DPI scaling and display characteristics.

### Changed

- Replaced the overlay's rendering pipeline with a natively managed layered window per monitor, rendered off the main UI thread, substantially reducing CPU usage and improving responsiveness.
- Added an "Applying..." indicator in Settings while a color-temperature change is still rendering.

## [v1.13.0] - 2026-08-08

### Added
Expand Down
93 changes: 93 additions & 0 deletions WindowsEdgeLight/GlowBitmapRenderer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
using MediaColor = System.Windows.Media.Color;

namespace WindowsEdgeLight;

// Builds the glow frame geometry and renders it off-screen to a bitmap. Shared by
// MainWindow and every additional-monitor NativeLayeredWindow, so the visual math (frame
// thickness, corner radii, gradient stops, blur) lives in exactly one place instead of
// being duplicated per window as it was before.
internal static class GlowBitmapRenderer
{
public const double FrameThickness = 80;
public const double OuterRadius = 100;
public const double InnerRadius = 60;
// Matches the old HoverCursorRing's 140px diameter - the hole cut into the frame at
// the cursor position is the same size the ring used to be.
public const double HoleRadius = 70;

// insetWidth/insetHeight are the frame's own bounds (already margin-adjusted, i.e.
// full window size minus 40) - holeCenter, if given, is in that same local coordinate
// space (see MainWindow's pathOffsetX/Y math for converting cursor position into it).
public static Geometry BuildFrameGeometry(double insetWidth, double insetHeight, System.Windows.Point? holeCenter)
{
var outerRect = new RectangleGeometry(new Rect(0, 0, insetWidth, insetHeight), OuterRadius, OuterRadius);
var innerRect = new RectangleGeometry(
new Rect(FrameThickness, FrameThickness, insetWidth - FrameThickness * 2, insetHeight - FrameThickness * 2),
InnerRadius, InnerRadius);
Geometry frame = new CombinedGeometry(GeometryCombineMode.Exclude, outerRect, innerRect);

if (holeCenter is System.Windows.Point center)
{
var hole = new EllipseGeometry(center, HoleRadius, HoleRadius);
frame = new CombinedGeometry(GeometryCombineMode.Exclude, frame, hole);
}

return frame;
}

// fullDipWidth/fullDipHeight are the full window size in DIPs (matching what the
// window used to be); frameGeometry must have been built with insetWidth/Height of
// (fullDipWidth - 40, fullDipHeight - 40) so the WPF layout centers it identically to
// the original XAML's HorizontalAlignment/VerticalAlignment="Center" behavior.
public static RenderTargetBitmap Render(
double fullDipWidth, double fullDipHeight, Geometry frameGeometry,
MediaColor temperatureColor, double blurRadius, double dpiScaleX, double dpiScaleY)
{
var gradient = new LinearGradientBrush
{
StartPoint = new System.Windows.Point(0, 0),
EndPoint = new System.Windows.Point(1, 1)
};
gradient.GradientStops.Add(new GradientStop(MediaColor.FromRgb(255, 255, 255), 0.0));
gradient.GradientStops.Add(new GradientStop(temperatureColor, 0.3));
gradient.GradientStops.Add(new GradientStop(temperatureColor, 0.5));
gradient.GradientStops.Add(new GradientStop(temperatureColor, 0.7));
gradient.GradientStops.Add(new GradientStop(MediaColor.FromRgb(255, 255, 255), 1.0));

var path = new System.Windows.Shapes.Path
{
Data = frameGeometry,
Fill = gradient,
Stretch = Stretch.None,
HorizontalAlignment = System.Windows.HorizontalAlignment.Center,
VerticalAlignment = System.Windows.VerticalAlignment.Center,
Effect = new DropShadowEffect
{
BlurRadius = blurRadius,
Opacity = 1,
ShadowDepth = 0,
Color = temperatureColor
}
};

var grid = new Grid { Width = fullDipWidth, Height = fullDipHeight };
grid.Children.Add(path);

// Off-screen elements need an explicit Measure/Arrange pass - they're never
// attached to a live visual tree, so nothing else will lay them out.
grid.Measure(new System.Windows.Size(fullDipWidth, fullDipHeight));
grid.Arrange(new Rect(0, 0, fullDipWidth, fullDipHeight));

int pixelWidth = Math.Max(1, (int)Math.Round(fullDipWidth * dpiScaleX));
int pixelHeight = Math.Max(1, (int)Math.Round(fullDipHeight * dpiScaleY));

var rtb = new RenderTargetBitmap(pixelWidth, pixelHeight, 96.0 * dpiScaleX, 96.0 * dpiScaleY, PixelFormats.Pbgra32);
rtb.Render(grid);
return rtb;
}
}
79 changes: 79 additions & 0 deletions WindowsEdgeLight/GlowRenderWorker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using System.Threading;
using System.Windows.Media;
using System.Windows.Threading;
using MediaColor = System.Windows.Media.Color;

namespace WindowsEdgeLight;

// The pixel bytes produced by one render, plus the dimensions needed to interpret them.
internal readonly record struct RenderedFrame(byte[] Pixels, int Width, int Height, int Stride);

// Runs GlowBitmapRenderer's WPF off-screen rendering (the expensive part - layout plus
// the blur Effect, measured at several hundred ms depending on monitor DPI/resolution and
// blur radius) on a dedicated background thread with its own WPF Dispatcher, instead of
// the main UI thread.
//
// Why this matters: the main UI thread also owns the global low-level mouse hook
// (SetWindowsHookEx in MainWindow). Windows delivers that hook's notifications
// synchronously on the thread that installed it, and expects the hook procedure to
// return promptly - while that thread is blocked in a long synchronous render, Windows
// can't get a timely response from the hook, which was showing up as system-wide mouse
// lag (not just this app) during color-temperature changes. Moving the render off that
// thread keeps it free to service input while rendering happens in parallel.
//
// WPF Visual/UIElement/RenderTargetBitmap objects have thread affinity - they can only be
// created and used on the thread that instantiated them - which is why this needs its own
// STA thread with its own Dispatcher, not just a plain background Task.
internal sealed class GlowRenderWorker : IDisposable
{
private readonly Thread _thread;
private readonly ManualResetEventSlim _ready = new(false);
private Dispatcher _dispatcher = null!;

public GlowRenderWorker()
{
_thread = new Thread(Run) { IsBackground = true, Name = "GlowRenderWorker" };
_thread.SetApartmentState(ApartmentState.STA);
_thread.Start();
_ready.Wait();
}

private void Run()
{
_dispatcher = Dispatcher.CurrentDispatcher;
_ready.Set();
Dispatcher.Run();
}

// Renders on the worker thread and returns the raw pixel bytes, or null if
// isStillCurrent() returns false by the time this job reaches the front of the
// worker's queue - lets a superseded request (e.g. from a fast color-temp drag) bail
// out before paying for an expensive render whose result would just be discarded.
public Task<RenderedFrame?> RenderAsync(
double dipWidth, double dipHeight, double insetWidth, double insetHeight,
MediaColor color, double blurRadius, double dpiScaleX, double dpiScaleY,
Func<bool> isStillCurrent)
{
return _dispatcher.InvokeAsync(() =>
{
if (!isStillCurrent()) return (RenderedFrame?)null;

var geometry = GlowBitmapRenderer.BuildFrameGeometry(insetWidth, insetHeight, holeCenter: null);
var bitmap = GlowBitmapRenderer.Render(dipWidth, dipHeight, geometry, color, blurRadius, dpiScaleX, dpiScaleY);

int width = bitmap.PixelWidth;
int height = bitmap.PixelHeight;
int stride = width * 4;
var pixels = new byte[stride * height];
bitmap.CopyPixels(System.Windows.Int32Rect.Empty, pixels, stride, 0);

return (RenderedFrame?)new RenderedFrame(pixels, width, height, stride);
}).Task;
}

public void Dispose()
{
_dispatcher?.InvokeShutdown();
_ready.Dispose();
}
}
55 changes: 17 additions & 38 deletions WindowsEdgeLight/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,54 +6,33 @@
xmlns:local="clr-namespace:WindowsEdgeLight"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="Windows Edge Light"
Width="800"
Height="450"
AllowsTransparency="True"
Background="Transparent"
Width="1"
Height="1"
KeyDown="Window_KeyDown"
Loaded="Window_Loaded"
ResizeMode="NoResize"
ShowInTaskbar="True"
Topmost="True"
WindowStyle="None"
mc:Ignorable="d">

<Window.TaskbarItemInfo>
<TaskbarItemInfo Description="Windows Edge Light - Right-click for options" />
</Window.TaskbarItemInfo>

<Grid x:Name="MainGrid" IsHitTestVisible="False">
<!-- Edge Light Frame with Rounded Inner and Outer Edges using Path -->
<Path
x:Name="EdgeLightBorder"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Stretch="None">
<Path.Fill>
<LinearGradientBrush StartPoint="0,0" EndPoint="1,1">
<GradientStop Offset="0.0" Color="#FFFFFF" />
<GradientStop Offset="0.3" Color="#F0F0F0" />
<GradientStop Offset="0.5" Color="#FFFFFF" />
<GradientStop Offset="0.7" Color="#F0F0F0" />
<GradientStop Offset="1.0" Color="#FFFFFF" />
</LinearGradientBrush>
</Path.Fill>
<Path.Effect>
<DropShadowEffect
BlurRadius="76"
Opacity="1"
ShadowDepth="0"
Color="#FFFFFF" />
</Path.Effect>
</Path>
<!--
Intentionally empty and pinned to 1x1px. This window no longer renders the glow
itself - it exists only to host the hwnd used for global hotkeys, the mouse hook,
and DPI/monitor tracking. The actual glow is drawn by a NativeLayeredWindow (see
MainWindow.xaml.cs and NativeLayeredWindow.cs), a raw Win32 layered window managed
entirely outside WPF.

<!-- Overlay canvas for cursor ring -->
<Canvas x:Name="CursorOverlay" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" IsHitTestVisible="False">
<Ellipse x:Name="HoverCursorRing"
Width="140" Height="140"
Fill="Transparent"
Visibility="Collapsed">
</Ellipse>
</Canvas>
</Grid>
Deliberately NOT AllowsTransparency/Topmost/monitor-sized anymore: a WPF window
that covers a whole monitor receives (and fully processes through WPF's own
input/hit-testing pipeline) every single mouse-move message on that monitor, even
with empty content and IsHitTestVisible="False" on its children - that pipeline
cost is real even when nothing is drawn, and was making cursor movement sluggish
on whichever monitor this window covered. Kept to 1x1px so it never intercepts
real screen area; see PositionMainWindowShell in MainWindow.xaml.cs.
-->
<Grid x:Name="MainGrid" IsHitTestVisible="False" />
</Window>
Loading