diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ceb112..84c1067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2026-07-15 + +### Added + +- Redesigned settings as a sidebar window (General, Event Visibility, Visual Style, + Keyboard Shortcuts, Profiles, Activity, Menu Layout). +- A live, interactive Preview Pad: click and drag inside it to see your current + pulse, laser, and drag settings without touching the rest of the screen. +- Profiles: save named visual setups and switch between them from a dropdown + (with a built-in Default), and import/export them as JSON to move between PCs. +- Activity: a local, on-device view of your daily clicks with a seven-day chart + and a Left/Right/Middle/Drags breakdown. +- Menu Layout: hide individual system-tray menu items and drag to reorder them + (Quit is always shown). +- A designed app icon (tray, window, and installer) and a wordmark on the + General settings tab. + +### Changed + +- Settings changes now apply when you click OK and can be discarded with Cancel, + instead of taking effect live as you edit. +- The settings color and profile dropdowns are dark-themed to match the window. + ## [1.3.0] - 2026-07-15 ### Added @@ -97,7 +120,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.3.0...HEAD +[Unreleased]: https://github.com/dvdstelt/ClickLightWin/compare/v1.4.0...HEAD +[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 [1.2.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.1.0...v1.2.0 [1.1.0]: https://github.com/dvdstelt/ClickLightWin/compare/v1.0.0...v1.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8359591 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Contributing to ClickLight for Windows + +Thanks for helping improve ClickLight. + +## Get Started + +1. Fork the repository and create a branch for your change. +2. Build and run the app (see below). +3. Keep changes focused, and update the docs and [CHANGELOG.md](CHANGELOG.md) when behavior changes. +4. Open a pull request against `main`. + +## Requirements + +- Windows 10 21H2+ or Windows 11 (WPF builds and runs on Windows only) +- [.NET 10 SDK](https://dotnet.microsoft.com/download) (`dotnet --version` shows `10.x`) + +## Build and Run + +From the repository root: + +```powershell +dotnet build src\ClickLightWin.sln +dotnet run --project src\ClickLightWin\ClickLightWin.csproj +``` + +The app starts in the system tray. Right-click the tray icon for the menu, or +press **Ctrl+Shift+L** to toggle highlighting. + +## Verify Changes + +Run the unit tests: + +```powershell +dotnet test src\ClickLightWin.sln +``` + +The tests cover the pure seams (coordinate mapping, settings persistence, shortcut +formatting). Most of the app is UI and system hooks, so also **run the app and +manually exercise what you changed**. Useful checks: + +- the tray menu and the Settings window (including the live Preview Pad) +- click highlights in another app (press, release, right-click, drag) +- laser-pointer and drawing modes, and any annotation gesture +- any new keyboard shortcut or pointer interaction + +Include your manual test steps in the pull request. + +## Code Style + +- Style is enforced by [.editorconfig](.editorconfig); please keep to it. +- Nullable reference types are enabled, and Release builds treat warnings as errors. +- Match the surrounding code: the project uses current C# features and favors small, + well-named methods over comments. +- Commit messages use short, imperative, gitmoji-style prefixes (e.g. `✨ Add ...`, + `🐛 Fix ...`, `📝 Update ...`) — match the existing history. + +## Pull Requests + +- Explain what changed and why. +- Include screenshots or a short recording for visible UI changes. +- Do not commit signing credentials, private keys, build output, or local system files. +- Keep changes to the release workflow separate unless that is what you are working on. + +For security-sensitive reports, please contact the maintainer privately rather than +opening a public issue. diff --git a/README.md b/README.md index fffe5c9..af0f0b0 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,63 @@ -# ClickLightWin - -A Windows menu-bar (system-tray) app that highlights your mouse clicks on screen -during live demos, screen sharing, and UX reviews. It is a ground-up C# / .NET -(WPF) reimplementation of the macOS app [ClickLight](https://github.com/aurorascharff/ClickLight), -not a port: every macOS primitive is mapped to a clean Windows equivalent. - -On each click, a short animated pulse is drawn at the cursor and fades out. The -app runs headless apart from a tray icon; there is no main window. - -## Status - -**1.1.0.** Feature-complete for daily use: - -- 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 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 -- A modern settings window and a dark tray menu (feature toggles, preset submenus) -- Global toggle hotkey **Ctrl+Shift+L**, launch-at-login, single-instance guard -- Settings persisted to `%APPDATA%\ClickLightWin\settings.json` -- **Auto-update** (installer build only): checks GitHub Releases in the background - and, when a newer version is out, shows a "Restart to update" prompt in the tray. - Nothing installs until you click it; the portable exe updates manually. +# ClickLight for Windows + +A small Windows application in the system-tray that highlights your clicks during live demos, screen sharing, and other moments where people need to be able to follow what you are doing. + +Screen recorders can add click effects after the fact. ClickLight is for the live moment itself, when you need the audience to see exactly when you clicked without interrupting your flow. + +It is a ground-up C# / .NET (WPF) reimplementation of the macOS app [ClickLight](https://github.com/aurorascharff/ClickLight), not a port: every macOS primitive is mapped to a clean Windows equivalent. + +On each click, a short animated pulse is drawn at the cursor and fades out. The app runs headless apart from a tray icon; there is no main window. + +## Demo + +-- soon -- + +## Features + +- Click highlights across macOS apps +- Separate visuals for press, release, right-click, and drag +- Optional laser pointer mode with drawing mode +- 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 +- Custom color picker in Settings +- One default ClickLight toggle shortcut, with optional shortcuts for other actions +- System-tray menu + - Quick presets for size, duration + - Customizable menu for hiding optional controls you do not use + +- Test pulse for verifying overlay behavior See [docs/04-build-checklist.md](docs/04-build-checklist.md) for the roadmap this was built against. -> Note: multi-monitor click routing and mixed-DPI placement are implemented but have -> only been verified on a single monitor (100% and 150%). Worth a pass on multi-monitor -> and mixed-DPI hardware. +> Note: multi-monitor click routing and mixed-DPI placement are implemented but have only been verified on a single monitor (100% and 150%). Worth a pass on multi-monitor and mixed-DPI hardware. + +## Keyboard Shortcuts + +ClickLight includes one default global shortcut for quick toggles during demos. Other actions can be assigned shortcuts in Settings if you want them. + +| Shortcut | Action | +| --- | --- | +| `Control + Shift + L` | Toggle ClickLight on/off | +| `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 | +| `Control + SHift + C` | Clear drawings | + +All shortcuts can be changed in Settings. ## Download Grab the latest from the [Releases](../../releases) page. Two options: -- **`ClickLight-vX.Y.Z-Setup.exe`** (installer, ~7 MB) — recommended. Installs the app, - adds Start-menu/desktop shortcuts, and installs the .NET 10 Desktop Runtime for you if - it is missing. -- **`ClickLight-vX.Y.Z-win-x64.exe`** (bare app, ~250 KB) — for machines that already - have the .NET 10 Desktop Runtime. Just run it. -- **`ClickLight-vX.Y.Z-win-arm64.exe`** — the same bare app for Windows-on-Arm - (needs the arm64 .NET 10 Desktop Runtime). +- **`ClickLight-vX.Y.Z-Setup.exe`** (installer, ~7 MB) — recommended. Installs the app, adds Start-menu/desktop shortcuts, and installs the .NET 10 Desktop Runtime for you if it is missing. +- **`ClickLight-vX.Y.Z-win-x64.exe`** (bare app, ~250 KB) — for machines that already have the .NET 10 Desktop Runtime. Just run it. +- **`ClickLight-vX.Y.Z-win-arm64.exe`** — the same bare app for Windows-on-Arm (needs the arm64 .NET 10 Desktop Runtime). -Until code signing is set up (see below), both are unsigned, so on first launch Windows -SmartScreen may say "Windows protected your PC" — click **More info -> Run anyway**. +> [!NOTE] +> +> Until code signing is set up (see below), both are unsigned, so on first launch Windows +> SmartScreen may say "Windows protected your PC" — click **More info -> Run anyway**. ## Known limitations @@ -64,7 +76,3 @@ SmartScreen may say "Windows protected your PC" — click **More info -> Run any - **Mixed-DPI multi-monitor.** Placement is verified at 100% and 150% on a single monitor. Cross-monitor routing and mixed-DPI setups (e.g. a 150% laptop panel plus a 100% external) should be verified on that hardware. - -The macOS Swift sources under `ClickLight/Sources/ClickLight/` are the source of -truth for behavior (pulse shapes, timing, presets). The mapping from Swift files -to Windows components is in [docs/01-architecture.md](docs/01-architecture.md). diff --git a/src/ClickLightWin/ActivityStore.cs b/src/ClickLightWin/ActivityStore.cs new file mode 100644 index 0000000..4a48730 --- /dev/null +++ b/src/ClickLightWin/ActivityStore.cs @@ -0,0 +1,108 @@ +using System.Globalization; +using System.IO; +using System.Text.Json; + +namespace ClickLightWin; + +/// +/// Tracks per-day click tallies, persisted to %APPDATA%\ClickLightWin\activity.json. +/// Everything stays on this machine. Records are cheap in-memory increments; the file +/// is written on a throttle by the caller (and on exit). Mirrors ClickActivityStore. +/// +public sealed class ActivityStore +{ + private static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; + + private readonly string _path; + private readonly Dictionary _days = []; + private bool _dirty; + + public ActivityStore() + { + _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ClickLightWin", "activity.json"); + Load(); + } + + private static string Key(DateTime date) => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + + private ClickActivityDay Today() + { + var key = Key(DateTime.Now); + if (!_days.TryGetValue(key, out var day)) + { + day = new ClickActivityDay { Date = key }; + _days[key] = day; + } + return day; + } + + public void RecordClick(ClickButton button) + { + var day = Today(); + switch (button) + { + case ClickButton.Left: day.Left++; break; + case ClickButton.Right: day.Right++; break; + case ClickButton.Middle: day.Middle++; break; + } + _dirty = true; + } + + public void RecordDrag() + { + Today().Drags++; + _dirty = true; + } + + /// The seven days ending today, oldest first, with gaps filled by empty days. + public IReadOnlyList LastSevenDays() + { + var list = new List(7); + for (var offset = 6; offset >= 0; offset--) + { + var key = Key(DateTime.Now.AddDays(-offset)); + list.Add(_days.TryGetValue(key, out var day) ? day : new ClickActivityDay { Date = key }); + } + return list; + } + + public void Reset() + { + _days.Clear(); + _dirty = true; + Save(); + } + + /// Write to disk if anything changed since the last save. Best-effort. + public void Save() + { + if (!_dirty) return; + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, JsonSerializer.Serialize(_days.Values, Options)); + _dirty = false; + } + catch + { + // Never crash the app over a failed activity write. + } + } + + private void Load() + { + try + { + if (!File.Exists(_path)) return; + var loaded = JsonSerializer.Deserialize>(File.ReadAllText(_path)); + if (loaded is null) return; + foreach (var day in loaded) _days[day.Date] = day; + } + catch + { + // Corrupt or unreadable file: start with no history. + } + } +} diff --git a/src/ClickLightWin/AppController.cs b/src/ClickLightWin/AppController.cs index 0254b13..782b47c 100644 --- a/src/ClickLightWin/AppController.cs +++ b/src/ClickLightWin/AppController.cs @@ -21,12 +21,16 @@ public sealed class AppController : IDisposable private readonly HotKeyManager _hotKeys = new(); private readonly SettingsStore _settingsStore = new(); private readonly LaunchAtLoginController _launchAtLogin = new(); + private readonly ProfileStore _profileStore = new(); + private readonly ActivityStore _activity = new(); private readonly UpdateService _updates = new(); private Settings _settings = new(); private TrayIcon? _tray; private OverlayManager? _overlays; private SettingsWindow? _settingsWindow; private DispatcherTimer? _updateTimer; + private DispatcherTimer? _activitySaveTimer; + private ClickButton? _dragCounted; // which button's current drag gesture already counted public void Start() { @@ -44,7 +48,7 @@ public void Start() if (e.PropertyName is nameof(Settings.Enabled) or nameof(Settings.ShowShortcuts)) UpdateKeyboardHook(); }; - _keyboardHook.ShortcutDetected += keys => _overlays?.DispatchShortcut(keys); + _keyboardHook.ShortcutDetected += keys => { if (!OverlaysSuspended) _overlays?.DispatchShortcut(keys); }; // The tray menu mutates the shared settings directly and refreshes its // checkmarks on open, so no per-item sync is needed here. @@ -73,6 +77,11 @@ public void Start() UpdateKeyboardHook(); // installs the keyboard hook only if the shortcut display is on + // Persist the click tallies periodically (they are cheap in-memory otherwise). + _activitySaveTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(30) }; + _activitySaveTimer.Tick += (_, _) => _activity.Save(); + _activitySaveTimer.Start(); + InitializeUpdates(); } @@ -131,6 +140,11 @@ 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; + // While the settings window is open the user edits a draft and previews in its pad, + // so the live overlay is paused: the real screen stays quiet (no laser following the + // cursor over the window, no double pulse when clicking in the pad) until OK/Cancel. + private bool OverlaysSuspended => _settingsWindow is not null; + private void ConfigureHotkeys() { _hotKeys.Configure(_settings.ToggleHotKey, _settings.ClearHotKey, _settings.DrawModeHotKey); @@ -151,7 +165,11 @@ private void WarnAboutUnavailableHotkeys() } // Left-drag draws an arrow, right-drag a box; the hook tags each with its tool. - private void OnAnnotation(AnnotationEvent evt) => _overlays?.DispatchAnnotation(evt); + private void OnAnnotation(AnnotationEvent evt) + { + if (OverlaysSuspended) return; + _overlays?.DispatchAnnotation(evt); + } public void ClearAnnotations() => _overlays?.ClearAnnotations(); @@ -171,7 +189,8 @@ private void ToggleDrawMode() private void OnClick(ClickEvent click) { - if (!_settings.Enabled) return; + RecordActivity(click); // count real clicks regardless of Enabled / settings being open + if (!_settings.Enabled || OverlaysSuspended) return; switch (click.Phase) { case ClickPhase.Down: @@ -200,6 +219,26 @@ private void OnClick(ClickEvent click) } } + // Tally clicks for the Activity view: each press counts for its button, and each + // drag gesture counts once (not once per move event). + private void RecordActivity(ClickEvent click) + { + switch (click.Phase) + { + case ClickPhase.Down: + _dragCounted = null; + _activity.RecordClick(click.Button); + break; + case ClickPhase.Drag when _dragCounted != click.Button: + _dragCounted = click.Button; + _activity.RecordDrag(); + break; + case ClickPhase.Up: + _dragCounted = null; + break; + } + } + // 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() @@ -220,12 +259,19 @@ private void ShowSettings() // shortcut recorder neither fires an action nor collides with the OS registration. _hotKeys.Suspend(); - _settingsWindow = new SettingsWindow(_settings); + // Edit a detached draft; the live settings (and overlays) are untouched until + // the user commits with OK. Cancel just discards the draft. + var draft = _settings.Clone(); + var window = _settingsWindow = new SettingsWindow(draft, _profileStore, _activity); _settingsWindow.Closed += (_, _) => { _settingsWindow = null; - ConfigureHotkeys(); // apply any rebindings and re-enable the hotkeys - _settingsStore.Save(_settings); // persist any edits made in the window + if (window.Committed) + { + _settings.CopyFrom(draft); // apply the draft to the overlays + _settingsStore.Save(_settings); // and persist it + } + ConfigureHotkeys(); // re-register from the (possibly updated) live settings and re-enable }; _settingsWindow.Show(); _settingsWindow.Activate(); @@ -235,6 +281,8 @@ public void Dispose() { _settingsStore.Save(_settings); _updateTimer?.Stop(); + _activitySaveTimer?.Stop(); + _activity.Save(); _hook.Dispose(); _keyboardHook.Dispose(); _hotKeys.Dispose(); diff --git a/src/ClickLightWin/Assets/clicklight-logo.png b/src/ClickLightWin/Assets/clicklight-logo.png new file mode 100644 index 0000000..389fb90 Binary files /dev/null and b/src/ClickLightWin/Assets/clicklight-logo.png differ diff --git a/src/ClickLightWin/Assets/icon.ico b/src/ClickLightWin/Assets/icon.ico new file mode 100644 index 0000000..9fc80b4 Binary files /dev/null and b/src/ClickLightWin/Assets/icon.ico differ diff --git a/src/ClickLightWin/ClickActivityDay.cs b/src/ClickLightWin/ClickActivityDay.cs new file mode 100644 index 0000000..e369c4d --- /dev/null +++ b/src/ClickLightWin/ClickActivityDay.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using System.Text.Json.Serialization; + +namespace ClickLightWin; + +/// +/// One day's click tallies, kept locally for the Activity view. Left/Right/Middle +/// count button presses; Drags counts drag gestures. Mirrors the macOS +/// ClickActivityDay. The date is a stable "yyyy-MM-dd" key so it round-trips through +/// JSON without culture surprises. +/// +public sealed class ClickActivityDay +{ + public string Date { get; set; } = ""; + public int Left { get; set; } + public int Right { get; set; } + public int Middle { get; set; } + public int Drags { get; set; } + + [JsonIgnore] public int TotalClicks => Left + Right + Middle; + + /// Short weekday label (e.g. "Mon") for the chart axis. + [JsonIgnore] + public string Label => + DateTime.TryParseExact(Date, "yyyy-MM-dd", CultureInfo.InvariantCulture, + DateTimeStyles.None, out var d) ? d.ToString("ddd", CultureInfo.CurrentCulture) : ""; +} diff --git a/src/ClickLightWin/ClickLightWin.csproj b/src/ClickLightWin/ClickLightWin.csproj index 87770bd..d18afc7 100644 --- a/src/ClickLightWin/ClickLightWin.csproj +++ b/src/ClickLightWin/ClickLightWin.csproj @@ -17,6 +17,7 @@ true app.manifest + Assets\icon.ico x64;arm64 + + + + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/ClickLightWin/ClickProfile.cs b/src/ClickLightWin/ClickProfile.cs new file mode 100644 index 0000000..719f5d0 --- /dev/null +++ b/src/ClickLightWin/ClickProfile.cs @@ -0,0 +1,70 @@ +using System.Text.Json.Serialization; + +namespace ClickLightWin; + +/// +/// A saved visual setup: the pulse size, duration, colors, and visibility toggles, +/// under a user-given name. Deliberately excludes hotkeys, launch-at-login, and menu +/// layout, matching the macOS ClickProfile so setups move cleanly between machines. +/// +public sealed class ClickProfile +{ + public string Name { get; set; } = ""; + public DateTime CreatedUtc { get; set; } + + public double BaseDiameterDips { get; set; } + public double PulseDurationMs { get; set; } + public string LeftColorHex { get; set; } = ""; + public string RightColorHex { get; set; } = ""; + public string MiddleColorHex { get; set; } = ""; + public string AnnotationColorHex { get; set; } = ""; + public bool ShowDrag { get; set; } + public bool ShowRelease { get; set; } + public bool ShowLaserPointer { get; set; } + public bool EnableAnnotations { get; set; } + public bool ShowShortcuts { get; set; } + + /// Local, human-friendly creation time for the list subtitle. + [JsonIgnore] public string CreatedDisplay => $"Created {CreatedUtc.ToLocalTime():g}"; + + /// Snapshot the visual settings of under a name. + public static ClickProfile Capture(string name, Settings s, DateTime nowUtc) + { + var profile = new ClickProfile { Name = name }; + profile.SnapshotFrom(s, nowUtc); + return profile; + } + + /// Overwrite this profile's visual values from (update in place). + public void SnapshotFrom(Settings s, DateTime nowUtc) + { + CreatedUtc = nowUtc; + BaseDiameterDips = s.BaseDiameterDips; + PulseDurationMs = s.PulseDurationMs; + LeftColorHex = s.LeftColorHex; + RightColorHex = s.RightColorHex; + MiddleColorHex = s.MiddleColorHex; + AnnotationColorHex = s.AnnotationColorHex; + ShowDrag = s.ShowDrag; + ShowRelease = s.ShowRelease; + ShowLaserPointer = s.ShowLaserPointer; + EnableAnnotations = s.EnableAnnotations; + ShowShortcuts = s.ShowShortcuts; + } + + /// Push this profile's visual values onto (e.g. the draft). + public void ApplyTo(Settings s) + { + s.BaseDiameterDips = BaseDiameterDips; + s.PulseDurationMs = PulseDurationMs; + s.LeftColorHex = LeftColorHex; + s.RightColorHex = RightColorHex; + s.MiddleColorHex = MiddleColorHex; + s.AnnotationColorHex = AnnotationColorHex; + s.ShowDrag = ShowDrag; + s.ShowRelease = ShowRelease; + s.ShowLaserPointer = ShowLaserPointer; + s.EnableAnnotations = EnableAnnotations; + s.ShowShortcuts = ShowShortcuts; + } +} diff --git a/src/ClickLightWin/ProfileStore.cs b/src/ClickLightWin/ProfileStore.cs new file mode 100644 index 0000000..8ac54b3 --- /dev/null +++ b/src/ClickLightWin/ProfileStore.cs @@ -0,0 +1,115 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace ClickLightWin; + +/// +/// Loads, holds, and persists saved s as JSON under +/// %APPDATA%\ClickLightWin\profiles.json. The list is observable so the settings +/// window updates live. Missing or unreadable state falls back to an empty list +/// rather than throwing. Mirrors the macOS ClickProfileStore. +/// +public sealed class ProfileStore +{ + private static readonly JsonSerializerOptions Options = new() { WriteIndented = true }; + + /// The built-in profile that always exists and cannot be deleted. + public const string DefaultProfileName = "Default"; + + private readonly string _path; + + public ObservableCollection Profiles { get; } = []; + + public ProfileStore() + { + _path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "ClickLightWin", "profiles.json"); + Load(); + EnsureDefault(); + } + + /// Guarantee a "Default" profile exists (factory look) at the top of the list. + private void EnsureDefault() + { + if (Profiles.Any(p => string.Equals(p.Name, DefaultProfileName, StringComparison.OrdinalIgnoreCase))) + return; + Profiles.Insert(0, ClickProfile.Capture(DefaultProfileName, new Settings(), DateTime.UtcNow)); + Persist(); + } + + /// True when a profile with this name (case-insensitive) already exists. + public bool Contains(string name) => + Profiles.Any(p => string.Equals(p.Name, name.Trim(), StringComparison.OrdinalIgnoreCase)); + + /// + /// Save the current visual settings under a name. If a profile with that name + /// already exists it is replaced (updated in place); otherwise a new one is added. + /// + public ClickProfile Save(string name, Settings settings) + { + name = name.Trim(); + var existing = Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); + if (existing is not null) + { + existing.SnapshotFrom(settings, DateTime.UtcNow); // update in place, keeping the same instance + Persist(); + return existing; + } + var profile = ClickProfile.Capture(name, settings, DateTime.UtcNow); + Profiles.Add(profile); + Persist(); + return profile; + } + + /// Delete a profile. The built-in Default is permanent and never removed. + public void Delete(ClickProfile profile) + { + if (string.Equals(profile.Name, DefaultProfileName, StringComparison.OrdinalIgnoreCase)) return; + if (Profiles.Remove(profile)) Persist(); + } + + /// Write all profiles to an arbitrary path (Export). Throws on failure. + public void Export(string path) => + File.WriteAllText(path, JsonSerializer.Serialize(Profiles, Options)); + + /// Read profiles from a file and append them. Returns how many were added. + public int Import(string path) + { + var imported = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (imported is null) return 0; + foreach (var profile in imported) Profiles.Add(profile); + Persist(); + return imported.Count; + } + + private void Load() + { + try + { + if (!File.Exists(_path)) return; + var loaded = JsonSerializer.Deserialize>(File.ReadAllText(_path)); + if (loaded is null) return; + foreach (var profile in loaded) Profiles.Add(profile); + } + catch + { + // Corrupt or unreadable file: start with no profiles. + } + } + + private void Persist() + { + try + { + Directory.CreateDirectory(Path.GetDirectoryName(_path)!); + File.WriteAllText(_path, JsonSerializer.Serialize(Profiles, Options)); + } + catch + { + // Best-effort persistence; never crash the app over a failed write. + } + } +} diff --git a/src/ClickLightWin/Settings.cs b/src/ClickLightWin/Settings.cs index 404aba7..e7317c6 100644 --- a/src/ClickLightWin/Settings.cs +++ b/src/ClickLightWin/Settings.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text.Json.Serialization; @@ -33,6 +34,7 @@ public sealed class Settings : INotifyPropertyChanged private HotKeyBinding _toggleHotKey = HotKeyBinding.DefaultToggle; private HotKeyBinding _clearHotKey = HotKeyBinding.DefaultClear; private HotKeyBinding _drawModeHotKey = HotKeyBinding.DefaultDrawMode; + private string _currentProfileName = ProfileStore.DefaultProfileName; // ---- Persisted, user-editable ------------------------------------------- @@ -55,6 +57,12 @@ public sealed class Settings : INotifyPropertyChanged public HotKeyBinding ClearHotKey { get => _clearHotKey; set => Set(ref _clearHotKey, value); } public HotKeyBinding DrawModeHotKey { get => _drawModeHotKey; set => Set(ref _drawModeHotKey, value); } + /// Name of the profile currently selected in the settings window. + public string CurrentProfileName { get => _currentProfileName; set => Set(ref _currentProfileName, value); } + + /// Order and visibility of the system-tray menu items (Quit always shown). + public ObservableCollection MenuLayout { get; set; } = TrayMenu.DefaultLayout(); + // ---- Computed render constants (not persisted, not user-editable yet) ---- [JsonIgnore] public double MaxScale => 2.2; @@ -106,6 +114,45 @@ public sealed class Settings : INotifyPropertyChanged [JsonIgnore] public Duration ShortcutFade => new(TimeSpan.FromMilliseconds(350)); [JsonIgnore] public int ShortcutStackMax => 6; + /// A detached copy of the user-editable settings, for editing in a draft. + public Settings Clone() + { + var copy = new Settings(); + copy.CopyFrom(this); + return copy; + } + + /// + /// Copy all persisted, user-editable values from into this + /// instance through the setters, so bindings, overlays, and hotkeys react. Used to + /// commit a settings-window draft back onto the live settings. + /// + public void CopyFrom(Settings other) + { + SchemaVersion = other.SchemaVersion; + Enabled = other.Enabled; + ShowDrag = other.ShowDrag; + ShowRelease = other.ShowRelease; + ShowLaserPointer = other.ShowLaserPointer; + EnableAnnotations = other.EnableAnnotations; + ShowShortcuts = other.ShowShortcuts; + BaseDiameterDips = other.BaseDiameterDips; + PulseDurationMs = other.PulseDurationMs; + LeftColorHex = other.LeftColorHex; + RightColorHex = other.RightColorHex; + MiddleColorHex = other.MiddleColorHex; + AnnotationColorHex = other.AnnotationColorHex; + ToggleHotKey = other.ToggleHotKey; + ClearHotKey = other.ClearHotKey; + DrawModeHotKey = other.DrawModeHotKey; + CurrentProfileName = other.CurrentProfileName; + + // Deep-copy the menu layout into this instance's own collection (kept, not + // replaced, so any UI bound to it stays live). + MenuLayout.Clear(); + foreach (var entry in other.MenuLayout) MenuLayout.Add(entry.Clone()); + } + public Color ColorFor(ClickButton button) => ParseHex(button switch { ClickButton.Left => _leftColorHex, diff --git a/src/ClickLightWin/SettingsStore.cs b/src/ClickLightWin/SettingsStore.cs index 95ea43b..88930da 100644 --- a/src/ClickLightWin/SettingsStore.cs +++ b/src/ClickLightWin/SettingsStore.cs @@ -47,6 +47,7 @@ internal static Settings Normalize(Settings settings) { settings.BaseDiameterDips = Nearest(Presets.Sizes, settings.BaseDiameterDips); settings.PulseDurationMs = Nearest(Presets.Durations, settings.PulseDurationMs); + TrayMenu.Normalize(settings.MenuLayout); // fill in any items added since this file was written return settings; } diff --git a/src/ClickLightWin/Tray/TrayIcon.cs b/src/ClickLightWin/Tray/TrayIcon.cs index cce9550..537382b 100644 --- a/src/ClickLightWin/Tray/TrayIcon.cs +++ b/src/ClickLightWin/Tray/TrayIcon.cs @@ -33,6 +33,11 @@ public sealed class TrayIcon : IDisposable private readonly List<(ToolStripMenuItem Item, double Value)> _sizeItems = []; private readonly List<(ToolStripMenuItem Item, double Value)> _durationItems = []; + // Every configurable item, keyed by id. The visible menu is (re)built from + // Settings.MenuLayout each time it opens, so ordering and hiding take effect live. + private readonly Dictionary _items; + private bool _updateAvailable; + public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, Action persist, Action openSettings, Action clearAnnotations, Action applyUpdate, Action quit) @@ -48,14 +53,13 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, ForeColor = Color.White, Font = _menuFont }; - menu.Opening += (_, _) => RefreshChecks(); + menu.Opening += (_, _) => { RefreshChecks(); RebuildMenu(); }; - // Hidden until an update is found, then shown at the very top as a prompt. + // Update prompt, pinned above the configured items when an update is found. _updateFont = new Font(_menuFont, FontStyle.Bold); _updateItem = Item("Restart to update", applyUpdate); _updateItem.Font = _updateFont; - _updateItem.Visible = false; - _updateSeparator = new ToolStripSeparator { Visible = false }; + _updateSeparator = new ToolStripSeparator(); _enabledItem = Check("Enabled", () => Toggle(s => s.Enabled = !s.Enabled)); _laserItem = Check("Laser pointer mode", () => Toggle(s => s.ShowLaserPointer = !s.ShowLaserPointer)); @@ -64,26 +68,25 @@ public TrayIcon(Settings settings, LaunchAtLoginController launchAtLogin, _shortcutsItem = Check("Show keyboard shortcuts", () => Toggle(s => s.ShowShortcuts = !s.ShowShortcuts)); _launchItem = Check("Launch at login", () => _launchAtLogin.SetEnabled(!_launchAtLogin.IsEnabled)); - menu.Items.Add(_updateItem); - menu.Items.Add(_updateSeparator); - menu.Items.Add(_enabledItem); - menu.Items.Add(_laserItem); - 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)); - menu.Items.Add(new ToolStripSeparator()); - menu.Items.Add(Item("Clear annotations", clearAnnotations)); - menu.Items.Add(_launchItem); - menu.Items.Add(new ToolStripSeparator()); - menu.Items.Add(Item("Settings...", openSettings)); - menu.Items.Add(Item("About ClickLight", ShowAbout)); - menu.Items.Add(Item("Quit", quit)); - - _iconImage = AppIconFactory.CreatePulseIcon(); + _items = new Dictionary + { + [TrayMenuItem.Enabled] = _enabledItem, + [TrayMenuItem.LaserPointer] = _laserItem, + [TrayMenuItem.ReleaseRing] = _releaseItem, + [TrayMenuItem.DragTrail] = _dragItem, + [TrayMenuItem.KeyboardShortcuts] = _shortcutsItem, + [TrayMenuItem.Size] = PresetSubmenu("Size", Presets.Sizes, _sizeItems, v => _settings.BaseDiameterDips = v), + [TrayMenuItem.Duration] = PresetSubmenu("Duration", Presets.Durations, _durationItems, v => _settings.PulseDurationMs = v), + [TrayMenuItem.ClearAnnotations] = Item("Clear annotations", clearAnnotations), + [TrayMenuItem.LaunchAtLogin] = _launchItem, + [TrayMenuItem.Settings] = Item("Settings...", openSettings), + [TrayMenuItem.About] = Item("About ClickLight", ShowAbout), + [TrayMenuItem.Quit] = Item("Quit", quit), + }; + + RebuildMenu(); + + _iconImage = LoadAppIcon(); _icon = new NotifyIcon { Icon = _iconImage, @@ -154,12 +157,30 @@ public void ShowInfo(string title, string text) => public void ShowUpdateAvailable(string version) { _updateItem.Text = $"Restart to update to v{version}"; - _updateItem.Visible = true; - _updateSeparator.Visible = true; + _updateAvailable = true; // the next menu open rebuilds with the prompt on top ShowInfo("ClickLight update available", $"Version {version} is ready. Right-click the tray icon and choose \"Restart to update\"."); } + // Rebuild the menu from the configured layout: the update prompt (if any), then the + // visible items in their saved order. Quit is always present and never hidden. + private void RebuildMenu() + { + _menu.Items.Clear(); + if (_updateAvailable) + { + _menu.Items.Add(_updateItem); + _menu.Items.Add(_updateSeparator); + } + foreach (var entry in _settings.MenuLayout) + { + if (!entry.Visible && entry.Item != TrayMenuItem.Quit) continue; + if (_items.TryGetValue(entry.Item, out var item)) _menu.Items.Add(item); + } + if (!_menu.Items.Contains(_items[TrayMenuItem.Quit])) + _menu.Items.Add(_items[TrayMenuItem.Quit]); // safety net; Quit is never hidden + } + private void Toggle(Action mutate) { mutate(_settings); @@ -180,6 +201,27 @@ private void RefreshChecks() private static bool Near(double a, double b) => Math.Abs(a - b) < 0.5; + // The shipped multi-resolution icon; fall back to the drawn pulse icon if the + // resource is somehow unavailable. + private static Icon LoadAppIcon() + { + try + { + var info = System.Windows.Application.GetResourceStream( + new Uri("pack://application:,,,/Assets/icon.ico")); + if (info is not null) + { + using var stream = info.Stream; + return new Icon(stream); + } + } + catch + { + // Fall through to the generated icon. + } + return AppIconFactory.CreatePulseIcon(); + } + 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" @@ -192,6 +234,11 @@ public void Dispose() _icon.Visible = false; _icon.Dispose(); _iconImage.Dispose(); + // Items not currently in the menu (hidden ones, the update prompt) are not owned + // by it, so dispose every item we created explicitly. + foreach (var item in _items.Values) item.Dispose(); + _updateItem.Dispose(); + _updateSeparator.Dispose(); _menu.Dispose(); // NotifyIcon does not own its ContextMenuStrip _menuFont.Dispose(); _updateFont.Dispose(); diff --git a/src/ClickLightWin/TrayMenuLayout.cs b/src/ClickLightWin/TrayMenuLayout.cs new file mode 100644 index 0000000..9452f3a --- /dev/null +++ b/src/ClickLightWin/TrayMenuLayout.cs @@ -0,0 +1,88 @@ +using System.Collections.ObjectModel; +using System.Linq; +using System.Text.Json.Serialization; + +namespace ClickLightWin; + +/// Identifies a configurable system-tray menu item. +public enum TrayMenuItem +{ + Enabled, + LaserPointer, + ReleaseRing, + DragTrail, + KeyboardShortcuts, + Size, + Duration, + ClearAnnotations, + LaunchAtLogin, + Settings, + About, + Quit +} + +/// One tray menu item's placement: which item it is, and whether it is shown. +public sealed class MenuLayoutEntry +{ + public TrayMenuItem Item { get; set; } + public bool Visible { get; set; } = true; + + [JsonIgnore] public string Label => TrayMenu.Label(Item); + [JsonIgnore] public bool CanHide => Item != TrayMenuItem.Quit; // Quit is always shown + + public MenuLayoutEntry Clone() => new() { Item = Item, Visible = Visible }; +} + +/// The set of tray menu items, their labels, and the default order. +public static class TrayMenu +{ + // Default order, matching the original hard-coded menu. + public static readonly TrayMenuItem[] DefaultOrder = + [ + TrayMenuItem.Enabled, TrayMenuItem.LaserPointer, TrayMenuItem.ReleaseRing, + TrayMenuItem.DragTrail, TrayMenuItem.KeyboardShortcuts, TrayMenuItem.Size, + TrayMenuItem.Duration, TrayMenuItem.ClearAnnotations, TrayMenuItem.LaunchAtLogin, + TrayMenuItem.Settings, TrayMenuItem.About, TrayMenuItem.Quit + ]; + + public static string Label(TrayMenuItem item) => item switch + { + TrayMenuItem.Enabled => "Enabled", + TrayMenuItem.LaserPointer => "Laser pointer mode", + TrayMenuItem.ReleaseRing => "Show release ring", + TrayMenuItem.DragTrail => "Show drag trail", + TrayMenuItem.KeyboardShortcuts => "Show keyboard shortcuts", + TrayMenuItem.Size => "Size", + TrayMenuItem.Duration => "Duration", + TrayMenuItem.ClearAnnotations => "Clear annotations", + TrayMenuItem.LaunchAtLogin => "Launch at login", + TrayMenuItem.Settings => "Settings...", + TrayMenuItem.About => "About ClickLight", + TrayMenuItem.Quit => "Quit", + _ => item.ToString() + }; + + public static ObservableCollection DefaultLayout() => + new(DefaultOrder.Select(i => new MenuLayoutEntry { Item = i, Visible = true })); + + /// + /// Ensure the layout has exactly one entry per menu item: drop unknown or duplicate + /// entries and append any items missing (e.g. after an upgrade) in default order, + /// always keeping Quit present and visible. Mutates the collection in place. + /// + public static void Normalize(ObservableCollection layout) + { + var seen = new HashSet(); + for (var i = 0; i < layout.Count;) + { + if (!Enum.IsDefined(layout[i].Item) || !seen.Add(layout[i].Item)) layout.RemoveAt(i); + else i++; + } + foreach (var item in DefaultOrder) + if (seen.Add(item)) + layout.Add(new MenuLayoutEntry { Item = item, Visible = true }); + + var quit = layout.First(e => e.Item == TrayMenuItem.Quit); + quit.Visible = true; + } +} diff --git a/src/ClickLightWin/Views/PreviewPad.cs b/src/ClickLightWin/Views/PreviewPad.cs new file mode 100644 index 0000000..1e95b2f --- /dev/null +++ b/src/ClickLightWin/Views/PreviewPad.cs @@ -0,0 +1,87 @@ +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Media; +using ClickLightWin.Rendering; +using Color = System.Windows.Media.Color; +using Cursors = System.Windows.Input.Cursors; +using MouseEventArgs = System.Windows.Input.MouseEventArgs; + +namespace ClickLightWin.Views; + +/// +/// The interactive Preview Pad in the settings window: a small canvas that draws +/// real click effects (press ring, drag trail, release ring, laser glow) as you +/// click and drag inside it, using the live from its +/// DataContext. Reuses the same renderers the overlays use, so the preview matches +/// what appears on screen. Mirrors the macOS InteractiveClickPreviewView. +/// +public sealed class PreviewPad : Canvas +{ + private PulseRenderer? _pulses; + private LaserRenderer? _laser; + + public PreviewPad() + { + ClipToBounds = true; + // A canvas only receives mouse input where it has a non-null background. + Background = new SolidColorBrush(Color.FromRgb(0x15, 0x15, 0x17)); + Cursor = Cursors.Cross; + Unloaded += (_, _) => _laser?.Dispose(); + } + + private Settings? Model => DataContext as Settings; + + protected override void OnMouseDown(MouseButtonEventArgs e) + { + if (Model is not { } settings || Map(e.ChangedButton) is not { } button) return; + _pulses ??= new PulseRenderer(this); + CaptureMouse(); // so a drag that leaves the small pad still tracks + _pulses.Spawn(e.GetPosition(this), new ClickEvent(button, ClickPhase.Down, 0, 0), settings); + e.Handled = true; + } + + protected override void OnMouseMove(MouseEventArgs e) + { + if (Model is not { } settings) return; + var p = e.GetPosition(this); + + if (settings.ShowLaserPointer) + { + _laser ??= new LaserRenderer(this, settings); + _laser.UpdateCursor(p, settings); + return; // the laser replaces the drag trail, matching the overlay behavior + } + + // Drag trail: driven by the button actually held during the move, so it does + // not depend on capture or a separate dragging flag staying in sync. + if (Held(e) is { } button && settings.ShowDrag) + { + _pulses ??= new PulseRenderer(this); + _pulses.Spawn(p, new ClickEvent(button, ClickPhase.Drag, 0, 0), settings); + } + } + + protected override void OnMouseUp(MouseButtonEventArgs e) + { + if (Model is not { } settings || Map(e.ChangedButton) is not { } button) return; + if (IsMouseCaptured) ReleaseMouseCapture(); + if (settings.ShowRelease) + { + _pulses ??= new PulseRenderer(this); + _pulses.Spawn(e.GetPosition(this), new ClickEvent(button, ClickPhase.Up, 0, 0), settings); + } + } + + private static ClickButton? Held(MouseEventArgs e) => + e.LeftButton == MouseButtonState.Pressed ? ClickButton.Left : + e.RightButton == MouseButtonState.Pressed ? ClickButton.Right : + e.MiddleButton == MouseButtonState.Pressed ? ClickButton.Middle : null; + + private static ClickButton? Map(MouseButton button) => button switch + { + MouseButton.Left => ClickButton.Left, + MouseButton.Right => ClickButton.Right, + MouseButton.Middle => ClickButton.Middle, + _ => null + }; +} diff --git a/src/ClickLightWin/Views/SettingsWindow.xaml b/src/ClickLightWin/Views/SettingsWindow.xaml index fde2a8f..0d13386 100644 --- a/src/ClickLightWin/Views/SettingsWindow.xaml +++ b/src/ClickLightWin/Views/SettingsWindow.xaml @@ -4,24 +4,81 @@ xmlns:local="clr-namespace:ClickLightWin" xmlns:views="clr-namespace:ClickLightWin.Views" Title="ClickLight Settings" - Width="400" - SizeToContent="Height" - ResizeMode="NoResize" + Icon="/Assets/icon.ico" + Width="900" Height="580" MinWidth="820" MinHeight="480" WindowStartupLocation="CenterScreen" ShowInTaskbar="True" - Background="#FFFFFF" + Background="#1E1E20" FontFamily="Segoe UI Variable Text, Segoe UI" TextOptions.TextFormattingMode="Ideal" TextOptions.TextRenderingMode="ClearType"> + + + + + + - - - - + + + + + Segoe Fluent Icons, Segoe MDL2 Assets - + + + + + + + + + + + + + + - - - + - - - + + + + + + - + + + + + - + + + - - - - + + + - - - - - - - - - - - - - - - - + + + + + + + + - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -