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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ app runs headless apart from a tray icon; there is no main window.
- System-wide click capture via a low-level mouse hook
- Transparent, click-through, topmost overlay per monitor, DPI-correct (Per-Monitor v2)
- Fading pulse on press, a contracting ring on hold + release, and a fading drag trail
- **Laser-pointer mode**: a glowing cursor that trails the pointer plus a fading freehand stroke on drag
- **Laser-pointer mode**: a glowing cursor that trails the pointer during presentations
- **Annotations**: hold Ctrl+Shift and drag — left-drag draws an arrow, right-drag a box; they persist until Ctrl+Shift+C clears them
- **Live shortcut display**: shows keyboard shortcuts (Ctrl+C, Alt+Tab, …) as key-cap pills for screencasts — modifier combos only, off by default
- Per-button and annotation colors, Size/Duration presets
Expand Down
53 changes: 53 additions & 0 deletions src/ClickLightWin.Tests/HotKeyBindingTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System.Text.Json;
using System.Windows.Input;
using ClickLightWin.Interop;

namespace ClickLightWin.Tests;

public class HotKeyBindingTests
{
[Fact]
public void Display_formats_modifiers_and_key()
{
var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Shift, Key.L);
Assert.Equal("Ctrl+Shift+L", b.Display);
}

[Fact]
public void Display_orders_ctrl_alt_shift_win_and_names_digits_and_fkeys()
{
Assert.Equal("Ctrl+Alt+5", new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Alt, Key.D5).Display);
Assert.Equal("Ctrl+F5", new HotKeyBinding(ModifierKeys.Control, Key.F5).Display);
Assert.Equal("Win+D", new HotKeyBinding(ModifierKeys.Windows, Key.D).Display);
}

[Fact]
public void Win32_modifiers_map_and_always_include_no_repeat()
{
var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Shift, Key.L);
var expected = NativeMethods.MOD_CONTROL | NativeMethods.MOD_SHIFT | NativeMethods.MOD_NOREPEAT;
Assert.Equal(expected, b.Win32Modifiers);
}

[Fact]
public void VirtualKey_matches_the_win32_code()
{
Assert.Equal(0x4Cu, new HotKeyBinding(ModifierKeys.Control, Key.L).VirtualKey); // 'L'
}

[Fact]
public void Requires_a_key_and_a_non_shift_modifier_to_be_valid()
{
Assert.True(new HotKeyBinding(ModifierKeys.Control, Key.L).IsValid);
Assert.False(new HotKeyBinding(ModifierKeys.Shift, Key.L).IsValid); // Shift alone is typing
Assert.False(new HotKeyBinding(ModifierKeys.Control, Key.None).IsValid);
}

[Fact]
public void Round_trips_through_json()
{
var b = new HotKeyBinding(ModifierKeys.Control | ModifierKeys.Alt, Key.K);
var restored = JsonSerializer.Deserialize<HotKeyBinding>(JsonSerializer.Serialize(b));
Assert.Equal(b, restored);
}
}
7 changes: 3 additions & 4 deletions src/ClickLightWin/AnnotationEvent.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
namespace ClickLightWin;

/// <summary>Which shape a Ctrl(+Shift) gesture draws.</summary>
/// <summary>Which shape a Ctrl+Shift gesture draws (by mouse button).</summary>
public enum AnnotationTool
{
Arrow, // Ctrl+Shift + left-drag (permanent)
Box, // Ctrl+Shift + right-drag (permanent)
LaserStroke // Ctrl + left-drag (temporary, fades; drawn by the laser renderer)
Arrow, // Ctrl+Shift + left-drag (permanent)
Box // Ctrl+Shift + right-drag (permanent)
}

/// <summary>The phase of an annotation gesture.</summary>
Expand Down
41 changes: 24 additions & 17 deletions src/ClickLightWin/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,18 +27,14 @@ public sealed class AppController : IDisposable
public void Start()
{
_settings = _settingsStore.Load();
_hook.EmitMoves = LaserActive; // only track the move stream when the laser needs it
_hook.LaserStrokeEnabled = LaserActive;
_hook.EmitMoves = LaserActive; // only track the move stream when the laser glow needs it
_hook.AnnotationsEnabled = AnnotationsActive;
// React to settings changes: the glow move stream and the Ctrl+drag laser
// stroke follow the laser (and Enabled); annotations follow their own toggle.
// React to settings changes: the glow move stream follows the laser (and
// Enabled); annotations follow their own toggle.
_settings.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(Settings.Enabled) or nameof(Settings.ShowLaserPointer))
{
_hook.EmitMoves = LaserActive;
_hook.LaserStrokeEnabled = LaserActive;
}
if (e.PropertyName is nameof(Settings.Enabled) or nameof(Settings.EnableAnnotations))
_hook.AnnotationsEnabled = AnnotationsActive;
if (e.PropertyName is nameof(Settings.Enabled) or nameof(Settings.ShowShortcuts))
Expand All @@ -57,12 +53,12 @@ public void Start()
// One overlay per monitor; rebuilds itself on display changes.
_overlays = new OverlayManager(_settings);

