Skip to content

Conversation

@Cookiesmuch
Copy link

@Cookiesmuch Cookiesmuch commented Jan 22, 2026

This PR adds Smart Profiles UI and significantly improves touchpad device handling to better support devices that switch between Bluetooth and wired modes. It introduces a temporary, heuristic-based device manager that reduces duplicate/stale entries and prefers the active connection. A follow-up PR will replace this temporary behavior with a proper device manager.
Current device management functions as such:
Profile switching based on app is on a per-device basis.
This allows you to use different profiles per device rather than the mouse speed settings and other settings changing by itself when you swittch devices.
The current system, however does not support instant switching between profiles when a device is connected. This functionalitty will be updated on my next PR.

Summary of changes
• Touchpad device handling
• TouchpadHelper:
• Added RefreshDevices() to re-enumerate HID devices on device-change events.
• Introduced deviceName, deviceLastSeenTime, and deviceHasSentInput tracking in TouchpadDeviceInfo/TouchpadHelper.
• Extracted normalized hardware IDs from raw device paths (GetNormalizedHardwareId(string, string, string)) to match wired/Bluetooth connections of the same hardware.

• Implemented deduplication of devices by hardware ID with a three-tier priority:

  1. Devices that have actually sent input (active)
  2. Newly discovered devices (during this refresh)
  3. Most recently seen devices (timestamp)

• Added a short grace period and cleanup logic (CleanupDisconnectedDevices()) to reduce flapping during mode switches.
• Update ParseInput() to mark devices as active when they send input.
• TouchpadDeviceInfo:
• Added deviceName property to store the raw device path used for normalization.
• ContactsManager:
• Calls RefreshDevices() on WM_INPUT_DEVICE_CHANGE.
• Uses Exists() checks and triggers UI updates after refresh.
• Logging: improved logging for device add/remove/deduplication events for easier debugging.
• Smart Profiles and profile UI
• Added/updated profile management UI and settings:
• ProfilesSettings.xaml.cs / ProfilesSettings.xaml.cs
• ThreeFingerDragSettings.xaml / ThreeFingerDragSettings.xaml.cs
• TouchpadSettings.xaml.cs
• SettingsWindow.xaml.cs / SettingsData.cs (profile support + JSON settings handling)
• SmartProfileSwitcher.cs (smart profile logic)
• ThreeFingerDragProfile updates (under settings/profiles)
• SettingsData updated to store profile info and load/save active profile.
• Profile file handling and rename/switch operations included.
• Misc
• Adjusted related code to call device refresh and update UI (HandlerWindow, Logger, etc.).
• Removed old/unused MouseSpeedSettings.cs.
• Other small fixes and logging improvements for stability during device change events.

Temporary behavior and limitations
• This PR implements a heuristic (temporary) approach to handle Bluetooth vs wired connections:
• It normalizes hardware IDs from device paths and deduplicates devices by that normalized ID.
• It prefers devices that have sent real input (touch reports), then newly discovered devices, then most recently seen.
• Limitations:
• Some devices/drivers may report different or inconsistent device path formats; heuristics might not match every vendor.
• Windows often leaves Bluetooth HID entries in Device Manager even when the device is powered off — those "zombie" entries can persist and in rare cases still appear in enumeration. The heuristics prefer active connections but cannot change Windows driver behavior.
• Edge cases remain (some devices still produce duplicate/stale entries in specific sequences). Logging has been added to help root-cause remaining cases.
• This is intentionally a pragmatic interim solution to improve user experience while a dedicated device manager is implemented.
Next steps (planned follow-up PR)
• A follow-up PR will replace heuristics with a proper device manager that:
• Uses Windows SetupAPI/WMI (or equivalent) to query device present/connected state and device-instance properties.
• Builds a robust mapping between device-instance identifiers and connection endpoints (Bluetooth vs USB).
• Features a UI tab for device management, including managing smart profiles with devices.
• Will also add better smart profiles, having a global profile that is switched to if no appplications witth a smart profile are open.
• Provides explicit device lifecycle handling and deterministic removal of stale driver entries.
• Adds unit/integration tests around device enumeration and profile switching.
• This PR is intentionally limited to heuristics + UI to make the feature usable now while the full device manager is developed.
Branch & commit
• Branch: feature/smart_profiles
• Primary commit: d0ee499 (improve touchpad device management for Bluetooth/wired mode switching)

