diff --git a/README.md b/README.md index 7fbc7ba..e8a9c99 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/ClickLightWin.Tests/HotKeyBindingTests.cs b/src/ClickLightWin.Tests/HotKeyBindingTests.cs new file mode 100644 index 0000000..f3bbad5 --- /dev/null +++ b/src/ClickLightWin.Tests/HotKeyBindingTests.cs @@ -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(JsonSerializer.Serialize(b)); + Assert.Equal(b, restored); + } +} diff --git a/src/ClickLightWin/AnnotationEvent.cs b/src/ClickLightWin/AnnotationEvent.cs index b40b233..24e7d39 100644 --- a/src/ClickLightWin/AnnotationEvent.cs +++ b/src/ClickLightWin/AnnotationEvent.cs @@ -1,11 +1,10 @@ namespace ClickLightWin; -/// Which shape a Ctrl(+Shift) gesture draws. +/// Which shape a Ctrl+Shift gesture draws (by mouse button). 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) } /// The phase of an annotation gesture. diff --git a/src/ClickLightWin/AppController.cs b/src/ClickLightWin/AppController.cs index 339b26b..8c8ad78 100644 --- a/src/ClickLightWin/AppController.cs +++ b/src/ClickLightWin/AppController.cs @@ -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)) @@ -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. @@ -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(); - 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. @@ -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(); diff --git a/src/ClickLightWin/HotKeyBinding.cs b/src/ClickLightWin/HotKeyBinding.cs new file mode 100644 index 0000000..8df3ebb --- /dev/null +++ b/src/ClickLightWin/HotKeyBinding.cs @@ -0,0 +1,65 @@ +using System.Text.Json.Serialization; +using System.Windows.Input; +using ClickLightWin.Interop; + +namespace ClickLightWin; + +/// +/// 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. +/// +public sealed record HotKeyBinding(ModifierKeys Modifiers, Key Key) +{ + /// A hotkey needs a key and at least one of Ctrl/Alt/Win to be a sane global shortcut. + [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); + + /// Human-readable form, e.g. "Ctrl+Shift+L". + [JsonIgnore] + public string Display + { + get + { + var parts = new List(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); + } + } + + /// The out-of-the-box binding for each action (also used by the reset buttons). + 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() + }; +} diff --git a/src/ClickLightWin/Interop/HotKeyManager.cs b/src/ClickLightWin/Interop/HotKeyManager.cs index db4073c..6a1d821 100644 --- a/src/ClickLightWin/Interop/HotKeyManager.cs +++ b/src/ClickLightWin/Interop/HotKeyManager.cs @@ -4,37 +4,33 @@ namespace ClickLightWin; /// -/// 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. /// 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; - /// False when another application already owns the combination. + /// False when the binding is invalid or another application already owns it. public bool ToggleRegistered { get; private set; } - - /// False when another application already owns the combination. public bool ClearRegistered { get; private set; } - - /// False when another application already owns the combination. public bool DrawModeRegistered { get; private set; } - public void Register() + /// Create the message-only window that receives WM_HOTKEY. + 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, @@ -43,11 +39,40 @@ public void Register() }; _source = new HwndSource(parameters); _source.AddHook(WndProc); + } + + /// Apply a new set of bindings and (re)register them. + public void Configure(HotKeyBinding toggle, HotKeyBinding clear, HotKeyBinding drawMode) + { + _toggle = toggle; + _clear = clear; + _drawMode = drawMode; + Reregister(); + } + + /// Unregister everything (e.g. while the settings window records a new combo). + 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) @@ -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; diff --git a/src/ClickLightWin/Interop/LowLevelMouseHook.cs b/src/ClickLightWin/Interop/LowLevelMouseHook.cs index 32ab638..1a82169 100644 --- a/src/ClickLightWin/Interop/LowLevelMouseHook.cs +++ b/src/ClickLightWin/Interop/LowLevelMouseHook.cs @@ -37,9 +37,6 @@ public sealed class LowLevelMouseHook : IDisposable /// When true, Ctrl+Shift + drag draws annotations instead of clicking through. public bool AnnotationsEnabled { get; set; } - /// When true, Ctrl + left-drag draws a laser stroke (swallowed, like annotations). - public bool LaserStrokeEnabled { get; set; } - public LowLevelMouseHook() => _proc = HookCallback; public void Install() @@ -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)); diff --git a/src/ClickLightWin/Overlay/OverlayManager.cs b/src/ClickLightWin/Overlay/OverlayManager.cs index a81cb0e..404d432 100644 --- a/src/ClickLightWin/Overlay/OverlayManager.cs +++ b/src/ClickLightWin/Overlay/OverlayManager.cs @@ -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; diff --git a/src/ClickLightWin/Overlay/OverlayWindow.xaml.cs b/src/ClickLightWin/Overlay/OverlayWindow.xaml.cs index d7e76c0..f4b869b 100644 --- a/src/ClickLightWin/Overlay/OverlayWindow.xaml.cs +++ b/src/ClickLightWin/Overlay/OverlayWindow.xaml.cs @@ -41,39 +41,17 @@ public void ShowPulse(ClickEvent click, Settings settings) _renderer.Spawn(ToLocal(click), click, settings); } - /// Drive the laser cursor/stroke for one event (physical pixels). + /// Move the laser cursor glow to follow the pointer (physical pixels). public void Laser(ClickEvent click, Settings settings) { _laser ??= new LaserRenderer(PulseCanvas, settings); - var local = ToLocal(click); - switch (click.Phase) - { - case ClickPhase.Down: _laser.BeginStroke(); break; - case ClickPhase.Move: _laser.UpdateCursor(local, settings); break; - case ClickPhase.Drag: _laser.UpdateCursor(local, settings); _laser.AppendPoint(local, settings); break; - case ClickPhase.Up: _laser.CompleteStroke(settings); break; - } + _laser.UpdateCursor(ToLocal(click), settings); } /// Drive an annotation gesture for one event (physical pixels). public void Annotate(AnnotationEvent evt, Settings settings) { var local = ToLocal(evt.ScreenX, evt.ScreenY); - - // The laser stroke reuses the same Ctrl-drag gesture plumbing but renders - // as a temporary fading stroke via the laser renderer. - if (evt.Tool == AnnotationTool.LaserStroke) - { - _laser ??= new LaserRenderer(PulseCanvas, settings); - switch (evt.Phase) - { - case AnnotationPhase.Begin: _laser.BeginStroke(); break; - case AnnotationPhase.Update: _laser.UpdateCursor(local, settings); _laser.AppendPoint(local, settings); break; - case AnnotationPhase.Commit: _laser.CompleteStroke(settings); break; - } - return; - } - _annotations ??= new AnnotationRenderer(PulseCanvas); switch (evt.Phase) { diff --git a/src/ClickLightWin/Palette.cs b/src/ClickLightWin/Palette.cs index 9b02eb8..11b8892 100644 --- a/src/ClickLightWin/Palette.cs +++ b/src/ClickLightWin/Palette.cs @@ -5,15 +5,17 @@ namespace ClickLightWin; -/// One selectable color, carrying both its hex (persisted) and a frozen brush (for the swatch). +/// One selectable color, carrying its hex (persisted), a display name, and a frozen brush. public sealed class ColorSwatch { public string Hex { get; } + public string Name { get; } public Brush Brush { get; } - public ColorSwatch(string hex) + public ColorSwatch(string hex, string name) { Hex = hex; + Name = name; var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); brush.Freeze(); Brush = brush; @@ -23,17 +25,17 @@ public ColorSwatch(string hex) /// /// The fixed palette of pulse colors offered per button in the settings window. /// The first three entries are the default left/right/middle colors, so a fresh -/// install shows a selected swatch in each row. +/// install shows a selected swatch in each dropdown. /// public static class Palette { public static readonly ColorSwatch[] Colors = [ - new("#3B82F6"), // blue (default left) - new("#F97316"), // orange (default right) - new("#22C55E"), // green (default middle) - new("#EF4444"), // red - new("#A855F7"), // purple - new("#06B6D4") // cyan + new("#3B82F6", "Blue"), // default left + new("#F97316", "Orange"), // default right + new("#22C55E", "Green"), // default middle + new("#EF4444", "Red"), + new("#A855F7", "Purple"), + new("#06B6D4", "Cyan") ]; } diff --git a/src/ClickLightWin/Rendering/LaserRenderer.cs b/src/ClickLightWin/Rendering/LaserRenderer.cs index cfefa98..6b4473a 100644 --- a/src/ClickLightWin/Rendering/LaserRenderer.cs +++ b/src/ClickLightWin/Rendering/LaserRenderer.cs @@ -12,11 +12,9 @@ namespace ClickLightWin.Rendering; /// -/// Draws the laser-pointer overlay on one monitor: a glowing halo that eases -/// along just behind the cursor and fades after movement stops, plus a fading -/// freehand stroke while a button is dragged. The stroke is drawn as three -/// layered lines (wide soft glow, mid, thin near-white core) so it reads as a -/// glowing red line with a bright center, matching ClickOverlayView.swift. +/// Draws the laser-pointer cursor on one monitor: a glowing halo (concentric +/// solid discs with a small bright center) that eases along just behind the +/// cursor and fades after movement stops. Matches ClickOverlayView.swift. /// public sealed class LaserRenderer : IDisposable { @@ -32,11 +30,6 @@ public sealed class LaserRenderer : IDisposable private bool _visible; private bool _rendering; - // Active stroke: three overlaid polylines sharing the same points. - private Canvas? _strokeLayer; - private Polyline[] _strokeLines = []; - private Point? _lastStrokePoint; - public LaserRenderer(Canvas canvas, Settings settings) { _canvas = canvas; @@ -66,64 +59,6 @@ public void UpdateCursor(Point center, Settings settings) _idle.Start(); } - public void BeginStroke() - { - _strokeLayer = null; - _strokeLines = []; - _lastStrokePoint = null; - } - - public void AppendPoint(Point p, Settings settings) - { - var minSq = settings.LaserMinSpacingDips * settings.LaserMinSpacingDips; - if (_lastStrokePoint is { } last && Distance2(last, p) < minSq) return; - - if (_strokeLayer is null) - { - _strokeLayer = new Canvas { IsHitTestVisible = false }; - var mid = settings.LaserStrokeThickness; - _strokeLines = - [ - MakeLine(settings.LaserColor, mid * 2.6, 0.28), // wide soft glow - MakeLine(settings.LaserColor, mid, 0.95), // mid red - MakeLine(settings.LaserCoreColor, mid * 0.45, 1.0) // thin bright core - ]; - foreach (var line in _strokeLines) _strokeLayer.Children.Add(line); - _canvas.Children.Add(_strokeLayer); - } - - foreach (var line in _strokeLines) line.Points.Add(p); - _lastStrokePoint = p; - } - - public void CompleteStroke(Settings settings) - { - if (_strokeLayer is null) return; - var layer = _strokeLayer; - _strokeLayer = null; - _strokeLines = []; - _lastStrokePoint = null; - - var fade = new DoubleAnimation(1, 0, settings.LaserStrokeFade) - { - EasingFunction = new CubicEase { EasingMode = EasingMode.EaseIn } - }; - fade.Completed += (_, _) => _canvas.Children.Remove(layer); - layer.BeginAnimation(UIElement.OpacityProperty, fade); - } - - private static Polyline MakeLine(Color color, double thickness, double opacity) => new() - { - Stroke = new SolidColorBrush(color), - StrokeThickness = thickness, - Opacity = opacity, - StrokeLineJoin = PenLineJoin.Round, - StrokeStartLineCap = PenLineCap.Round, - StrokeEndLineCap = PenLineCap.Round, - IsHitTestVisible = false, - Points = [] - }; - private void EnsureRendering() { if (_rendering) return; @@ -210,11 +145,4 @@ public void Dispose() } private static Color WithAlpha(Color c, double a) => Color.FromArgb((byte)(a * 255), c.R, c.G, c.B); - - private static double Distance2(Point a, Point b) - { - var dx = a.X - b.X; - var dy = a.Y - b.Y; - return dx * dx + dy * dy; - } } diff --git a/src/ClickLightWin/Settings.cs b/src/ClickLightWin/Settings.cs index 76e006f..404aba7 100644 --- a/src/ClickLightWin/Settings.cs +++ b/src/ClickLightWin/Settings.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using System.Text.Json.Serialization; using System.Windows; +using System.Windows.Input; using Color = System.Windows.Media.Color; using Colors = System.Windows.Media.Colors; @@ -29,6 +30,9 @@ public sealed class Settings : INotifyPropertyChanged private string _rightColorHex = "#F97316"; // orange private string _middleColorHex = "#22C55E"; // green private string _annotationColorHex = "#EF4444"; // red (shared by arrows and boxes) + private HotKeyBinding _toggleHotKey = HotKeyBinding.DefaultToggle; + private HotKeyBinding _clearHotKey = HotKeyBinding.DefaultClear; + private HotKeyBinding _drawModeHotKey = HotKeyBinding.DefaultDrawMode; // ---- Persisted, user-editable ------------------------------------------- @@ -47,6 +51,9 @@ public sealed class Settings : INotifyPropertyChanged public string RightColorHex { get => _rightColorHex; set => Set(ref _rightColorHex, value); } public string MiddleColorHex { get => _middleColorHex; set => Set(ref _middleColorHex, value); } public string AnnotationColorHex { get => _annotationColorHex; set => Set(ref _annotationColorHex, value); } + public HotKeyBinding ToggleHotKey { get => _toggleHotKey; set => Set(ref _toggleHotKey, value); } + public HotKeyBinding ClearHotKey { get => _clearHotKey; set => Set(ref _clearHotKey, value); } + public HotKeyBinding DrawModeHotKey { get => _drawModeHotKey; set => Set(ref _drawModeHotKey, value); } // ---- Computed render constants (not persisted, not user-editable yet) ---- diff --git a/src/ClickLightWin/Tray/TrayIcon.cs b/src/ClickLightWin/Tray/TrayIcon.cs index 49d4a56..07fc584 100644 --- a/src/ClickLightWin/Tray/TrayIcon.cs +++ b/src/ClickLightWin/Tray/TrayIcon.cs @@ -24,6 +24,7 @@ public sealed class TrayIcon : IDisposable private readonly ToolStripMenuItem _laserItem; private readonly ToolStripMenuItem _releaseItem; private readonly ToolStripMenuItem _dragItem; + private readonly ToolStripMenuItem _shortcutsItem; private readonly ToolStripMenuItem _launchItem; private readonly List<(ToolStripMenuItem Item, double Value)> _sizeItems = []; private readonly List<(ToolStripMenuItem Item, double Value)> _durationItems = []; @@ -48,6 +49,7 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, _laserItem = Check("Laser pointer mode", () => Toggle(s => s.ShowLaserPointer = !s.ShowLaserPointer)); _releaseItem = Check("Show release ring", () => Toggle(s => s.ShowRelease = !s.ShowRelease)); _dragItem = Check("Show drag trail", () => Toggle(s => s.ShowDrag = !s.ShowDrag)); + _shortcutsItem = Check("Show keyboard shortcuts", () => Toggle(s => s.ShowShortcuts = !s.ShowShortcuts)); _launchItem = Check("Launch at login", () => _launchAtLogin.SetEnabled(!_launchAtLogin.IsEnabled)); menu.Items.Add(_enabledItem); @@ -55,6 +57,7 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(_releaseItem); menu.Items.Add(_dragItem); + menu.Items.Add(_shortcutsItem); menu.Items.Add(new ToolStripSeparator()); menu.Items.Add(PresetSubmenu("Size", Presets.Sizes, _sizeItems, v => _settings.BaseDiameterDips = v)); menu.Items.Add(PresetSubmenu("Duration", Presets.Durations, _durationItems, v => _settings.PulseDurationMs = v)); @@ -134,6 +137,7 @@ private void RefreshChecks() _laserItem.Checked = _settings.ShowLaserPointer; _releaseItem.Checked = _settings.ShowRelease; _dragItem.Checked = _settings.ShowDrag; + _shortcutsItem.Checked = _settings.ShowShortcuts; _launchItem.Checked = _launchAtLogin.IsEnabled; foreach (var (item, value) in _sizeItems) item.Checked = Near(_settings.BaseDiameterDips, value); foreach (var (item, value) in _durationItems) item.Checked = Near(_settings.PulseDurationMs, value); @@ -144,8 +148,8 @@ private void RefreshChecks() private static void ShowAbout() => MessageBox.Show( "ClickLight for Windows\n\nHighlights your mouse clicks on screen during demos and " + "screen sharing.\n\nCtrl+Shift+L toggle\nCtrl+Shift+D frozen drawing mode" - + "\nCtrl + drag laser stroke (laser on)\nCtrl+Shift + left-drag draw an arrow" - + "\nCtrl+Shift + right-drag draw a box\nCtrl+Shift+C clear annotations", + + "\nCtrl+Shift + left-drag draw an arrow\nCtrl+Shift + right-drag draw a box" + + "\nCtrl+Shift+C clear annotations", "About ClickLight", MessageBoxButtons.OK, MessageBoxIcon.Information); public void Dispose() diff --git a/src/ClickLightWin/Views/SettingsWindow.xaml b/src/ClickLightWin/Views/SettingsWindow.xaml index fef4365..fde2a8f 100644 --- a/src/ClickLightWin/Views/SettingsWindow.xaml +++ b/src/ClickLightWin/Views/SettingsWindow.xaml @@ -2,6 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="clr-namespace:ClickLightWin" + xmlns:views="clr-namespace:ClickLightWin.Views" Title="ClickLight Settings" Width="400" SizeToContent="Height" @@ -92,44 +93,46 @@ - - + + + + + + + + + + + + + + @@ -143,6 +146,33 @@ + + + @@ -192,25 +222,27 @@ SelectedValue="{Binding PulseDurationMs, Mode=TwoWay}" Margin="0,0,0,20" /> - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + @@ -230,17 +262,34 @@ - - - - - - - - - - + + + + + + + + + + + + + +