Compare commits

...

2 Commits

Author SHA1 Message Date
Niels Laute
5bc92569e4 Mouse Highlighter: unified ripple hold/drag animation and crosshairs
- Change default mode to ripple effect
- Add unified press-hold-release ripple animation:
  ring expands to held size on press, follows cursor during drag,
  continues expanding and fading on release
- Add crosshair lines on right-click release animation
- Add press-down shrink animation for basic circle mode
- Update defaults: radius=30, fadeDelay=400ms, fadeDuration=400ms
- Thinner ring strokes for cleaner ripple aesthetic

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-29 14:35:54 +02:00
Clint Rutkas
3bcfa78b20 [Mouse Highlighter] Add ClickLight-style ripple mode
Adds a third "Ripple" option to the Mouse Highlighter Highlight-mode picker
(alongside the existing Spotlight and Circle modes), inspired by
aurorascharff/ClickLight. Each click spawns a fast cubic-ease-out pulse
composed of an expanding stroked ring and a softer glow halo that fade out
independently, so rapid clicks stack without waiting for the previous pulse.

Implementation details:
- Animates EllipseGeometry.Radius (Vector2KeyFrameAnimation) and
  CompositionColorBrush.Color with CubicBezier(0.215, 0.61, 0.355, 1.0)
  over the existing highlight_fade_duration_ms (clamped 60-2000 ms).
- Ring: 0.20x -> 1.05x base radius, stroke kept crisp via IsStrokeNonScaling.
- Glow: 0.30x -> 1.40x base radius, starting alpha = clickColor.A * 0.35.
- Cleanup uses CompositionScopedBatch.Completed marshalled back to the
  DispatcherQueue, guarded by Highlighter::instance null-check.
- Mode exclusivity: ripple is suppressed when spotlight_mode is true; the
  ripple branch only handles button-down events so the existing trail line
  and fade paths naturally skip.

