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/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..41b2d83 --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/CloneGroupTopologyTests.cs @@ -0,0 +1,206 @@ +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("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); + + Assert.IsNull(method, "ApplyDisplayPosition deve ter sido removido por ser dead code."); + } + + [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..d19652e --- /dev/null +++ b/DisplayProfileManager.Tests/Tests/CloneGroupValidationTests.cs @@ -0,0 +1,264 @@ +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)); + } + + [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/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/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/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/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..787d5a6 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 & 0xFFFF; + 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; + } + + /// + /// 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,239 +643,160 @@ 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) - { - var foundPathIndex = Array.FindIndex(paths, - x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); - - if (foundPathIndex == -1) - { - logger.Warn($"Could not find path for display {displayInfo.DeviceName} (TargetId: {displayInfo.TargetId}, SourceId: {displayInfo.SourceId})"); - continue; - } - - if (displayInfo.IsEnabled) - { - paths[foundPathIndex].flags |= (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - paths[foundPathIndex].targetInfo.rotation = (uint)displayInfo.Rotation; - } - else - { - paths[foundPathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - } - - 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 + // Build mapping of TargetId to path index + var targetIdToPathIndex = new Dictionary(); for (int i = 0; i < paths.Length; i++) { - var path = paths[i]; - - // Check if this monitor is connected/available - if (!path.targetInfo.targetAvailable) - 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) + if (paths[i].targetInfo.targetAvailable) { - // This monitor is connected but not in the profile, so disable it - paths[i].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - - logger.Debug($"Disabling monitor not in profile: TargetId={path.targetInfo.id}, " + - $"SourceId={path.sourceInfo.id}, PathIndex={i}"); + uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; + if (!targetIdToPathIndex.ContainsKey(baseTargetId)) + { + targetIdToPathIndex[baseTargetId] = i; + } } } - // Apply the new configuration - result = SetDisplayConfig( - pathCount, - paths, - modeCount, - modes, - SetDisplayConfigFlags.SDC_APPLY | - SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); + logger.Info($"Found {targetIdToPathIndex.Count} available displays"); - if (result != ERROR_SUCCESS) + // 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)) { - logger.Error($"SetDisplayConfig failed with error: {result}"); - - // Try to provide more specific error information - string errorMessage = ""; - switch (result) + if (!sourceIdToCloneGroup.ContainsKey(display.SourceId)) { - 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; + sourceIdToCloneGroup[display.SourceId] = nextCloneGroup++; } + } - // 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) + 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)) { - 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); + // 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 { - 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); + // Disable display + paths[pathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; + paths[pathIndex].sourceInfo.modeInfoIdx = DISPLAYCONFIG_PATH_MODE_IDX_INVALID; + logger.Debug($"Disabling TargetId {targetId}"); } - - return false; } - logger.Info("Display topology applied successfully"); - + // Assign unique source IDs per adapter for all active paths + var sourceIdTable = new Dictionary(); + int activeCount = 0; - bool displayPositionApplied = ApplyDisplayPosition(displayConfigs); - if (!displayPositionApplied) - { - logger.Warn("Failed to apply display position"); - } - else + for (int i = 0; i < paths.Length; i++) { - logger.Info("Display position applied successfully"); - } - - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error applying display topology"); - return false; - } - } - - public static bool ApplyPartialDisplayTopology(List partialConfig) - { - try - { - logger.Info($"Applying partial display topology for {partialConfig.Count} displays."); + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) + { + LUID adapterId = paths[i].sourceInfo.adapterId; - uint pathCount = 0; - uint modeCount = 0; + if (!sourceIdTable.ContainsKey(adapterId)) + { + sourceIdTable[adapterId] = 0; + } - int result = GetDisplayConfigBufferSizes(QueryDisplayConfigFlags.QDC_ALL_PATHS, out pathCount, out modeCount); - if (result != ERROR_SUCCESS) - { - logger.Error($"GetDisplayConfigBufferSizes failed with error: {result}"); - return false; + paths[i].sourceInfo.id = sourceIdTable[adapterId]++; + activeCount++; + } } - 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) + if (activeCount == 0) { - logger.Error($"QueryDisplayConfig failed with error: {result}"); + logger.Error("No active displays to enable"); return false; } - // Modify only the paths specified in the partialConfig - foreach (var displayInfo in partialConfig) + 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++) { - var foundPathIndex = Array.FindIndex(paths, - x => (x.targetInfo.id == displayInfo.TargetId) && (x.sourceInfo.id == displayInfo.SourceId)); - - if (foundPathIndex == -1) - { - logger.Warn($"Could not find path for display {displayInfo.DeviceName} in partial application. Skipping."); - continue; - } - - if (displayInfo.IsEnabled) + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) { - paths[foundPathIndex].flags |= (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - paths[foundPathIndex].targetInfo.rotation = (uint)displayInfo.Rotation; + 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}"); } - else - { - paths[foundPathIndex].flags &= ~(uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE; - } - - logger.Debug($"Partially updating TargetId {displayInfo.TargetId} flags to: 0x{paths[foundPathIndex].flags:X}"); } - // NOTE: We do NOT disable unspecified monitors here. That is the key difference. - + // 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, + 0, + null, + SetDisplayConfigFlags.SDC_TOPOLOGY_SUPPLIED | SetDisplayConfigFlags.SDC_APPLY | - SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | - SetDisplayConfigFlags.SDC_ALLOW_CHANGES | - SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE); + SetDisplayConfigFlags.SDC_ALLOW_PATH_ORDER_CHANGES | + SetDisplayConfigFlags.SDC_VIRTUAL_MODE_AWARE); if (result != ERROR_SUCCESS) { - logger.Error($"SetDisplayConfig failed during partial application with error: {result}"); + 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("Partial display topology applied successfully."); + logger.Info("✓ Displays enabled successfully"); return true; } catch (Exception ex) { - logger.Error(ex, "Error applying partial display topology"); + logger.Error(ex, "Error enabling displays"); return false; } } - public static bool ApplyDisplayPosition(List displayConfigs) + /// + /// 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 { - uint pathCount = 0; - uint modeCount = 0; + 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( - QueryDisplayConfigFlags.QDC_ALL_PATHS, + queryFlags, out pathCount, out modeCount); @@ -878,11 +806,13 @@ public static bool ApplyDisplayPosition(List displayConfigs) 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, + queryFlags, ref pathCount, paths, ref modeCount, @@ -895,215 +825,276 @@ public static bool ApplyDisplayPosition(List displayConfigs) return false; } - // 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)); + // 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"); - if (!paths[foundPathIndex].targetInfo.targetAvailable) - continue; + // Build mapping of TargetId to path index + var targetIdToPathIndex = new Dictionary(); + for (int i = 0; i < paths.Length; i++) + { + if (paths[i].targetInfo.targetAvailable) + { + uint baseTargetId = paths[i].targetInfo.id & 0xFFFF; + if (!targetIdToPathIndex.ContainsKey(baseTargetId)) + { + targetIdToPathIndex[baseTargetId] = i; + } + } + } - // Set monitor position - var modeInfoIndex = paths[foundPathIndex].sourceInfo.modeInfoIdx; + logger.Info($"Found {targetIdToPathIndex.Count} available target displays"); - if (modeInfoIndex >= 0 && modeInfoIndex < modes.Length) + // 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) { - 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}"); + 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}"); } } - // Find the rightmost edge of monitors in the profile - int currentRightEdge = 0; - if (displayConfigs.Count > 0) + // 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)) { - currentRightEdge = displayConfigs.Max(d => d.DisplayPositionX + d.Width); + 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)); } - // 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++) + foreach (var kvp in displaysBySourceId) { - var path = paths[i]; + uint sourceId = kvp.Key; + var groupDisplays = kvp.Value; + LUID adapterId = paths[groupDisplays[0].pathIndex].sourceInfo.adapterId; - // Check if this monitor is connected/available - if (!path.targetInfo.targetAvailable) - continue; + if (!adapterSourceModeUsage.ContainsKey(adapterId)) + adapterSourceModeUsage[adapterId] = 0; - if(path.targetInfo.scanLineOrdering == 0) + if (!adapterToSourceModes.ContainsKey(adapterId) || + adapterSourceModeUsage[adapterId] >= adapterToSourceModes[adapterId].Count) + { + logger.Warn($"SourceId {sourceId}: No available source mode for adapter"); continue; + } + + // Consume ONE source mode for this SourceId (clone members share the same source mode) + int sourceModeIdx = adapterToSourceModes[adapterId][adapterSourceModeUsage[adapterId]++]; + var primary = groupDisplays[0].info; // Clone members share resolution/position - // Check if this path exists in the displayConfigs list - bool foundInProfile = displayConfigs.Any(d => - d.TargetId == path.targetInfo.id && - d.SourceId == path.sourceInfo.id); + 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)]"); - if (!foundInProfile) + foreach (var (pathIndex, displayInfo) in groupDisplays) { - // Set monitor position - var modeInfoIndex = paths[i].sourceInfo.modeInfoIdx; + // Point all paths in this clone group to the same source mode index + paths[pathIndex].sourceInfo.SourceModeInfoIdx = (uint)sourceModeIdx; - if (modeInfoIndex >= 0 && modeInfoIndex < modes.Length) + // 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) { - // 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; - - - logger.Debug($"Change position of monitor not in profile: TargetId={path.targetInfo.id}, " + - $"SourceId={path.sourceInfo.id}, PathIndex={i}"); + 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; - // 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) + foreach (var displayInfo in displayConfigs.Where(d => d.IsEnabled)) { - logger.Error($"Applying display position failed with error: {result}"); - - // Try to provide more specific error information - switch (result) + if (!sourceIdToCloneGroup.ContainsKey(displayInfo.SourceId)) { - 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; + sourceIdToCloneGroup[displayInfo.SourceId] = nextCloneGroup++; } - return false; } - return true; - } - catch (Exception ex) - { - logger.Error(ex, "Error applying display position"); - return false; - } - } + // Log clone group information + var cloneGroupsInfo = displayConfigs + .Where(d => d.IsEnabled) + .GroupBy(d => d.SourceId) + .Where(g => g.Count() > 1) + .ToList(); - 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; - } + 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)"); + } - // Ensure all required fields are set - foreach (var display in topology) - { - if (string.IsNullOrEmpty(display.DeviceName)) + // 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) { - logger.Warn($"Invalid topology: Display at index {display.PathIndex} has no device name"); - return false; + if (!targetIdToPathIndex.TryGetValue(displayInfo.TargetId, out int pathIndex)) + { + logger.Warn($"Could not find path for TargetId {displayInfo.TargetId} ({displayInfo.FriendlyName})"); + continue; + } + + if (displayInfo.IsEnabled) + { + // 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}"); + } + } + + // Disable displays not in the profile + foreach (var kvp in targetIdToPathIndex) + { + uint targetId = kvp.Key; + int pathIndex = kvp.Value; + + if (!profileTargetIds.Contains(targetId)) + { + 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)"); + } } - } - return true; - } + // 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++) + { + if ((paths[i].flags & (uint)DisplayConfigPathInfoFlags.DISPLAYCONFIG_PATH_ACTIVE) != 0) + { + activeCount++; + } + } - 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) + if (activeCount == 0) { - logger.Error("Primary display not found"); + logger.Error("No active paths found to apply"); 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.Info($"Configured {activeCount} active paths out of {paths.Length} total paths"); - logger.Debug($"Current primary position: ({newPrimary.DisplayPositionX}, {newPrimary.DisplayPositionY})"); - logger.Debug($"Offset to apply: ({offsetX}, {offsetY})"); + // 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( + (uint)paths.Length, + paths, + (uint)modes.Length, + modes, + SetDisplayConfigFlags.SDC_USE_SUPPLIED_DISPLAY_CONFIG | + SetDisplayConfigFlags.SDC_APPLY | + SetDisplayConfigFlags.SDC_ALLOW_CHANGES | + SetDisplayConfigFlags.SDC_SAVE_TO_DATABASE | + SetDisplayConfigFlags.SDC_VIRTUAL_MODE_AWARE); - // Step 4: Stage changes for ALL displays with adjusted positions - foreach (var displayConfig in displayConfigs) + if (result != ERROR_SUCCESS) { - if (displayConfig.IsPrimary) + 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)) { - displayConfig.DisplayPositionX = 0; - displayConfig.DisplayPositionY = 0; + logger.Info("✓ Configuration verified as applied despite non-zero SetDisplayConfig return code"); } 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})"); + 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; } } - - // Check for any disconnected displays - bool allDisplayConnected = true; - foreach (var displayConfig in displayConfigs) - { - if (!DisplayHelper.IsMonitorConnected(displayConfig.DeviceName)) - { - allDisplayConnected = false; - } - } + logger.Info("✓ Display configuration applied successfully"); + return true; + } + catch (Exception ex) + { + logger.Error(ex, "Error applying display topology"); + return 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) + /// + /// 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 + { + 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 +1141,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 +1183,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 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 433617b..db897c0 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; @@ -58,20 +61,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 +139,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 +151,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 +170,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,13 +179,24 @@ 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); + 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 @@ -166,8 +214,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 +289,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); } @@ -732,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; @@ -739,74 +907,185 @@ 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(); } 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"]; - var headerText = new TextBlock + // ── Row 1: name area ───────────────────────────────────────────── + FrameworkElement nameRow; + if (_isCloneGroup && _cloneGroupMembers.Count > 1) { - Text = $"Monitor {_monitorIndex}", - 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 @@ -815,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(); @@ -900,12 +1179,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 +1191,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 +1435,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 +1508,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 +1521,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 +1534,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() @@ -1392,6 +1711,123 @@ 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; + int sharedX = _setting.DisplayPositionX; + int sharedY = _setting.DisplayPositionY; + + foreach (var member in _cloneGroupMembers) + member.CloneGroupId = newCloneGroupId; + + foreach (var member in other._cloneGroupMembers) + { + 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(); + } + + 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 @@ -1422,4 +1858,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