Implemented profile management system with create, rename, duplicate, delete

Added Smart Profile Switching to auto-switch based on active programs

Created ProfilesSettings page with modern WinUI 3 card-based UI

Detection modes: Windows Hook (instant) and Interval-based (fallback)

Profile selector in top bar for quick switching

Right-click context menu for profile actions

Auto-text selection in dialogs for better UX
- Add grace period for device removal to handle reconnections

- Implement RefreshDevices() to re-enumerate devices on change events

- Add device activity tracking (hasSentInput) to distinguish active vs stale devices

- Extract normalized hardware IDs from device paths for better deduplication

- Implement three-tier priority system for device deduplication:

  1. Devices actively sending input

  2. Newly discovered devices

  3. Most recently seen devices

- Add deviceName field to TouchpadDeviceInfo for connection type detection

- Update ContactsManager to call RefreshDevices on WM_INPUT_DEVICE_CHANGE

- Improve logging for device addition, removal, and deduplication

These changes significantly improve handling of touchpads that switch between Bluetooth and wired connections, though edge cases with stale Windows drivers may still occur.
Copilot AI review requested due to automatic review settings January 22, 2026 07:11
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces Smart Profiles functionality and significantly improves touchpad device handling for devices that switch between Bluetooth and wired modes. The implementation uses a temporary heuristic-based approach to reduce duplicate device entries while a full device manager is planned for a future PR.

Changes:

  • Added Smart Profiles UI with profile management, switching, and per-device smart switching configuration
  • Implemented device deduplication logic using normalized hardware IDs extracted from device paths
  • Refactored three-finger drag settings to use profile-based configuration instead of global settings

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 49 comments.

Show a summary per file
File Description
TouchpadHelper.cs Added device refresh, deduplication, and cleanup logic with tracking for device activity
TouchpadDeviceInfo.cs Added deviceName property to store raw device path for connection type detection
ContactsManager.cs Integrated RefreshDevices call on device change events
MouseOperations.cs Updated to use ActiveProfile settings instead of global settings
SmartProfileSwitcher.cs New class implementing automatic profile switching based on foreground application
ThreeFingerDragProfile.cs New profile class with XML serialization and per-device smart switching configuration
SettingsData.cs Added profile management, loading, switching, and renaming functionality
ProfilesSettings.xaml/.cs New profile management UI with device-specific smart switching settings
SettingsWindow.xaml/.cs Added profile selector in title bar and profile management dialogs
ThreeFingerDrag.cs Updated to use ActiveProfile settings instead of global/device-specific settings
DistanceManager.cs Simplified to use ActiveProfile settings instead of per-device configs
FingerCounter.cs Updated comments to reference ActiveProfile settings
HandlerWindow.xaml.cs Added SmartProfileSwitcher device tracking and UI refresh on device changes
App.xaml.cs Initialized and started SmartProfileSwitcher
MouseSpeedSettings.cs Removed (obsolete per-device settings class)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 177 to 212
var now = DateTime.Now;
var devicesToRemove = new List<IntPtr>();

foreach (var kvp in availableDeviceInfos)
{
var device = kvp.Key;

// Check if device is still valid
if (!IsDeviceStillValid(device))
{
// If we haven't tracked this device's last seen time, set it now
if (!deviceLastSeenTime.ContainsKey(device))
{
deviceLastSeenTime[device] = now;
}
// Only remove if grace period has elapsed
else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod)
{
devicesToRemove.Add(device);
}
}
else
{
// Device is valid, update last seen time
deviceLastSeenTime[device] = now;
}
}

foreach (var device in devicesToRemove)
{
var deviceInfo = availableDeviceInfos[device];
availableDeviceInfos.Remove(device);
deviceLastSeenTime.Remove(device);
deviceHasSentInput.Remove(device);
Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}");
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential race condition in the device cleanup logic. The deviceLastSeenTime dictionary is checked and modified in multiple places without synchronization. If CleanupDisconnectedDevices() and ParseInput() run concurrently (which is possible since one is called from device change events and the other from input events), there could be issues with concurrent dictionary access. Consider adding proper locking or using a thread-safe collection.