UI/settings:
- New ripple_mode bool in MouseHighlighterSettings (C++) and
  MouseHighlighterProperties (C#); schema is additive and back-compat.
- New tri-state HighlightModeIndex VM property (0=Spotlight, 1=Circle,
  2=Ripple) drives the ComboBox SelectedIndex directly.
- New HighlightMode_Ripple_Mode.Content "Ripple" string in en-us
  Resources.resw; updated HighlightMode.Description.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 22:19:16 -07:00
7 changed files with 775 additions and 13 deletions

View File

@@ -48,6 +48,14 @@ private:
void ClearDrawingPoint();
void ClearDrawing();
void BringToFront();
// Spawn a ClickLight-style expanding ripple pulse for the given click.
void SpawnRipplePulse(MouseButton button);
// Ripple mode hold/drag dot
void SpawnRippleHoldDot(MouseButton button);
void FadeRippleHoldDot(MouseButton button);
// Spotlight press/release animation helpers
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -71,6 +79,13 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
// Held ripple glow (for drag tracking in ripple mode)
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
// Spotlight overlay (mask with soft feathered edge)
winrt::SpriteVisual m_overlay{ nullptr };
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
@@ -84,6 +99,8 @@ private:
bool m_rightPointerEnabled = true;
bool m_alwaysPointerEnabled = true;
bool m_spotlightMode = false;
bool m_spotlightPressed = false;
bool m_rippleMode = true;
bool m_leftButtonPressed = false;
bool m_rightButtonPressed = false;
@@ -194,11 +211,33 @@ void Highlighter::AddDrawingPoint(MouseButton button)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
m_leftPointer = circleShape;
m_leftGeometry = circleGeometry;
// Animate shrink after a short hold delay (won't be visible on quick clicks)
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else if (button == MouseButton::Right)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
m_rightPointer = circleShape;
m_rightGeometry = circleGeometry;
// Animate shrink after a short hold delay (won't be visible on quick clicks)
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else
{
@@ -238,17 +277,36 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
if (button == MouseButton::Left)
{
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_leftRippleGlow)
{
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else if (button == MouseButton::Right)
{
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_rightRippleGlow)
{
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
// always / spotlight idle
if (m_spotlightMode)
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
if (m_spotlightPressed)
{
// Only update position while pressed — radius is being animated
if (m_spotlightMaskGradient)
{
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
}
else if (m_alwaysPointer)
{
@@ -259,14 +317,23 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
void Highlighter::StartDrawingPointFading(MouseButton button)
{
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
if (button == MouseButton::Left)
{
circleShape = m_leftPointer;
geom = m_leftGeometry;
}
else
{
// right
circleShape = m_rightPointer;
geom = m_rightGeometry;
}
// Stop the shrink animation and let the fade handle disappearance
if (geom && m_compositor)
{
geom.StopAnimation(L"Radius");
}
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
@@ -329,6 +396,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
switch (wParam)
{
case WM_LBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_leftPointerEnabled)
{
instance->SpawnRippleHoldDot(MouseButton::Left);
instance->m_leftButtonPressed = true;
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_leftPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
@@ -354,6 +439,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_rightPointerEnabled)
{
instance->SpawnRippleHoldDot(MouseButton::Right);
instance->m_rightButtonPressed = true;
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_rightPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
@@ -390,11 +493,22 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_LBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_leftButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Left);
if (instance->m_rippleMode)
{
instance->FadeRippleHoldDot(MouseButton::Left);
}
else
{
instance->StartDrawingPointFading(MouseButton::Left);
}
instance->m_leftButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
{
// Add AlwaysPointer only when it's enabled and RightPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -402,11 +516,22 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_rightButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Right);
if (instance->m_rippleMode)
{
instance->FadeRippleHoldDot(MouseButton::Right);
}
else
{
instance->StartDrawingPointFading(MouseButton::Right);
}
instance->m_rightButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
{
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -451,6 +576,12 @@ void Highlighter::StopDrawing()
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
m_leftGeometry = nullptr;
m_rightGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_rightRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
m_rightGlowGeometry = nullptr;
if (m_overlay)
{
m_overlay.IsVisible(false);
@@ -478,6 +609,7 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
m_rippleMode = settings.rippleMode && !m_spotlightMode;
if (m_spotlightMode)
{
@@ -643,6 +775,555 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
}
}
void Highlighter::SpotlightAnimatePress()
{
if (!m_spotlightMaskGradient || !m_compositor)
{
return;
}
m_spotlightPressed = true;
// Animate radius shrinking to 85% to give a "pressed" feel
const float pressedRadius = m_radius * 0.85f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(120));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
void Highlighter::SpotlightAnimateRelease()
{
if (!m_spotlightMaskGradient || !m_compositor)
{
return;
}
m_spotlightPressed = false;
// Animate radius back to normal
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
anim.Duration(std::chrono::milliseconds(200));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Create a held ripple ring+glow that expands to an intermediate size and holds.
// On release, ReleaseRippleHeld continues the expansion and fades out.
void Highlighter::SpawnRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
// Clean up any existing held shapes (e.g., stray double button-down without up)
auto shapes = m_shape.Shapes();
uint32_t index = 0;
if (button == MouseButton::Left)
{
if (m_leftPointer && shapes.IndexOf(m_leftPointer, index))
shapes.RemoveAt(index);
if (m_leftRippleGlow && shapes.IndexOf(m_leftRippleGlow, index))
shapes.RemoveAt(index);
}
else
{
if (m_rightPointer && shapes.IndexOf(m_rightPointer, index))
shapes.RemoveAt(index);
if (m_rightRippleGlow && shapes.IndexOf(m_rightRippleGlow, index))
shapes.RemoveAt(index);
}
POINT pt;
GetCursorPos(&pt);
ScreenToClient(m_hwnd, &pt);
winrt::Windows::UI::Color color;
if (button == MouseButton::Left)
{
color = m_leftClickColor;
}
else
{
color = m_rightClickColor;
}
if (color.A == 0)
{
return;
}
const float baseRadius = (m_radius < 1.0f) ? 1.0f : m_radius;
// Start small (like the ripple) and expand to held size
const float ringStart = baseRadius * 0.20f;
const float glowStart = baseRadius * 0.30f;
const float ringHeld = baseRadius * 0.55f;
const float glowHeld = baseRadius * 0.65f;
// Glow (filled, low-alpha halo)
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(
static_cast<uint8_t>(static_cast<float>(color.A) * 0.30f),
color.R, color.G, color.B);
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
glowShape.FillBrush(glowBrush);
// Ring (stroked, full color)
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(color);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness((std::max)(1.5f, baseRadius * 0.08f));
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(glowShape);
m_shape.Shapes().Append(ringShape);
if (button == MouseButton::Left)
{
m_leftPointer = ringShape;
m_leftGeometry = ringGeom;
m_leftRippleGlow = glowShape;
m_leftGlowGeometry = glowGeom;
}
else
{
m_rightPointer = ringShape;
m_rightGeometry = ringGeom;
m_rightRippleGlow = glowShape;
m_rightGlowGeometry = glowGeom;
}
// Animate from small start to held size — no delay, immediate expansion
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringAnim.InsertKeyFrame(1.0f, { ringHeld, ringHeld }, ease);
ringAnim.Duration(std::chrono::milliseconds(250));
ringGeom.StartAnimation(L"Radius", ringAnim);
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowAnim.InsertKeyFrame(1.0f, { glowHeld, glowHeld }, ease);
glowAnim.Duration(std::chrono::milliseconds(250));
glowGeom.StartAnimation(L"Radius", glowAnim);
}
// On release: continue expanding the held ring+glow to full size while fading out, then clean up.
void Highlighter::FadeRippleHoldDot(MouseButton button)
{
winrt::CompositionSpriteShape ringShape{ nullptr };
winrt::CompositionSpriteShape glowShape{ nullptr };
winrt::CompositionEllipseGeometry ringGeom{ nullptr };
winrt::CompositionEllipseGeometry glowGeom{ nullptr };
if (button == MouseButton::Left)
{
ringShape = m_leftPointer;
glowShape = m_leftRippleGlow;
ringGeom = m_leftGeometry;
glowGeom = m_leftGlowGeometry;
m_leftPointer = nullptr;
m_leftGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
}
else
{
ringShape = m_rightPointer;
glowShape = m_rightRippleGlow;
ringGeom = m_rightGeometry;
glowGeom = m_rightGlowGeometry;
m_rightPointer = nullptr;
m_rightGeometry = nullptr;
m_rightRippleGlow = nullptr;
m_rightGlowGeometry = nullptr;
}
if (!ringShape || !m_compositor)
{
return;
}
const float baseRadius = (m_radius < 1.0f) ? 1.0f : m_radius;
const float ringEnd = baseRadius * 1.05f;
const float glowEnd = baseRadius * 1.40f;
int duration = m_fadeDuration_ms;
if (duration < 60)
duration = 60;
if (duration > 2000)
duration = 2000;
auto dur = std::chrono::milliseconds(duration);
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Expand ring to full size
auto ringSizeAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringSizeAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringSizeAnim.Duration(dur);
// Expand glow to full size
auto glowSizeAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowSizeAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowSizeAnim.Duration(dur);
// Fade ring color to transparent
auto ringColor = ringShape.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
auto transparentColor = winrt::Windows::UI::ColorHelper::FromArgb(0, ringColor.R, ringColor.G, ringColor.B);
auto ringFadeAnim = m_compositor.CreateColorKeyFrameAnimation();
ringFadeAnim.InsertKeyFrame(1.0f, transparentColor, ease);
ringFadeAnim.Duration(dur);
// Fade glow color to transparent
auto glowFadeAnim = m_compositor.CreateColorKeyFrameAnimation();
glowFadeAnim.InsertKeyFrame(1.0f, transparentColor, ease);
glowFadeAnim.Duration(dur);
// For right-click, add animated crosshair lines on release
winrt::Windows::UI::Composition::CompositionSpriteShape hLineShape{ nullptr };
winrt::Windows::UI::Composition::CompositionSpriteShape vLineShape{ nullptr };
if (button == MouseButton::Right)
{
auto ringColor2 = ringShape.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
const float ringHeld = baseRadius * 0.55f;
const float crosshairStart = ringHeld * 0.85f;
const float crosshairEnd = ringEnd * 0.85f;
const float strokeWidth = (std::max)(1.5f, baseRadius * 0.06f);
auto crosshairBrush = m_compositor.CreateColorBrush(ringColor2);
auto hLineGeom = m_compositor.CreateLineGeometry();
hLineGeom.Start({ -crosshairStart, 0.0f });
hLineGeom.End({ crosshairStart, 0.0f });
hLineShape = m_compositor.CreateSpriteShape(hLineGeom);
hLineShape.Offset(ringShape.Offset());
hLineShape.StrokeBrush(crosshairBrush);
hLineShape.StrokeThickness(strokeWidth);
auto vLineGeom = m_compositor.CreateLineGeometry();
vLineGeom.Start({ 0.0f, -crosshairStart });
vLineGeom.End({ 0.0f, crosshairStart });
vLineShape = m_compositor.CreateSpriteShape(vLineGeom);
vLineShape.Offset(ringShape.Offset());
vLineShape.StrokeBrush(crosshairBrush);
vLineShape.StrokeThickness(strokeWidth);
m_shape.Shapes().Append(hLineShape);
m_shape.Shapes().Append(vLineShape);
// Animate crosshair expansion
auto hStartAnim = m_compositor.CreateVector2KeyFrameAnimation();
hStartAnim.InsertKeyFrame(0.0f, { -crosshairStart, 0.0f });
hStartAnim.InsertKeyFrame(1.0f, { -crosshairEnd, 0.0f }, ease);
hStartAnim.Duration(dur);
auto hEndAnim = m_compositor.CreateVector2KeyFrameAnimation();
hEndAnim.InsertKeyFrame(0.0f, { crosshairStart, 0.0f });
hEndAnim.InsertKeyFrame(1.0f, { crosshairEnd, 0.0f }, ease);
hEndAnim.Duration(dur);
auto vStartAnim = m_compositor.CreateVector2KeyFrameAnimation();
vStartAnim.InsertKeyFrame(0.0f, { 0.0f, -crosshairStart });
vStartAnim.InsertKeyFrame(1.0f, { 0.0f, -crosshairEnd }, ease);
vStartAnim.Duration(dur);
auto vEndAnim = m_compositor.CreateVector2KeyFrameAnimation();
vEndAnim.InsertKeyFrame(0.0f, { 0.0f, crosshairStart });
vEndAnim.InsertKeyFrame(1.0f, { 0.0f, crosshairEnd }, ease);
vEndAnim.Duration(dur);
hLineGeom.StartAnimation(L"Start", hStartAnim);
hLineGeom.StartAnimation(L"End", hEndAnim);
vLineGeom.StartAnimation(L"Start", vStartAnim);
vLineGeom.StartAnimation(L"End", vEndAnim);
// Fade crosshairs
auto crossFadeAnim = m_compositor.CreateColorKeyFrameAnimation();
crossFadeAnim.InsertKeyFrame(1.0f, transparentColor, ease);
crossFadeAnim.Duration(dur);
crosshairBrush.StartAnimation(L"Color", crossFadeAnim);
}
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
ringGeom.StartAnimation(L"Radius", ringSizeAnim);
ringShape.StrokeBrush().StartAnimation(L"Color", ringFadeAnim);
if (glowGeom)
{
glowGeom.StartAnimation(L"Radius", glowSizeAnim);
}
if (glowShape)
{
glowShape.FillBrush().StartAnimation(L"Color", glowFadeAnim);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, ringShape, glowShape, hLineShape, vLineShape](auto&&, auto&&) {
dispatcher.TryEnqueue([ringShape, glowShape, hLineShape, vLineShape]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
uint32_t index = 0;
if (vLineShape && shapes.IndexOf(vLineShape, index))
{
shapes.RemoveAt(index);
}
if (hLineShape && shapes.IndexOf(hLineShape, index))
{
shapes.RemoveAt(index);
}
if (shapes.IndexOf(ringShape, index))
{
shapes.RemoveAt(index);
}
if (glowShape && shapes.IndexOf(glowShape, index))
{
shapes.RemoveAt(index);
}
}
catch (...)
{
}
});
});
}
// Spawn a ClickLight-inspired expanding ring + glow pulse at the cursor.
// Each click emits a transient, self-cleaning pulse — pulses can overlap.
void Highlighter::SpawnRipplePulse(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
POINT pt;
GetCursorPos(&pt);
ScreenToClient(m_hwnd, &pt);
winrt::Windows::UI::Color color;
if (button == MouseButton::Left)
{
color = m_leftClickColor;
}
else if (button == MouseButton::Right)
{
color = m_rightClickColor;
}
else
{
color = m_alwaysColor;
}
if (color.A == 0)
{
return;
}
const float baseRadius = (m_radius < 1.0f) ? 1.0f : m_radius;
// ClickLight-style proportions: ring expands from ~0.20× to ~1.05× of the base radius,
// glow halo follows from ~0.30× to ~1.40× at a lower intensity.
const float ringStart = baseRadius * 0.20f;
const float ringEnd = baseRadius * 1.05f;
const float glowStart = baseRadius * 0.30f;
const float glowEnd = baseRadius * 1.40f;
int duration = m_fadeDuration_ms;
if (duration < 60)
{
duration = 60;
}
if (duration > 2000)
{
duration = 2000;
}
auto dur = std::chrono::milliseconds(duration);
// Cubic ease-out (matches ClickLight's 1 - (1 - p)^3 feel).
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Final, fully-transparent target color (preserves RGB to avoid mid-animation tint shifts).
auto transparentColor = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Glow (filled, low-alpha halo).
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(
static_cast<uint8_t>(static_cast<float>(color.A) * 0.35f),
color.R,
color.G,
color.B);
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
glowShape.FillBrush(glowBrush);
// Ring (stroked, full color).
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(color);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness((std::max)(1.5f, baseRadius * 0.08f));
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(glowShape);
m_shape.Shapes().Append(ringShape);
// For right-click, add animated crosshair lines (horizontal + vertical) inside the ring.
winrt::Windows::UI::Composition::CompositionSpriteShape hLineShape{ nullptr };
winrt::Windows::UI::Composition::CompositionSpriteShape vLineShape{ nullptr };
winrt::Windows::UI::Composition::CompositionColorBrush crosshairBrush{ nullptr };
if (button == MouseButton::Right)
{
const float crosshairStart = ringStart * 0.85f;
const float crosshairEnd = ringEnd * 0.85f;
const float strokeWidth = (std::max)(1.5f, baseRadius * 0.06f);
crosshairBrush = m_compositor.CreateColorBrush(color);
// Horizontal line geometry
auto hLineGeom = m_compositor.CreateLineGeometry();
hLineGeom.Start({ -crosshairStart, 0.0f });
hLineGeom.End({ crosshairStart, 0.0f });
hLineShape = m_compositor.CreateSpriteShape(hLineGeom);
hLineShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
hLineShape.StrokeBrush(crosshairBrush);
hLineShape.StrokeThickness(strokeWidth);
// Vertical line geometry
auto vLineGeom = m_compositor.CreateLineGeometry();
vLineGeom.Start({ 0.0f, -crosshairStart });
vLineGeom.End({ 0.0f, crosshairStart });
vLineShape = m_compositor.CreateSpriteShape(vLineGeom);
vLineShape.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
vLineShape.StrokeBrush(crosshairBrush);
vLineShape.StrokeThickness(strokeWidth);
m_shape.Shapes().Append(hLineShape);
m_shape.Shapes().Append(vLineShape);
// Animate crosshair expansion
auto hStartAnim = m_compositor.CreateVector2KeyFrameAnimation();
hStartAnim.InsertKeyFrame(0.0f, { -crosshairStart, 0.0f });
hStartAnim.InsertKeyFrame(1.0f, { -crosshairEnd, 0.0f }, ease);
hStartAnim.Duration(dur);
auto hEndAnim = m_compositor.CreateVector2KeyFrameAnimation();
hEndAnim.InsertKeyFrame(0.0f, { crosshairStart, 0.0f });
hEndAnim.InsertKeyFrame(1.0f, { crosshairEnd, 0.0f }, ease);
hEndAnim.Duration(dur);
auto vStartAnim = m_compositor.CreateVector2KeyFrameAnimation();
vStartAnim.InsertKeyFrame(0.0f, { 0.0f, -crosshairStart });
vStartAnim.InsertKeyFrame(1.0f, { 0.0f, -crosshairEnd }, ease);
vStartAnim.Duration(dur);
auto vEndAnim = m_compositor.CreateVector2KeyFrameAnimation();
vEndAnim.InsertKeyFrame(0.0f, { 0.0f, crosshairStart });
vEndAnim.InsertKeyFrame(1.0f, { 0.0f, crosshairEnd }, ease);
vEndAnim.Duration(dur);
hLineGeom.StartAnimation(L"Start", hStartAnim);
hLineGeom.StartAnimation(L"End", hEndAnim);
vLineGeom.StartAnimation(L"Start", vStartAnim);
vLineGeom.StartAnimation(L"End", vEndAnim);
}
// Radius animations (scale-equivalent, keeps stroke crisp).
auto glowSizeAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowSizeAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowSizeAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowSizeAnim.Duration(dur);
auto ringSizeAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringSizeAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringSizeAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringSizeAnim.Duration(dur);
// Color (alpha) fade-out animations.
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
glowColorAnim.InsertKeyFrame(1.0f, transparentColor, ease);
glowColorAnim.Duration(dur);
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
ringColorAnim.InsertKeyFrame(0.0f, color);
ringColorAnim.InsertKeyFrame(1.0f, transparentColor, ease);
ringColorAnim.Duration(dur);
// Batch all animations so we can clean up the shapes when the pulse ends.
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
glowGeom.StartAnimation(L"Radius", glowSizeAnim);
ringGeom.StartAnimation(L"Radius", ringSizeAnim);
glowBrush.StartAnimation(L"Color", glowColorAnim);
ringBrush.StartAnimation(L"Color", ringColorAnim);
if (crosshairBrush)
{
auto crosshairColorAnim = m_compositor.CreateColorKeyFrameAnimation();
crosshairColorAnim.InsertKeyFrame(0.0f, color);
crosshairColorAnim.InsertKeyFrame(1.0f, transparentColor, ease);
crosshairColorAnim.Duration(dur);
crosshairBrush.StartAnimation(L"Color", crosshairColorAnim);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, glowShape, ringShape, hLineShape, vLineShape](auto&&, auto&&) {
// Marshal shape removal back to the compositor thread.
dispatcher.TryEnqueue([glowShape, ringShape, hLineShape, vLineShape]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
uint32_t index = 0;
if (vLineShape && shapes.IndexOf(vLineShape, index))
{
shapes.RemoveAt(index);
}
if (hLineShape && shapes.IndexOf(hLineShape, index))
{
shapes.RemoveAt(index);
}
if (shapes.IndexOf(ringShape, index))
{
shapes.RemoveAt(index);
}
if (shapes.IndexOf(glowShape, index))
{
shapes.RemoveAt(index);
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)

View File

@@ -4,9 +4,9 @@
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
struct MouseHighlighterSettings
@@ -19,6 +19,7 @@ struct MouseHighlighterSettings
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
bool spotlightMode = false;
bool rippleMode = true;
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -21,6 +21,7 @@ namespace
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -392,6 +393,16 @@ public:
{
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
}
try
{
// Parse ripple mode
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
}
}
else
{

View File

@@ -44,6 +44,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("spotlight_mode")]
public BoolProperty SpotlightMode { get; set; }
[JsonPropertyName("ripple_mode")]
public BoolProperty RippleMode { get; set; }
public MouseHighlighterProperties()
{
ActivationShortcut = DefaultActivationShortcut;
@@ -51,11 +54,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library
RightButtonClickColor = new StringProperty("#a60000FF");
AlwaysColor = new StringProperty("#00FF0000");
HighlightOpacity = new IntProperty(166); // for migration from <=1.1 to 1.2
HighlightRadius = new IntProperty(20);
HighlightFadeDelayMs = new IntProperty(500);
HighlightFadeDurationMs = new IntProperty(250);
HighlightRadius = new IntProperty(30);
HighlightFadeDelayMs = new IntProperty(400);
HighlightFadeDurationMs = new IntProperty(400);
AutoActivate = new BoolProperty(false);
SpotlightMode = new BoolProperty(false);
RippleMode = new BoolProperty(true);
}
}
}

View File

@@ -283,9 +283,10 @@
<ComboBox
x:Uid="MouseUtils_MouseHighlighter_SpotlightModeType"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind ViewModel.IsSpotlightModeEnabled, Converter={StaticResource ReverseBoolToComboBoxIndexConverter}, Mode=TwoWay}">
SelectedIndex="{x:Bind ViewModel.HighlightModeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="HighlightMode_Spotlight_Mode" />
<ComboBoxItem x:Uid="HighlightMode_Circle_Highlight_Mode" />
<ComboBoxItem x:Uid="HighlightMode_Ripple_Mode" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="MouseUtils_MouseHighlighter_HighlightRadius">

View File

@@ -5161,7 +5161,7 @@ The break timer font matches the text font.</value>
<value>No shortcuts to show.</value>
</data>
<data name="HighlightMode.Description" xml:space="preserve">
<value>Highlight the cursor or dim the screen to spotlight it</value>
<value>Highlight the cursor, dim the screen to spotlight it, or pulse a ripple on each click</value>
</data>
<data name="HighlightMode.Header" xml:space="preserve">
<value>Highlight mode</value>
@@ -5172,6 +5172,10 @@ The break timer font matches the text font.</value>
<data name="HighlightMode_Spotlight_Mode.Content" xml:space="preserve">
<value>Spotlight</value>
</data>
<data name="HighlightMode_Ripple_Mode.Content" xml:space="preserve">
<value>Ripple</value>
<comment>Name of the highlight mode that draws an expanding ring pulse on each click.</comment>
</data>
<data name="GeneralPage_EnableDataDiagnosticsText.Text" xml:space="preserve">
<value>Helps us make PowerToys faster, more stable, and better over time</value>
</data>

View File

@@ -77,6 +77,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
string alwaysColor = MouseHighlighterSettingsConfig.Properties.AlwaysColor.Value;
_highlighterAlwaysColor = !string.IsNullOrEmpty(alwaysColor) ? alwaysColor : "#00FF0000";
_isSpotlightModeEnabled = MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value;
_isRippleModeEnabled = MouseHighlighterSettingsConfig.Properties.RippleMode.Value;
_highlighterRadius = MouseHighlighterSettingsConfig.Properties.HighlightRadius.Value;
_highlightFadeDelayMs = MouseHighlighterSettingsConfig.Properties.HighlightFadeDelayMs.Value;
@@ -608,6 +609,64 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool IsRippleModeEnabled
{
get => _isRippleModeEnabled;
set
{
if (_isRippleModeEnabled != value)
{
_isRippleModeEnabled = value;
MouseHighlighterSettingsConfig.Properties.RippleMode.Value = value;
NotifyMouseHighlighterPropertyChanged();
}
}
}
// ComboBox index for the highlight mode selector.
// 0 = Spotlight, 1 = Circle, 2 = Ripple
public int HighlightModeIndex
{
get
{
if (_isSpotlightModeEnabled)
{
return 0;
}
return _isRippleModeEnabled ? 2 : 1;
}
set
{
bool spotlight = value == 0;
bool ripple = value == 2;
bool changed = false;
if (_isSpotlightModeEnabled != spotlight)
{
_isSpotlightModeEnabled = spotlight;
MouseHighlighterSettingsConfig.Properties.SpotlightMode.Value = spotlight;
OnPropertyChanged(nameof(IsSpotlightModeEnabled));
changed = true;
}
if (_isRippleModeEnabled != ripple)
{
_isRippleModeEnabled = ripple;
MouseHighlighterSettingsConfig.Properties.RippleMode.Value = ripple;
OnPropertyChanged(nameof(IsRippleModeEnabled));
changed = true;
}
if (changed)
{
OnPropertyChanged(nameof(HighlightModeIndex));
NotifyMouseHighlighterPropertyChanged();
}
}
}
public int MouseHighlighterRadius
{
get
@@ -1214,6 +1273,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private string _highlighterRightButtonClickColor;
private string _highlighterAlwaysColor;
private bool _isSpotlightModeEnabled;
private bool _isRippleModeEnabled;
private int _highlighterRadius;
private int _highlightFadeDelayMs;
private int _highlightFadeDurationMs;