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
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
-
-
-
+
+
+
+
+
+
-
+
+
+
+
+
-
+
+
+
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/ClickLightWin/Views/SettingsWindow.xaml.cs b/src/ClickLightWin/Views/SettingsWindow.xaml.cs
index 56fae1a..cfad89f 100644
--- a/src/ClickLightWin/Views/SettingsWindow.xaml.cs
+++ b/src/ClickLightWin/Views/SettingsWindow.xaml.cs
@@ -1,21 +1,67 @@
+using System.Linq;
using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Media;
+using Brush = System.Windows.Media.Brush;
+using DragDropEffects = System.Windows.DragDropEffects;
+using DragEventArgs = System.Windows.DragEventArgs;
+using HorizontalAlignment = System.Windows.HorizontalAlignment;
+using MessageBox = System.Windows.MessageBox;
+using MouseEventArgs = System.Windows.Input.MouseEventArgs;
+using Point = System.Windows.Point;
+using VerticalAlignment = System.Windows.VerticalAlignment;
namespace ClickLightWin.Views;
///
-/// A normal (non-overlay) window that binds two-way to the live ,
-/// so edits apply to the overlays immediately. Persistence happens when the window
-/// closes. Maps to SettingsWindowController.swift / ClickLightSettingsView.swift.
+/// A sidebar-navigation settings window (General / Event Visibility / Visual Style /
+/// Keyboard Shortcuts / Profiles / Activity / Menu Layout) that binds two-way to the
+/// live , so edits apply to the overlays immediately.
+/// Persistence happens when the window closes. Maps to ClickLightSettingsView.swift.
///
public partial class SettingsWindow : Window
{
- public SettingsWindow(Settings settings)
+ private readonly StackPanel[] _panes;
+ private readonly Random _rng = new();
+ private readonly ProfileStore _profiles;
+ private readonly ActivityStore _activity;
+ private bool _suppressProfileLoad;
+ private Point _menuDragStart;
+ private MenuLayoutEntry? _menuDragItem;
+
+ /// True when the user closed with OK; the caller then applies the draft.
+ public bool Committed { get; private set; }
+
+ public SettingsWindow(Settings settings, ProfileStore profiles, ActivityStore activity)
{
InitializeComponent();
DataContext = settings;
+ _profiles = profiles;
+ _activity = activity;
+ ProfileCombo.ItemsSource = profiles.Profiles;
+ // Reflect the current profile without reloading it, so any unsaved live tweaks
+ // that differ from the profile's snapshot are preserved on open.
+ SelectProfileByName(settings.CurrentProfileName);
+ PopulateActivity();
+ _panes = [PaneGeneral, PaneEvents, PaneStyle, PaneShortcuts, PaneProfiles, PaneActivity, PaneMenu];
+ Nav.SelectedIndex = 0;
+ }
+
+ private void OnOk(object sender, RoutedEventArgs e)
+ {
+ Committed = true;
+ Close();
}
- private void OnCloseClick(object sender, RoutedEventArgs e) => Close();
+ private void OnCancel(object sender, RoutedEventArgs e) => Close();
+
+ private void OnNavChanged(object sender, SelectionChangedEventArgs e)
+ {
+ var index = Nav.SelectedIndex;
+ for (var i = 0; i < _panes.Length; i++)
+ _panes[i].Visibility = i == index ? Visibility.Visible : Visibility.Collapsed;
+ }
private void OnResetShortcut(object sender, RoutedEventArgs e)
{
@@ -27,4 +73,253 @@ private void OnResetShortcut(object sender, RoutedEventArgs e)
case "draw": settings.DrawModeHotKey = HotKeyBinding.DefaultDrawMode; break;
}
}
+
+ private void OnResetDefaults(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is not Settings s) return;
+ var confirm = MessageBox.Show(
+ "Restore size, duration, colors, and visibility toggles to their defaults?",
+ "Reset ClickLight settings", MessageBoxButton.OKCancel, MessageBoxImage.Warning);
+ if (confirm != MessageBoxResult.OK) return;
+
+ var d = new Settings();
+ s.BaseDiameterDips = d.BaseDiameterDips;
+ s.PulseDurationMs = d.PulseDurationMs;
+ s.LeftColorHex = d.LeftColorHex;
+ s.RightColorHex = d.RightColorHex;
+ s.MiddleColorHex = d.MiddleColorHex;
+ s.AnnotationColorHex = d.AnnotationColorHex;
+ s.ShowDrag = d.ShowDrag;
+ s.ShowRelease = d.ShowRelease;
+ s.ShowLaserPointer = d.ShowLaserPointer;
+ s.EnableAnnotations = d.EnableAnnotations;
+ s.ShowShortcuts = d.ShowShortcuts;
+ }
+
+ private ClickProfile? CurrentProfile => ProfileCombo.SelectedItem as ClickProfile;
+
+ private static bool IsDefault(ClickProfile p) =>
+ string.Equals(p.Name, ProfileStore.DefaultProfileName, StringComparison.OrdinalIgnoreCase);
+
+ // The user picked another profile: load its snapshot into the draft (previews in
+ // the pad, commits on OK).
+ private void OnProfileSelected(object sender, SelectionChangedEventArgs e)
+ {
+ if (_suppressProfileLoad) return;
+ if (CurrentProfile is not { } p || DataContext is not Settings s) return;
+ p.ApplyTo(s);
+ s.CurrentProfileName = p.Name;
+ UpdateDeleteButton();
+ ShowProfileStatus($"Loaded \"{p.Name}\".");
+ }
+
+ // Update the currently selected profile in place with the current draft.
+ private void OnSaveChanges(object sender, RoutedEventArgs e)
+ {
+ if (CurrentProfile is not { } p || DataContext is not Settings s) return;
+ _profiles.Save(p.Name, s);
+ ShowProfileStatus($"Saved changes to \"{p.Name}\".");
+ }
+
+ // Create a new profile from the current draft and make it the current one.
+ private void OnCreateProfile(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is not Settings s) return;
+ var name = NewProfileName.Text.Trim();
+ if (name.Length == 0) return;
+ var existed = _profiles.Contains(name);
+ var profile = _profiles.Save(name, s);
+ NewProfileName.Clear();
+ SelectProfile(profile);
+ s.CurrentProfileName = profile.Name;
+ ShowProfileStatus(existed ? $"Updated \"{name}\"." : $"Created \"{name}\".");
+ }
+
+ // Delete the selected profile (never Default) and fall back to Default, keeping the
+ // current draft settings.
+ private void OnDeleteProfile(object sender, RoutedEventArgs e)
+ {
+ if (CurrentProfile is not { } p || IsDefault(p) || DataContext is not Settings s) return;
+ var name = p.Name;
+ _suppressProfileLoad = true;
+ _profiles.Delete(p); // removing the item clears the combo selection
+ SelectProfileByName(ProfileStore.DefaultProfileName);
+ s.CurrentProfileName = ProfileStore.DefaultProfileName;
+ ShowProfileStatus($"Deleted \"{name}\".");
+ }
+
+ private void SelectProfileByName(string name)
+ {
+ var match = _profiles.Profiles.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase))
+ ?? _profiles.Profiles.FirstOrDefault();
+ SelectProfile(match);
+ }
+
+ private void SelectProfile(ClickProfile? profile)
+ {
+ _suppressProfileLoad = true;
+ ProfileCombo.SelectedItem = profile;
+ _suppressProfileLoad = false;
+ UpdateDeleteButton();
+ }
+
+ private void UpdateDeleteButton() =>
+ DeleteProfileButton.IsEnabled = CurrentProfile is { } p && !IsDefault(p);
+
+ private void OnExportProfiles(object sender, RoutedEventArgs e)
+ {
+ if (_profiles.Profiles.Count == 0) { ShowProfileStatus("No profiles to export."); return; }
+ var dialog = new Microsoft.Win32.SaveFileDialog
+ {
+ FileName = "ClickLight Profiles.json",
+ Filter = "JSON files (*.json)|*.json"
+ };
+ if (dialog.ShowDialog() != true) return;
+ try
+ {
+ _profiles.Export(dialog.FileName);
+ ShowProfileStatus($"Exported {_profiles.Profiles.Count} profile(s).");
+ }
+ catch
+ {
+ ShowProfileStatus("Export failed.");
+ }
+ }
+
+ private void OnImportProfiles(object sender, RoutedEventArgs e)
+ {
+ var dialog = new Microsoft.Win32.OpenFileDialog { Filter = "JSON files (*.json)|*.json" };
+ if (dialog.ShowDialog() != true) return;
+ try
+ {
+ var count = _profiles.Import(dialog.FileName);
+ ShowProfileStatus($"Imported {count} profile(s).");
+ }
+ catch
+ {
+ ShowProfileStatus("Import failed. Is it a ClickLight profiles file?");
+ }
+ }
+
+ private void ShowProfileStatus(string text)
+ {
+ ProfileStatus.Text = text;
+ ProfileStatus.Visibility = Visibility.Visible;
+ }
+
+ // Fill the Activity pane: today's totals plus a 7-day bar chart built by hand.
+ private void PopulateActivity()
+ {
+ var days = _activity.LastSevenDays();
+ var today = days[^1];
+ TodayTotal.Text = today.TotalClicks.ToString();
+ MetricLeft.Text = today.Left.ToString();
+ MetricRight.Text = today.Right.ToString();
+ MetricMiddle.Text = today.Middle.ToString();
+ MetricDrags.Text = today.Drags.ToString();
+
+ var accent = (Brush)FindResource("Accent");
+ var muted = (Brush)FindResource("Muted");
+
+ ActivityChart.Children.Clear();
+ var max = Math.Max(1, days.Max(d => d.TotalClicks));
+ const double maxBar = 96;
+
+ foreach (var day in days)
+ {
+ var column = new Grid { Margin = new Thickness(5, 0, 5, 0) };
+ column.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+ column.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) });
+ column.RowDefinitions.Add(new RowDefinition { Height = GridLength.Auto });
+
+ var count = new TextBlock
+ {
+ Text = day.TotalClicks.ToString(),
+ Foreground = muted,
+ FontSize = 11,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 0, 0, 4)
+ };
+ Grid.SetRow(count, 0);
+
+ var height = day.TotalClicks == 0 ? 3 : Math.Max(6, day.TotalClicks / (double)max * maxBar);
+ var bar = new Border
+ {
+ Height = height,
+ Background = accent,
+ CornerRadius = new CornerRadius(4),
+ VerticalAlignment = VerticalAlignment.Bottom
+ };
+ Grid.SetRow(bar, 1);
+
+ var label = new TextBlock
+ {
+ Text = day.Label,
+ Foreground = muted,
+ FontSize = 11,
+ HorizontalAlignment = HorizontalAlignment.Center,
+ Margin = new Thickness(0, 6, 0, 0)
+ };
+ Grid.SetRow(label, 2);
+
+ column.Children.Add(count);
+ column.Children.Add(bar);
+ column.Children.Add(label);
+ ActivityChart.Children.Add(column);
+ }
+ }
+
+ private void OnResetActivity(object sender, RoutedEventArgs e)
+ {
+ var confirm = MessageBox.Show(
+ "Remove all click counts stored on this PC?",
+ "Reset activity history", MessageBoxButton.OKCancel, MessageBoxImage.Warning);
+ if (confirm != MessageBoxResult.OK) return;
+ _activity.Reset();
+ PopulateActivity();
+ ActivityStatus.Text = "Activity history cleared.";
+ ActivityStatus.Visibility = Visibility.Visible;
+ }
+
+ // ---- Menu Layout drag-to-reorder ----
+
+ private void OnMenuItemPreviewDown(object sender, MouseButtonEventArgs e)
+ {
+ _menuDragStart = e.GetPosition(null);
+ _menuDragItem = (e.OriginalSource as FrameworkElement)?.DataContext as MenuLayoutEntry;
+ }
+
+ private void OnMenuItemPreviewMove(object sender, MouseEventArgs e)
+ {
+ if (e.LeftButton != MouseButtonState.Pressed || _menuDragItem is null) return;
+ var pos = e.GetPosition(null);
+ // Only begin a drag once past the system threshold, so a plain click still
+ // toggles the checkbox instead of starting a reorder.
+ if (Math.Abs(pos.X - _menuDragStart.X) < SystemParameters.MinimumHorizontalDragDistance &&
+ Math.Abs(pos.Y - _menuDragStart.Y) < SystemParameters.MinimumVerticalDragDistance) return;
+ DragDrop.DoDragDrop(MenuList, _menuDragItem, DragDropEffects.Move);
+ _menuDragItem = null;
+ }
+
+ private void OnMenuItemDrop(object sender, DragEventArgs e)
+ {
+ if (_menuDragItem is null || DataContext is not Settings s) return;
+ var target = (e.OriginalSource as FrameworkElement)?.DataContext as MenuLayoutEntry;
+ var from = s.MenuLayout.IndexOf(_menuDragItem);
+ var to = target is null ? s.MenuLayout.Count - 1 : s.MenuLayout.IndexOf(target);
+ if (from >= 0 && to >= 0 && from != to) s.MenuLayout.Move(from, to);
+ _menuDragItem = null;
+ }
+
+ // Pick random presets and colors, in the spirit of the macOS "Randomize" button.
+ private void OnRandomize(object sender, RoutedEventArgs e)
+ {
+ if (DataContext is not Settings s) return;
+ var colors = Palette.Colors;
+ s.BaseDiameterDips = Presets.Sizes[_rng.Next(Presets.Sizes.Length)].Value;
+ s.PulseDurationMs = Presets.Durations[_rng.Next(Presets.Durations.Length)].Value;
+ s.LeftColorHex = colors[_rng.Next(colors.Length)].Hex;
+ s.RightColorHex = colors[_rng.Next(colors.Length)].Hex;
+ s.MiddleColorHex = colors[_rng.Next(colors.Length)].Hex;
+ }
}