// Global hotkeys (Ctrl+Shift+L toggle, Ctrl+Shift+C clear, Ctrl+Shift+D draw mode).
// Global hotkeys (defaults Ctrl+Shift+L/C/D; user-configurable in settings).
_hotKeys.TogglePressed += ToggleEnabled;
_hotKeys.ClearPressed += ClearAnnotations;
_hotKeys.DrawModePressed += ToggleDrawMode;
_hotKeys.Register();
WarnAboutUnavailableHotkeys();
_hotKeys.Start();
ConfigureHotkeys();

// Install the system-wide mouse hook on the UI thread so the callback fires
// here and can touch the overlays without cross-thread marshaling.
Expand All @@ -88,17 +84,23 @@ private void UpdateKeyboardHook()
// The laser features (glow move stream, Ctrl+drag stroke) run only while the laser is on.
private bool LaserActive => _settings.Enabled && _settings.ShowLaserPointer;

// If another app owns a hotkey, RegisterHotKey fails silently; tell the user
// once so a dead shortcut is not mistaken for a ClickLight bug.
private void ConfigureHotkeys()
{
_hotKeys.Configure(_settings.ToggleHotKey, _settings.ClearHotKey, _settings.DrawModeHotKey);
WarnAboutUnavailableHotkeys();
}

// If a binding is invalid or another app owns it, RegisterHotKey fails silently;
// tell the user once so a dead shortcut is not mistaken for a ClickLight bug.
private void WarnAboutUnavailableHotkeys()
{
var taken = new List<string>();
if (!_hotKeys.ToggleRegistered) taken.Add("Ctrl+Shift+L (toggle)");
if (!_hotKeys.ClearRegistered) taken.Add("Ctrl+Shift+C (clear annotations)");
if (!_hotKeys.DrawModeRegistered) taken.Add("Ctrl+Shift+D (drawing mode)");
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 (taken.Count == 0) return;
_tray?.ShowWarning("ClickLight hotkey unavailable",
$"Another app already uses {string.Join(" and ", taken)}. That shortcut won't work while it does.");
_tray?.ShowWarning("ClickLight shortcut unavailable",
$"{string.Join(" and ", taken)} could not be registered (already in use or invalid).");
}

// Left-drag draws an arrow, right-drag a box; the hook tags each with its tool.
Expand Down Expand Up @@ -167,10 +169,15 @@ private void ShowSettings()
return;
}

// Suspend the global hotkeys while configuring, so pressing a combo in the
// shortcut recorder neither fires an action nor collides with the OS registration.
_hotKeys.Suspend();

_settingsWindow = new SettingsWindow(_settings);
_settingsWindow.Closed += (_, _) =>
{
_settingsWindow = null;
ConfigureHotkeys(); // apply any rebindings and re-enable the hotkeys
_settingsStore.Save(_settings); // persist any edits made in the window
};
_settingsWindow.Show();
Expand Down
65 changes: 65 additions & 0 deletions src/ClickLightWin/HotKeyBinding.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Text.Json.Serialization;
using System.Windows.Input;
using ClickLightWin.Interop;

namespace ClickLightWin;