Suggested change
var now = DateTime.Now;
var devicesToRemove = new List<IntPtr>();
foreach (var kvp in availableDeviceInfos)
{
var device = kvp.Key;
// Check if device is still valid
if (!IsDeviceStillValid(device))
{
// If we haven't tracked this device's last seen time, set it now
if (!deviceLastSeenTime.ContainsKey(device))
{
deviceLastSeenTime[device] = now;
}
// Only remove if grace period has elapsed
else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod)
{
devicesToRemove.Add(device);
}
}
else
{
// Device is valid, update last seen time
deviceLastSeenTime[device] = now;
}
}
foreach (var device in devicesToRemove)
{
var deviceInfo = availableDeviceInfos[device];
availableDeviceInfos.Remove(device);
deviceLastSeenTime.Remove(device);
deviceHasSentInput.Remove(device);
Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}");
}
lock (deviceLastSeenTime)
{
var now = DateTime.Now;
var devicesToRemove = new List<IntPtr>();
foreach (var kvp in availableDeviceInfos)
{
var device = kvp.Key;
// Check if device is still valid
if (!IsDeviceStillValid(device))
{
// If we haven't tracked this device's last seen time, set it now
if (!deviceLastSeenTime.ContainsKey(device))
{
deviceLastSeenTime[device] = now;
}
// Only remove if grace period has elapsed
else if (now - deviceLastSeenTime[device] > DeviceRemovalGracePeriod)
{
devicesToRemove.Add(device);
}
}
else
{
// Device is valid, update last seen time
deviceLastSeenTime[device] = now;
}
}
foreach (var device in devicesToRemove)
{
var deviceInfo = availableDeviceInfos[device];
availableDeviceInfos.Remove(device);
deviceLastSeenTime.Remove(device);
deviceHasSentInput.Remove(device);
Logger.Log($"[TouchpadHelper] Removed disconnected device after grace period: {device}, deviceId: {deviceInfo.deviceId}");
}
}

Copilot uses AI. Check for mistakes.

// Update smart profile switcher with current device
var deviceInfo = TouchpadHelper.GetDeivceInfo(currentDevice);
App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId);
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential null reference exception. If TouchpadHelper.GetDeivceInfo(currentDevice) returns null (which is possible based on the implementation in TouchpadHelper), then deviceInfo.deviceId will throw a NullReferenceException. Add a null check before accessing deviceInfo properties.

Suggested change
App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId);
if (deviceInfo != null){
App.SmartProfileSwitcher?.SetCurrentDevice(deviceInfo.deviceId);
}

Copilot uses AI. Check for mistakes.
Comment on lines 267 to 271
App.Instance.DispatcherQueue.TryEnqueue(() => {
if(App.SettingsWindow is SettingsWindow settingsWindow){
settingsWindow.LoadProfiles();
}
});
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-thread UI access issue. The code calls App.Instance.DispatcherQueue.TryEnqueue() to marshal UI updates to the UI thread, but if the DispatcherQueue is null or not initialized, this will throw a NullReferenceException. Add a null check before attempting to enqueue.

Suggested change
App.Instance.DispatcherQueue.TryEnqueue(() => {
if(App.SettingsWindow is SettingsWindow settingsWindow){
settingsWindow.LoadProfiles();
}
});
var dispatcherQueue = App.Instance?.DispatcherQueue;
if (dispatcherQueue != null)
{
dispatcherQueue.TryEnqueue(() => {
if(App.SettingsWindow is SettingsWindow settingsWindow){
settingsWindow.LoadProfiles();
}
});
}
else
{
Logger.Log("[SmartSwitcher] DispatcherQueue is not available; skipping settings window refresh.");
}

Copilot uses AI. Check for mistakes.
Comment on lines 144 to 155
if (File.Exists(oldPath))
{
try
{
File.Delete(oldPath);
Logger.Log($"Renamed profile file from {oldPath} to {newPath}");
}
catch (Exception e)
{
Logger.Log($"Error deleting old profile file: {e.Message}");
}
}
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential file system race condition. Between checking File.Exists(oldPath) and calling File.Delete(oldPath), another process or thread could delete the file, causing an exception. While this is unlikely in practice, consider wrapping the File.Delete call in a try-catch or using File.Exists check inside the try block for more robust error handling.

