Skip to content
Merged
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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.6.0] - 2026-07-16

### Added

- A configurable **Ctrl+Shift+K** hotkey that toggles the live keyboard-shortcut display.
- An on-screen badge that announces the current mode (color-coded) when you use the
toggle hotkey.
- **Screen zoom** (default **Ctrl+Shift+Z**): a ZoomIt-style full-screen zoom that
freezes the monitor under the cursor and lets you scroll to zoom (up to 6×) and move
the mouse to pan, keeping the point under the cursor fixed. Scrolling all the way
back out, Esc, right-click, or the hotkey again exits. The hotkey is configurable.

### Changed

- The toggle hotkey now **cycles Off → ClickLight → Laser Pointer** instead of a plain
on/off toggle.

## [1.5.0] - 2026-07-16

### Added
Expand Down Expand Up @@ -134,7 +151,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Initial release: fading click pulses drawn at the cursor, a system-tray
presence, a transparent click-through overlay, and a system-wide mouse hook.

[Unreleased]: https://github.com/dvdstelt/ClickLightWin/compare/v1.5.0...HEAD
[Unreleased]: https://github.com/dvdstelt/ClickLightWin/compare/v1.6.0...HEAD
[1.6.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.5.0...v1.6.0
[1.5.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.2.0...v1.3.0
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ On each click, a short animated pulse is drawn at the cursor and fades out. The
- Click highlights across macOS apps
- Separate visuals for press, release, right-click, and drag
- Optional laser pointer mode with drawing mode
- Screen zoom: freeze and magnify the display ZoomIt-style (scroll to zoom, move to pan)
- Optional keyboard shortcuts
- Local daily click activity chart with a resettable seven-day history
- Dedicated settings window with presets, profiles, and a sidebar preview pad with Randomize
Expand All @@ -38,7 +39,9 @@ ClickLight includes one default global shortcut for quick toggles during demos.

| Shortcut | Action |
| --- | --- |
| `Control + Shift + L` | Toggle ClickLight on/off |
| `Control + Shift + L` | Cycle mode: off -> ClickLight -> laser pointer |
| `Control + Shift + Z` | Screen zoom (scroll to zoom, move to pan; scroll out, Esc, or right-click to exit) |
| `Control + Shift + K` | Toggle the keyboard-shortcut display |
| `Control + Shift + D` | Toggle drawing mode, cleared by exiting drawing mode. |
| `Control + Shift + left-click` & drag | Draw an arrow |
| `Control + Shift + right-click` & drag | Draw a box |
Expand Down
57 changes: 51 additions & 6 deletions src/ClickLightWin/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed class AppController : IDisposable
private readonly LaunchAtLoginController _launchAtLogin = new();
private readonly ProfileStore _profileStore = new();
private readonly ActivityStore _activity = new();
private readonly ZoomController _zoom = new();
private readonly UpdateService _updates = new();
private Settings _settings = new();
private TrayIcon? _tray;
Expand Down Expand Up @@ -63,9 +64,11 @@ public void Start()
_overlays = new OverlayManager(_settings);

// Global hotkeys (defaults Ctrl+Shift+L/C/D; user-configurable in settings).
_hotKeys.TogglePressed += ToggleEnabled;
_hotKeys.TogglePressed += CycleMode;
_hotKeys.ClearPressed += ClearAnnotations;
_hotKeys.DrawModePressed += ToggleDrawMode;
_hotKeys.ShortcutsPressed += ToggleShortcuts;
_hotKeys.ZoomPressed += () => _zoom.Toggle();
_hotKeys.Start();
ConfigureHotkeys();

Expand Down Expand Up @@ -147,7 +150,8 @@ private void UpdateKeyboardHook()

private void ConfigureHotkeys()
{
_hotKeys.Configure(_settings.ToggleHotKey, _settings.ClearHotKey, _settings.DrawModeHotKey);
_hotKeys.Configure(_settings.ToggleHotKey, _settings.ClearHotKey, _settings.DrawModeHotKey,
_settings.ShortcutsHotKey, _settings.ZoomHotKey);
WarnAboutUnavailableHotkeys();
}

Expand All @@ -159,6 +163,8 @@ private void WarnAboutUnavailableHotkeys()
if (!_hotKeys.ToggleRegistered) taken.Add($"{_settings.ToggleHotKey.Display} (toggle)");
if (!_hotKeys.ClearRegistered) taken.Add($"{_settings.ClearHotKey.Display} (clear annotations)");
if (!_hotKeys.DrawModeRegistered) taken.Add($"{_settings.DrawModeHotKey.Display} (drawing mode)");
if (!_hotKeys.ShortcutsRegistered) taken.Add($"{_settings.ShortcutsHotKey.Display} (keyboard shortcuts)");
if (!_hotKeys.ZoomRegistered) taken.Add($"{_settings.ZoomHotKey.Display} (zoom)");
if (taken.Count == 0) return;
_tray?.ShowWarning("ClickLight shortcut unavailable",
$"{string.Join(" and ", taken)} could not be registered (already in use or invalid).");
Expand Down Expand Up @@ -239,12 +245,50 @@ private void RecordActivity(ClickEvent click)
}
}

// The global hotkey path. The tray menu toggles Enabled on its own; both just
// mutate the shared settings, and the tray refreshes its checkmarks on open.
private void ToggleEnabled()
// The toggle hotkey cycles through three modes: Off -> ClickLight (pulses only)
// -> Laser Pointer (pulses + laser glow) -> Off. The tray still toggles Enabled and
// the laser independently; the tray refreshes its checkmarks on open.
private void CycleMode()
{
_settings.Enabled = !_settings.Enabled;
if (!_settings.Enabled)
{
_settings.ShowLaserPointer = false; // Off -> ClickLight
_settings.Enabled = true;
}
else if (!_settings.ShowLaserPointer)
{
_settings.ShowLaserPointer = true; // ClickLight -> Laser Pointer
}
else
{
_settings.Enabled = false; // Laser Pointer -> Off
}
_settingsStore.Save(_settings);
AnnounceMode();
}

// Show a transient badge naming the mode we just switched to, color-coded so it
// stands apart from the live keyboard-shortcut pills.
private void AnnounceMode()
{
var (text, color) = !_settings.Enabled
? ("Off", System.Windows.Media.Color.FromRgb(0x9A, 0x9A, 0xA2))
: !_settings.ShowLaserPointer
? ("ClickLight", System.Windows.Media.Color.FromRgb(0x3B, 0x82, 0xF6))
: ("Laser Pointer", System.Windows.Media.Color.FromRgb(0xFF, 0x29, 0x05));
_overlays?.ShowMode(text, color);
}

// Toggle the live keyboard-shortcut display. Since it is otherwise invisible until
// the next shortcut is pressed, announce the new state with a toast.
private void ToggleShortcuts()
{
_settings.ShowShortcuts = !_settings.ShowShortcuts;
_settingsStore.Save(_settings);
var (text, color) = _settings.ShowShortcuts
? ("Keyboard Shortcuts On", System.Windows.Media.Color.FromRgb(0x3B, 0x82, 0xF6))
: ("Keyboard Shortcuts Off", System.Windows.Media.Color.FromRgb(0x9A, 0x9A, 0xA2));
_overlays?.ShowMode(text, color);
}

private void ShowSettings()
Expand Down Expand Up @@ -287,6 +331,7 @@ public void Dispose()
_keyboardHook.Dispose();
_hotKeys.Dispose();
_overlays?.Dispose();
_zoom.Dispose();
_tray?.Dispose();
}
}
2 changes: 2 additions & 0 deletions src/ClickLightWin/HotKeyBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public string Display
public static readonly HotKeyBinding DefaultToggle = new(ModifierKeys.Control | ModifierKeys.Shift, Key.L);
public static readonly HotKeyBinding DefaultClear = new(ModifierKeys.Control | ModifierKeys.Shift, Key.C);
public static readonly HotKeyBinding DefaultDrawMode = new(ModifierKeys.Control | ModifierKeys.Shift, Key.D);
public static readonly HotKeyBinding DefaultShortcuts = new(ModifierKeys.Control | ModifierKeys.Shift, Key.K);
public static readonly HotKeyBinding DefaultZoom = new(ModifierKeys.Control | ModifierKeys.Shift, Key.Z);

private static string KeyName(Key key) => key switch
{
Expand Down
21 changes: 18 additions & 3 deletions src/ClickLightWin/Interop/HotKeyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,24 @@ public sealed class HotKeyManager : IDisposable
private const int ToggleHotKeyId = 1;
private const int ClearHotKeyId = 2;
private const int DrawModeHotKeyId = 3;
private const int ShortcutsHotKeyId = 4;
private const int ZoomHotKeyId = 5;

private HwndSource? _source;
private HotKeyBinding? _toggle, _clear, _drawMode;
private HotKeyBinding? _toggle, _clear, _drawMode, _shortcuts, _zoom;

public event Action? TogglePressed;
public event Action? ClearPressed;
public event Action? DrawModePressed;
public event Action? ShortcutsPressed;
public event Action? ZoomPressed;

/// <summary>False when the binding is invalid or another application already owns it.</summary>
public bool ToggleRegistered { get; private set; }
public bool ClearRegistered { get; private set; }
public bool DrawModeRegistered { get; private set; }
public bool ShortcutsRegistered { get; private set; }
public bool ZoomRegistered { get; private set; }

/// <summary>Create the message-only window that receives WM_HOTKEY.</summary>
public void Start()
Expand All @@ -42,11 +48,14 @@ public void Start()
}