/// <summary>
/// A user-configurable global hotkey: a set of modifiers plus a key. Stored in
/// WPF terms (so the recorder and display are simple) and converted to Win32
/// modifiers/virtual-key for RegisterHotKey. Maps to HotKeyBinding.swift.
/// </summary>
public sealed record HotKeyBinding(ModifierKeys Modifiers, Key Key)
{
/// <summary>A hotkey needs a key and at least one of Ctrl/Alt/Win to be a sane global shortcut.</summary>
[JsonIgnore]
public bool IsValid => Key != Key.None
&& (Modifiers & (ModifierKeys.Control | ModifierKeys.Alt | ModifierKeys.Windows)) != 0;

[JsonIgnore]
public uint Win32Modifiers
{
get
{
var m = NativeMethods.MOD_NOREPEAT;
if (Modifiers.HasFlag(ModifierKeys.Control)) m |= NativeMethods.MOD_CONTROL;
if (Modifiers.HasFlag(ModifierKeys.Alt)) m |= NativeMethods.MOD_ALT;
if (Modifiers.HasFlag(ModifierKeys.Shift)) m |= NativeMethods.MOD_SHIFT;
if (Modifiers.HasFlag(ModifierKeys.Windows)) m |= NativeMethods.MOD_WIN;
return m;
}
}

[JsonIgnore] public uint VirtualKey => (uint)KeyInterop.VirtualKeyFromKey(Key);

/// <summary>Human-readable form, e.g. "Ctrl+Shift+L".</summary>
[JsonIgnore]
public string Display
{
get
{
var parts = new List<string>(4);
if (Modifiers.HasFlag(ModifierKeys.Control)) parts.Add("Ctrl");
if (Modifiers.HasFlag(ModifierKeys.Alt)) parts.Add("Alt");
if (Modifiers.HasFlag(ModifierKeys.Shift)) parts.Add("Shift");
if (Modifiers.HasFlag(ModifierKeys.Windows)) parts.Add("Win");
parts.Add(KeyName(Key));
return string.Join("+", parts);
}
}

/// <summary>The out-of-the-box binding for each action (also used by the reset buttons).</summary>
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);

private static string KeyName(Key key) => key switch
{
>= Key.A and <= Key.Z => key.ToString(),
>= Key.D0 and <= Key.D9 => key.ToString()[1..], // "D5" -> "5"
>= Key.NumPad0 and <= Key.NumPad9 => "Num" + key.ToString()[6..],
>= Key.F1 and <= Key.F24 => key.ToString(),
Key.None => "?",
_ => key.ToString()
};
}
63 changes: 43 additions & 20 deletions src/ClickLightWin/Interop/HotKeyManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,33 @@
namespace ClickLightWin;

/// <summary>
/// Registers the system-wide hotkeys on a message-only window: Ctrl+Shift+L to
/// toggle ClickLight and Ctrl+Shift+C to clear annotations. The Windows analogue
/// of HotKeyManager.swift, which uses Carbon RegisterEventHotKey on macOS.
/// Registers the configurable global hotkeys (toggle, clear annotations, drawing
/// mode) on a message-only window and raises an event when one fires. Bindings can
/// be reconfigured at runtime, and suspended while the settings window is open so
/// they neither fire nor collide with the shortcut recorder. Maps to HotKeyManager.swift.
/// </summary>
public sealed class HotKeyManager : IDisposable
{
private const int ToggleHotKeyId = 1;
private const int ClearHotKeyId = 2;
private const int DrawModeHotKeyId = 3;
private const uint VkL = 0x4C; // 'L'
private const uint VkC = 0x43; // 'C'
private const uint VkD = 0x44; // 'D'

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

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

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

/// <summary>False when another application already owns the combination.</summary>
public bool ClearRegistered { get; private set; }

/// <summary>False when another application already owns the combination.</summary>
public bool DrawModeRegistered { get; private set; }

public void Register()
/// <summary>Create the message-only window that receives WM_HOTKEY.</summary>
public void Start()
{
// A message-only window receives WM_HOTKEY without a visible or taskbar window.
if (_source is not null) return;
var parameters = new HwndSourceParameters("ClickLightWinHotKeyWindow")
{
Width = 0,
Expand All @@ -43,11 +39,40 @@ public void Register()
};
_source = new HwndSource(parameters);
_source.AddHook(WndProc);
}

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

/// <summary>Unregister everything (e.g. while the settings window records a new combo).</summary>
public void Suspend() => UnregisterAll();

const uint mod = NativeMethods.MOD_CONTROL | NativeMethods.MOD_SHIFT | NativeMethods.MOD_NOREPEAT;
ToggleRegistered = NativeMethods.RegisterHotKey(_source.Handle, ToggleHotKeyId, mod, VkL);
ClearRegistered = NativeMethods.RegisterHotKey(_source.Handle, ClearHotKeyId, mod, VkC);
DrawModeRegistered = NativeMethods.RegisterHotKey(_source.Handle, DrawModeHotKeyId, mod, VkD);
private void Reregister()
{
UnregisterAll();
if (_source is null) return;
ToggleRegistered = TryRegister(ToggleHotKeyId, _toggle);
ClearRegistered = TryRegister(ClearHotKeyId, _clear);
DrawModeRegistered = TryRegister(DrawModeHotKeyId, _drawMode);
}

private bool TryRegister(int id, HotKeyBinding? binding) =>
binding is { IsValid: true }
&& NativeMethods.RegisterHotKey(_source!.Handle, id, binding.Win32Modifiers, binding.VirtualKey);

private void UnregisterAll()
{
if (_source is null) return;
NativeMethods.UnregisterHotKey(_source.Handle, ToggleHotKeyId);
NativeMethods.UnregisterHotKey(_source.Handle, ClearHotKeyId);
NativeMethods.UnregisterHotKey(_source.Handle, DrawModeHotKeyId);
ToggleRegistered = ClearRegistered = DrawModeRegistered = false;
}

private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool handled)
Expand All @@ -65,9 +90,7 @@ private nint WndProc(nint hwnd, int msg, nint wParam, nint lParam, ref bool hand
public void Dispose()
{
if (_source is null) return;
if (ToggleRegistered) NativeMethods.UnregisterHotKey(_source.Handle, ToggleHotKeyId);
if (ClearRegistered) NativeMethods.UnregisterHotKey(_source.Handle, ClearHotKeyId);
if (DrawModeRegistered) NativeMethods.UnregisterHotKey(_source.Handle, DrawModeHotKeyId);
UnregisterAll();
_source.RemoveHook(WndProc);
_source.Dispose();
_source = null;
Expand Down
20 changes: 6 additions & 14 deletions src/ClickLightWin/Interop/LowLevelMouseHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,6 @@ public sealed class LowLevelMouseHook : IDisposable
/// <summary>When true, Ctrl+Shift + drag draws annotations instead of clicking through.</summary>
public bool AnnotationsEnabled { get; set; }

/// <summary>When true, Ctrl + left-drag draws a laser stroke (swallowed, like annotations).</summary>
public bool LaserStrokeEnabled { get; set; }

public LowLevelMouseHook() => _proc = HookCallback;

public void Install()
Expand Down Expand Up @@ -119,21 +116,16 @@ or NativeMethods.WM_MBUTTONDOWN or NativeMethods.WM_LBUTTONUP
or NativeMethods.WM_RBUTTONUP or NativeMethods.WM_MBUTTONUP;
}

// Ctrl+Shift + drag = a permanent annotation (arrow by left, box by right),
// swallowed so it never reaches the app. Plain Ctrl is deliberately left
// alone so Ctrl+click (links, multi-select) keeps working.
var isDown = msg is NativeMethods.WM_LBUTTONDOWN or NativeMethods.WM_RBUTTONDOWN;
if (!isDown || !NativeMethods.IsDown(NativeMethods.VK_CONTROL)) return false;

// Ctrl+Shift + drag = a permanent annotation (arrow/box by button).
// Ctrl + left-drag = a temporary laser stroke. Both are swallowed so the
// gesture never reaches the app underneath.
var shift = NativeMethods.IsDown(NativeMethods.VK_SHIFT);
if (shift && AnnotationsEnabled)
_annotatingTool = msg == NativeMethods.WM_LBUTTONDOWN ? AnnotationTool.Arrow : AnnotationTool.Box;
else if (!shift && LaserStrokeEnabled && msg == NativeMethods.WM_LBUTTONDOWN)
_annotatingTool = AnnotationTool.LaserStroke;
else
if (!isDown || !AnnotationsEnabled
|| !NativeMethods.IsDown(NativeMethods.VK_CONTROL) || !NativeMethods.IsDown(NativeMethods.VK_SHIFT))
return false;

_annotating = true;
_annotatingTool = msg == NativeMethods.WM_LBUTTONDOWN ? AnnotationTool.Arrow : AnnotationTool.Box;
_annotatingUpMsg = msg == NativeMethods.WM_LBUTTONDOWN ? NativeMethods.WM_LBUTTONUP : NativeMethods.WM_RBUTTONUP;
var begin = new AnnotationEvent(_annotatingTool, AnnotationPhase.Begin, x, y);
Raise(() => AnnotationDetected?.Invoke(begin));
Expand Down
5 changes: 1 addition & 4 deletions src/ClickLightWin/Overlay/OverlayManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,7 @@ public void DispatchAnnotation(AnnotationEvent evt)
_annotatingOverlay?.Annotate(evt, _settings);
if (evt.Phase == AnnotationPhase.Commit)
{
// Laser strokes are temporary (they fade), so they are not remembered
// for re-rendering after a display change like arrows and boxes are.
if (evt.Tool != AnnotationTool.LaserStroke
&& _annotatingOverlay is not null && _annotationStart is { } start)
if (_annotatingOverlay is not null && _annotationStart is { } start)
_committed.Add(new(evt.Tool, start.X, start.Y, evt.ScreenX, evt.ScreenY));
_annotatingOverlay = null;
_annotationStart = null;
Expand Down
Loading
Loading