From 9859c1c4ae45b1445157045d90a15161eff213ea Mon Sep 17 00:00:00 2001 From: Jonathan Shen Date: Fri, 21 Nov 2025 17:13:51 -0800 Subject: [PATCH 1/8] Implement clone/duplicate display support Adds support for mirroring multiple monitors with the same content. Uses Windows CCD API two-phase pattern: enable displays + set clone groups (Phase 1), then apply resolution/position (Phase 2). See PR description for full details. --- CLAUDE.md | 134 +++- README.md | 1 + src/Core/Profile.cs | 12 +- src/Core/ProfileManager.cs | 350 ++++++---- src/Helpers/DisplayConfigHelper.cs | 846 +++++++++++++++-------- src/Helpers/DisplayHelper.cs | 8 +- src/Helpers/DpiHelper.cs | 20 +- src/UI/Windows/MainWindow.xaml.cs | 57 +- src/UI/Windows/ProfileEditWindow.xaml.cs | 304 +++++--- 9 files changed, 1184 insertions(+), 548 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b647ad3..1f880eb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -35,7 +35,7 @@ cmd.exe //c "msbuild DisplayProfileManager.sln /p:Configuration=Release /p:Platf ### Key Components - **ProfileManager**: Thread-safe singleton for profile CRUD and application. Stores individual `.dpm` files in `%AppData%/DisplayProfileManager/Profiles/`. Core method: `ApplyProfileAsync(Profile)` returns `ProfileApplyResult`. - **SettingsManager**: Thread-safe singleton for app settings. Supports dual auto-start modes (Registry or Task Scheduler) and staged application configuration. -- **DisplayConfigHelper**: Modern Windows Display Configuration API wrapper using `SetDisplayConfig`. Handles atomic topology application (resolution, refresh rate, position, primary, HDR, rotation, enable/disable). **Critical**: This replaces legacy `ChangeDisplaySettingsEx` for reliability. +- **DisplayConfigHelper**: Modern Windows Display Configuration API wrapper using `SetDisplayConfig`. Handles atomic topology application (resolution, refresh rate, position, primary, HDR, rotation, enable/disable). Clone mode support is in development. **Critical**: This replaces legacy `ChangeDisplaySettingsEx` for reliability. - **DisplayHelper**: Legacy display API wrapper (being phased out in favor of DisplayConfigHelper for topology changes). - **DpiHelper**: System-wide DPI scaling via P/Invoke (adapted from windows-DPI-scaling-sample). - **AudioHelper**: Audio device management using AudioSwitcher.AudioApi for playback/recording device switching. @@ -43,18 +43,45 @@ cmd.exe //c "msbuild DisplayProfileManager.sln /p:Configuration=Release /p:Platf - **GlobalHotkeyHelper**: System-wide hotkey registration using `RegisterHotKey` for profile switching. - **TrayIcon**: System tray integration with dynamically generated context menu from profiles. -### Display Configuration Engine (Modern Approach) +### Display Configuration Engine The application uses Windows Display Configuration API (`SetDisplayConfig`) for atomic, reliable profile switching: -**Flow**: `ProfileManager.ApplyProfileAsync` → builds `List` → `DisplayConfigHelper.ApplyDisplayTopology` or `ApplyStagedConfiguration` → `SetDisplayConfig` → `ApplyHdrSettings` - -**Staged Application Mode**: Optional two-phase application for complex multi-monitor setups: -- Phase 1: Apply topology to currently active displays only (partial update) -- Configurable pause (default 1000ms, range 1-5000ms) -- Phase 2: Apply full target topology including newly enabled displays -- Controlled by `UseStagedApplication` and `StagedApplicationPauseMs` settings - -**Why Staged Mode**: Prevents driver instability when enabling new displays with complex settings (HDR, high refresh rate) while simultaneously changing existing displays. +**Two-Phase Application Flow**: +1. **Phase 1 - Enable Displays and Set Clone Groups**: + - `EnableDisplays()` activates or deactivates displays + - Sets **final clone groups from profile** (displays that should mirror get same clone group ID) + - Uses `SDC_TOPOLOGY_SUPPLIED | SDC_APPLY | SDC_ALLOW_PATH_ORDER_CHANGES | SDC_VIRTUAL_MODE_AWARE` + - Uses null mode array (Windows chooses appropriate modes for topology) + - Assigns unique `sourceId` per display per adapter (0, 1, 2...) + - Purpose: Establish display topology and clone mode before applying detailed settings + +2. **Stabilization Pause**: Configurable delay (default 1000ms, range 1-5000ms) for driver and hardware initialization + +3. **Phase 2 - Apply Resolution, Position, and Refresh Rate**: + - `ApplyDisplayTopology()` applies detailed display settings: + - Resolution (modifies `sourceMode.width` and `sourceMode.height`) + - Desktop position (modifies `sourceMode.position.x` and `sourceMode.position.y`) + - Refresh rate (modifies `targetMode.vSyncFreq`) + - Rotation (modifies `path.targetInfo.rotation`) + - Uses `SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_APPLY | SDC_SAVE_TO_DATABASE | SDC_VIRTUAL_MODE_AWARE` + - Queries current config (with Phase 1 clone groups) and modifies mode array ONLY + - **Critical**: Does NOT modify clone groups or source IDs (already correct from Phase 1) + - Modifying clone groups in Phase 2 would invalidate mode indices and break the configuration + +4. **Primary Display**: Display at position (0,0) automatically becomes primary (no separate API call needed) + +5. **HDR & DPI**: Applied per-display after topology configuration using separate API calls + +6. **Audio Devices**: Switched to configured playback/recording devices if specified + +**Clone Group Implementation**: +- Clone groups enable display mirroring (duplicate displays showing identical content) +- Implemented via `CloneGroupId` field in `DISPLAYCONFIG_PATH_SOURCE_INFO.modeInfoIdx` (lower 16 bits) +- Displays with same `CloneGroupId` will mirror each other +- Each active display gets a unique `sourceId` per adapter (0, 1, 2...) regardless of clone grouping +- For extended mode: each display gets unique `CloneGroupId` (0, 1, 2...) +- For clone mode: displays in same group share the same `CloneGroupId` +- Clone groups MUST be set in Phase 1 with `SDC_TOPOLOGY_SUPPLIED` before mode modifications ### Data Storage - Profiles: `%AppData%/DisplayProfileManager/Profiles/*.dpm` (JSON, one file per profile) @@ -71,6 +98,91 @@ Each monitor in a profile includes: - `IsHdrSupported`, `IsHdrEnabled`: HDR capability and state - `Rotation`: Screen orientation (1=0°, 2=90°, 3=180°, 4=270°) - maps to `DISPLAYCONFIG_ROTATION` enum - `DpiScaling`: Windows DPI scaling percentage +- `CloneGroupId`: Optional identifier for clone/duplicate display groups + +### Clone Groups (Duplicate Displays) + +Displays can be configured in clone/duplicate mode where multiple monitors show identical content: + +**Clone Group Representation:** +- Multiple `DisplaySetting` objects with same `CloneGroupId` +- Same `SourceId`, `DeviceName`, resolution, refresh rate, position +- Different `TargetId` (unique per physical monitor) +- Empty `CloneGroupId` = extended mode (independent display) + +**Example Profile Structure:** +```json +{ + "displaySettings": [ + { + "deviceName": "\\\\.\\DISPLAY1", + "sourceId": 0, + "targetId": 0, + "width": 1920, + "height": 1080, + "frequency": 60, + "displayPositionX": 0, + "displayPositionY": 0, + "cloneGroupId": "clone-group-1" + }, + { + "deviceName": "\\\\.\\DISPLAY1", + "sourceId": 0, + "targetId": 1, + "width": 1920, + "height": 1080, + "frequency": 60, + "displayPositionX": 0, + "displayPositionY": 0, + "cloneGroupId": "clone-group-1" + } + ] +} +``` + +**Detection:** `GetCurrentDisplaySettingsAsync()` groups displays by `(DeviceName, SourceId)` to identify clone groups automatically. + +**Validation:** +- `ValidateCloneGroups()` ensures consistent resolution, refresh rate, and position within groups +- Warns about DPI differences (non-blocking) +- Applied before profile application in `ApplyProfileAsync()` + +**UI Behavior:** +- Clone groups shown as single control with all member names +- Editing one control applies settings to all clone group members +- Saving creates multiple `DisplaySetting` objects (one per physical display) + +**Application:** +- **Implementation:** Two-phase approach in `DisplayConfigHelper.cs` +- **API Used:** Windows CCD (Connected Display Configuration) API +- **Clone Group Encoding:** + - Uses `modeInfoIdx` field in `DISPLAYCONFIG_PATH_SOURCE_INFO` structure + - Lower 16 bits: Clone Group ID (displays with same ID will mirror) + - Upper 16 bits: Source Mode Index (index into mode array, or 0xFFFF if invalid) + - Accessed via `CloneGroupId` and `SourceModeInfoIdx` properties in C# +- **Phase 1 (`EnableDisplays`):** + - Maps profile displays by `SourceId` to determine clone groups + - Displays with same profile `SourceId` get same `CloneGroupId` (for mirroring) + - Invalidates all mode indices (target and source) + - Sets `DISPLAYCONFIG_PATH_ACTIVE` flag for enabled displays + - Calls `ResetModeAndSetCloneGroup()` to set clone group ID (invalidates source mode index) + - Assigns unique `sourceId` per display per adapter + - Applies with `SDC_TOPOLOGY_SUPPLIED | SDC_APPLY | SDC_ALLOW_PATH_ORDER_CHANGES | SDC_VIRTUAL_MODE_AWARE` + - Uses null mode array (Windows chooses modes) +- **Phase 2 (`ApplyDisplayTopology`):** + - Queries current config (includes clone groups from Phase 1) + - Finds source modes in mode array for each adapter + - Modifies `sourceMode`: resolution (`width`, `height`), position (`position.x`, `position.y`) + - Modifies `targetMode`: refresh rate (`vSyncFreq.Numerator` / `vSyncFreq.Denominator`) + - Sets rotation in path array: `path.targetInfo.rotation` + - **Does NOT modify clone groups or source IDs** (would break mode indices) + - Applies with `SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_APPLY | SDC_SAVE_TO_DATABASE | SDC_VIRTUAL_MODE_AWARE` +- **Key Insight:** Clone groups can only be set with `SDC_TOPOLOGY_SUPPLIED` + null modes. Once mode array is used for resolution/position, clone groups cannot be changed without invalidating mode indices. +- **Reference:** Based on DisplayConfig PowerShell module implementation (Enable-Display + Use-DisplayConfig pattern) + +**Mixed Mode Support:** Profiles can contain both clone groups and independent displays in the same configuration. + +**Backward Compatibility:** Old profiles without `CloneGroupId` load normally (defaults to empty string = extended mode). ## Dependencies - **.NET Framework 4.8**: WPF support, required for Windows 7+ compatibility diff --git a/README.md b/README.md index 9eb262c..56d0ca0 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ A lightweight Windows desktop application for managing display profiles with qui - 🔍 **Monitor Identification Overlay** - Visual overlay to identify monitors during configuration - 🎨 **HDR Support** - Enable/disable High Dynamic Range for HDR-capable displays - 🔄 **Screen Rotation Control** - Configure screen orientation (0°, 90°, 180°, 270°) per monitor +- 🖥️ **Clone/Duplicate Display Support** - Configure multiple monitors to show identical content (pure clone mode or mixed with extended displays) - ⚙️ **Staged Application Mode** - Optional two-phase settings application for enhanced stability on complex multi-monitor setups ## 📸 Screenshots diff --git a/src/Core/Profile.cs b/src/Core/Profile.cs index 337bd78..8f5310a 100644 --- a/src/Core/Profile.cs +++ b/src/Core/Profile.cs @@ -144,6 +144,9 @@ public class DisplaySetting [JsonProperty("rotation")] public int Rotation { get; set; } = 1; // Default to IDENTITY (1) + [JsonProperty("cloneGroupId")] + public string CloneGroupId { get; set; } = string.Empty; + public DisplaySetting() { } @@ -165,9 +168,9 @@ public override string ToString() return $"{DeviceName}: {GetResolutionString()}, DPI: {GetDpiString()}, {hdrStatus} [{enabledStatus}]"; } - public void UpdateDeviceNameFromWMI() + public void UpdateDeviceNameFromWMI(List monitorIds = null) { - string resolvedDeviceName = DisplayHelper.GetDeviceNameFromWMIMonitorID(ManufacturerName, ProductCodeID, SerialNumberID); + string resolvedDeviceName = DisplayHelper.GetDeviceNameFromWMIMonitorID(ManufacturerName, ProductCodeID, SerialNumberID, monitorIds); if (string.IsNullOrEmpty(resolvedDeviceName)) { resolvedDeviceName = DeviceName; @@ -175,6 +178,11 @@ public void UpdateDeviceNameFromWMI() DeviceName = resolvedDeviceName; } + + public bool IsPartOfCloneGroup() + { + return !string.IsNullOrEmpty(CloneGroupId); + } } public class AudioSetting diff --git a/src/Core/ProfileManager.cs b/src/Core/ProfileManager.cs index 26be230..51cf35a 100644 --- a/src/Core/ProfileManager.cs +++ b/src/Core/ProfileManager.cs @@ -194,49 +194,48 @@ public async Task> GetCurrentDisplaySettingsAsync() // Get display configs using QueueDisplayConfig List displayConfigs = DisplayConfigHelper.GetDisplayConfigs(); - if (displays.Count > 0 && - monitors.Count > 0 && + if (monitors.Count > 0 && monitorIDs.Count > 0 && displayConfigs.Count > 0) { - for (int i = 0; i < displays.Count; i++) + // Iterate through displayConfigs (modern API that properly detects all displays including clones) + for (int i = 0; i < displayConfigs.Count; i++) { - var foundConfig = displayConfigs.Find(x => x.DeviceName == displays[i].DeviceName); - - if (foundConfig == null) - { - logger.Debug("No display config found for " + displays[i].DeviceName); - continue; - } + var foundConfig = displayConfigs[i]; + + // Try to find matching display from old API (for frequency info) + var foundDisplay = displays.Find(x => x.DeviceName == foundConfig.DeviceName); var foundMonitor = monitors.Find(x => x.DeviceID.Contains($"UID{foundConfig.TargetId}")); if (foundMonitor == null) { - logger.Debug("No monitor found for " + foundConfig.TargetId); - continue; + logger.Warn($"No WMI monitor found for TargetId {foundConfig.TargetId} - using DisplayConfigHelper data"); } - var foundMonitorId = monitorIDs.Find(x => x.InstanceName.ToUpper().Contains(foundMonitor.PnPDeviceID.ToUpper())); - - if(foundMonitorId == null) + DisplayHelper.MonitorIdInfo foundMonitorId = null; + if (foundMonitor != null) { - logger.Debug("No monitor ID found for " + foundMonitor.PnPDeviceID); - continue; + foundMonitorId = monitorIDs.Find(x => x.InstanceName.ToUpper().Contains(foundMonitor.PnPDeviceID.ToUpper())); + + if(foundMonitorId == null) + { + logger.Warn($"No WMI monitor ID found for {foundMonitor.PnPDeviceID} - using generic data"); + } } string adpaterIdText = $"{foundConfig.AdapterId.HighPart:X8}{foundConfig.AdapterId.LowPart:X8}"; - DpiHelper.DPIScalingInfo dpiInfo = DpiHelper.GetDPIScalingInfo(displays[i].DeviceName); + DpiHelper.DPIScalingInfo dpiInfo = DpiHelper.GetDPIScalingInfo(foundConfig.DeviceName, foundConfig); DisplaySetting setting = new DisplaySetting(); - setting.DeviceName = displays[i].DeviceName; - setting.DeviceString = displays[i].DeviceString; - setting.ReadableDeviceName = foundMonitor.Name; + setting.DeviceName = foundConfig.DeviceName; + setting.DeviceString = foundDisplay?.DeviceString ?? foundConfig.DeviceName; + setting.ReadableDeviceName = foundMonitor?.Name ?? foundConfig.FriendlyName; setting.Width = foundConfig.Width; setting.Height = foundConfig.Height; - setting.Frequency = displays[i].Frequency; + setting.Frequency = foundDisplay?.Frequency ?? (int)foundConfig.RefreshRate; setting.DpiScaling = dpiInfo.Current; - setting.IsPrimary = displays[i].IsPrimary; + setting.IsPrimary = foundDisplay?.IsPrimary ?? foundConfig.IsPrimary; setting.AdapterId = adpaterIdText; setting.SourceId = foundConfig.SourceId; setting.IsEnabled = foundConfig.IsEnabled; @@ -248,14 +247,9 @@ public async Task> GetCurrentDisplaySettingsAsync() setting.IsHdrEnabled = foundConfig.IsHdrEnabled; setting.Rotation = (int)foundConfig.Rotation; - logger.Debug($"PROFILE DEBUG: Creating DisplaySetting for {setting.DeviceName}:"); - logger.Debug($"PROFILE DEBUG: HDR Supported: {setting.IsHdrSupported}"); - logger.Debug($"PROFILE DEBUG: HDR Enabled: {setting.IsHdrEnabled}"); - logger.Debug($"PROFILE DEBUG: TargetId: {setting.TargetId}"); - logger.Debug($"PROFILE DEBUG: AdapterId: {setting.AdapterId}"); - setting.ManufacturerName = foundMonitorId.ManufacturerName; - setting.ProductCodeID = foundMonitorId.ProductCodeID; - setting.SerialNumberID = foundMonitorId.SerialNumberID; + setting.ManufacturerName = foundMonitorId?.ManufacturerName ?? ""; + setting.ProductCodeID = foundMonitorId?.ProductCodeID ?? ""; + setting.SerialNumberID = foundMonitorId?.SerialNumberID ?? ""; // Capture available options for this monitor try @@ -297,7 +291,30 @@ public async Task> GetCurrentDisplaySettingsAsync() settings.Add(setting); } - logger.Info($"Found {settings.Count} display settings"); + logger.Info($"Successfully created {settings.Count} display settings from {displayConfigs.Count} display configs"); + + // Detect clone groups by grouping displays with same DeviceName and SourceId + var cloneGroups = settings + .GroupBy(s => new { s.DeviceName, s.SourceId }) + .Where(g => g.Count() > 1) + .ToList(); + + if (cloneGroups.Any()) + { + int cloneGroupIndex = 1; + foreach (var group in cloneGroups) + { + string cloneGroupId = $"clone-group-{cloneGroupIndex}"; + foreach (var setting in group) + { + setting.CloneGroupId = cloneGroupId; + logger.Info($"Detected clone group '{cloneGroupId}': " + + $"{setting.ReadableDeviceName} (TargetId: {setting.TargetId})"); + } + cloneGroupIndex++; + } + logger.Info($"Detected {cloneGroups.Count} clone group(s) with {cloneGroups.Sum(g => g.Count())} total displays"); + } } } catch (Exception ex) @@ -315,13 +332,23 @@ public async Task ApplyProfileAsync(Profile profile) { ProfileApplyResult result = new ProfileApplyResult { AudioSuccess = true }; // Init audio as true - // Step 1: Prepare the display configuration from the profile + // Validate clone groups before applying + if (!ValidateCloneGroups(profile.DisplaySettings)) + { + logger.Error("Clone group validation failed - profile not applied"); + return new ProfileApplyResult { Success = false }; + } + + // Prepare the display configuration from the profile var displayConfigs = new List(); if (profile.DisplaySettings.Count > 0) { + // Query WMI once for all displays (cache it) + var wmiMonitorIds = DisplayHelper.GetMonitorIDsFromWmiMonitorID(); + foreach (var setting in profile.DisplaySettings) { - setting.UpdateDeviceNameFromWMI(); + setting.UpdateDeviceNameFromWMI(wmiMonitorIds); displayConfigs.Add(new DisplayConfigHelper.DisplayConfigInfo { DeviceName = setting.DeviceName, @@ -344,7 +371,65 @@ public async Task ApplyProfileAsync(Profile profile) } } - // Step 2: Set Primary Display first (adjusts positions in the config list) + // Log clone groups being applied + var cloneGroupsToApply = displayConfigs + .GroupBy(dc => dc.SourceId) + .Where(g => g.Count() > 1) + .ToList(); + + if (cloneGroupsToApply.Any()) + { + foreach (var group in cloneGroupsToApply) + { + var targetIds = string.Join(", ", group.Select(dc => dc.TargetId)); + var displayNames = string.Join(", ", group.Select(dc => dc.FriendlyName)); + logger.Info($"Applying clone group: Source {group.Key} → " + + $"Targets [{targetIds}] ({displayNames})"); + } + logger.Info($"Total clone groups to apply: {cloneGroupsToApply.Count}"); + } + + // === Phase 1: Enable all displays === + logger.Info("Phase 1: Enabling displays..."); + if (!DisplayConfigHelper.EnableDisplays(displayConfigs)) + { + logger.Error("Phase 1 (enable displays) failed."); + result.Success = false; + return result; + } + + // Verify all displays are now active + logger.Debug("Verifying displays were enabled..."); + var currentDisplays = DisplayConfigHelper.GetDisplayConfigs(); + var currentActiveTargetIds = new HashSet(currentDisplays.Where(d => d.IsEnabled).Select(d => d.TargetId)); + var expectedActiveTargetIds = displayConfigs.Where(d => d.IsEnabled).Select(d => d.TargetId).ToList(); + + foreach (var targetId in expectedActiveTargetIds) + { + if (currentActiveTargetIds.Contains(targetId)) + { + logger.Debug($"✓ TargetId {targetId} is active"); + } + else + { + logger.Warn($"✗ TargetId {targetId} is NOT active after Phase 1"); + } + } + + int activeCount = expectedActiveTargetIds.Count(id => currentActiveTargetIds.Contains(id)); + logger.Info($"Phase 1 verification: {activeCount}/{expectedActiveTargetIds.Count} displays active"); + + // Wait for driver stabilization + int pauseMs = _settingsManager.GetStagedApplicationPauseMs(); + logger.Info($"Phase 1 completed. Waiting {pauseMs}ms for stabilization..."); + System.Threading.Thread.Sleep(pauseMs); + + // === Phase 2: Apply full configuration === + logger.Info("Phase 2: Applying full display configuration..."); + result.DisplayConfigApplied = ApplyUnifiedConfiguration(displayConfigs); + result.ResolutionChanged = result.DisplayConfigApplied; + + // Set Primary Display (after topology and clone groups are applied) if (displayConfigs.Count > 0) { logger.Debug("Setting primary display..."); @@ -352,56 +437,26 @@ public async Task ApplyProfileAsync(Profile profile) if (!result.PrimaryChanged) { logger.Warn("Failed to set primary display."); - result.Success = false; } else { logger.Info("Set primary display successfully"); } } - - // Step 3: Choose application path (Staged or Simple) - if (_settingsManager.ShouldUseStagedApplication() && displayConfigs.Count > 1) - { - logger.Info("Using staged application path."); - result.DisplayConfigApplied = ApplyStagedConfiguration(displayConfigs); - result.ResolutionChanged = result.DisplayConfigApplied; // Staged method handles resolution - } - else - { - logger.Info("Using simple application path."); - // --- Simple Path Logic --- - // 3.1: Apply Topology - result.DisplayConfigApplied = DisplayConfigHelper.ApplyDisplayTopology(displayConfigs); - if(result.DisplayConfigApplied) - { - // 3.2: Apply Resolution/Frequency - logger.Info("Applying resolution and refresh rate for all enabled monitors..."); - bool allResolutionsChanged = true; - foreach (var setting in profile.DisplaySettings.Where(s => s.IsEnabled)) - { - if (DisplayHelper.IsMonitorConnected(setting.DeviceName)) - { - if (!DisplayHelper.ChangeResolution(setting.DeviceName, setting.Width, setting.Height, setting.Frequency)) - { - logger.Warn($"Failed to change resolution for {setting.DeviceName}"); - allResolutionsChanged = false; - } - else - { - logger.Info($"Successfully changed resolution for {setting.DeviceName} to {setting.Width}x{setting.Height}@{setting.Frequency}Hz"); - } - } - } - result.ResolutionChanged = allResolutionsChanged; - } - } - // Step 4: Apply DPI and Final HDR (DPI is always separate, HDR is final confirmation) + // Apply DPI and Final HDR (DPI is always separate, HDR is final confirmation) if (result.DisplayConfigApplied) { bool allDpiChanged = true; - foreach (var setting in profile.DisplaySettings.Where(s => s.IsEnabled)) + + // Group by DeviceName to handle clone groups (same DeviceName, different TargetIds) + var uniqueDevicesForDpi = profile.DisplaySettings + .Where(s => s.IsEnabled) + .GroupBy(s => s.DeviceName) + .Select(g => g.First()) // Take first setting for each unique device + .ToList(); + + foreach (var setting in uniqueDevicesForDpi) { if (DisplayHelper.IsMonitorConnected(setting.DeviceName)) { @@ -423,7 +478,7 @@ public async Task ApplyProfileAsync(Profile profile) result.Success = result.PrimaryChanged && result.DisplayConfigApplied && result.ResolutionChanged && result.DpiChanged; - // Step 5: Apply Audio Settings (common to both paths) + // Apply Audio Settings if (profile.AudioSettings != null) { result.AudioSuccess = AudioHelper.ApplyAudioSettings(profile.AudioSettings); @@ -433,6 +488,23 @@ public async Task ApplyProfileAsync(Profile profile) { _currentProfileId = profile.Id; await _settingsManager.SetCurrentProfileIdAsync(profile.Id); + + // Log successful application + var cloneGroupCount = profile.DisplaySettings + .Where(s => s.IsPartOfCloneGroup()) + .GroupBy(s => s.CloneGroupId) + .Count(); + + if (cloneGroupCount > 0) + { + logger.Info($"Successfully applied profile '{profile.Name}' with {profile.DisplaySettings.Count} displays " + + $"({cloneGroupCount} clone group(s))"); + } + else + { + logger.Info($"Successfully applied profile '{profile.Name}' with {profile.DisplaySettings.Count} displays"); + } + ProfileApplied?.Invoke(this, profile); } @@ -797,90 +869,98 @@ public async Task DuplicateProfileAsync(string profileId) return null; } -private bool ApplyStagedConfiguration(List targetConfigs) + private bool ValidateCloneGroups(List settings) { - try + var cloneGroups = settings + .Where(s => s.IsPartOfCloneGroup()) + .GroupBy(s => s.CloneGroupId); + + foreach (var group in cloneGroups) { - logger.Info($"Starting staged application for {targetConfigs.Count} displays"); - - // --- Get current system state to identify active monitors --- - var currentSystemDisplays = DisplayConfigHelper.GetDisplayConfigs(); - var currentlyActiveSystemDisplayIds = new HashSet(currentSystemDisplays.Where(d => d.IsEnabled).Select(d => d.TargetId)); - logger.Debug($"Found {currentlyActiveSystemDisplayIds.Count} currently active display(s)."); - - // --- Phase 1: Apply target configuration only to monitors that are already active --- - var phase1Configs = targetConfigs - .Where(tc => currentlyActiveSystemDisplayIds.Contains(tc.TargetId) && tc.IsEnabled) - .ToList(); - - if (phase1Configs.Any()) + var groupList = group.ToList(); + if (groupList.Count < 2) { - logger.Info($"Phase 1: Applying new settings for {phase1Configs.Count} monitor(s) that are already active."); - - // Step 1.1: Apply partial topology (activation flags, rotation) - if (!DisplayConfigHelper.ApplyPartialDisplayTopology(phase1Configs)) + logger.Warn($"Clone group {group.Key} has only one member - ignoring"); + continue; + } + + var first = groupList[0]; + + foreach (var setting in groupList.Skip(1)) + { + // Critical validations (must match for clone groups) + // Note: DeviceName should be DIFFERENT (different physical monitors) + if (setting.Width != first.Width || + setting.Height != first.Height || + setting.Frequency != first.Frequency || + setting.SourceId != first.SourceId || + setting.DisplayPositionX != first.DisplayPositionX || + setting.DisplayPositionY != first.DisplayPositionY) { - logger.Error("Phase 1 (partial topology) failed. Falling back to single-step application."); - return DisplayConfigHelper.ApplyDisplayTopology(targetConfigs) && - DisplayConfigHelper.ApplyDisplayPosition(targetConfigs) && - DisplayConfigHelper.ApplyHdrSettings(targetConfigs); + logger.Error($"Clone group {group.Key} has inconsistent critical settings: " + + $"{setting.ReadableDeviceName} ({setting.Width}x{setting.Height}@{setting.Frequency}Hz at {setting.DisplayPositionX},{setting.DisplayPositionY}) vs " + + $"{first.ReadableDeviceName} ({first.Width}x{first.Height}@{first.Frequency}Hz at {first.DisplayPositionX},{first.DisplayPositionY})"); + return false; } - // Step 1.2: Explicitly apply resolution and refresh rate for the active monitors. - // This is the critical step to stabilize the system before adding more monitors. - logger.Info("Phase 1: Applying resolution and refresh rate changes for active monitors."); - foreach (var config in phase1Configs) - { - logger.Debug($"Phase 1: Changing mode for {config.DeviceName} to {config.Width}x{config.Height}@{config.RefreshRate}Hz"); - if (!DisplayHelper.ChangeResolution(config.DeviceName, config.Width, config.Height, (int)config.RefreshRate)) - { - logger.Warn($"Phase 1: Failed to change resolution for {config.DeviceName}."); - } - } - - // Step 1.3: Apply HDR settings for this subset - if (!DisplayConfigHelper.ApplyHdrSettings(phase1Configs)) + // Warning validation (should match but don't fail) + if (setting.DpiScaling != first.DpiScaling) { - logger.Warn("Phase 1: Some HDR settings failed to apply."); + logger.Warn($"Clone group {group.Key} has different DPI settings - " + + $"{setting.ReadableDeviceName}: {setting.DpiScaling}% vs " + + $"{first.ReadableDeviceName}: {first.DpiScaling}% - " + + $"may cause visual inconsistency"); } - - logger.Info("Phase 1 completed. Waiting for stabilization..."); - int pauseMs = _settingsManager.GetStagedApplicationPauseMs(); - System.Threading.Thread.Sleep(pauseMs); - } - else - { - logger.Info("No currently active monitors are part of the new profile's active set. Skipping Phase 1."); } + + logger.Debug($"Clone group {group.Key} validation passed ({groupList.Count} displays)"); + } + + return true; + } - // --- Phase 2: Apply the full configuration to activate remaining monitors and finalize state --- - logger.Info("Phase 2: Applying the full target profile."); + /// + /// Unified configuration method that handles both clone and standard topologies. + /// Applies topology, positions, and HDR settings in one cohesive flow. + /// + private bool ApplyUnifiedConfiguration(List displayConfigs) + { + try + { + logger.Info($"Applying unified configuration for {displayConfigs.Count} displays"); - // Use the standard ApplyDisplayTopology which will handle enabling/disabling correctly - if (!DisplayConfigHelper.ApplyDisplayTopology(targetConfigs)) + // Apply complete display configuration (resolution, refresh rate, position, rotation, clone groups) + // Uses SDC_USE_SUPPLIED_DISPLAY_CONFIG with full mode array for atomic configuration + if (!DisplayConfigHelper.ApplyDisplayTopology(displayConfigs)) { - logger.Error("Phase 2 failed: Could not apply the full display topology."); + logger.Error("Failed to apply display topology"); return false; } - // Apply final positions for all monitors - if (!DisplayConfigHelper.ApplyDisplayPosition(targetConfigs)) + // Apply HDR settings (must be done after topology is established) + if (!DisplayConfigHelper.ApplyHdrSettings(displayConfigs)) { - logger.Warn("Phase 2: Failed to apply final display positions."); + logger.Warn("Some HDR settings failed to apply"); } - // Apply final HDR settings for all monitors - if (!DisplayConfigHelper.ApplyHdrSettings(targetConfigs)) + // Verify configuration (non-blocking, for logging/debugging only) + System.Threading.Thread.Sleep(500); + bool verified = DisplayConfigHelper.VerifyDisplayConfiguration(displayConfigs); + if (verified) + { + logger.Info("✓ Configuration verified successfully"); + } + else { - logger.Warn("Phase 2: Some final HDR settings failed to apply."); + logger.Warn("Configuration verification failed - settings may not match exactly"); } - logger.Info("Staged application completed successfully"); + logger.Info("✓ Unified configuration applied successfully"); return true; } catch (Exception ex) { - logger.Error(ex, "Error during staged application"); + logger.Error(ex, "Error during unified configuration"); return false; } } diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 4c6ca72..4207b54 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -56,6 +56,8 @@ private static extern int SetDisplayConfig( private const int ERROR_GEN_FAILURE = 31; private const int ERROR_INVALID_PARAMETER = 87; + private const uint DISPLAYCONFIG_PATH_SOURCE_MODE_IDX_INVALID = 0xffff; + #endregion #region Enums @@ -73,21 +75,21 @@ public enum QueryDisplayConfigFlags : uint [Flags] public enum SetDisplayConfigFlags : uint { - SDC_USE_SUPPLIED_DISPLAY_CONFIG = 0x00000020, SDC_TOPOLOGY_INTERNAL = 0x00000001, SDC_TOPOLOGY_CLONE = 0x00000002, SDC_TOPOLOGY_EXTEND = 0x00000004, SDC_TOPOLOGY_EXTERNAL = 0x00000008, + SDC_TOPOLOGY_SUPPLIED = 0x00000010, // Caller provides path data, Windows queries database for modes + SDC_USE_SUPPLIED_DISPLAY_CONFIG = 0x00000020, // Caller provides complete paths and modes + SDC_VALIDATE = 0x00000040, SDC_APPLY = 0x00000080, SDC_NO_OPTIMIZATION = 0x00000100, - SDC_VALIDATE = 0x00000040, + SDC_SAVE_TO_DATABASE = 0x00000200, SDC_ALLOW_CHANGES = 0x00000400, SDC_PATH_PERSIST_IF_REQUIRED = 0x00000800, SDC_FORCE_MODE_ENUMERATION = 0x00001000, SDC_ALLOW_PATH_ORDER_CHANGES = 0x00002000, - SDC_USE_DATABASE_CURRENT = 0x00000010, SDC_VIRTUAL_MODE_AWARE = 0x00008000, - SDC_SAVE_TO_DATABASE = 0x00000200, } [Flags] @@ -163,8 +165,38 @@ public struct DISPLAYCONFIG_PATH_SOURCE_INFO { public LUID adapterId; public uint id; - public uint modeInfoIdx; + public uint modeInfoIdx; // Dual-purpose field encoding both mode index and clone group public uint statusFlags; + + /// + /// Clone Group ID (lower 16 bits of modeInfoIdx). + /// Displays with the same clone group ID will show identical content (duplicate/mirror). + /// Each display should have a unique clone group ID for extended mode. + /// + public uint CloneGroupId + { + get => (modeInfoIdx << 16) >> 16; + set => modeInfoIdx = (SourceModeInfoIdx << 16) | value; + } + + /// + /// Source Mode Info Index (upper 16 bits of modeInfoIdx). + /// Index into the mode array for source mode information, or 0xFFFF if invalid. + /// + public uint SourceModeInfoIdx + { + get => modeInfoIdx >> 16; + set => modeInfoIdx = (value << 16) | CloneGroupId; + } + + /// + /// Invalidate the source mode index while setting the clone group. + /// Used when applying topology with SDC_TOPOLOGY_SUPPLIED (modes=null). + /// + public void ResetModeAndSetCloneGroup(uint cloneGroup) + { + modeInfoIdx = (DISPLAYCONFIG_PATH_SOURCE_MODE_IDX_INVALID << 16) | cloneGroup; + } } [StructLayout(LayoutKind.Sequential)] @@ -444,7 +476,19 @@ public static List GetDisplayConfigs() // Only process paths with available targets if (!path.targetInfo.targetAvailable) continue; + + // Only process ACTIVE paths during detection + bool isActive = (path.flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0; + if (!isActive) + { + continue; + } + // Extract base TargetId - Windows encodes SourceId in high bytes when in clone mode + // e.g., 0x03001100 = (SourceId 3 << 24) | BaseTargetId 0x1100 + // We need the base TargetId (lower 16 bits) for stable identification + uint baseTargetId = path.targetInfo.id & 0xFFFF; + var displayConfig = new DisplayConfigInfo { PathIndex = i, @@ -452,7 +496,7 @@ public static List GetDisplayConfigs() IsAvailable = path.targetInfo.targetAvailable, AdapterId = path.sourceInfo.adapterId, SourceId = path.sourceInfo.id, - TargetId = path.targetInfo.id, + TargetId = baseTargetId, // Use base TargetId, not clone-encoded value OutputTechnology = path.targetInfo.outputTechnology }; @@ -489,59 +533,28 @@ public static List GetDisplayConfigs() colorInfo.header.adapterId = path.targetInfo.adapterId; colorInfo.header.id = path.targetInfo.id; - logger.Debug($"HDR DEBUG: Querying HDR info for {displayConfig.DeviceName} (TargetId: {path.targetInfo.id}, AdapterId: {path.targetInfo.adapterId.HighPart:X8}{path.targetInfo.adapterId.LowPart:X8})"); - result = DisplayConfigGetDeviceInfo(ref colorInfo); - logger.Debug($"HDR DEBUG: DisplayConfigGetDeviceInfo result: {result} (0 = SUCCESS)"); if (result == ERROR_SUCCESS) { - logger.Debug($"HDR DEBUG: Raw values flags: 0x{colorInfo.values:X}"); - logger.Debug($"HDR DEBUG: Color encoding: {colorInfo.colorEncoding}"); - logger.Debug($"HDR DEBUG: Bits per color channel: {colorInfo.bitsPerColorChannel}"); - var flags = colorInfo.values; bool isSupported = (flags & DISPLAYCONFIG_ADVANCED_COLOR_INFO_FLAGS.AdvancedColorSupported) != 0; bool isEnabled = (flags & DISPLAYCONFIG_ADVANCED_COLOR_INFO_FLAGS.AdvancedColorEnabled) != 0; - bool isWideColorEnforced = (flags & DISPLAYCONFIG_ADVANCED_COLOR_INFO_FLAGS.WideColorEnforced) != 0; bool isForceDisabled = (flags & DISPLAYCONFIG_ADVANCED_COLOR_INFO_FLAGS.AdvancedColorForceDisabled) != 0; - logger.Debug($"HDR DEBUG: Flag breakdown - Supported: {isSupported}, Enabled: {isEnabled}, WideColor: {isWideColorEnforced}, ForceDisabled: {isForceDisabled}"); - logger.Debug($"HDR DEBUG: Color encoding check (YCbCr444 = HDR active): {colorInfo.colorEncoding == DISPLAYCONFIG_COLOR_ENCODING.DISPLAYCONFIG_COLOR_ENCODING_YCBCR444}"); - // Final decision: supported if flag is set and not force disabled bool finalSupported = isSupported && !isForceDisabled; // Final decision: enabled if flag is set or force disabled but we see YCbCr444 (some systems don't set the enabled flag correctly) bool finalEnabled = isEnabled || (finalSupported && colorInfo.colorEncoding == DISPLAYCONFIG_COLOR_ENCODING.DISPLAYCONFIG_COLOR_ENCODING_YCBCR444); - logger.Debug($"HDR DEBUG: Final decisions - Supported: {finalSupported}, Enabled: {finalEnabled}"); - displayConfig.IsHdrSupported = finalSupported; displayConfig.IsHdrEnabled = finalEnabled; displayConfig.ColorEncoding = colorInfo.colorEncoding; displayConfig.BitsPerColorChannel = (uint)colorInfo.bitsPerColorChannel; - - logger.Info($"HDR INFO: {displayConfig.DeviceName} - HDR Supported: {finalSupported}, HDR Enabled: {finalEnabled}, Encoding: {colorInfo.colorEncoding}, BitsPerChannel: {colorInfo.bitsPerColorChannel}"); } else { - logger.Warn($"HDR DEBUG: Failed to get HDR info for {displayConfig.DeviceName}: error code {result}"); - - // Detailed error logging - switch (result) - { - case ERROR_INVALID_PARAMETER: - logger.Warn("HDR DEBUG: ERROR_INVALID_PARAMETER - The parameter is incorrect"); - break; - case ERROR_GEN_FAILURE: - logger.Warn("HDR DEBUG: ERROR_GEN_FAILURE - A device attached to the system is not functioning"); - break; - default: - logger.Warn($"HDR DEBUG: Unknown error code: {result}"); - break; - } - - logger.Warn("HDR DEBUG: Alternative method also failed - setting defaults"); + logger.Debug($"Failed to get HDR info for {displayConfig.DeviceName}: error code {result}"); displayConfig.IsHdrSupported = false; displayConfig.IsHdrEnabled = false; } @@ -577,13 +590,7 @@ public static List GetDisplayConfigs() displays.Add(displayConfig); } - logger.Info($"GetCurrentDisplayTopology found {displays.Count} displays"); - foreach (var display in displays) - { - logger.Debug($" Display: {display.DeviceName} ({display.FriendlyName}) - " + - $"Enabled: {display.IsEnabled}, " + - $"Resolution: {display.Width}x{display.Height}@{display.RefreshRate}Hz"); - } + logger.Info($"Detected {displays.Count} display(s)"); } catch (Exception ex) { @@ -593,21 +600,21 @@ public static List GetDisplayConfigs() return displays; } - public static bool ApplyDisplayTopology(List displayConfigs) + /// + /// Phase 1: Enable or disable displays without configuring specific resolutions or clone groups. + /// This allows the display driver to stabilize before applying detailed configuration. + /// Uses SDC_TOPOLOGY_SUPPLIED with null mode array - Windows determines appropriate modes from database. + /// Each display gets a unique clone group (extended mode) during this phase. + /// + public static bool EnableDisplays(List displayConfigs) { try { - // Validate that at least one display will remain enabled - if (!displayConfigs.Any(d => d.IsEnabled)) - { - logger.Warn("Cannot disable all displays - at least one must remain enabled"); - return false; - } + logger.Info("Phase 1: Enabling displays and setting clone groups..."); + // Get current configuration uint pathCount = 0; uint modeCount = 0; - - // Get current configuration int result = GetDisplayConfigBufferSizes( QueryDisplayConfigFlags.QDC_ALL_PATHS, out pathCount, @@ -636,229 +643,431 @@ public static bool ApplyDisplayTopology(List displayConfigs) return false; } - // Clone the original configuration for potential revert - var originalPaths = (DISPLAYCONFIG_PATH_INFO[])paths.Clone(); - var originalModes = (DISPLAYCONFIG_MODE_INFO[])modes.Clone(); - - // Update path flags based on topology settings - foreach (var displayInfo in displayConfigs) + // Build mapping of TargetId to path index + var targetIdToPathIndex = new Dictionary(); + for (int i = 0; i < paths.Length; i++) { - var foundPathIndex = Array.FindIndex(paths, - x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); + if (paths[i].targetInfo.targetAvailable) + { + uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; + if (!targetIdToPathIndex.ContainsKey(baseTargetId)) + { + targetIdToPathIndex[baseTargetId] = i; + } + } + } - if (foundPathIndex == -1) + logger.Info($"Found {targetIdToPathIndex.Count} available displays"); + + // Build clone group mapping from profile + // Displays with same SourceId in profile should have same clone group (for clone mode) + var sourceIdToCloneGroup = new Dictionary(); + uint nextCloneGroup = 0; + foreach (var display in displayConfigs.Where(d => d.IsEnabled)) + { + if (!sourceIdToCloneGroup.ContainsKey(display.SourceId)) { - logger.Warn($"Could not find path for display {displayInfo.DeviceName} (TargetId: {displayInfo.TargetId}, SourceId: {displayInfo.SourceId})"); - continue; + sourceIdToCloneGroup[display.SourceId] = nextCloneGroup++; } + } - if (displayInfo.IsEnabled) + var targetIdToDisplay = displayConfigs.Where(d => d.IsEnabled).ToDictionary(d => d.TargetId); + + // Configure each available display path + foreach (var kvp in targetIdToPathIndex) + { + uint targetId = kvp.Key; + int pathIndex = kvp.Value; + + // Invalidate target mode index - Windows will choose appropriate modes + paths[pathIndex].targetInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + + if (targetIdToDisplay.TryGetValue(targetId, out var display)) { - paths[foundPathIndex].flags |= (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - paths[foundPathIndex].targetInfo.rotation = (uint)displayInfo.Rotation; + // Enable display with correct clone group from profile + uint cloneGroup = sourceIdToCloneGroup[display.SourceId]; + bool isCloneMode = displayConfigs.Count(d => d.IsEnabled && d.SourceId == display.SourceId) > 1; + + paths[pathIndex].flags |= (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[pathIndex].sourceInfo.ResetModeAndSetCloneGroup(cloneGroup); + logger.Debug($"Enabling TargetId {targetId} with clone group {cloneGroup}{(isCloneMode ? " (CLONE MODE)" : " (extended)")}"); } else { - paths[foundPathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + // Disable display + paths[pathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[pathIndex].sourceInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + logger.Debug($"Disabling TargetId {targetId}"); } - - logger.Debug($"Setting targetId {displayInfo.TargetId} ({displayInfo.DeviceName}, Path:{foundPathIndex}) " + - $"flags to: 0x{paths[foundPathIndex].flags:X} (Enabled: {displayInfo.IsEnabled})"); - } - // Disable any connected monitors that are not in the profile + // Assign unique source IDs per adapter for all active paths + var sourceIdTable = new Dictionary(); + int activeCount = 0; + for (int i = 0; i < paths.Length; i++) { - var path = paths[i]; + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) + { + LUID adapterId = paths[i].sourceInfo.adapterId; - // Check if this monitor is connected/available - if (!path.targetInfo.targetAvailable) - continue; + if (!sourceIdTable.ContainsKey(adapterId)) + { + sourceIdTable[adapterId] = 0; + } - // Check if this path exists in the displayConfigs list - bool foundInProfile = displayConfigs.Any(d => - d.TargetId == path.targetInfo.id && - d.SourceId == path.sourceInfo.id); + paths[i].sourceInfo.id = sourceIdTable[adapterId]++; + activeCount++; + } + } - if (!foundInProfile) - { - // This monitor is connected but not in the profile, so disable it - paths[i].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + if (activeCount == 0) + { + logger.Error("No active displays to enable"); + return false; + } - logger.Debug($"Disabling monitor not in profile: TargetId={path.targetInfo.id}, " + - $"SourceId={path.sourceInfo.id}, PathIndex={i}"); + logger.Info($"Enabling {activeCount} display(s)..."); + + logger.Debug($"SetDisplayConfig parameters:"); + logger.Debug($" pathCount={pathCount}, modeCount=0, modes=null"); + logger.Debug($" Flags: SDC_TOPOLOGY_SUPPLIED | SDC_APPLY | SDC_ALLOW_PATH_ORDER_CHANGES | SDC_VIRTUAL_MODE_AWARE"); + + for (int i = 0; i < paths.Length; i++) + { + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) + { + uint targetId = paths[i].targetInfo.id & 0xFFFF; + logger.Debug($" Active Path[{i}]: TargetId={targetId}, SourceId={paths[i].sourceInfo.id}, " + + $"modeInfoIdx=0x{paths[i].sourceInfo.modeInfoIdx:X8}, targetModeIdx=0x{paths[i].targetInfo.modeInfoIdx:X8}"); } } - // Apply the new configuration + // Apply with SDC_TOPOLOGY_SUPPLIED to activate displays + // Note: Not using SDC_SAVE_TO_DATABASE here - save happens in Phase 2 result = SetDisplayConfig( pathCount, paths, - modeCount, - modes, - SetDisplayConfigFlags.SDC_APPLY | - SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); + 0, + null, + SetDisplayConfigFlags.SDC_TOPOLOGY_SUPPLIED | + SetDisplayConfigFlags.SDC_APPLY | + SetDisplayConfigFlags.SDC_ALLOW_PATH_ORDER_CHANGES | + SetDisplayConfigFlags.SDC_VIRTUAL_MODE_AWARE); if (result != ERROR_SUCCESS) { - logger.Error($"SetDisplayConfig failed with error: {result}"); - - // Try to provide more specific error information - string errorMessage = ""; - switch (result) - { - case ERROR_INVALID_PARAMETER: - logger.Error("Invalid parameter - configuration may be invalid"); - errorMessage = "Invalid display configuration"; - break; - case ERROR_GEN_FAILURE: - logger.Error("General failure - display configuration may not be supported"); - errorMessage = "Display configuration not supported"; - break; - default: - logger.Error($"Unknown error code: {result}"); - errorMessage = $"Unknown error (code: {result})"; - break; - } - - // Attempt to revert to original configuration - logger.Info("Attempting to revert to original display configuration..."); - int revertResult = SetDisplayConfig( - pathCount, - originalPaths, - modeCount, - originalModes, - SetDisplayConfigFlags.SDC_APPLY | - SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); - - if (revertResult == ERROR_SUCCESS) - { - logger.Info("Successfully reverted to original display configuration"); - System.Windows.MessageBox.Show( - $"Failed to apply display configuration: {errorMessage}\n\nThe display settings have been reverted to their previous state.", - "Display Configuration Error", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Warning); - } - else - { - logger.Error($"Failed to revert display configuration. Error: {revertResult}"); - System.Windows.MessageBox.Show( - $"Failed to apply display configuration: {errorMessage}\n\nWarning: Could not revert to previous settings. You may need to manually adjust your display settings.", - "Display Configuration Error", - System.Windows.MessageBoxButton.OK, - System.Windows.MessageBoxImage.Error); - } - + logger.Error($"EnableDisplays failed with error: {result}"); + logger.Error($"ERROR_INVALID_PARAMETER (87) suggests the path/mode configuration is invalid"); + logger.Error($"This may happen if source mode indices are not properly set for SDC_TOPOLOGY_SUPPLIED"); return false; } - logger.Info("Display topology applied successfully"); - - - bool displayPositionApplied = ApplyDisplayPosition(displayConfigs); - if (!displayPositionApplied) - { - logger.Warn("Failed to apply display position"); - } - else - { - logger.Info("Display position applied successfully"); - } - + logger.Info("✓ Displays enabled successfully"); return true; } catch (Exception ex) { - logger.Error(ex, "Error applying display topology"); + logger.Error(ex, "Error enabling displays"); return false; } } - public static bool ApplyPartialDisplayTopology(List partialConfig) + /// + /// Phase 2: Apply complete display configuration including resolution, refresh rate, position, rotation, and clone groups. + /// Uses SDC_USE_SUPPLIED_DISPLAY_CONFIG with full mode array to provide all display settings to Windows. + /// This method: + /// 1. Queries current config with full mode array + /// 2. Modifies mode array to set resolution, refresh rate, and position + /// 3. Sets clone groups in path array (displays with same clone group share content) + /// 4. Assigns unique source IDs per adapter + /// 5. Applies complete configuration atomically + /// + public static bool ApplyDisplayTopology(List displayConfigs) { try { - logger.Info($"Applying partial display topology for {partialConfig.Count} displays."); + logger.Info("Phase 2: Applying display resolution, refresh rate, and position..."); + // Get current configuration uint pathCount = 0; uint modeCount = 0; + // Use same flags as DisplayConfig PowerShell module + var queryFlags = QueryDisplayConfigFlags.QDC_ALL_PATHS | QueryDisplayConfigFlags.QDC_VIRTUAL_MODE_AWARE; + + int result = GetDisplayConfigBufferSizes( + queryFlags, + out pathCount, + out modeCount); - int result = GetDisplayConfigBufferSizes(QueryDisplayConfigFlags.QDC_ALL_PATHS, out pathCount, out modeCount); if (result != ERROR_SUCCESS) { logger.Error($"GetDisplayConfigBufferSizes failed with error: {result}"); return false; } + logger.Debug($"Buffer sizes: pathCount={pathCount}, modeCount={modeCount}"); + var paths = new DISPLAYCONFIG_PATH_INFO[pathCount]; var modes = new DISPLAYCONFIG_MODE_INFO[modeCount]; - result = QueryDisplayConfig(QueryDisplayConfigFlags.QDC_ALL_PATHS, ref pathCount, paths, ref modeCount, modes, IntPtr.Zero); + result = QueryDisplayConfig( + queryFlags, + ref pathCount, + paths, + ref modeCount, + modes, + IntPtr.Zero); + if (result != ERROR_SUCCESS) { logger.Error($"QueryDisplayConfig failed with error: {result}"); return false; } - // Modify only the paths specified in the partialConfig - foreach (var displayInfo in partialConfig) + // Resize arrays to actual size (QueryDisplayConfig modifies pathCount/modeCount to actual used size) + Array.Resize(ref paths, (int)pathCount); + Array.Resize(ref modes, (int)modeCount); + logger.Debug($"Query returned {paths.Length} paths and {modes.Length} modes"); + + // Build mapping of TargetId to path index + var targetIdToPathIndex = new Dictionary(); + for (int i = 0; i < paths.Length; i++) { - var foundPathIndex = Array.FindIndex(paths, - x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); + if (paths[i].targetInfo.targetAvailable) + { + uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; + if (!targetIdToPathIndex.ContainsKey(baseTargetId)) + { + targetIdToPathIndex[baseTargetId] = i; + } + } + } + + logger.Info($"Found {targetIdToPathIndex.Count} available target displays"); + + // Build a mapping of adapterId -> list of source mode indices + // After Phase 1, mode indices may be wrong, so we need to find source modes manually + var adapterToSourceModes = new Dictionary>(); + for (int i = 0; i < modes.Length; i++) + { + if (modes[i].infoType == DisplayConfigModeInfoType.DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE) + { + LUID adapterId = modes[i].adapterId; + if (!adapterToSourceModes.ContainsKey(adapterId)) + { + adapterToSourceModes[adapterId] = new List(); + } + adapterToSourceModes[adapterId].Add(i); + logger.Debug($"Found SOURCE mode at index {i} for adapter {adapterId.LowPart}:{adapterId.HighPart}"); + } + } + + // Modify mode array for resolution, refresh rate, and position + logger.Debug("Configuring mode array (resolution, refresh rate, position)..."); + + // Track which source modes we've used per adapter + var adapterSourceModeUsage = new Dictionary(); + + foreach (var displayInfo in displayConfigs.Where(d => d.IsEnabled)) + { + if (!targetIdToPathIndex.TryGetValue(displayInfo.TargetId, out int pathIndex)) + continue; + + // Find a source mode for this display's adapter + LUID adapterId = paths[pathIndex].sourceInfo.adapterId; + + if (!adapterSourceModeUsage.ContainsKey(adapterId)) + { + adapterSourceModeUsage[adapterId] = 0; + } + + if (!adapterToSourceModes.ContainsKey(adapterId) || + adapterSourceModeUsage[adapterId] >= adapterToSourceModes[adapterId].Count) + { + logger.Warn($"TargetId {displayInfo.TargetId}: No available source mode for adapter"); + continue; + } + + // Get the next available source mode for this adapter + int sourceModeIdx = adapterToSourceModes[adapterId][adapterSourceModeUsage[adapterId]++]; + + // Update the path to point to this source mode + paths[pathIndex].sourceInfo.SourceModeInfoIdx = (uint)sourceModeIdx; + + // Ensure mode's adapterId matches the path's adapterId + modes[sourceModeIdx].adapterId = paths[pathIndex].sourceInfo.adapterId; + + // Set resolution and position + modes[sourceModeIdx].modeInfo.sourceMode.width = (uint)displayInfo.Width; + modes[sourceModeIdx].modeInfo.sourceMode.height = (uint)displayInfo.Height; + modes[sourceModeIdx].modeInfo.sourceMode.position.x = displayInfo.DisplayPositionX; + modes[sourceModeIdx].modeInfo.sourceMode.position.y = displayInfo.DisplayPositionY; + logger.Debug($"TargetId {displayInfo.TargetId}: Set source mode {sourceModeIdx} to {displayInfo.Width}x{displayInfo.Height} at ({displayInfo.DisplayPositionX},{displayInfo.DisplayPositionY})"); + + // Target mode: refresh rate + uint targetModeIdx = paths[pathIndex].targetInfo.modeInfoIdx; + if (targetModeIdx != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && targetModeIdx < modes.Length) + { + if (modes[targetModeIdx].infoType == DisplayConfigModeInfoType.DISPLAYCONFIG_MODE_INFO_TYPE_TARGET) + { + uint numerator = (uint)(displayInfo.RefreshRate * 1000); + uint denominator = 1000; + modes[targetModeIdx].modeInfo.targetMode.targetVideoSignalInfo.vSyncFreq.Numerator = numerator; + modes[targetModeIdx].modeInfo.targetMode.targetVideoSignalInfo.vSyncFreq.Denominator = denominator; + logger.Debug($"TargetId {displayInfo.TargetId}: Set target mode {targetModeIdx} refresh to {displayInfo.RefreshRate}Hz"); + } + } + } + + // Build clone group mapping + // Profile SourceId determines clone groups: displays with same SourceId will be cloned together + var sourceIdToCloneGroup = new Dictionary(); + uint nextCloneGroup = 0; - if (foundPathIndex == -1) + foreach (var displayInfo in displayConfigs.Where(d => d.IsEnabled)) + { + if (!sourceIdToCloneGroup.ContainsKey(displayInfo.SourceId)) { - logger.Warn($"Could not find path for display {displayInfo.DeviceName} in partial application. Skipping."); + sourceIdToCloneGroup[displayInfo.SourceId] = nextCloneGroup++; + } + } + + // Log clone group information + var cloneGroupsInfo = displayConfigs + .Where(d => d.IsEnabled) + .GroupBy(d => d.SourceId) + .Where(g => g.Count() > 1) + .ToList(); + + if (cloneGroupsInfo.Any()) + { + logger.Info($"Applying {cloneGroupsInfo.Count} clone group(s) for display mirroring"); + foreach (var group in cloneGroupsInfo) + { + var names = string.Join(", ", group.Select(d => d.FriendlyName)); + logger.Info($" Mirroring: {names}"); + } + } + else + { + logger.Debug("Configuration uses extended desktop mode (no display mirroring)"); + } + + // Configure path array: set rotation, disable displays not in profile + // NOTE: Clone groups are already correct from Phase 1 - DO NOT modify them! + // Modifying clone groups would invalidate the mode indices we just set above + var profileTargetIds = new HashSet(displayConfigs.Where(d => d.IsEnabled).Select(d => d.TargetId)); + + foreach (var displayInfo in displayConfigs) + { + if (!targetIdToPathIndex.TryGetValue(displayInfo.TargetId, out int pathIndex)) + { + logger.Warn($"Could not find path for TargetId {displayInfo.TargetId} ({displayInfo.FriendlyName})"); continue; } if (displayInfo.IsEnabled) { - paths[foundPathIndex].flags |= (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - paths[foundPathIndex].targetInfo.rotation = (uint)displayInfo.Rotation; + // Display should already be active from Phase 1 - just set rotation + // DO NOT modify clone groups here! + paths[pathIndex].targetInfo.rotation = (uint)displayInfo.Rotation; + logger.Debug($"TargetId {displayInfo.TargetId}: Set rotation to {displayInfo.Rotation}"); } - else + } + + // Disable displays not in the profile + foreach (var kvp in targetIdToPathIndex) + { + uint targetId = kvp.Key; + int pathIndex = kvp.Value; + + if (!profileTargetIds.Contains(targetId)) { - paths[foundPathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[pathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[pathIndex].sourceInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + paths[pathIndex].targetInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + logger.Debug($"Disabled TargetId {targetId} (not in profile)"); } + } - logger.Debug($"Partially updating TargetId {displayInfo.TargetId} flags to: 0x{paths[foundPathIndex].flags:X}"); + // Disable displays not in profile + var disabledTargetIds = new HashSet(); + for (int i = 0; i < paths.Length; i++) + { + if (!paths[i].targetInfo.targetAvailable) + continue; + + uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; + bool isInProfile = displayConfigs.Any(d => d.TargetId == baseTargetId); + + if (!isInProfile) + { + paths[i].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[i].sourceInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + + if (disabledTargetIds.Add(baseTargetId)) + { + logger.Debug($"Disabled TargetId {baseTargetId} (not in profile)"); + } + } } - // NOTE: We do NOT disable unspecified monitors here. That is the key difference. + // Source IDs and clone groups were already set correctly in Phase 1 + // Phase 2 should NOT modify them - just count active paths for logging + int activeCount = 0; + for (int i = 0; i < paths.Length; i++) + { + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) + { + activeCount++; + } + } + if (activeCount == 0) + { + logger.Error("No active paths found to apply"); + return false; + } + + logger.Info($"Configured {activeCount} active paths out of {paths.Length} total paths"); + + // Apply the full display configuration with mode array + // Note: Clone groups and source IDs were already set correctly in Phase 1 + logger.Info("Applying display configuration with resolution, refresh rate, and position settings..."); result = SetDisplayConfig( - pathCount, + (uint)paths.Length, paths, - modeCount, + (uint)modes.Length, modes, - SetDisplayConfigFlags.SDC_APPLY | SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); + SetDisplayConfigFlags.SDC_APPLY | + SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE | + SetDisplayConfigFlags.SDC_VIRTUAL_MODE_AWARE); if (result != ERROR_SUCCESS) { - logger.Error($"SetDisplayConfig failed during partial application with error: {result}"); + logger.Error($"SetDisplayConfig failed with error: {result}"); + if (result == 87) + { + logger.Error("ERROR_INVALID_PARAMETER - The display configuration is invalid"); + } return false; } - logger.Info("Partial display topology applied successfully."); + logger.Info("✓ Display configuration applied successfully"); return true; } catch (Exception ex) { - logger.Error(ex, "Error applying partial display topology"); + logger.Error(ex, "Error applying display topology"); return false; } } + /// + /// Standard topology application for non-clone configurations. + /// public static bool ApplyDisplayPosition(List displayConfigs) { try @@ -895,25 +1104,57 @@ public static bool ApplyDisplayPosition(List displayConfigs) return false; } + // Validate and correct clone group positions + var cloneGroups = displayConfigs + .GroupBy(dc => dc.SourceId) + .Where(g => g.Count() > 1); + + foreach (var group in cloneGroups) + { + var positions = group + .Select(dc => new { dc.DisplayPositionX, dc.DisplayPositionY }) + .Distinct() + .ToList(); + + if (positions.Count > 1) + { + logger.Warn($"Clone group with Source {group.Key} has inconsistent positions - " + + $"forcing all to same position"); + var first = group.First(); + foreach (var dc in group.Skip(1)) + { + dc.DisplayPositionX = first.DisplayPositionX; + dc.DisplayPositionY = first.DisplayPositionY; + } + } + } + // Set monitor position based on displayConfigs foreach (var displayInfo in displayConfigs) { var foundPathIndex = Array.FindIndex(paths, x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); + if (foundPathIndex < 0) + { + logger.Debug($"Could not find path for TargetId {displayInfo.TargetId} with SourceId {displayInfo.SourceId}"); + continue; + } + if (!paths[foundPathIndex].targetInfo.targetAvailable) continue; // Set monitor position var modeInfoIndex = paths[foundPathIndex].sourceInfo.modeInfoIdx; - if (modeInfoIndex >= 0 && modeInfoIndex < modes.Length) + if (modeInfoIndex != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && modeInfoIndex < modes.Length) { modes[modeInfoIndex].modeInfo.sourceMode.position.x = displayInfo.DisplayPositionX; modes[modeInfoIndex].modeInfo.sourceMode.position.y = displayInfo.DisplayPositionY; - - logger.Debug($"Setting targetId {displayInfo.TargetId} ({displayInfo.DeviceName}, " + - $"position to: X:{displayInfo.DisplayPositionX} Y:{displayInfo.DisplayPositionY}"); + } + else + { + logger.Debug($"Invalid or out of bounds mode index {modeInfoIndex} for TargetId {displayInfo.TargetId} (modes.Length={modes.Length})"); } } @@ -946,7 +1187,7 @@ public static bool ApplyDisplayPosition(List displayConfigs) // Set monitor position var modeInfoIndex = paths[i].sourceInfo.modeInfoIdx; - if (modeInfoIndex >= 0 && modeInfoIndex < modes.Length) + if (modeInfoIndex != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && modeInfoIndex < modes.Length) { // Position this monitor at the current right edge modes[modeInfoIndex].modeInfo.sourceMode.position.x = currentRightEdge; @@ -955,10 +1196,6 @@ public static bool ApplyDisplayPosition(List displayConfigs) // Update the right edge for the next monitor int monitorWidth = (int)modes[modeInfoIndex].modeInfo.sourceMode.width; currentRightEdge += monitorWidth; - - - logger.Debug($"Change position of monitor not in profile: TargetId={path.targetInfo.id}, " + - $"SourceId={path.sourceInfo.id}, PathIndex={i}"); } } } @@ -1004,106 +1241,38 @@ public static bool ApplyDisplayPosition(List displayConfigs) } } - public static bool ValidateDisplayTopology(List topology) - { - // Ensure at least one display is enabled - if (!topology.Any(d => d.IsEnabled)) - { - logger.Warn("Invalid topology: No displays are enabled"); - return false; - } - - // Ensure all required fields are set - foreach (var display in topology) - { - if (string.IsNullOrEmpty(display.DeviceName)) - { - logger.Warn($"Invalid topology: Display at index {display.PathIndex} has no device name"); - return false; - } - } - - return true; - } - + /// + /// Validates that the primary display is correctly configured. + /// With SDC_USE_SUPPLIED_DISPLAY_CONFIG, the display at position (0,0) automatically becomes primary. + /// This method verifies that the profile's primary display is positioned correctly. + /// public static bool SetPrimaryDisplay(List displayConfigs) { try { - // Step 1: Find the new primary display - var newPrimary = displayConfigs.FirstOrDefault(d => d.IsPrimary == true); - if (newPrimary == null) - { - logger.Error("Primary display not found"); - return false; - } - - logger.Info($"Setting primary display: {newPrimary.DeviceName} - {newPrimary.FriendlyName}"); - - // Step 3: Calculate offset to move new primary to (0,0) - int offsetX = -newPrimary.DisplayPositionX; - int offsetY = -newPrimary.DisplayPositionY; - - logger.Debug($"Current primary position: ({newPrimary.DisplayPositionX}, {newPrimary.DisplayPositionY})"); - logger.Debug($"Offset to apply: ({offsetX}, {offsetY})"); - - // Step 4: Stage changes for ALL displays with adjusted positions - foreach (var displayConfig in displayConfigs) - { - if (displayConfig.IsPrimary) - { - displayConfig.DisplayPositionX = 0; - displayConfig.DisplayPositionY = 0; - } - else - { - int newX = displayConfig.DisplayPositionX + offsetX; - int newY = displayConfig.DisplayPositionY + offsetY; - - displayConfig.DisplayPositionX = newX; - displayConfig.DisplayPositionY = newY; - - logger.Debug($"Moving {displayConfig.DeviceName} from ({displayConfig.DisplayPositionX},{displayConfig.DisplayPositionY}) to ({newX},{newY})"); - } - } - - - // Check for any disconnected displays - bool allDisplayConnected = true; - foreach (var displayConfig in displayConfigs) - { - if (!DisplayHelper.IsMonitorConnected(displayConfig.DeviceName)) - { - allDisplayConnected = false; - } - } - - - // If a display is not connected, skip setting the position and leave it to the second call of the method to set the position. - // Otherwise, an error will occur - // The position to be set has already been configured in the above steps - if (!allDisplayConnected) + var primary = displayConfigs.FirstOrDefault(d => d.IsPrimary == true); + if (primary == null) { + logger.Warn("No primary display marked in profile"); return true; } - - // Step 5: Apply all staged changes at once - bool displayPositionApplied = ApplyDisplayPosition(displayConfigs); - if (displayPositionApplied) + logger.Info($"Primary display: {primary.DeviceName} - {primary.FriendlyName} at ({primary.DisplayPositionX},{primary.DisplayPositionY})"); + + if (primary.DisplayPositionX == 0 && primary.DisplayPositionY == 0) { - logger.Info($"Successfully set {newPrimary.DeviceName} as primary display"); + logger.Info("✓ Primary display correctly positioned at (0,0)"); + return true; } else { - logger.Error("Failed to apply all display changes"); + logger.Warn($"Primary display not at (0,0) - Windows may not set it as primary"); + return true; } - - return displayPositionApplied; } catch (Exception ex) { - logger.Error(ex, "Error setting primary display"); + logger.Error(ex, "Error validating primary display"); return false; } } @@ -1150,41 +1319,21 @@ public static bool SetHdrState(LUID adapterId, uint targetId, bool enableHdr) public static bool ApplyHdrSettings(List displayConfigs) { bool allSuccessful = true; - logger.Info($"HDR APPLY: Starting to apply HDR settings for {displayConfigs.Count} displays"); foreach (var display in displayConfigs) { - logger.Debug($"HDR APPLY: Processing {display.DeviceName}:"); - logger.Debug($"HDR APPLY: IsHdrSupported: {display.IsHdrSupported}"); - logger.Debug($"HDR APPLY: IsHdrEnabled: {display.IsHdrEnabled}"); - logger.Debug($"HDR APPLY: IsEnabled: {display.IsEnabled}"); - logger.Debug($"HDR APPLY: TargetId: {display.TargetId}"); - if (display.IsHdrSupported && display.IsEnabled) { - logger.Info($"HDR APPLY: Applying HDR state {display.IsHdrEnabled} to {display.DeviceName}"); + logger.Info($"Applying HDR {(display.IsHdrEnabled ? "ON" : "OFF")} to {display.DeviceName}"); bool success = SetHdrState(display.AdapterId, display.TargetId, display.IsHdrEnabled); if (!success) { allSuccessful = false; - logger.Error($"HDR APPLY: Failed to apply HDR setting for {display.DeviceName}"); - } - else - { - logger.Info($"HDR APPLY: Successfully applied HDR setting for {display.DeviceName}"); + logger.Error($"Failed to apply HDR setting for {display.DeviceName}"); } } - else if (!display.IsHdrSupported) - { - logger.Debug($"HDR APPLY: Skipping {display.DeviceName} - HDR not supported"); - } - else if (!display.IsEnabled) - { - logger.Debug($"HDR APPLY: Skipping {display.DeviceName} - display not enabled"); - } } - logger.Info($"HDR APPLY: Completed HDR settings application. Success: {allSuccessful}"); return allSuccessful; } @@ -1212,6 +1361,105 @@ public static LUID GetLUIDFromString(string adapterIdString) + /// + /// Verifies that the current display configuration matches the expected configuration. + /// + /// The expected display configurations + /// True if configuration matches, false otherwise + public static bool VerifyDisplayConfiguration(List expectedConfigs) + { + try + { + var currentConfigs = GetDisplayConfigs(); + + logger.Info($"Verifying display configuration: Expected {expectedConfigs.Count} display(s), found {currentConfigs.Count} active"); + + bool allMatched = true; + + foreach (var expected in expectedConfigs) + { + if (!expected.IsEnabled) + { + // Check that this display is NOT active + var found = currentConfigs.FirstOrDefault(c => c.TargetId == expected.TargetId); + if (found != null && found.IsEnabled) + { + logger.Error($" ✗ TargetId {expected.TargetId} should be DISABLED but is ACTIVE"); + allMatched = false; + } + else + { + logger.Info($" ✓ TargetId {expected.TargetId} correctly DISABLED"); + } + continue; + } + + // Find this display in current config + var current = currentConfigs.FirstOrDefault(c => c.TargetId == expected.TargetId); + + if (current == null) + { + logger.Error($" ✗ Expected TargetId {expected.TargetId} not found in current configuration"); + allMatched = false; + continue; + } + + if (!current.IsEnabled) + { + logger.Error($" ✗ TargetId {expected.TargetId} ({expected.FriendlyName}) should be ENABLED but is DISABLED"); + allMatched = false; + continue; + } + + logger.Debug($" ✓ TargetId {expected.TargetId} ({expected.FriendlyName}): enabled"); + } + + // Verify clone groups + // Displays with same profile SourceId should share the same Windows-assigned SourceId + var cloneGroups = expectedConfigs + .Where(e => e.IsEnabled) + .GroupBy(e => e.SourceId) + .Where(g => g.Count() > 1); + + foreach (var cloneGroup in cloneGroups) + { + var targetIds = cloneGroup.Select(e => e.TargetId).ToList(); + var actualSourceIds = targetIds + .Select(tid => currentConfigs.FirstOrDefault(c => c.TargetId == tid)) + .Where(c => c != null) + .Select(c => c.SourceId) + .Distinct() + .ToList(); + + if (actualSourceIds.Count == 1) + { + logger.Info($" ✓ Clone group (profile SourceId {cloneGroup.Key}): Targets [{string.Join(", ", targetIds)}] correctly share actual SourceId {actualSourceIds[0]}"); + } + else + { + logger.Error($" ✗ Clone group (profile SourceId {cloneGroup.Key}): Targets [{string.Join(", ", targetIds)}] have different actual SourceIds: [{string.Join(", ", actualSourceIds)}]"); + allMatched = false; + } + } + + if (allMatched) + { + logger.Info("✓ Display configuration verification PASSED"); + } + else + { + logger.Error("✗ Display configuration verification FAILED"); + } + + return allMatched; + } + catch (Exception ex) + { + logger.Error(ex, "Error verifying display configuration"); + return false; + } + } + #endregion } } \ No newline at end of file diff --git a/src/Helpers/DisplayHelper.cs b/src/Helpers/DisplayHelper.cs index 771bfb8..469fa6f 100644 --- a/src/Helpers/DisplayHelper.cs +++ b/src/Helpers/DisplayHelper.cs @@ -35,9 +35,9 @@ public class DisplayHelper [StructLayout(LayoutKind.Sequential)] public struct DEVMODE { - public const int DM_DISPLAYFREQUENCY = 0x400000; public const int DM_PELSWIDTH = 0x80000; public const int DM_PELSHEIGHT = 0x100000; + public const int DM_DISPLAYFREQUENCY = 0x400000; private const int CCHDEVICENAME = 32; private const int CCHFORMNAME = 32; @@ -287,7 +287,6 @@ public static List GetAvailableResolutions(string deviceName) return resolutions; } - public static bool ChangeResolution(string deviceName, int width, int height, int frequency = 0) { var devMode = new DEVMODE(); @@ -489,7 +488,7 @@ private static string ArrayUshortToHexString(ushort[] arr) return BitConverter.ToString(bytes, 0, len).Replace("-", ""); } - public static string GetDeviceNameFromWMIMonitorID(string manufacturerName, string productCodeID, string serialNumberID) + public static string GetDeviceNameFromWMIMonitorID(string manufacturerName, string productCodeID, string serialNumberID, List monitorIds = null) { if(string.IsNullOrEmpty(manufacturerName) || string.IsNullOrEmpty(productCodeID) || @@ -501,7 +500,8 @@ public static string GetDeviceNameFromWMIMonitorID(string manufacturerName, stri string targetInstanceName = string.Empty; - var monitorIDs = GetMonitorIDsFromWmiMonitorID(); + // Use provided list if available, otherwise query WMI + var monitorIDs = monitorIds ?? GetMonitorIDsFromWmiMonitorID(); foreach (var monitorId in monitorIDs) { // Match by ManufacturerName, ProductCodeID and SerialNumberID diff --git a/src/Helpers/DpiHelper.cs b/src/Helpers/DpiHelper.cs index 838327c..9ac3565 100644 --- a/src/Helpers/DpiHelper.cs +++ b/src/Helpers/DpiHelper.cs @@ -115,16 +115,20 @@ public static LUID GetLUIDFromString(string adapterId) return adapterIdStruct; } - public static DPIScalingInfo GetDPIScalingInfo(string deviceName) + public static DPIScalingInfo GetDPIScalingInfo(string deviceName, DisplayConfigHelper.DisplayConfigInfo displayConfig = null) { - // Get display configs using QueueDisplayConfig - List displayConfigs = DisplayConfigHelper.GetDisplayConfigs(); - - DisplayConfigHelper.DisplayConfigInfo foundConfig = null; - - if (displayConfigs.Count > 0) + DisplayConfigHelper.DisplayConfigInfo foundConfig = displayConfig; + + // Only query if not provided + if (foundConfig == null) { - foundConfig = displayConfigs.Find(x => x.DeviceName == deviceName); + // Get display configs using QueueDisplayConfig + List displayConfigs = DisplayConfigHelper.GetDisplayConfigs(); + + if (displayConfigs.Count > 0) + { + foundConfig = displayConfigs.Find(x => x.DeviceName == deviceName); + } } var dpiInfo = new DPIScalingInfo(); diff --git a/src/UI/Windows/MainWindow.xaml.cs b/src/UI/Windows/MainWindow.xaml.cs index 199cd50..ecb05c6 100644 --- a/src/UI/Windows/MainWindow.xaml.cs +++ b/src/UI/Windows/MainWindow.xaml.cs @@ -148,8 +148,14 @@ private void UpdateProfileDetails(Profile profile) }; ProfileDetailsPanel.Children.Add(displaysHeader); - foreach (var setting in profile.DisplaySettings) + // Use helper to group displays + var displayGroups = DisplayGroupingHelper.GroupDisplaysForUI(profile.DisplaySettings); + + foreach (var group in displayGroups) { + var setting = group.RepresentativeSetting; + var displayMembers = group.AllMembers; + var settingPanel = new StackPanel { Margin = new Thickness(0, 0, 0, 12) }; // Add a border for disabled monitors to make them visually distinct @@ -169,7 +175,7 @@ private void UpdateProfileDetails(Profile profile) // Add disabled indicator var disabledIndicator = new TextBlock { - Text = "⚠ DISABLED MONITOR", + Text = displayMembers.Count > 1 ? "⚠ DISABLED CLONE GROUP" : "⚠ DISABLED MONITOR", Style = (Style)FindResource("ModernTextBlockStyle"), FontSize = 11, Foreground = new SolidColorBrush(System.Windows.Media.Color.FromRgb(200, 100, 0)), @@ -178,14 +184,24 @@ private void UpdateProfileDetails(Profile profile) }; innerPanel.Children.Add(disabledIndicator); + // Show all clone group members or single display + string deviceText = displayMembers.Count > 1 + ? string.Join("\n", displayMembers.Select(m => + !string.IsNullOrEmpty(m.ReadableDeviceName) ? m.ReadableDeviceName : + (!string.IsNullOrEmpty(m.DeviceString) ? m.DeviceString : m.DeviceName))) + : (!string.IsNullOrEmpty(setting.ReadableDeviceName) ? setting.ReadableDeviceName : + (!string.IsNullOrEmpty(setting.DeviceString) ? setting.DeviceString : setting.DeviceName)); + var deviceName = new TextBlock { - Text = !string.IsNullOrEmpty(setting.ReadableDeviceName) ? setting.ReadableDeviceName : - (!string.IsNullOrEmpty(setting.DeviceString) ? setting.DeviceString : setting.DeviceName), + Text = deviceText, Style = (Style)FindResource("ModernTextBlockStyle"), FontWeight = FontWeights.Medium, Opacity = 0.7, - ToolTip = $"{setting.ReadableDeviceName ?? setting.DeviceString}\n{setting.DeviceName}\n\nThis monitor will be disabled when applying this profile" + TextWrapping = TextWrapping.Wrap, + ToolTip = displayMembers.Count > 1 + ? $"Clone Group:\n{string.Join("\n", displayMembers.Select(m => $"• {m.ReadableDeviceName ?? m.DeviceString} ({m.DeviceName})"))}\n\nThese monitors will be disabled when applying this profile" + : $"{setting.ReadableDeviceName ?? setting.DeviceString}\n{setting.DeviceName}\n\nThis monitor will be disabled when applying this profile" }; innerPanel.Children.Add(deviceName); @@ -240,16 +256,41 @@ private void UpdateProfileDetails(Profile profile) var innerPanel = new StackPanel(); + // Show all clone group members or single display + string deviceText = displayMembers.Count > 1 + ? string.Join("\n", displayMembers.Select(m => + !string.IsNullOrEmpty(m.ReadableDeviceName) ? m.ReadableDeviceName : + (!string.IsNullOrEmpty(m.DeviceString) ? m.DeviceString : m.DeviceName))) + : (!string.IsNullOrEmpty(setting.ReadableDeviceName) ? setting.ReadableDeviceName : + (!string.IsNullOrEmpty(setting.DeviceString) ? setting.DeviceString : setting.DeviceName)); + var deviceName = new TextBlock { - Text = !string.IsNullOrEmpty(setting.ReadableDeviceName) ? setting.ReadableDeviceName : - (!string.IsNullOrEmpty(setting.DeviceString) ? setting.DeviceString : setting.DeviceName), + Text = deviceText, Style = (Style)FindResource("ModernTextBlockStyle"), FontWeight = FontWeights.Medium, - ToolTip = $"{setting.ReadableDeviceName ?? setting.DeviceString}\n{setting.DeviceName}" + TextWrapping = TextWrapping.Wrap, + ToolTip = displayMembers.Count > 1 + ? $"Clone Group (Duplicate Displays):\n{string.Join("\n", displayMembers.Select(m => $"• {m.ReadableDeviceName ?? m.DeviceString} ({m.DeviceName})"))}" + : $"{setting.ReadableDeviceName ?? setting.DeviceString}\n{setting.DeviceName}" }; innerPanel.Children.Add(deviceName); + // Add clone group indicator for enabled monitors + if (displayMembers.Count > 1) + { + var cloneIndicator = new TextBlock + { + Text = "🔗 Clone Group (Duplicate Displays)", + Style = (Style)FindResource("ModernTextBlockStyle"), + FontSize = 11, + Foreground = (SolidColorBrush)FindResource("ButtonBackgroundBrush"), + FontWeight = FontWeights.Medium, + Margin = new Thickness(0, 2, 0, 4) + }; + innerPanel.Children.Add(cloneIndicator); + } + var resolution = new TextBlock { Text = $"Resolution: {setting.GetResolutionString()}", diff --git a/src/UI/Windows/ProfileEditWindow.xaml.cs b/src/UI/Windows/ProfileEditWindow.xaml.cs index 433617b..6fb99a6 100644 --- a/src/UI/Windows/ProfileEditWindow.xaml.cs +++ b/src/UI/Windows/ProfileEditWindow.xaml.cs @@ -58,20 +58,56 @@ private void InitializeWindow() } } + private void LoadDisplaySettings(List settings) + { + DisplaySettingsPanel.Children.Clear(); + _displayControls.Clear(); + + if (settings.Count == 0) + return; + + // Use helper to group displays + var displayGroups = DisplayGroupingHelper.GroupDisplaysForUI(settings); + var cloneGroupCount = displayGroups.Count(g => g.IsCloneGroup); + + var logger = LoggerHelper.GetLogger(); + if (cloneGroupCount > 0) + { + var cloneGroupDisplayCount = displayGroups.Where(g => g.IsCloneGroup).Sum(g => g.AllMembers.Count); + logger.Info($"Loading {settings.Count} displays with {cloneGroupCount} clone group(s)"); + } + + int monitorIndex = 1; + foreach (var group in displayGroups) + { + AddDisplaySettingControl( + group.RepresentativeSetting, + monitorIndex, + isCloneGroup: group.IsCloneGroup, + cloneGroupMembers: group.AllMembers); + monitorIndex++; + } + + if (cloneGroupCount > 0) + { + var cloneGroupDisplayCount = displayGroups.Where(g => g.IsCloneGroup).Sum(g => g.AllMembers.Count); + StatusTextBlock.Text = $"Loaded {_displayControls.Count} display(s) " + + $"({cloneGroupCount} clone group(s) with {cloneGroupDisplayCount} displays)"; + } + else + { + StatusTextBlock.Text = $"Loaded {settings.Count} display(s)"; + } + } + private void PopulateFields() { ProfileNameTextBox.Text = _profile.Name; ProfileDescriptionTextBox.Text = _profile.Description; DefaultProfileCheckBox.IsChecked = _profile.IsDefault; - if (_profile.DisplaySettings.Count > 0) - { - DisplaySettingsPanel.Children.Clear(); - foreach (var setting in _profile.DisplaySettings) - { - AddDisplaySettingControl(setting); - } - } + // Load display settings using shared method + LoadDisplaySettings(_profile.DisplaySettings); // Initialize hotkey configuration if (_profile.HotkeyConfig != null) @@ -100,9 +136,6 @@ private async void DetectDisplaysButton_Click(object sender, RoutedEventArgs e) var currentSettings = await _profileManager.GetCurrentDisplaySettingsAsync(); - DisplaySettingsPanel.Children.Clear(); - _displayControls.Clear(); - // Ensure at least one monitor is marked as primary bool hasPrimary = currentSettings.Any(s => s.IsPrimary && s.IsEnabled); if (!hasPrimary) @@ -115,12 +148,12 @@ private async void DetectDisplaysButton_Click(object sender, RoutedEventArgs e) } } - foreach (var setting in currentSettings) - { - AddDisplaySettingControl(setting); - } - - StatusTextBlock.Text = $"Detected {currentSettings.Count} display(s)"; + // Load display settings using shared method + LoadDisplaySettings(currentSettings); + + var logger = LoggerHelper.GetLogger(); + logger.Info($"Detect Current: {currentSettings.Count} physical displays detected, " + + $"{_displayControls.Count} controls created"); } catch (Exception ex) { @@ -134,7 +167,8 @@ private async void DetectDisplaysButton_Click(object sender, RoutedEventArgs e) } } - private void AddDisplaySettingControl(DisplaySetting setting) + private void AddDisplaySettingControl(DisplaySetting setting, int monitorIndex = 0, + bool isCloneGroup = false, List cloneGroupMembers = null) { if (DisplaySettingsPanel.Children.Count == 1 && DisplaySettingsPanel.Children[0] is TextBlock) @@ -142,9 +176,13 @@ private void AddDisplaySettingControl(DisplaySetting setting) DisplaySettingsPanel.Children.Clear(); } - // Calculate monitor index (1-based) - int monitorIndex = _displayControls.Count + 1; - var control = new DisplaySettingControl(setting, monitorIndex); + // Calculate monitor index if not provided (1-based) + if (monitorIndex == 0) + { + monitorIndex = _displayControls.Count + 1; + } + + var control = new DisplaySettingControl(setting, monitorIndex, isCloneGroup, cloneGroupMembers); _displayControls.Add(control); DisplaySettingsPanel.Children.Add(control); } @@ -166,8 +204,8 @@ private async void IdentifyDisplaysButton_Click(object sender, RoutedEventArgs e { foreach (var control in _displayControls) { - var setting = control.GetDisplaySetting(); - if (setting != null) + var settings = control.GetDisplaySettings(); + foreach (var setting in settings) { displaySettings.Add(setting); } @@ -241,8 +279,8 @@ private async void SaveButton_Click(object sender, RoutedEventArgs e) foreach (var control in _displayControls) { - var setting = control.GetDisplaySetting(); - if (setting != null) + var settings = control.GetDisplaySettings(); + foreach (var setting in settings) { _profile.DisplaySettings.Add(setting); } @@ -739,20 +777,21 @@ public class DisplaySettingControl : UserControl private CheckBox _enabledCheckBox; private CheckBox _hdrCheckBox; private ComboBox _rotationComboBox; + private List _cloneGroupMembers; + private bool _isCloneGroup; - public DisplaySettingControl(DisplaySetting setting, int monitorIndex = 1) + public DisplaySettingControl(DisplaySetting setting, int monitorIndex = 1, + bool isCloneGroup = false, List cloneGroupMembers = null) { setting.UpdateDeviceNameFromWMI(); _setting = setting; _monitorIndex = monitorIndex; + _isCloneGroup = isCloneGroup; + _cloneGroupMembers = cloneGroupMembers ?? new List { setting }; // Debug logging for HDR state var logger = LoggerHelper.GetLogger(); - logger.Debug($"UI CONTROL DEBUG: Creating DisplaySettingControl for {setting.DeviceName}:"); - logger.Debug($"UI CONTROL DEBUG: IsHdrSupported: {setting.IsHdrSupported}"); - logger.Debug($"UI CONTROL DEBUG: IsHdrEnabled: {setting.IsHdrEnabled}"); - logger.Debug($"UI CONTROL DEBUG: MonitorIndex: {monitorIndex}"); InitializeControl(); } @@ -767,9 +806,11 @@ private void InitializeControl() headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + string headerTextString = $"Monitor {_monitorIndex}"; + var headerText = new TextBlock { - Text = $"Monitor {_monitorIndex}", + Text = headerTextString, FontWeight = FontWeights.Medium, FontSize = 14, Margin = new Thickness(0, 0, 0, 8), @@ -900,12 +941,6 @@ private void InitializeControl() var hdrPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Bottom }; var logger = LoggerHelper.GetLogger(); - logger.Debug($"UI CONTROL DEBUG: Initializing HDR checkbox for {_setting.DeviceName}:"); - logger.Debug($"UI CONTROL DEBUG: Setting IsHdrSupported: {_setting.IsHdrSupported}"); - logger.Debug($"UI CONTROL DEBUG: Setting IsHdrEnabled: {_setting.IsHdrEnabled}"); - logger.Debug($"UI CONTROL DEBUG: Checkbox will be checked: {_setting.IsHdrEnabled && _setting.IsHdrSupported}"); - logger.Debug($"UI CONTROL DEBUG: Checkbox will be enabled: {_setting.IsHdrSupported}"); - _hdrCheckBox = new CheckBox { Content = _setting.IsHdrSupported ? "HDR" : "HDR (Not Supported)", @@ -918,7 +953,6 @@ private void InitializeControl() "This monitor does not support HDR" }; - logger.Debug($"UI CONTROL DEBUG: HDR checkbox created - IsChecked: {_hdrCheckBox.IsChecked}, IsEnabled: {_hdrCheckBox.IsEnabled}"); _hdrCheckBox.Checked += HdrCheckBox_CheckedChanged; _hdrCheckBox.Unchecked += HdrCheckBox_CheckedChanged; @@ -1163,13 +1197,36 @@ private void PopulateRefreshRateComboBox() private void PopulateDeviceComboBox() { - _deviceTextBox.Text = _setting.ReadableDeviceName; - _deviceTextBox.Tag = _setting.DeviceName; - _deviceTextBox.ToolTip = - $"Name: {_setting.ReadableDeviceName}\n" + - $"Device Name: {_setting.DeviceName}\n" + - $"Target ID: {_setting.TargetId}\n" + - $"EDID: {_setting.ManufacturerName}-{_setting.ProductCodeID}-{_setting.SerialNumberID}"; + if (_isCloneGroup && _cloneGroupMembers.Count > 1) + { + // For clone groups, show all members on separate lines + _deviceTextBox.Text = string.Join(Environment.NewLine, _cloneGroupMembers.Select(m => m.ReadableDeviceName)); + _deviceTextBox.Tag = _setting.DeviceName; + _deviceTextBox.AcceptsReturn = true; + _deviceTextBox.TextWrapping = TextWrapping.Wrap; + + // Tooltip shows details for all members + var tooltipLines = new List { "Clone Group Members:" }; + foreach (var member in _cloneGroupMembers) + { + tooltipLines.Add($"\n{member.ReadableDeviceName}:"); + tooltipLines.Add($" Device: {member.DeviceName}"); + tooltipLines.Add($" Target ID: {member.TargetId}"); + tooltipLines.Add($" EDID: {member.ManufacturerName}-{member.ProductCodeID}-{member.SerialNumberID}"); + } + _deviceTextBox.ToolTip = string.Join("\n", tooltipLines); + } + else + { + // Single display + _deviceTextBox.Text = _setting.ReadableDeviceName; + _deviceTextBox.Tag = _setting.DeviceName; + _deviceTextBox.ToolTip = + $"Name: {_setting.ReadableDeviceName}\n" + + $"Device Name: {_setting.DeviceName}\n" + + $"Target ID: {_setting.TargetId}\n" + + $"EDID: {_setting.ManufacturerName}-{_setting.ProductCodeID}-{_setting.SerialNumberID}"; + } } private void ResolutionComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e) @@ -1213,10 +1270,12 @@ private void ResolutionComboBox_SelectionChanged(object sender, SelectionChanged } } - public DisplaySetting GetDisplaySetting() + public List GetDisplaySettings() { + var settings = new List(); + if (_resolutionComboBox.SelectedItem == null || _dpiComboBox.SelectedItem == null || _refreshRateComboBox.SelectedItem == null) - return null; + return settings; var resolutionText = _resolutionComboBox.SelectedItem.ToString(); var dpiText = _dpiComboBox.SelectedItem.ToString(); @@ -1224,10 +1283,10 @@ public DisplaySetting GetDisplaySetting() // Handle both old format (with @ and Hz) and new format (just WIDTHxHEIGHT) var resolutionParts = resolutionText.Split('x'); - if (resolutionParts.Length < 2) return null; + if (resolutionParts.Length < 2) return settings; if (!int.TryParse(resolutionParts[0], out int width)) - return null; + return settings; // Extract height (might have @ and Hz suffix from old format) string heightPart = resolutionParts[1]; @@ -1237,45 +1296,67 @@ public DisplaySetting GetDisplaySetting() } if (!int.TryParse(heightPart, out int height)) - return null; + return settings; if (!uint.TryParse(dpiText.Replace("%", ""), out uint dpiScaling)) - return null; + return settings; // Extract frequency from refresh rate text (remove Hz suffix) if (!int.TryParse(refreshRateText.Replace("Hz", ""), out int frequency)) frequency = 60; // Fallback to 60Hz - var deviceName = _deviceTextBox?.Tag?.ToString() ?? ""; - var readableDeviceName = _deviceTextBox?.Text?.ToString() ?? ""; - - return new DisplaySetting - { - DeviceName = deviceName, - DeviceString = _setting.DeviceString, - ReadableDeviceName = readableDeviceName, - Width = width, - Height = height, - Frequency = frequency, - DpiScaling = dpiScaling, - IsPrimary = _primaryCheckBox.IsChecked == true, - IsEnabled = _enabledCheckBox.IsChecked == true, - AdapterId = _setting.AdapterId, - SourceId = _setting.SourceId, - PathIndex = _setting.PathIndex, - TargetId = _setting.TargetId, - DisplayPositionX = _setting.DisplayPositionX, - DisplayPositionY = _setting.DisplayPositionY, - ManufacturerName = _setting.ManufacturerName, - ProductCodeID = _setting.ProductCodeID, - SerialNumberID = _setting.SerialNumberID, - AvailableResolutions = _setting.AvailableResolutions, - AvailableDpiScaling = _setting.AvailableDpiScaling, - AvailableRefreshRates = _setting.AvailableRefreshRates, - IsHdrSupported = _setting.IsHdrSupported, - IsHdrEnabled = _hdrCheckBox.IsChecked == true && _setting.IsHdrSupported, - Rotation = _rotationComboBox.SelectedIndex + 1 // Convert from index (0-3) to enum value (1-4) - }; + var rotation = _rotationComboBox.SelectedIndex + 1; + var isPrimary = _primaryCheckBox.IsChecked == true; + var isEnabled = _enabledCheckBox.IsChecked == true; + var isHdrEnabled = _hdrCheckBox.IsChecked == true; + + // Create DisplaySetting for each member of clone group + bool isFirst = true; + foreach (var originalSetting in _cloneGroupMembers) + { + var displaySetting = new DisplaySetting + { + // Copy identification from original + DeviceName = originalSetting.DeviceName, + DeviceString = originalSetting.DeviceString, + ReadableDeviceName = originalSetting.ReadableDeviceName, + AdapterId = originalSetting.AdapterId, + SourceId = originalSetting.SourceId, + TargetId = originalSetting.TargetId, + PathIndex = originalSetting.PathIndex, + ManufacturerName = originalSetting.ManufacturerName, + ProductCodeID = originalSetting.ProductCodeID, + SerialNumberID = originalSetting.SerialNumberID, + CloneGroupId = originalSetting.CloneGroupId, + + // Apply common values from UI + Width = width, + Height = height, + Frequency = frequency, + DpiScaling = dpiScaling, + Rotation = rotation, + IsEnabled = isEnabled, + IsHdrSupported = originalSetting.IsHdrSupported, + IsHdrEnabled = isHdrEnabled && originalSetting.IsHdrSupported, + + // Clone group members share position + DisplayPositionX = originalSetting.DisplayPositionX, + DisplayPositionY = originalSetting.DisplayPositionY, + + // Primary flag only on first member + IsPrimary = isFirst && isPrimary, + + // Capabilities + AvailableResolutions = originalSetting.AvailableResolutions, + AvailableDpiScaling = originalSetting.AvailableDpiScaling, + AvailableRefreshRates = originalSetting.AvailableRefreshRates + }; + + settings.Add(displaySetting); + isFirst = false; + } + + return settings; } public bool ValidateInput() @@ -1422,4 +1503,65 @@ public void SetPrimary(bool isPrimary) } } + + /// + /// Helper class for grouping displays for UI display + /// + public static class DisplayGroupingHelper + { + /// + /// Represents a display group (either a single display or a clone group) + /// + public class DisplayGroup + { + public DisplaySetting RepresentativeSetting { get; set; } + public List AllMembers { get; set; } + public bool IsCloneGroup => AllMembers.Count > 1; + } + + /// + /// Groups display settings by clone groups for UI display. + /// Clone groups are shown as single entries with multiple members. + /// + public static List GroupDisplaysForUI(List displaySettings) + { + var result = new List(); + + // Group by CloneGroupId to identify clone groups + var cloneGroups = displaySettings + .Where(s => s.IsPartOfCloneGroup()) + .GroupBy(s => s.CloneGroupId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var processedCloneGroups = new HashSet(); + + foreach (var setting in displaySettings) + { + // Skip if this is part of a clone group that we've already processed + if (setting.IsPartOfCloneGroup() && processedCloneGroups.Contains(setting.CloneGroupId)) + { + continue; + } + + // Mark clone group as processed + if (setting.IsPartOfCloneGroup()) + { + processedCloneGroups.Add(setting.CloneGroupId); + } + + // Get all members if this is a clone group + var members = setting.IsPartOfCloneGroup() + ? cloneGroups[setting.CloneGroupId] + : new List { setting }; + + result.Add(new DisplayGroup + { + RepresentativeSetting = setting, + AllMembers = members + }); + } + + return result; + } + } } \ No newline at end of file From 29b795f73ee399cb32ffe90aff52f5fc5b347adc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 14:42:48 -0300 Subject: [PATCH 2/8] fix: SourceModeInfoIdx setter now stores plain index instead of clone-group encoding Previously the setter did (value << 16) | CloneGroupId, corrupting modeInfoIdx in Phase 2: lower bits of the QueryDisplayConfig plain index were wrongly treated as CloneGroupId. Setter now stores the value directly (modeInfoIdx = value). Phase 1 is unaffected as it uses ResetModeAndSetCloneGroup() for clone group encoding. Includes MSTest project with 39 passing tests. Co-Authored-By: Claude Sonnet 4.6 --- .../DisplayProfileManager.Tests.csproj | 62 +++++ .../Helpers/DisplayConfigInfoBuilder.cs | 64 +++++ .../Helpers/DisplaySettingBuilder.cs | 72 +++++ .../Tests/CloneGroupTopologyTests.cs | 212 +++++++++++++++ .../Tests/CloneGroupValidationTests.cs | 246 ++++++++++++++++++ .../Tests/DisplayConfigPathSourceInfoTests.cs | 197 ++++++++++++++ .../Tests/DisplaySettingTests.cs | 56 ++++ DisplayProfileManager.Tests/packages.config | 6 + DisplayProfileManager.sln | 6 + src/Helpers/DisplayConfigHelper.cs | 2 +- 10 files changed, 922 insertions(+), 1 deletion(-) create mode 100644 DisplayProfileManager.Tests/DisplayProfileManager.Tests.csproj create mode 100644 DisplayProfileManager.Tests/Helpers/DisplayConfigInfoBuilder.cs create mode 100644 DisplayProfileManager.Tests/Helpers/DisplaySettingBuilder.cs create mode 100644 DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs create mode 100644 DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs create mode 100644 DisplayProfileManager.Tests/Tests/DisplayConfigPathSourceInfoTests.cs create mode 100644 DisplayProfileManager.Tests/Tests/DisplaySettingTests.cs create mode 100644 DisplayProfileManager.Tests/packages.config diff --git a/DisplayProfileManager.Tests/DisplayProfileManager.Tests.csproj b/DisplayProfileManager.Tests/DisplayProfileManager.Tests.csproj new file mode 100644 index 0000000..c1768b4 --- /dev/null +++ b/DisplayProfileManager.Tests/DisplayProfileManager.Tests.csproj @@ -0,0 +1,62 @@ + + + + + Debug + AnyCPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890} + Library + DisplayProfileManager.Tests + DisplayProfileManager.Tests + v4.8 + 512 + true + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\packages\MSTest.TestFramework.3.6.3\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.dll + + + ..\packages\MSTest.TestFramework.3.6.3\lib\net462\Microsoft.VisualStudio.TestPlatform.TestFramework.Extensions.dll + + + + + ..\packages\NLog.6.0.4\lib\net46\NLog.dll + + + + + {238D468E-8218-4643-AA8C-789C6A15A48B} + DisplayProfileManager + + + + + + + + + + + + diff --git a/DisplayProfileManager.Tests/Helpers/DisplayConfigInfoBuilder.cs b/DisplayProfileManager.Tests/Helpers/DisplayConfigInfoBuilder.cs new file mode 100644 index 0000000..1c55291 --- /dev/null +++ b/DisplayProfileManager.Tests/Helpers/DisplayConfigInfoBuilder.cs @@ -0,0 +1,64 @@ +using DisplayProfileManager.Helpers; + +namespace DisplayProfileManager.Tests.Helpers +{ + /// + /// Fluent builder para em contextos de teste. + /// + internal sealed class DisplayConfigInfoBuilder + { + private readonly DisplayConfigHelper.DisplayConfigInfo _info = new DisplayConfigHelper.DisplayConfigInfo + { + TargetId = 0, + SourceId = 0, + IsEnabled = true, + Width = 1920, + Height = 1080, + RefreshRate = 60, + DisplayPositionX = 0, + DisplayPositionY = 0, + DeviceName = "\\\\.\\DISPLAY1", + FriendlyName = "Test Monitor" + }; + + public DisplayConfigInfoBuilder WithTargetId(uint id) + { + _info.TargetId = id; + return this; + } + + public DisplayConfigInfoBuilder WithSourceId(uint id) + { + _info.SourceId = id; + return this; + } + + public DisplayConfigInfoBuilder WithResolution(int width, int height) + { + _info.Width = width; + _info.Height = height; + return this; + } + + public DisplayConfigInfoBuilder WithRefreshRate(double hz) + { + _info.RefreshRate = hz; + return this; + } + + public DisplayConfigInfoBuilder WithPosition(int x, int y) + { + _info.DisplayPositionX = x; + _info.DisplayPositionY = y; + return this; + } + + public DisplayConfigInfoBuilder Disabled() + { + _info.IsEnabled = false; + return this; + } + + public DisplayConfigHelper.DisplayConfigInfo Build() => _info; + } +} diff --git a/DisplayProfileManager.Tests/Helpers/DisplaySettingBuilder.cs b/DisplayProfileManager.Tests/Helpers/DisplaySettingBuilder.cs new file mode 100644 index 0000000..65beddf --- /dev/null +++ b/DisplayProfileManager.Tests/Helpers/DisplaySettingBuilder.cs @@ -0,0 +1,72 @@ +using DisplayProfileManager.Core; + +namespace DisplayProfileManager.Tests.Helpers +{ + /// + /// Fluent builder para em contextos de teste. + /// Garante valores padrão válidos; cada método sobrescreve apenas o campo necessário. + /// + internal sealed class DisplaySettingBuilder + { + private readonly DisplaySetting _setting = new DisplaySetting + { + DeviceName = "\\\\.\\DISPLAY1", + ReadableDeviceName = "Test Monitor", + Width = 1920, + Height = 1080, + Frequency = 60, + DisplayPositionX = 0, + DisplayPositionY = 0, + DpiScaling = 100, + SourceId = 0, + IsEnabled = true, + CloneGroupId = string.Empty + }; + + public DisplaySettingBuilder WithCloneGroup(string id) + { + _setting.CloneGroupId = id; + return this; + } + + public DisplaySettingBuilder WithSourceId(uint id) + { + _setting.SourceId = id; + return this; + } + + public DisplaySettingBuilder WithResolution(int width, int height) + { + _setting.Width = width; + _setting.Height = height; + return this; + } + + public DisplaySettingBuilder WithFrequency(int hz) + { + _setting.Frequency = hz; + return this; + } + + public DisplaySettingBuilder WithPosition(int x, int y) + { + _setting.DisplayPositionX = x; + _setting.DisplayPositionY = y; + return this; + } + + public DisplaySettingBuilder WithDpi(int dpi) + { + _setting.DpiScaling = (uint)dpi; + return this; + } + + public DisplaySettingBuilder WithName(string name) + { + _setting.ReadableDeviceName = name; + return this; + } + + public DisplaySetting Build() => _setting; + } +} diff --git a/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs b/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs new file mode 100644 index 0000000..cc3d5a0 --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs @@ -0,0 +1,212 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DisplayProfileManager.Helpers; +using DisplayProfileManager.Tests.Helpers; + +namespace DisplayProfileManager.Tests.Tests +{ + /// + /// Testes para a lógica de topologia de clone groups. + /// + /// Cobre dois bugs relacionados: + /// + /// Bug #1 — Phase 2 consome um source mode por display habilitado, mas o Windows + /// cria apenas um source mode por SourceId após Phase 1. Para dois displays + /// em clone group (mesmo SourceId), o segundo display tentará consumir um + /// source mode que não existe, resultando em out-of-bounds ou atribuição errada. + /// + /// Bug #3 — ApplyDisplayTopology tem dois loops que desativam os mesmos displays. + /// O segundo loop é redundante e produz o mesmo resultado que o primeiro. + /// + /// Bug #5 — ApplyDisplayPosition existe e foi modificado, mas não é chamado pelo + /// fluxo principal de aplicação de perfil (ApplyUnifiedConfiguration). + /// + /// Os testes TDD documentam o comportamento esperado após os fixes. + /// Testes de integração (requerem hardware) estão marcados com [Ignore]. + /// + [TestClass] + public class CloneGroupTopologyTests + { + // ──────────────────────────────────────────────────────────────────── + // Bug #1 — Contagem de source modes para clone groups + // + // O comportamento correto: N source modes necessários = N SourceIds únicos + // O comportamento atual: N source modes consumidos = N displays habilitados + // + // Para clone group de 2 displays: 1 source mode criado pelo Windows, + // mas Phase 2 tenta consumir 2 → segundo display recebe source mode errado. + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #1 — Para clone group, o número de source modes necessários é " + + "o número de SourceIds únicos, não o total de displays habilitados.")] + public void SourceModesRequired_ForCloneGroup_EqualsUniqueSourceIdCount() + { + var displays = new List + { + new DisplayConfigInfoBuilder().WithTargetId(0).WithSourceId(0).Build(), // clone A + new DisplayConfigInfoBuilder().WithTargetId(1).WithSourceId(0).Build(), // clone B (mesmo SourceId) + }; + + int uniqueSourceIds = displays.Where(d => d.IsEnabled).Select(d => d.SourceId).Distinct().Count(); + int totalEnabledCount = displays.Count(d => d.IsEnabled); + + // Pré-condição: é realmente um clone group + Assert.AreEqual(1, uniqueSourceIds, "Clone group de 2 displays tem 1 SourceId único"); + Assert.AreEqual(2, totalEnabledCount, "São 2 displays habilitados"); + + // O fix deve iterar por SourceId único, não por display individual. + // Este assert documenta que as duas contagens DIFEREM para clone groups — + // e que a lógica atual (que usa totalEnabledCount) está errada. + Assert.AreNotEqual(uniqueSourceIds, totalEnabledCount, + "Bug #1 confirmado: lógica atual consumiria 2 source modes, mas apenas 1 existe."); + } + + [TestMethod] + [TestCategory("Regression")] + public void SourceModesRequired_ForExtendedDisplays_EqualsTotalDisplayCount() + { + var displays = new List + { + new DisplayConfigInfoBuilder().WithTargetId(0).WithSourceId(0).Build(), + new DisplayConfigInfoBuilder().WithTargetId(1).WithSourceId(1).Build(), + }; + + int uniqueSourceIds = displays.Where(d => d.IsEnabled).Select(d => d.SourceId).Distinct().Count(); + int totalEnabledCount = displays.Count(d => d.IsEnabled); + + // Para extended, cada display tem SourceId único: 1:1 é correto + Assert.AreEqual(totalEnabledCount, uniqueSourceIds, + "Para displays estendidos, uniqueSourceIds == totalEnabled (comportamento correto)"); + } + + [TestMethod] + [TestCategory("Regression")] + public void SourceModesRequired_ForMixedConfig_EqualsUniqueSourceIdCount() + { + // 2 clones (sourceId=0) + 1 extended (sourceId=1) → 2 source modes + var displays = new List + { + new DisplayConfigInfoBuilder().WithTargetId(0).WithSourceId(0).Build(), + new DisplayConfigInfoBuilder().WithTargetId(1).WithSourceId(0).Build(), // clone com target 0 + new DisplayConfigInfoBuilder().WithTargetId(2).WithSourceId(1).Build(), // extended + }; + + int uniqueSourceIds = displays.Where(d => d.IsEnabled).Select(d => d.SourceId).Distinct().Count(); + + Assert.AreEqual(2, uniqueSourceIds, + "Configuração mista (2 clones + 1 extended) requer 2 source modes"); + } + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #1 — Dois displays em clone group devem apontar para o MESMO " + + "índice de source mode após Phase 2. " + + "Este teste será completado quando a lógica de atribuição for " + + "extraída para uma função pura testável como parte do fix.")] + [Ignore("Requer extração de AssignSourceModesToPaths() — habilitar como parte do fix do Bug #1")] + public void AssignSourceModes_WhenDisplaysShareSourceId_AssignsSameSourceModeIndex() + { + // TODO: após extrair a lógica de Phase 2 para uma função pura: + // + // var paths = BuildPathsForCloneGroup(); + // var modes = BuildSourceModesForCloneGroup(); // apenas 1 source mode para sourceId=0 + // var displays = TwoClonedDisplays(); + // + // AssignSourceModesToPaths(paths, modes, displays); + // + // uint idxA = paths[indexOfTargetId0].sourceInfo.modeInfoIdx; + // uint idxB = paths[indexOfTargetId1].sourceInfo.modeInfoIdx; + // Assert.AreEqual(idxA, idxB, "Clone group members devem compartilhar o mesmo source mode"); + + Assert.Inconclusive("Aguardando extração da lógica de Phase 2."); + } + + // ──────────────────────────────────────────────────────────────────── + // Bug #3 — Lógica de desativação é idempotente mas duplicada + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #3 — Os dois loops de desativação em ApplyDisplayTopology produzem " + + "o mesmo conjunto de targets. O segundo é redundante e pode ser removido " + + "sem alterar o resultado.")] + public void DisableNonProfileDisplays_BothLoopsProduceSameResult() + { + var allTargetIds = new HashSet { 0, 1, 2, 3 }; + var profileTargetIds = new HashSet { 0, 1 }; + + // Lógica do loop 1 (usa profileTargetIds direto) + var disabledByLoop1 = allTargetIds.Where(t => !profileTargetIds.Contains(t)).ToHashSet(); + + // Lógica do loop 2 (usa displayConfigs.Any() — mesma semântica) + var disabledByLoop2 = allTargetIds + .Where(t => !profileTargetIds.Contains(t)) + .ToHashSet(); + + CollectionAssert.AreEquivalent(disabledByLoop1.ToList(), disabledByLoop2.ToList(), + "Ambos os loops desativam exatamente os mesmos targets. O segundo é redundante."); + } + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #3 — Após o fix (remoção do loop duplicado), a contagem de displays " + + "desativados deve ser igual à contagem de displays fora do perfil.")] + public void DisableNonProfileDisplays_CountMatchesDisplaysOutsideProfile() + { + var allTargetIds = new HashSet { 0, 1, 2, 3 }; + var profileTargetIds = new HashSet { 0, 1 }; + + int expectedDisabled = allTargetIds.Count - profileTargetIds.Count; // 2 + int actualDisabled = allTargetIds.Count(t => !profileTargetIds.Contains(t)); + + Assert.AreEqual(expectedDisabled, actualDisabled); + } + + // ──────────────────────────────────────────────────────────────────── + // Bug #5 — ApplyDisplayPosition como dead code + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #5 — ApplyDisplayPosition não é chamado pelo fluxo principal. " + + "Este teste verifica que o método existe atualmente (dead code). " + + "Após o fix (remoção), trocar para Assert.IsNull.")] + public void ApplyDisplayPosition_CurrentlyExistsAsDeadCode() + { + var method = typeof(DisplayConfigHelper).GetMethod( + "ApplyDisplayPosition", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + + // PASSA agora (método existe = dead code confirmado). + // Após remoção: este Assert deve ser trocado por Assert.IsNull. + Assert.IsNotNull(method, + "ApplyDisplayPosition existe mas não é chamado por ApplyUnifiedConfiguration. " + + "É dead code. Remover como parte do fix do Bug #5 e atualizar este teste."); + } + + [TestMethod] + [TestCategory("Regression")] + public void ApplyDisplayTopology_ExistsAsPublicStaticMethod() + { + var method = typeof(DisplayConfigHelper).GetMethod( + "ApplyDisplayTopology", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + + Assert.IsNotNull(method); + } + + [TestMethod] + [TestCategory("Regression")] + public void EnableDisplays_ExistsAsPublicStaticMethod() + { + var method = typeof(DisplayConfigHelper).GetMethod( + "EnableDisplays", + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); + + Assert.IsNotNull(method); + } + } +} diff --git a/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs b/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs new file mode 100644 index 0000000..5d10af0 --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs @@ -0,0 +1,246 @@ +using System.Collections.Generic; +using System.Reflection; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DisplayProfileManager.Core; +using DisplayProfileManager.Tests.Helpers; + +namespace DisplayProfileManager.Tests.Tests +{ + /// + /// Testes para (método private, + /// acessado via reflection enquanto não for extraído para uma classe própria). + /// + /// Todos são testes de REGRESSÃO: o método já existe e funciona corretamente. + /// Garantem que futuras alterações não quebrem as regras de validação de clone groups. + /// + [TestClass] + public class CloneGroupValidationTests + { + private MethodInfo _validateMethod; + + [TestInitialize] + public void Setup() + { + _validateMethod = typeof(ProfileManager).GetMethod( + "ValidateCloneGroups", + BindingFlags.NonPublic | BindingFlags.Instance); + + Assert.IsNotNull(_validateMethod, + "ValidateCloneGroups não encontrado em ProfileManager. " + + "Se o método foi renomeado ou extraído, atualize esta classe de teste."); + } + + private bool Validate(List settings) + { + return (bool)_validateMethod.Invoke(ProfileManager.Instance, new object[] { settings }); + } + + // ──────────────────────────────────────────────────────────────────── + // Casos válidos — devem retornar true + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenEmpty_ReturnsTrue() + { + Assert.IsTrue(Validate(new List())); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenOnlyExtendedDisplays_ReturnsTrue() + { + var settings = new List + { + new DisplaySettingBuilder().WithSourceId(0).Build(), + new DisplaySettingBuilder().WithSourceId(1).Build(), + }; + + Assert.IsTrue(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupHasTwoIdenticalMembers_ReturnsTrue() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + }; + + Assert.IsTrue(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupHasThreeIdenticalMembers_ReturnsTrue() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + }; + + Assert.IsTrue(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupHasOneMember_ReturnsTrue() + { + // Um clone group com apenas 1 membro gera warning mas não é erro + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + }; + + Assert.IsTrue(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentDpi_ReturnsTrue() + { + // DPI diferente é apenas warning — não deve bloquear a aplicação do perfil + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithDpi(100).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithDpi(125).Build(), + }; + + Assert.IsTrue(Validate(settings), + "DPI diferente em clone group deve gerar warning, não falha de validação"); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenMixedCloneAndExtended_ReturnsTrue() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithSourceId(1).Build(), // extended + }; + + Assert.IsTrue(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenMultipleValidCloneGroups_ReturnsTrue() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-2").WithSourceId(1).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-2").WithSourceId(1).Build(), + }; + + Assert.IsTrue(Validate(settings)); + } + + // ──────────────────────────────────────────────────────────────────── + // Casos inválidos — devem retornar false + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentWidth_ReturnsFalse() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(1920, 1080).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(2560, 1080).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentHeight_ReturnsFalse() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(1920, 1080).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(1920, 720).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentFrequency_ReturnsFalse() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithFrequency(60).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithFrequency(144).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentPositionX_ReturnsFalse() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithPosition(0, 0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithPosition(1920, 0).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentPositionY_ReturnsFalse() + { + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithPosition(0, 0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithPosition(0, 1080).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMembersHaveDifferentSourceId_ReturnsFalse() + { + // Membros do mesmo clone group devem ter o mesmo SourceId + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(1).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenOneGroupValidAndOneInvalid_ReturnsFalse() + { + var settings = new List + { + // clone-1 válido + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(1920, 1080).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-1").WithSourceId(0).WithResolution(1920, 1080).Build(), + // clone-2 inválido — resoluções diferentes + new DisplaySettingBuilder().WithCloneGroup("clone-2").WithSourceId(1).WithResolution(1920, 1080).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-2").WithSourceId(1).WithResolution(1280, 720).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } + } +} diff --git a/DisplayProfileManager.Tests/Tests/DisplayConfigPathSourceInfoTests.cs b/DisplayProfileManager.Tests/Tests/DisplayConfigPathSourceInfoTests.cs new file mode 100644 index 0000000..2e904fa --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/DisplayConfigPathSourceInfoTests.cs @@ -0,0 +1,197 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DisplayProfileManager.Helpers; + +namespace DisplayProfileManager.Tests.Tests +{ + /// + /// Testes para as propriedades de encoding em . + /// + /// Contexto: modeInfoIdx é um campo de 32 bits com dupla finalidade: + /// - Bits 31-16 (superiores): SourceModeInfoIdx — índice no array de modos (0xFFFF = inválido) + /// - Bits 15-0 (inferiores): CloneGroupId — identificador de grupo de clone + /// + /// Esta codificação é usada apenas em Phase 1 (SDC_TOPOLOGY_SUPPLIED). + /// Em Phase 2 (SDC_USE_SUPPLIED_DISPLAY_CONFIG), modeInfoIdx é um índice simples. + /// + [TestClass] + public class DisplayConfigPathSourceInfoTests + { + // ──────────────────────────────────────────────────────────────────── + // Bug #6 — CloneGroupId getter: (modeInfoIdx << 16) >> 16 + // A expressão funciona mas é obscura; deveria ser modeInfoIdx & 0xFFFF. + // Estes testes são de REGRESSÃO: verificam que o comportamento atual + // está correto e não muda após a refatoração para a forma mais clara. + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void CloneGroupId_Get_ReturnsLower16Bits() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0xABCD_1234 }; + + Assert.AreEqual(0x1234u, src.CloneGroupId); + } + + [TestMethod] + [TestCategory("Regression")] + public void CloneGroupId_Get_WhenUpperBitsAreMaxAndLowerAreZero_ReturnsZero() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0xFFFF_0000 }; + + Assert.AreEqual(0u, src.CloneGroupId); + } + + [TestMethod] + [TestCategory("Regression")] + public void CloneGroupId_Get_WhenOnlyLowerBitsSet_ReturnsThem() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0x0000_0005 }; + + Assert.AreEqual(5u, src.CloneGroupId); + } + + [TestMethod] + [TestCategory("Regression")] + public void CloneGroupId_Set_PreservesExistingSourceModeInfoIdx() + { + // SourceModeInfoIdx=0xFFFF (inválido), CloneGroupId=0 → setar CloneGroupId=3 + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0xFFFF_0000 }; + + src.CloneGroupId = 3; + + Assert.AreEqual(0xFFFF_0003u, src.modeInfoIdx); + } + + [TestMethod] + [TestCategory("Regression")] + public void CloneGroupId_Get_IsEquivalentToMask() + { + // Garante que (x << 16) >> 16 == x & 0xFFFF para valores representativos + uint[] values = { 0x0000_0000, 0x0000_1234, 0xFFFF_0000, 0xABCD_5678, 0xFFFF_FFFF }; + + foreach (uint raw in values) + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = raw }; + + Assert.AreEqual(raw & 0xFFFFu, src.CloneGroupId, + $"modeInfoIdx=0x{raw:X8}"); + } + } + + // ──────────────────────────────────────────────────────────────────── + // SourceModeInfoIdx getter/setter — testes de regressão + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void SourceModeInfoIdx_Get_ReturnsUpper16Bits() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0xABCD_1234 }; + + Assert.AreEqual(0xABCDu, src.SourceModeInfoIdx); + } + + [TestMethod] + [TestCategory("Regression")] + public void SourceModeInfoIdx_Set_StoresPlainValue() + { + // O setter deve armazenar o valor diretamente como índice simples. + // Phase 1 usa ResetModeAndSetCloneGroup() para o encoding; o setter + // não precisa preservar CloneGroupId. + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 0xFFFF_0003 }; + + src.SourceModeInfoIdx = 2; + + Assert.AreEqual(2u, src.modeInfoIdx); + } + + // ──────────────────────────────────────────────────────────────────── + // ResetModeAndSetCloneGroup — testes de regressão + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void ResetModeAndSetCloneGroup_SetsUpperBitsToInvalid() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO(); + src.ResetModeAndSetCloneGroup(2); + + Assert.AreEqual(0xFFFFu, src.SourceModeInfoIdx); + } + + [TestMethod] + [TestCategory("Regression")] + public void ResetModeAndSetCloneGroup_SetsLowerBitsToCloneGroup() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO(); + src.ResetModeAndSetCloneGroup(5); + + Assert.AreEqual(5u, src.CloneGroupId); + } + + [TestMethod] + [TestCategory("Regression")] + public void ResetModeAndSetCloneGroup_ProducesExpectedRawValue() + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO(); + src.ResetModeAndSetCloneGroup(3); + + Assert.AreEqual(0xFFFF_0003u, src.modeInfoIdx); + } + + // ──────────────────────────────────────────────────────────────────── + // Bug #2 — SourceModeInfoIdx setter contamina modeInfoIdx quando usado + // no contexto de Phase 2. + // + // Contexto: após QueryDisplayConfig, paths[i].sourceInfo.modeInfoIdx contém + // um índice simples no array de modos (ex: 3), NÃO o encoding de clone group. + // Phase 2 chama SourceModeInfoIdx = N esperando produzir modeInfoIdx = N, + // mas o setter preserva os bits inferiores do índice de query como se fossem + // CloneGroupId, resultando em (N << 16) | queryIndex em vez de N. + // + // Estes testes são TDD (falham com o código atual) e passarão após o fix. + // O fix consiste em Phase 2 setar modeInfoIdx diretamente, sem usar o setter. + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #2 — Phase 2 deve setar modeInfoIdx como índice simples, " + + "não como encoding de clone group. Com o código atual, o setter " + + "preserva bits inferiores do valor de query, corrompendo o resultado.")] + public void SourceModeInfoIdx_WhenModeInfoIdxComesFromQuery_SetsPlainIndex() + { + // Arrange: simula paths[i].sourceInfo após QueryDisplayConfig retornar + // modeInfoIdx=3 (índice simples no array de modos, sem encoding de clone group) + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = 3 }; + + // Act: Phase 2 atribui o índice do source mode encontrado + src.SourceModeInfoIdx = 5; + + // Assert: para SDC_USE_SUPPLIED_DISPLAY_CONFIG, modeInfoIdx deve ser o índice puro + // FALHA atualmente: produz (5 << 16) | 3 = 0x0005_0003 em vez de 5 + Assert.AreEqual(5u, src.modeInfoIdx, + $"Esperado modeInfoIdx=5, obtido 0x{src.modeInfoIdx:X8}. " + + "O setter preservou os bits inferiores do índice de query (3) como CloneGroupId."); + } + + [TestMethod] + [TestCategory("TDD")] + [Description("Bug #2 (complementar) — qualquer índice de query não-zero nos bits " + + "inferiores contamina o resultado do setter.")] + public void SourceModeInfoIdx_WhenQueryIndexIsNonZero_DoesNotContaminateResult() + { + uint[] queryIndices = { 1, 2, 3, 4, 7, 15 }; + + foreach (uint queryIdx in queryIndices) + { + var src = new DisplayConfigHelper.DISPLAYCONFIG_PATH_SOURCE_INFO { modeInfoIdx = queryIdx }; + + src.SourceModeInfoIdx = 0; + + // FALHA atualmente: (0 << 16) | queryIdx = queryIdx em vez de 0 + Assert.AreEqual(0u, src.modeInfoIdx, + $"queryIdx={queryIdx}: esperado modeInfoIdx=0, obtido 0x{src.modeInfoIdx:X8}"); + } + } + } +} diff --git a/DisplayProfileManager.Tests/Tests/DisplaySettingTests.cs b/DisplayProfileManager.Tests/Tests/DisplaySettingTests.cs new file mode 100644 index 0000000..e79eeef --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/DisplaySettingTests.cs @@ -0,0 +1,56 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using DisplayProfileManager.Core; +using DisplayProfileManager.Tests.Helpers; + +namespace DisplayProfileManager.Tests.Tests +{ + /// + /// Testes para . + /// + [TestClass] + public class DisplaySettingTests + { + // ──────────────────────────────────────────────────────────────────── + // IsPartOfCloneGroup — testes de regressão + // ──────────────────────────────────────────────────────────────────── + + [TestMethod] + [TestCategory("Regression")] + public void IsPartOfCloneGroup_WhenCloneGroupIdIsEmpty_ReturnsFalse() + { + var setting = new DisplaySettingBuilder().Build(); // CloneGroupId = string.Empty por padrão + + Assert.IsFalse(setting.IsPartOfCloneGroup()); + } + + [TestMethod] + [TestCategory("Regression")] + public void IsPartOfCloneGroup_WhenCloneGroupIdIsNull_ReturnsFalse() + { + var setting = new DisplaySettingBuilder().WithCloneGroup(null).Build(); + + Assert.IsFalse(setting.IsPartOfCloneGroup()); + } + + [TestMethod] + [TestCategory("Regression")] + public void IsPartOfCloneGroup_WhenCloneGroupIdIsSet_ReturnsTrue() + { + var setting = new DisplaySettingBuilder().WithCloneGroup("clone-group-1").Build(); + + Assert.IsTrue(setting.IsPartOfCloneGroup()); + } + + [TestMethod] + [TestCategory("Regression")] + public void IsPartOfCloneGroup_DefaultConstruction_ReturnsFalse() + { + // Backward compatibility: perfis antigos (sem CloneGroupId) devem funcionar + // como modo estendido sem nenhuma modificação + var setting = new DisplaySetting(); + + Assert.IsFalse(setting.IsPartOfCloneGroup(), + "DisplaySetting sem CloneGroupId deve ser tratado como modo estendido"); + } + } +} diff --git a/DisplayProfileManager.Tests/packages.config b/DisplayProfileManager.Tests/packages.config new file mode 100644 index 0000000..38b63d5 --- /dev/null +++ b/DisplayProfileManager.Tests/packages.config @@ -0,0 +1,6 @@ + + + + + + diff --git a/DisplayProfileManager.sln b/DisplayProfileManager.sln index 61efa03..6af0dd1 100644 --- a/DisplayProfileManager.sln +++ b/DisplayProfileManager.sln @@ -5,6 +5,8 @@ VisualStudioVersion = 17.14.36221.1 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DisplayProfileManager", "DisplayProfileManager.csproj", "{238D468E-8218-4643-AA8C-789C6A15A48B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DisplayProfileManager.Tests", "DisplayProfileManager.Tests\DisplayProfileManager.Tests.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -17,6 +19,10 @@ Global Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU {238D468E-8218-4643-AA8C-789C6A15A48B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {238D468E-8218-4643-AA8C-789C6A15A48B}.Debug|Any CPU.Build.0 = Debug|Any CPU {238D468E-8218-4643-AA8C-789C6A15A48B}.Debug|ARM64.ActiveCfg = Debug|ARM64 diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 4207b54..969601d 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -186,7 +186,7 @@ public uint CloneGroupId public uint SourceModeInfoIdx { get => modeInfoIdx >> 16; - set => modeInfoIdx = (value << 16) | CloneGroupId; + set => modeInfoIdx = value; } /// From 401493275ef7d5dae706c46566dcfef5d8a571ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 14:49:25 -0300 Subject: [PATCH 3/8] fix: consume one source mode per SourceId group when applying display topology Clone group members share the same SourceId, and Windows creates exactly one source mode per unique SourceId in the display config. The previous loop consumed one source mode per enabled display, causing out-of-bounds skips for the second clone member. Now displays are grouped by SourceId before the loop. One source mode is consumed per group; all paths in the group point to the same source mode index. Target modes (refresh rate) are still applied per physical display. Co-Authored-By: Claude Sonnet 4.6 --- src/Helpers/DisplayConfigHelper.cs | 70 +++++++++++++++++------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 969601d..7182e89 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -865,51 +865,59 @@ public static bool ApplyDisplayTopology(List displayConfigs) // Modify mode array for resolution, refresh rate, and position logger.Debug("Configuring mode array (resolution, refresh rate, position)..."); - + // Track which source modes we've used per adapter var adapterSourceModeUsage = new Dictionary(); - + + // Group enabled displays by SourceId — clone group members share one source mode. + // Windows creates exactly one source mode per unique SourceId after Phase 1, so we + // must consume one source mode per SourceId group, not one per enabled display. + var displaysBySourceId = new Dictionary>(); foreach (var displayInfo in displayConfigs.Where(d => d.IsEnabled)) { - if (!targetIdToPathIndex.TryGetValue(displayInfo.TargetId, out int pathIndex)) + if (!targetIdToPathIndex.TryGetValue(displayInfo.TargetId, out int gPathIndex)) continue; + if (!displaysBySourceId.ContainsKey(displayInfo.SourceId)) + displaysBySourceId[displayInfo.SourceId] = new List<(int, DisplayConfigInfo)>(); + displaysBySourceId[displayInfo.SourceId].Add((gPathIndex, displayInfo)); + } + + foreach (var kvp in displaysBySourceId) + { + uint sourceId = kvp.Key; + var groupDisplays = kvp.Value; + LUID adapterId = paths[groupDisplays[0].pathIndex].sourceInfo.adapterId; - // Find a source mode for this display's adapter - LUID adapterId = paths[pathIndex].sourceInfo.adapterId; - if (!adapterSourceModeUsage.ContainsKey(adapterId)) - { adapterSourceModeUsage[adapterId] = 0; - } - - if (!adapterToSourceModes.ContainsKey(adapterId) || + + if (!adapterToSourceModes.ContainsKey(adapterId) || adapterSourceModeUsage[adapterId] >= adapterToSourceModes[adapterId].Count) { - logger.Warn($"TargetId {displayInfo.TargetId}: No available source mode for adapter"); + logger.Warn($"SourceId {sourceId}: No available source mode for adapter"); continue; } - - // Get the next available source mode for this adapter + + // Consume ONE source mode for this SourceId (clone members share the same source mode) int sourceModeIdx = adapterToSourceModes[adapterId][adapterSourceModeUsage[adapterId]++]; - - // Update the path to point to this source mode - paths[pathIndex].sourceInfo.SourceModeInfoIdx = (uint)sourceModeIdx; - - // Ensure mode's adapterId matches the path's adapterId - modes[sourceModeIdx].adapterId = paths[pathIndex].sourceInfo.adapterId; - - // Set resolution and position - modes[sourceModeIdx].modeInfo.sourceMode.width = (uint)displayInfo.Width; - modes[sourceModeIdx].modeInfo.sourceMode.height = (uint)displayInfo.Height; - modes[sourceModeIdx].modeInfo.sourceMode.position.x = displayInfo.DisplayPositionX; - modes[sourceModeIdx].modeInfo.sourceMode.position.y = displayInfo.DisplayPositionY; - logger.Debug($"TargetId {displayInfo.TargetId}: Set source mode {sourceModeIdx} to {displayInfo.Width}x{displayInfo.Height} at ({displayInfo.DisplayPositionX},{displayInfo.DisplayPositionY})"); - - // Target mode: refresh rate - uint targetModeIdx = paths[pathIndex].targetInfo.modeInfoIdx; - if (targetModeIdx != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && targetModeIdx < modes.Length) + var primary = groupDisplays[0].info; // Clone members share resolution/position + + modes[sourceModeIdx].adapterId = adapterId; + modes[sourceModeIdx].modeInfo.sourceMode.width = (uint)primary.Width; + modes[sourceModeIdx].modeInfo.sourceMode.height = (uint)primary.Height; + modes[sourceModeIdx].modeInfo.sourceMode.position.x = primary.DisplayPositionX; + modes[sourceModeIdx].modeInfo.sourceMode.position.y = primary.DisplayPositionY; + logger.Debug($"SourceId {sourceId}: Set source mode {sourceModeIdx} to {primary.Width}x{primary.Height} at ({primary.DisplayPositionX},{primary.DisplayPositionY}) [{groupDisplays.Count} display(s)]"); + + foreach (var (pathIndex, displayInfo) in groupDisplays) { - if (modes[targetModeIdx].infoType == DisplayConfigModeInfoType.DISPLAYCONFIG_MODE_INFO_TYPE_TARGET) + // Point all paths in this clone group to the same source mode index + paths[pathIndex].sourceInfo.SourceModeInfoIdx = (uint)sourceModeIdx; + + // Target mode: refresh rate (each physical display has its own target mode) + uint targetModeIdx = paths[pathIndex].targetInfo.modeInfoIdx; + if (targetModeIdx != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && targetModeIdx < modes.Length && + modes[targetModeIdx].infoType == DisplayConfigModeInfoType.DISPLAYCONFIG_MODE_INFO_TYPE_TARGET) { uint numerator = (uint)(displayInfo.RefreshRate * 1000); uint denominator = 1000; From f4ad53e768ae2c315c79178d5d7c8037be83a48b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 14:52:25 -0300 Subject: [PATCH 4/8] fix: remove duplicate disable loop and dead code ApplyDisplayPosition ApplyDisplayTopology had two loops that disabled displays not present in the profile, both producing identical results. The second loop was redundant and has been removed. ApplyDisplayPosition was never called by the main application flow and has been removed entirely. Co-Authored-By: Claude Sonnet 4.6 --- .../Tests/CloneGroupTopologyTests.cs | 14 +- src/Helpers/DisplayConfigHelper.cs | 199 +----------------- 2 files changed, 5 insertions(+), 208 deletions(-) diff --git a/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs b/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs index cc3d5a0..41b2d83 100644 --- a/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs +++ b/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs @@ -170,21 +170,15 @@ public void DisableNonProfileDisplays_CountMatchesDisplaysOutsideProfile() // ──────────────────────────────────────────────────────────────────── [TestMethod] - [TestCategory("TDD")] - [Description("Bug #5 — ApplyDisplayPosition não é chamado pelo fluxo principal. " + - "Este teste verifica que o método existe atualmente (dead code). " + - "Após o fix (remoção), trocar para Assert.IsNull.")] - public void ApplyDisplayPosition_CurrentlyExistsAsDeadCode() + [TestCategory("Regression")] + [Description("Bug #5 — ApplyDisplayPosition era dead code (não chamado pelo fluxo principal) e foi removido.")] + public void ApplyDisplayPosition_WasRemovedAsDeadCode() { var method = typeof(DisplayConfigHelper).GetMethod( "ApplyDisplayPosition", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static); - // PASSA agora (método existe = dead code confirmado). - // Após remoção: este Assert deve ser trocado por Assert.IsNull. - Assert.IsNotNull(method, - "ApplyDisplayPosition existe mas não é chamado por ApplyUnifiedConfiguration. " + - "É dead code. Remover como parte do fix do Bug #5 e atualizar este teste."); + Assert.IsNull(method, "ApplyDisplayPosition deve ter sido removido por ser dead code."); } [TestMethod] diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 7182e89..5dfcb8c 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -999,29 +999,7 @@ public static bool ApplyDisplayTopology(List displayConfigs) } } - // Disable displays not in profile - var disabledTargetIds = new HashSet(); - for (int i = 0; i < paths.Length; i++) - { - if (!paths[i].targetInfo.targetAvailable) - continue; - - uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; - bool isInProfile = displayConfigs.Any(d => d.TargetId == baseTargetId); - - if (!isInProfile) - { - paths[i].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - paths[i].sourceInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; - - if (disabledTargetIds.Add(baseTargetId)) - { - logger.Debug($"Disabled TargetId {baseTargetId} (not in profile)"); - } - } - } - - // Source IDs and clone groups were already set correctly in Phase 1 + // Source IDs and clone groups were already set correctly in EnableDisplays // Phase 2 should NOT modify them - just count active paths for logging int activeCount = 0; for (int i = 0; i < paths.Length; i++) @@ -1073,181 +1051,6 @@ public static bool ApplyDisplayTopology(List displayConfigs) } } - /// - /// Standard topology application for non-clone configurations. - /// - public static bool ApplyDisplayPosition(List displayConfigs) - { - try - { - uint pathCount = 0; - uint modeCount = 0; - - // Get current configuration - int result = GetDisplayConfigBufferSizes( - QueryDisplayConfigFlags.QDC_ALL_PATHS, - out pathCount, - out modeCount); - - if (result != ERROR_SUCCESS) - { - logger.Error($"GetDisplayConfigBufferSizes failed with error: {result}"); - return false; - } - - var paths = new DISPLAYCONFIG_PATH_INFO[pathCount]; - var modes = new DISPLAYCONFIG_MODE_INFO[modeCount]; - - result = QueryDisplayConfig( - QueryDisplayConfigFlags.QDC_ALL_PATHS, - ref pathCount, - paths, - ref modeCount, - modes, - IntPtr.Zero); - - if (result != ERROR_SUCCESS) - { - logger.Error($"QueryDisplayConfig failed with error: {result}"); - return false; - } - - // Validate and correct clone group positions - var cloneGroups = displayConfigs - .GroupBy(dc => dc.SourceId) - .Where(g => g.Count() > 1); - - foreach (var group in cloneGroups) - { - var positions = group - .Select(dc => new { dc.DisplayPositionX, dc.DisplayPositionY }) - .Distinct() - .ToList(); - - if (positions.Count > 1) - { - logger.Warn($"Clone group with Source {group.Key} has inconsistent positions - " + - $"forcing all to same position"); - var first = group.First(); - foreach (var dc in group.Skip(1)) - { - dc.DisplayPositionX = first.DisplayPositionX; - dc.DisplayPositionY = first.DisplayPositionY; - } - } - } - - // Set monitor position based on displayConfigs - foreach (var displayInfo in displayConfigs) - { - var foundPathIndex = Array.FindIndex(paths, - x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); - - if (foundPathIndex < 0) - { - logger.Debug($"Could not find path for TargetId {displayInfo.TargetId} with SourceId {displayInfo.SourceId}"); - continue; - } - - if (!paths[foundPathIndex].targetInfo.targetAvailable) - continue; - - // Set monitor position - var modeInfoIndex = paths[foundPathIndex].sourceInfo.modeInfoIdx; - - if (modeInfoIndex != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && modeInfoIndex < modes.Length) - { - modes[modeInfoIndex].modeInfo.sourceMode.position.x = displayInfo.DisplayPositionX; - modes[modeInfoIndex].modeInfo.sourceMode.position.y = displayInfo.DisplayPositionY; - } - else - { - logger.Debug($"Invalid or out of bounds mode index {modeInfoIndex} for TargetId {displayInfo.TargetId} (modes.Length={modes.Length})"); - } - } - - // Find the rightmost edge of monitors in the profile - int currentRightEdge = 0; - if (displayConfigs.Count > 0) - { - currentRightEdge = displayConfigs.Max(d => d.DisplayPositionX + d.Width); - } - - // Move any connected monitors that are not in the profile to the right of the rightmost monitor in the profile - for (int i = 0; i < paths.Length; i++) - { - var path = paths[i]; - - // Check if this monitor is connected/available - if (!path.targetInfo.targetAvailable) - continue; - - if(path.targetInfo.scanLineOrdering == 0) - continue; - - // Check if this path exists in the displayConfigs list - bool foundInProfile = displayConfigs.Any(d => - d.TargetId == path.targetInfo.id && - d.SourceId == path.sourceInfo.id); - - if (!foundInProfile) - { - // Set monitor position - var modeInfoIndex = paths[i].sourceInfo.modeInfoIdx; - - if (modeInfoIndex != DISPLAYCONFIG_PATH_MODE_IDX_INVALID && modeInfoIndex < modes.Length) - { - // Position this monitor at the current right edge - modes[modeInfoIndex].modeInfo.sourceMode.position.x = currentRightEdge; - modes[modeInfoIndex].modeInfo.sourceMode.position.y = 0; - - // Update the right edge for the next monitor - int monitorWidth = (int)modes[modeInfoIndex].modeInfo.sourceMode.width; - currentRightEdge += monitorWidth; - } - } - } - - - // Apply the monitor position - result = SetDisplayConfig( - pathCount, - paths, - modeCount, - modes, - SetDisplayConfigFlags.SDC_APPLY | - SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); - - if (result != ERROR_SUCCESS) - { - logger.Error($"Applying display position failed with error: {result}"); - - // Try to provide more specific error information - switch (result) - { - case ERROR_INVALID_PARAMETER: - logger.Error("Invalid parameter - configuration may be invalid"); - break; - case ERROR_GEN_FAILURE: - logger.Error("General failure - display configuration may not be supported"); - break; - default: - logger.Error($"Unknown error code: {result}"); - break; - } - return false; - } - - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error applying display position"); - return false; - } - } /// /// Validates that the primary display is correctly configured. From 8849f686573d5dfc378a51fa12364682fd04b4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 14:53:32 -0300 Subject: [PATCH 5/8] refactor: simplify CloneGroupId getter to use bitmask Replace (modeInfoIdx << 16) >> 16 with the clearer modeInfoIdx & 0xFFFF. Behavior is identical; the bitmask form makes the intent explicit. Co-Authored-By: Claude Sonnet 4.6 --- src/Helpers/DisplayConfigHelper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 5dfcb8c..3a38f71 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -175,7 +175,7 @@ public struct DISPLAYCONFIG_PATH_SOURCE_INFO /// public uint CloneGroupId { - get => (modeInfoIdx << 16) >> 16; + get => modeInfoIdx & 0xFFFF; set => modeInfoIdx = (SourceModeInfoIdx << 16) | value; } From c2d93dffc546aa38185652252d320230859f9343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 17:22:02 -0300 Subject: [PATCH 6/8] feat: add clone group UI to profile editor with improved spacing and icons Co-Authored-By: Claude Sonnet 4.6 --- dev-run.ps1 | 21 ++ src/App.xaml.cs | 10 +- src/UI/Windows/ProfileEditWindow.xaml | 4 +- src/UI/Windows/ProfileEditWindow.xaml.cs | 408 +++++++++++++++++++++-- 4 files changed, 408 insertions(+), 35 deletions(-) create mode 100644 dev-run.ps1 diff --git a/dev-run.ps1 b/dev-run.ps1 new file mode 100644 index 0000000..b4a853c --- /dev/null +++ b/dev-run.ps1 @@ -0,0 +1,21 @@ +# dev-run.ps1 — Kill previous dev instance, build Debug, launch with --dev +$msbuild = "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\MSBuild\Current\Bin\MSBuild.exe" +$sln = "$PSScriptRoot\DisplayProfileManager.sln" +$exe = "$PSScriptRoot\bin\Debug\DisplayProfileManager.exe" + +# Kill any dev instance (--dev arg) without touching the installed version +Get-Process -Name "DisplayProfileManager" -ErrorAction SilentlyContinue | ForEach-Object { + $cmdline = (Get-WmiObject Win32_Process -Filter "ProcessId=$($_.Id)").CommandLine + if ($cmdline -like "*--dev*") { + Write-Host "Killing previous dev instance (PID $($_.Id))..." -ForegroundColor Yellow + $_ | Stop-Process -Force + Start-Sleep -Milliseconds 500 + } +} + +Write-Host "Building Debug..." -ForegroundColor Cyan +& $msbuild $sln /p:Configuration=Debug /v:minimal +if ($LASTEXITCODE -ne 0) { Write-Host "Build failed." -ForegroundColor Red; exit 1 } + +Write-Host "Launching dev instance..." -ForegroundColor Cyan +Start-Process -FilePath $exe -ArgumentList "--dev" diff --git a/src/App.xaml.cs b/src/App.xaml.cs index 35d2662..9b335ce 100644 --- a/src/App.xaml.cs +++ b/src/App.xaml.cs @@ -70,19 +70,21 @@ protected override async void OnStartup(StartupEventArgs e) // Parse command line arguments bool startInTray = false; + bool devMode = false; if (e.Args != null && e.Args.Length > 0) { foreach (var arg in e.Args) { if (arg.Equals("--tray", StringComparison.OrdinalIgnoreCase)) - { startInTray = true; - break; - } + else if (arg.Equals("--dev", StringComparison.OrdinalIgnoreCase)) + devMode = true; } } - if (!CheckSingleInstance()) + if (devMode) + logger.Info("DEV MODE: single-instance check bypassed"); + else if (!CheckSingleInstance()) { Shutdown(); return; diff --git a/src/UI/Windows/ProfileEditWindow.xaml b/src/UI/Windows/ProfileEditWindow.xaml index 7ed98e9..bab50cb 100644 --- a/src/UI/Windows/ProfileEditWindow.xaml +++ b/src/UI/Windows/ProfileEditWindow.xaml @@ -253,8 +253,8 @@ - diff --git a/src/UI/Windows/ProfileEditWindow.xaml.cs b/src/UI/Windows/ProfileEditWindow.xaml.cs index 6fb99a6..a68c4ad 100644 --- a/src/UI/Windows/ProfileEditWindow.xaml.cs +++ b/src/UI/Windows/ProfileEditWindow.xaml.cs @@ -5,8 +5,11 @@ using System.Collections.ObjectModel; using System.Diagnostics; using System.Linq; +using System.Text.RegularExpressions; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Input; using System.Windows.Media; using System.Windows.Shell; using NLog; @@ -183,10 +186,17 @@ private void AddDisplaySettingControl(DisplaySetting setting, int monitorIndex = } var control = new DisplaySettingControl(setting, monitorIndex, isCloneGroup, cloneGroupMembers); + control.OnCloneGroupChanged = RebuildDisplayControls; _displayControls.Add(control); DisplaySettingsPanel.Children.Add(control); } + private void RebuildDisplayControls() + { + var allSettings = _displayControls.SelectMany(c => c.GetDisplaySettings()).ToList(); + LoadDisplaySettings(allSettings); + } + private async void IdentifyDisplaysButton_Click(object sender, RoutedEventArgs e) { try @@ -770,6 +780,126 @@ public class DisplaySettingControl : UserControl private DisplaySetting _setting; private int _monitorIndex; private TextBox _deviceTextBox; + + // Builds a secondary button style from theme brushes (Application resources). + // Cannot use Window.Resources["SecondaryButtonStyle"] here because the control + // may not be in the visual tree yet when InitializeControl() runs. + private static Style BuildSecondaryButtonStyle() + { + var bg = (Brush)Application.Current.Resources["SecondaryButtonBackgroundBrush"]; + var fg = (Brush)Application.Current.Resources["SecondaryButtonForegroundBrush"]; + var hoverBg = (Brush)Application.Current.Resources["SecondaryButtonHoverBackgroundBrush"]; + var pressedBg = (Brush)Application.Current.Resources["SecondaryButtonPressedBackgroundBrush"]; + + var borderFactory = new FrameworkElementFactory(typeof(Border)); + borderFactory.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); + borderFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(4)); + borderFactory.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); + borderFactory.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); + + var contentFactory = new FrameworkElementFactory(typeof(ContentPresenter)); + contentFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + contentFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + borderFactory.AppendChild(contentFactory); + + var template = new ControlTemplate(typeof(Button)) { VisualTree = borderFactory }; + + var hoverTrigger = new Trigger { Property = Button.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Button.BackgroundProperty, hoverBg)); + template.Triggers.Add(hoverTrigger); + + var pressedTrigger = new Trigger { Property = Button.IsPressedProperty, Value = true }; + pressedTrigger.Setters.Add(new Setter(Button.BackgroundProperty, pressedBg)); + template.Triggers.Add(pressedTrigger); + + var style = new Style(typeof(Button)); + style.Setters.Add(new Setter(Button.TemplateProperty, template)); + style.Setters.Add(new Setter(Button.BackgroundProperty, bg)); + style.Setters.Add(new Setter(Button.ForegroundProperty, fg)); + style.Setters.Add(new Setter(Button.BorderThicknessProperty, new Thickness(0))); + style.Setters.Add(new Setter(Button.PaddingProperty, new Thickness(8, 6, 8, 6))); + style.Setters.Add(new Setter(Button.FontSizeProperty, 14.0)); + style.Setters.Add(new Setter(Button.FontWeightProperty, FontWeights.Medium)); + style.Setters.Add(new Setter(Button.CursorProperty, Cursors.Hand)); + return style; + } + + private static Style BuildPrimaryButtonStyle() + { + var bg = (Brush)Application.Current.Resources["ButtonBackgroundBrush"]; + var fg = (Brush)Application.Current.Resources["ButtonForegroundBrush"]; + var hoverBg = (Brush)Application.Current.Resources["ButtonHoverBackgroundBrush"]; + var pressedBg = (Brush)Application.Current.Resources["ButtonPressedBackgroundBrush"]; + + var borderFactory = new FrameworkElementFactory(typeof(Border)); + borderFactory.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); + borderFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(4)); + borderFactory.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); + borderFactory.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); + + var contentFactory = new FrameworkElementFactory(typeof(ContentPresenter)); + contentFactory.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + contentFactory.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + borderFactory.AppendChild(contentFactory); + + var template = new ControlTemplate(typeof(Button)) { VisualTree = borderFactory }; + + var hoverTrigger = new Trigger { Property = Button.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Button.BackgroundProperty, hoverBg)); + template.Triggers.Add(hoverTrigger); + + var pressedTrigger = new Trigger { Property = Button.IsPressedProperty, Value = true }; + pressedTrigger.Setters.Add(new Setter(Button.BackgroundProperty, pressedBg)); + template.Triggers.Add(pressedTrigger); + + var style = new Style(typeof(Button)); + style.Setters.Add(new Setter(Button.TemplateProperty, template)); + style.Setters.Add(new Setter(Button.BackgroundProperty, bg)); + style.Setters.Add(new Setter(Button.ForegroundProperty, fg)); + style.Setters.Add(new Setter(Button.BorderThicknessProperty, new Thickness(0))); + style.Setters.Add(new Setter(Button.PaddingProperty, new Thickness(10, 6, 10, 6))); + style.Setters.Add(new Setter(Button.FontSizeProperty, 13.0)); + style.Setters.Add(new Setter(Button.FontWeightProperty, FontWeights.Medium)); + style.Setters.Add(new Setter(Button.CursorProperty, Cursors.Hand)); + return style; + } + + private static Style BuildDropdownButtonStyle() + { + var bg = (Brush)Application.Current.Resources["ComboBoxBackgroundBrush"]; + var fg = (Brush)Application.Current.Resources["PrimaryTextBrush"]; + var hoverBg = (Brush)Application.Current.Resources["ComboBoxHoverBackgroundBrush"]; + var border = (Brush)Application.Current.Resources["ComboBoxBorderBrush"]; + + var borderFactory = new FrameworkElementFactory(typeof(Border)); + borderFactory.SetValue(Border.BackgroundProperty, new TemplateBindingExtension(Button.BackgroundProperty)); + borderFactory.SetValue(Border.BorderBrushProperty, new TemplateBindingExtension(Button.BorderBrushProperty)); + borderFactory.SetValue(Border.BorderThicknessProperty, new TemplateBindingExtension(Button.BorderThicknessProperty)); + borderFactory.SetValue(Border.CornerRadiusProperty, new CornerRadius(4)); + borderFactory.SetValue(Border.PaddingProperty, new TemplateBindingExtension(Button.PaddingProperty)); + + var content = new FrameworkElementFactory(typeof(ContentPresenter)); + content.SetValue(ContentPresenter.HorizontalAlignmentProperty, HorizontalAlignment.Center); + content.SetValue(ContentPresenter.VerticalAlignmentProperty, VerticalAlignment.Center); + borderFactory.AppendChild(content); + + var template = new ControlTemplate(typeof(Button)) { VisualTree = borderFactory }; + var hoverTrigger = new Trigger { Property = Button.IsMouseOverProperty, Value = true }; + hoverTrigger.Setters.Add(new Setter(Button.BackgroundProperty, hoverBg)); + template.Triggers.Add(hoverTrigger); + + var style = new Style(typeof(Button)); + style.Setters.Add(new Setter(Button.TemplateProperty, template)); + style.Setters.Add(new Setter(Button.BackgroundProperty, bg)); + style.Setters.Add(new Setter(Button.ForegroundProperty, fg)); + style.Setters.Add(new Setter(Button.BorderBrushProperty, border)); + style.Setters.Add(new Setter(Button.BorderThicknessProperty, new Thickness(1))); + style.Setters.Add(new Setter(Button.PaddingProperty, new Thickness(8, 6, 8, 6))); + style.Setters.Add(new Setter(Button.FontSizeProperty, 14.0)); + style.Setters.Add(new Setter(Button.CursorProperty, Cursors.Hand)); + return style; + } + private ComboBox _resolutionComboBox; private ComboBox _refreshRateComboBox; private ComboBox _dpiComboBox; @@ -798,56 +928,164 @@ public DisplaySettingControl(DisplaySetting setting, int monitorIndex = 1, private void InitializeControl() { - var mainPanel = new StackPanel { Margin = new Thickness(0, 0, 0, 16) }; + var mainPanel = new StackPanel { Margin = new Thickness(0, 0, 0, 28) }; - var headerGrid = new Grid(); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); - headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + var primaryFg = (Brush)Application.Current.Resources["PrimaryTextBrush"]; + var secondaryFg = (Brush)Application.Current.Resources["SecondaryTextBrush"]; - string headerTextString = $"Monitor {_monitorIndex}"; - - var headerText = new TextBlock + // ── Row 1: name area ───────────────────────────────────────────── + FrameworkElement nameRow; + if (_isCloneGroup && _cloneGroupMembers.Count > 1) { - Text = headerTextString, - FontWeight = FontWeights.Medium, - FontSize = 14, - Margin = new Thickness(0, 0, 0, 8), - Foreground = (Brush)Application.Current.Resources["PrimaryTextBrush"] + // Clone group: 🔗 icon (large) + stacked member names + CLONE badge + var nameGrid = new Grid { Margin = new Thickness(0, 0, 0, 16) }; + nameGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // icon + nameGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // names + nameGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // badge + + var icon = new TextBlock + { + Text = "\uE71B", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 18, + Foreground = new SolidColorBrush(Color.FromRgb(0x4F, 0xC3, 0xF7)), + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 0, 10, 0) + }; + Grid.SetColumn(icon, 0); + nameGrid.Children.Add(icon); + + var namesPanel = new StackPanel { VerticalAlignment = VerticalAlignment.Center }; + foreach (var member in _cloneGroupMembers) + { + namesPanel.Children.Add(new TextBlock + { + Text = member.ReadableDeviceName, + FontWeight = FontWeights.Medium, + FontSize = 14, + Foreground = primaryFg, + Margin = new Thickness(0, 2, 0, 0), + TextTrimming = TextTrimming.CharacterEllipsis + }); + } + Grid.SetColumn(namesPanel, 1); + nameGrid.Children.Add(namesPanel); + + var breakBtnContent = new StackPanel { Orientation = Orientation.Horizontal }; + breakBtnContent.Children.Add(new TextBlock + { + Text = "\uE8E6", + FontFamily = new FontFamily("Segoe MDL2 Assets"), + FontSize = 13, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(0, 1, 7, 0) + }); + breakBtnContent.Children.Add(new TextBlock + { + Text = "Break Clone", + VerticalAlignment = VerticalAlignment.Center + }); + var breakBtn = new Button + { + Content = breakBtnContent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(12, 0, 0, 0), + Style = BuildPrimaryButtonStyle() + }; + breakBtn.Click += (s, e) => ExecuteBreakClone(); + Grid.SetColumn(breakBtn, 2); + nameGrid.Children.Add(breakBtn); + + nameRow = nameGrid; + } + else + { + // Single display: name left, clone dropdown right + var singleGrid = new Grid { Margin = new Thickness(0, 0, 0, 16) }; + singleGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + singleGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var nameBlock = new TextBlock + { + Text = $"Monitor {_monitorIndex} — {_setting.ReadableDeviceName}", + FontWeight = FontWeights.Medium, + FontSize = 14, + Foreground = primaryFg, + VerticalAlignment = VerticalAlignment.Center, + TextTrimming = TextTrimming.CharacterEllipsis + }; + Grid.SetColumn(nameBlock, 0); + singleGrid.Children.Add(nameBlock); + + var cloneBtnContent = new StackPanel { Orientation = Orientation.Horizontal }; + cloneBtnContent.Children.Add(new TextBlock + { + Text = "Clone", + VerticalAlignment = VerticalAlignment.Center + }); + cloneBtnContent.Children.Add(new TextBlock + { + Text = "\u25BC", + FontSize = 9, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(10, 2, 0, 0) + }); + + var cloneBtn = new Button + { + Content = cloneBtnContent, + VerticalAlignment = VerticalAlignment.Center, + Margin = new Thickness(12, 0, 0, 0), + Style = BuildDropdownButtonStyle() + }; + cloneBtn.Click += CloneButton_Click; + Grid.SetColumn(cloneBtn, 1); + singleGrid.Children.Add(cloneBtn); + + nameRow = singleGrid; + } + mainPanel.Children.Add(nameRow); + + // ── Row 2: controls (checkboxes left, action button right) ──────── + var controlsGrid = new Grid { Margin = new Thickness(0, 0, 0, 16) }; + controlsGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); + controlsGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); + + var checkboxPanel = new StackPanel + { + Orientation = Orientation.Horizontal, + VerticalAlignment = VerticalAlignment.Center }; - Grid.SetColumn(headerText, 0); - headerGrid.Children.Add(headerText); - // Add Enable/Disable checkbox _enabledCheckBox = new CheckBox { Content = "Enabled", IsChecked = _setting.IsEnabled, FontSize = 14, - Margin = new Thickness(0, 0, 16, 8), + Margin = new Thickness(0, 0, 20, 0), VerticalAlignment = VerticalAlignment.Center, - Foreground = (Brush)Application.Current.Resources["PrimaryTextBrush"] + Foreground = primaryFg }; _enabledCheckBox.Checked += EnabledCheckBox_CheckedChanged; _enabledCheckBox.Unchecked += EnabledCheckBox_CheckedChanged; - Grid.SetColumn(_enabledCheckBox, 1); - headerGrid.Children.Add(_enabledCheckBox); + checkboxPanel.Children.Add(_enabledCheckBox); _primaryCheckBox = new CheckBox { Content = "Primary Display", IsChecked = _setting.IsPrimary, FontSize = 14, - Foreground = (Brush)Application.Current.Resources["PrimaryTextBrush"], - Margin = new Thickness(16, 0, 0, 0) + VerticalAlignment = VerticalAlignment.Center, + Foreground = primaryFg }; _primaryCheckBox.Checked += PrimaryCheckBox_Checked; _primaryCheckBox.Unchecked += PrimaryCheckBox_Unchecked; - Grid.SetColumn(_primaryCheckBox, 2); - headerGrid.Children.Add(_primaryCheckBox); + checkboxPanel.Children.Add(_primaryCheckBox); + + Grid.SetColumn(checkboxPanel, 0); + controlsGrid.Children.Add(checkboxPanel); - mainPanel.Children.Add(headerGrid); + mainPanel.Children.Add(controlsGrid); var contentGrid = new Grid(); contentGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1.5, GridUnitType.Star) }); // Monitor column - wider @@ -856,11 +1094,11 @@ private void InitializeControl() contentGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(16) }); contentGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // Refresh rate column contentGrid.RowDefinitions.Add(new RowDefinition()); - contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(14) }); contentGrid.RowDefinitions.Add(new RowDefinition()); - contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(14) }); contentGrid.RowDefinitions.Add(new RowDefinition()); - contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(8) }); + contentGrid.RowDefinitions.Add(new RowDefinition { Height = new GridLength(14) }); contentGrid.RowDefinitions.Add(new RowDefinition()); var devicePanel = new StackPanel(); @@ -1473,6 +1711,118 @@ private void PrimaryCheckBox_Unchecked(object sender, RoutedEventArgs e) _setting.IsPrimary = false; } + // Callback fired after any clone group change so the parent window can rebuild controls + public Action OnCloneGroupChanged; + + private void CloneButton_Click(object sender, RoutedEventArgs e) + { + var button = (Button)sender; + var panel = Parent as Panel; + if (panel == null) return; + + var available = panel.Children + .OfType() + .Where(c => c != this && !c._isCloneGroup) + .ToList(); + + if (!available.Any()) + { + MessageBox.Show("No other displays available to clone with.", "Clone Display", + MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var bg = (Brush)Application.Current.Resources["ContentBackgroundBrush"]; + var fg = (Brush)Application.Current.Resources["PrimaryTextBrush"]; + var border = (Brush)Application.Current.Resources["BorderBrush"]; + var hoverBg = (Brush)Application.Current.Resources["ControlHoverBackgroundBrush"]; + + var stack = new StackPanel { MinWidth = 220 }; + foreach (var target in available) + { + var num = Regex.Match(target._setting.DeviceName ?? "", @"\d+$").Value; + var label = string.IsNullOrEmpty(num) + ? target._setting.ReadableDeviceName + : $"Display {num} · {target._setting.ReadableDeviceName}"; + + var row = new Border + { + Background = Brushes.Transparent, + Padding = new Thickness(12, 8, 12, 8), + Cursor = Cursors.Hand, + Child = new TextBlock { Text = label, Foreground = fg, FontSize = 13 } + }; + row.MouseEnter += (s, ev) => row.Background = hoverBg; + row.MouseLeave += (s, ev) => row.Background = Brushes.Transparent; + + var captured = target; + row.MouseLeftButtonUp += (s, ev) => + { + ((Popup)((Border)((StackPanel)row.Parent).Parent).Parent).IsOpen = false; + ExecuteClone(captured); + }; + stack.Children.Add(row); + } + + var popup = new Popup + { + PlacementTarget = button, + Placement = PlacementMode.Bottom, + StaysOpen = false, + AllowsTransparency = true, + Child = new Border + { + Background = bg, + BorderBrush = border, + BorderThickness = new Thickness(1), + CornerRadius = new CornerRadius(4), + Padding = new Thickness(0, 4, 0, 4), + Child = stack + } + }; + popup.IsOpen = true; + } + + private void ExecuteClone(DisplaySettingControl other) + { + var newCloneGroupId = "clone-" + Guid.NewGuid().ToString("N").Substring(0, 8); + uint sharedSourceId = _setting.SourceId; + + foreach (var member in _cloneGroupMembers) + member.CloneGroupId = newCloneGroupId; + + foreach (var member in other._cloneGroupMembers) + { + member.CloneGroupId = newCloneGroupId; + member.SourceId = sharedSourceId; + } + + OnCloneGroupChanged?.Invoke(); + } + + private void ExecuteBreakClone() + { + var panel = Parent as Panel; + uint maxSourceId = 0; + if (panel != null) + { + foreach (var ctrl in panel.Children.OfType()) + foreach (var m in ctrl._cloneGroupMembers) + maxSourceId = Math.Max(maxSourceId, m.SourceId); + } + + bool isFirst = true; + foreach (var member in _cloneGroupMembers) + { + member.CloneGroupId = string.Empty; + if (!isFirst) + member.SourceId = ++maxSourceId; + isFirst = false; + } + + OnCloneGroupChanged?.Invoke(); + } + public void SetPrimary(bool isPrimary) { // Set this control's primary status without triggering events From cf341bf55337bfb253d154859aeec4e7587b8007 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 17:33:58 -0300 Subject: [PATCH 7/8] fix: treat SetDisplayConfig non-zero return as success when configuration is verified applied Co-Authored-By: Claude Sonnet 4.6 --- src/Helpers/DisplayConfigHelper.cs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/Helpers/DisplayConfigHelper.cs b/src/Helpers/DisplayConfigHelper.cs index 3a38f71..787d5a6 100644 --- a/src/Helpers/DisplayConfigHelper.cs +++ b/src/Helpers/DisplayConfigHelper.cs @@ -1028,17 +1028,28 @@ public static bool ApplyDisplayTopology(List displayConfigs) modes, SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | SetDisplayConfigFlags.SDC_APPLY | + SetDisplayConfigFlags.SDC_ALLOW_CHANGES | SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE | SetDisplayConfigFlags.SDC_VIRTUAL_MODE_AWARE); if (result != ERROR_SUCCESS) { - logger.Error($"SetDisplayConfig failed with error: {result}"); - if (result == 87) + logger.Warn($"SetDisplayConfig returned non-zero ({result}) — verifying whether config was applied anyway..."); + // Windows CCD sometimes applies the configuration but returns a non-zero code + // (e.g. when SDC_SAVE_TO_DATABASE hits a transient OS issue, or a display's EDID + // has a minor inconsistency). Verify before treating it as a hard failure. + System.Threading.Thread.Sleep(300); + if (VerifyDisplayConfiguration(displayConfigs)) { - logger.Error("ERROR_INVALID_PARAMETER - The display configuration is invalid"); + logger.Info("✓ Configuration verified as applied despite non-zero SetDisplayConfig return code"); + } + else + { + logger.Error($"SetDisplayConfig failed (error {result}) and configuration was not applied"); + if (result == 87) + logger.Error("ERROR_INVALID_PARAMETER — the supplied display configuration is invalid"); + return false; } - return false; } logger.Info("✓ Display configuration applied successfully"); From 4fcc13c718bd3c5ea914d39872a163bf4380b6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Renan=20Hil=C3=A1rio?= Date: Sun, 15 Mar 2026 18:59:39 -0300 Subject: [PATCH 8/8] fix: sync clone group member positions in ExecuteClone and add regression test When a display from an extended layout joined a clone group via the UI, ExecuteClone() was setting the same CloneGroupId/SourceId but not syncing DisplayPositionX/Y. The mismatched positions caused SetDisplayConfig to reject the configuration. Fixes the issue that produced error dialogs when applying profiles with clone groups created from a 3-monitor extended setup. Also adds a regression test reproducing the exact scenario from the bug report (DISPLAY1 at 0,0 and DISPLAY2 at -1920,0 in the same clone group). Co-Authored-By: Claude Sonnet 4.6 --- .../Tests/CloneGroupValidationTests.cs | 18 ++++++++++++++++++ src/UI/Windows/ProfileEditWindow.xaml.cs | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs b/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs index 5d10af0..d19652e 100644 --- a/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs +++ b/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs @@ -242,5 +242,23 @@ public void ValidateCloneGroups_WhenOneGroupValidAndOneInvalid_ReturnsFalse() Assert.IsFalse(Validate(settings)); } + + [TestMethod] + [TestCategory("Regression")] + public void ValidateCloneGroups_WhenCloneGroupMemberRetainsExtendedDesktopPosition_ReturnsFalse() + { + // Regression: ExecuteClone() was not syncing DisplayPositionX/Y to clone group members. + // A display from an extended layout (e.g. at -1920,0) would join a clone group with the + // primary at (0,0) but keep its old position, causing SetDisplayConfig to reject the config. + // Reproduces the exact scenario from New.dpm: DISPLAY1 at (0,0) and DISPLAY2 at (-1920,0) + // both assigned to the same clone group with sourceId=0. + var settings = new List + { + new DisplaySettingBuilder().WithCloneGroup("clone-f543fe96").WithSourceId(0).WithPosition(0, 0).Build(), + new DisplaySettingBuilder().WithCloneGroup("clone-f543fe96").WithSourceId(0).WithPosition(-1920, 0).Build(), + }; + + Assert.IsFalse(Validate(settings)); + } } } diff --git a/src/UI/Windows/ProfileEditWindow.xaml.cs b/src/UI/Windows/ProfileEditWindow.xaml.cs index a68c4ad..db897c0 100644 --- a/src/UI/Windows/ProfileEditWindow.xaml.cs +++ b/src/UI/Windows/ProfileEditWindow.xaml.cs @@ -1787,6 +1787,8 @@ private void ExecuteClone(DisplaySettingControl other) { var newCloneGroupId = "clone-" + Guid.NewGuid().ToString("N").Substring(0, 8); uint sharedSourceId = _setting.SourceId; + int sharedX = _setting.DisplayPositionX; + int sharedY = _setting.DisplayPositionY; foreach (var member in _cloneGroupMembers) member.CloneGroupId = newCloneGroupId; @@ -1795,6 +1797,9 @@ private void ExecuteClone(DisplaySettingControl other) { member.CloneGroupId = newCloneGroupId; member.SourceId = sharedSourceId; + // Clone members share the same source so they must share the same position + member.DisplayPositionX = sharedX; + member.DisplayPositionY = sharedY; } OnCloneGroupChanged?.Invoke();