/// <summary>Apply a new set of bindings and (re)register them.</summary>
public void Configure(HotKeyBinding toggle, HotKeyBinding clear, HotKeyBinding drawMode)
public void Configure(HotKeyBinding toggle, HotKeyBinding clear, HotKeyBinding drawMode,
HotKeyBinding shortcuts, HotKeyBinding zoom)
{
_toggle = toggle;
_clear = clear;
_drawMode = drawMode;
_shortcuts = shortcuts;
_zoom = zoom;
Reregister();
}

Expand All @@ -60,6 +69,8 @@ private void Reregister()
ToggleRegistered = TryRegister(ToggleHotKeyId, _toggle);
ClearRegistered = TryRegister(ClearHotKeyId, _clear);
DrawModeRegistered = TryRegister(DrawModeHotKeyId, _drawMode);
ShortcutsRegistered = TryRegister(ShortcutsHotKeyId, _shortcuts);
ZoomRegistered = TryRegister(ZoomHotKeyId, _zoom);
}

private bool TryRegister(int id, HotKeyBinding? binding) =>
Expand All @@ -72,7 +83,9 @@ private void UnregisterAll()
NativeMethods.UnregisterHotKey(_source.Handle, ToggleHotKeyId);
NativeMethods.UnregisterHotKey(_source.Handle, ClearHotKeyId);
NativeMethods.UnregisterHotKey(_source.Handle, DrawModeHotKeyId);
ToggleRegistered = ClearRegistered = DrawModeRegistered = false;
NativeMethods.UnregisterHotKey(_source.Handle, ShortcutsHotKeyId);
NativeMethods.UnregisterHotKey(_source.Handle, ZoomHotKeyId);
ToggleRegistered = ClearRegistered = DrawModeRegistered = ShortcutsRegistered = ZoomRegistered = false;
}

private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
Expand All @@ -83,6 +96,8 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand
case ToggleHotKeyId: TogglePressed?.Invoke(); handled = true; break;
case ClearHotKeyId: ClearPressed?.Invoke(); handled = true; break;
case DrawModeHotKeyId: DrawModePressed?.Invoke(); handled = true; break;
case ShortcutsHotKeyId: ShortcutsPressed?.Invoke(); handled = true; break;
case ZoomHotKeyId: ZoomPressed?.Invoke(); handled = true; break;
}
return 0;
}
Expand Down
5 changes: 5 additions & 0 deletions src/ClickLightWin/Interop/NativeMethods.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ public static partial bool SetWindowPos(
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool DestroyIcon(nint hIcon);

// Free a GDI object (e.g. the HBITMAP from a screen capture).
[LibraryImport("gdi32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
public static partial bool DeleteObject(nint hObject);

// ---- Foreground activation -----------------------------------------------

// A tray context menu shown by hand never gets foreground activation, so a
Expand Down
9 changes: 9 additions & 0 deletions src/ClickLightWin/Overlay/OverlayManager.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Win32; // SystemEvents
using Color = System.Windows.Media.Color;
using Screen = System.Windows.Forms.Screen;

namespace ClickLightWin.Overlay;
Expand Down Expand Up @@ -106,6 +107,14 @@ public void DispatchShortcut(IReadOnlyList<string> keys)
overlay?.ShowShortcut(keys, _settings, pt.X, pt.Y);
}

/// <summary>Announce a mode switch on the monitor holding the cursor.</summary>
public void ShowMode(string text, Color accent)
{
Interop.NativeMethods.GetCursorPos(out var pt);
var overlay = FindByPoint(pt.X, pt.Y) ?? (_overlays.Count > 0 ? _overlays[0] : null);
overlay?.ShowMode(text, accent);
}

private OverlayWindow? Find(ClickEvent click) => FindByPoint(click.ScreenX, click.ScreenY);

private OverlayWindow? FindByPoint(int x, int y)
Expand Down
3 changes: 3 additions & 0 deletions src/ClickLightWin/Overlay/OverlayWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
<StackPanel x:Name="ShortcutStack" IsHitTestVisible="False"
HorizontalAlignment="Center" VerticalAlignment="Bottom"
Margin="0,0,0,64" />
<!-- Mode switch badge: a transient color-coded toast at the top-center. -->
<Grid x:Name="ModeHost" IsHitTestVisible="False"
HorizontalAlignment="Center" VerticalAlignment="Top" Margin="0,52,0,0" />
<!-- Draw-mode indicator: a border + badge shown while click-through is off. -->
<Border x:Name="DrawModeBorder" IsHitTestVisible="False" Visibility="Collapsed"
BorderBrush="#FF2905" BorderThickness="3" Background="Transparent">
Expand Down
8 changes: 8 additions & 0 deletions src/ClickLightWin/Overlay/OverlayWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public partial class OverlayWindow : Window
private LaserRenderer? _laser;
private AnnotationRenderer? _annotations;
private ShortcutStackRenderer? _shortcuts;
private ModeIndicatorRenderer? _mode;
private DrawStrokeRenderer? _drawStrokes;
private bool _drawing;
private nint _hwnd;
Expand Down Expand Up @@ -152,6 +153,13 @@ public void ShowShortcut(IReadOnlyList<string> keys, Settings settings, int scre
_shortcuts.Show(keys, settings);
}

/// <summary>Show a transient, color-coded mode-switch badge at the top-center.</summary>
public void ShowMode(string text, Color accent)
{
_mode ??= new ModeIndicatorRenderer(ModeHost);
_mode.Show(text, accent);
}

private void ShowShortcutNearPointer(IReadOnlyList<string> keys, Settings settings, int screenX, int screenY)
{
var pill = ShortcutStackRenderer.BuildPill(keys, settings.ShortcutFontSize);
Expand Down
68 changes: 68 additions & 0 deletions src/ClickLightWin/Overlay/ZoomController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Windows;
using System.Windows.Interop;
using System.Windows.Media.Imaging;
using ClickLightWin.Interop;
using DrawingPoint = System.Drawing.Point;
using Screen = System.Windows.Forms.Screen;

namespace ClickLightWin.Overlay;

/// <summary>
/// Toggles the ZoomIt-style full-screen zoom: on activate it snapshots the monitor under
/// the cursor and shows it in a <see cref="ZoomWindow"/> that scales and pans around the
/// cursor. Esc, right-click, or the hotkey again closes it.
/// </summary>
public sealed class ZoomController : IDisposable
{
private ZoomWindow? _window;

public bool Active => _window is not null;

public void Toggle()
{
if (Active) Close();
else Open();
}

private void Open()
{
if (!NativeMethods.GetCursorPos(out var pt)) return;
var screen = Screen.FromPoint(new DrawingPoint(pt.X, pt.Y));
var window = new ZoomWindow(screen, CaptureScreen(screen)); // capture before showing
window.ExitRequested += Close;
_window = window;
window.Show();
window.Activate();
}

private void Close()
{
if (_window is null) return;
_window.ExitRequested -= Close;
_window.Close();
_window = null;
}

private static BitmapSource CaptureScreen(Screen screen)
{
var b = screen.Bounds;
using var bmp = new System.Drawing.Bitmap(b.Width, b.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = System.Drawing.Graphics.FromImage(bmp))
g.CopyFromScreen(b.X, b.Y, 0, 0, new System.Drawing.Size(b.Width, b.Height));

var hbitmap = bmp.GetHbitmap();
try
{
var source = Imaging.CreateBitmapSourceFromHBitmap(
hbitmap, 0, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
source.Freeze();
return source;
}
finally
{
NativeMethods.DeleteObject(hbitmap);
}
}

public void Dispose() => Close();
}
22 changes: 22 additions & 0 deletions src/ClickLightWin/Overlay/ZoomWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<Window x:Class="ClickLightWin.Overlay.ZoomWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
WindowStyle="None"
Background="Black"
Topmost="True"
ShowInTaskbar="False"
ResizeMode="NoResize"
WindowStartupLocation="Manual"
UseLayoutRounding="True"
RenderOptions.BitmapScalingMode="HighQuality">
<!-- A frozen snapshot of the monitor, scaled and panned around the cursor. Because
it is a single static image with a render transform, zoom/pan are GPU-cheap. -->
<Image x:Name="Frozen" Stretch="Fill">
<Image.RenderTransform>
<TransformGroup>
<ScaleTransform x:Name="Scale" ScaleX="1" ScaleY="1" />
<TranslateTransform x:Name="Trans" X="0" Y="0" />
</TransformGroup>
</Image.RenderTransform>
</Image>
</Window>
Loading
Loading