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