Suggested change
if (File.Exists(oldPath))
{
try
{
File.Delete(oldPath);
Logger.Log($"Renamed profile file from {oldPath} to {newPath}");
}
catch (Exception e)
{
Logger.Log($"Error deleting old profile file: {e.Message}");
}
}
try
{
File.Delete(oldPath);
Logger.Log($"Renamed profile file from {oldPath} to {newPath}");
}
catch (Exception e)
{
Logger.Log($"Error deleting old profile file: {e.Message}");
}

Copilot uses AI. Check for mistakes.
newDeviceInfo.productId = deviceInfo.hid.dwProductId.ToString();

availableDeviceInfos[hwnd] = newDeviceInfo;
deviceLastSeenTime[hwnd] = DateTime.Now; // Only set timestamp for NEW devices
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment says "Only set timestamp for NEW devices", but the timestamp is set unconditionally here. The logic is correct (it's inside the if (isNewDevice) block), but the comment placement could be misleading. Consider moving the comment to line 89 before the assignment to make it clearer that this entire block only executes for new devices.

Suggested change
deviceLastSeenTime[hwnd] = DateTime.Now; // Only set timestamp for NEW devices
// Only set timestamp for NEW devices
deviceLastSeenTime[hwnd] = DateTime.Now;

Copilot uses AI. Check for mistakes.
// 1. Devices that have sent actual input (active devices)
// 2. Newly added devices (likely the new connection)
// 3. Most recent timestamp (fallback)
var sortedDevices = group.OrderByDescending(kvp => deviceHasSentInput.ContainsKey(kvp.Key) && deviceHasSentInput[kvp.Key] ? 1 : 0)
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inefficient use of 'ContainsKey' and indexer.

Copilot uses AI. Check for mistakes.
int idObject, int idChild, uint dwEventThread, uint dwmsEventTime);

private readonly Timer _checkTimer;
private WinEventDelegate _hookDelegate;
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field '_hookDelegate' can be 'readonly'.

Copilot uses AI. Check for mistakes.

public sealed partial class ProfilesSettings : Page
{
private ObservableCollection<ProfileViewModel> _profiles = new ObservableCollection<ProfileViewModel>();
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field '_profiles' can be 'readonly'.

Copilot uses AI. Check for mistakes.
public const int RIM_INPUTSINK = 1;

private static Dictionary<IntPtr, TouchpadDeviceInfo> availableDeviceInfos = new Dictionary<IntPtr, TouchpadDeviceInfo>(2);
private static Dictionary<IntPtr, DateTime> deviceLastSeenTime = new Dictionary<IntPtr, DateTime>(2);
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field 'deviceLastSeenTime' can be 'readonly'.

Copilot uses AI. Check for mistakes.

private static Dictionary<IntPtr, TouchpadDeviceInfo> availableDeviceInfos = new Dictionary<IntPtr, TouchpadDeviceInfo>(2);
private static Dictionary<IntPtr, DateTime> deviceLastSeenTime = new Dictionary<IntPtr, DateTime>(2);
private static Dictionary<IntPtr, bool> deviceHasSentInput = new Dictionary<IntPtr, bool>(2);
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Field 'deviceHasSentInput' can be 'readonly'.

Copilot uses AI. Check for mistakes.
…ality

- Add thread synchronization locks around deviceLastSeenTime dictionary access

- Add null checks for GetDeivceInfo() result to prevent NullReferenceException

- Add null check for App.Instance.DispatcherQueue in SmartProfileSwitcher

- Remove redundant File.Exists check (File.Delete handles non-existent files)

- Replace inefficient ContainsKey+indexer pattern with TryGetValue

- Mark fields as readonly: deviceLastSeenTime, deviceHasSentInput, _hookDelegate, _profiles

- Improve comment placement for better code clarity

Addresses all issues identified by GitHub Copilot automated code review.
@Cookiesmuch
Copy link
Author

@copilot open a new pull request to apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant