diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 4a7cc71..6b427aa 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +### Fixes +- Displays: going back to the PC restores the exact layout from before (the second monitor no longer slides aside) and keeps the TV off. Only the screens that were on come back, the TV is detached before the positions, and the result is checked against the backup and redone if needed. (#134) + ## [1.6.0-alpha.5] ### Fixes diff --git a/CHANGELOG.md b/CHANGELOG.md index 5074c8d..75539b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +### Correções +- Telas: ao voltar para o PC o layout volta idêntico ao de antes (a tela 2 não desliza mais para o lado) e a TV continua desligada. Só voltam as telas que estavam ligadas, a TV é desconectada antes das posições, o resultado é conferido com o backup e refeito se preciso. (#134) + ## [1.6.0-alpha.5] ### Correções diff --git a/docs/TESTING.md b/docs/TESTING.md index 6918d61..8ed945d 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -290,6 +290,16 @@ Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (sa - [ ] Menu da sessão (Select + Y): o volume inicial é o do Windows; ◀/▶ muda de 5 em 5 e tira o mudo; A alterna o mudo. Resultado: ______ - [ ] Ao restaurar a mesa, o som volta para a saída de antes do modo console. Resultado: ______ +## Restaurar a mesa: posições das telas + +Pré-condições: pelo menos 3 telas (a TV desligada na mesa normal, mais duas telas empilhadas ou lado a lado, p. ex. uma ultrawide com outra logo abaixo, alinhadas à esquerda); anote o mapa em Configurações → Sistema → Tela antes de começar. + +- [ ] Estratégia "Desconectar": **Jogar agora** e depois **Voltar ao PC** (botão, menu da sessão e tray): o mapa em Configurações → Sistema → Tela volta **idêntico** ao anotado (cada tela no mesmo lugar, mesma tela principal, a TV desligada como antes). Resultado: ______ +- [ ] O `consolemode.log` mostra a ordem `Telas: desativar ` **antes** de `Telas: layout restaurado => 0` e não tem `posições fora do backup`. Se tiver, o texto diz quais telas e em qual tentativa a restauração acertou. Resultado: ______ +- [ ] Repetir 3 vezes seguidas: o layout não deriva a cada ciclo. Resultado: ______ +- [ ] TV que já estava ligada (como tela estendida) antes da sessão: depois de voltar, ela continua ligada no mesmo lugar. Resultado: ______ +- [ ] Se uma das telas originais não voltar (cabo solto): o log diz que a tela de jogo ficou ligada e o PC não fica sem imagem. Resultado: ______ + ## Telas com código próprio (issue #91) Pré-condições: 1.6 instalada por cima da 1.5, com a configuração da 1.5 (tela de jogo e telas a esconder escolhidas). Anote antes, na 1.5, como o mapa de telas aparece, para comparar. diff --git a/src/ConsoleMode/Native/CcdHelper.cs b/src/ConsoleMode/Native/CcdHelper.cs index 6263813..041818e 100644 --- a/src/ConsoleMode/Native/CcdHelper.cs +++ b/src/ConsoleMode/Native/CcdHelper.cs @@ -276,6 +276,37 @@ public static int SetHdrState(string gdiDeviceName, bool enable) return DisplayConfigGetDeviceInfo(ref req) != 0 ? null : req.viewGdiDeviceName; } + /// + /// Moves active screens to the given desktop positions in one CCD call. Only the paths that are active + /// right now are touched, so a screen that is detached stays detached (unlike the registry-based + /// ChangeDisplaySettingsEx flush, which can bring one back). 0 = nothing to change or applied. + /// + public static int SetPositions(IReadOnlyDictionary positions) + { + var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes); + if (err != 0) return err; + var paths = new PATH_INFO[numPaths]; + var modes = new MODE_INFO[numModes]; + err = QueryDisplayConfig(QDC_ONLY_ACTIVE_PATHS, ref numPaths, paths, ref numModes, modes, 0); + if (err != 0) return err; + + var changed = false; + for (var i = 0; i < numModes; i++) + { + if (modes[i].infoType != MODE_INFO_TYPE_SOURCE) continue; + var name = GetSourceGdiName(modes[i].adapterId, modes[i].id); + if (name is null || !positions.TryGetValue(name, out var target)) continue; + if (modes[i].mode.sourceMode.position.x == target.X && modes[i].mode.sourceMode.position.y == target.Y) continue; + modes[i].mode.sourceMode.position.x = target.X; + modes[i].mode.sourceMode.position.y = target.Y; + changed = true; + } + if (!changed) return 0; + + return SetDisplayConfig(numPaths, paths, numModes, modes, + SDC_APPLY | SDC_USE_SUPPLIED_DISPLAY_CONFIG | SDC_SAVE_TO_DATABASE | SDC_ALLOW_CHANGES); + } + public static int SetPrimary(string gdiDeviceName) { var err = GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, out var numPaths, out var numModes); diff --git a/src/ConsoleMode/Native/NativeDisplays.cs b/src/ConsoleMode/Native/NativeDisplays.cs index d8a69ae..2c912d6 100644 --- a/src/ConsoleMode/Native/NativeDisplays.cs +++ b/src/ConsoleMode/Native/NativeDisplays.cs @@ -162,7 +162,11 @@ public static int Disable(IReadOnlyCollection gdiNames) public static int QueueMode(string gdiName, int width, int height, int frequency, int bitsPerPixel, int? x, int? y, int orientation = -1) { var dm = new NativeWindows.DEVMODE { dmSize = (short)Marshal.SizeOf() }; - if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) return -1; + if (!NativeWindows.EnumDisplaySettings(gdiName, NativeWindows.ENUM_CURRENT_SETTINGS, ref dm)) + { + AppLog.Write($"Telas: {gdiName} sem modo atual; posição e modo não aplicados"); + return -1; + } dm.dmFields = 0; if (width > 0 && height > 0) { diff --git a/src/ConsoleMode/Services/DisplayBackend.cs b/src/ConsoleMode/Services/DisplayBackend.cs index 79dc19f..46e8006 100644 --- a/src/ConsoleMode/Services/DisplayBackend.cs +++ b/src/ConsoleMode/Services/DisplayBackend.cs @@ -114,6 +114,7 @@ public Dictionary> ResolveLayout(Dictionary names) => NativeDisplays.Enable(names); public void Disable(IReadOnlyList names) => NativeDisplays.Disable(names); public void SetPrimary(string name) => CcdHelper.SetPrimary(name); + public int SetPositions(IReadOnlyDictionary positions) => CcdHelper.SetPositions(positions); public void PowerOn(string name) => NativeDisplays.SetPower(name, on: true); public void PowerOff(string name) => NativeDisplays.SetPower(name, on: false); diff --git a/src/ConsoleMode/Services/DisplayIdentity.cs b/src/ConsoleMode/Services/DisplayIdentity.cs index 8da9441..9e98ff1 100644 --- a/src/ConsoleMode/Services/DisplayIdentity.cs +++ b/src/ConsoleMode/Services/DisplayIdentity.cs @@ -166,6 +166,29 @@ public static Dictionary> ParseLayout(IEnumer return specs; } + /// + /// Monitors that were on in the backup (they have a size) whose current state is wrong: not active, or not + /// at the saved position. Used to check a restore and to retry it. Specs without a saved position are skipped. + /// + public static List LayoutMismatches( + IReadOnlyDictionary> specs, + IEnumerable<(string Name, bool IsActive, int X, int Y)> current) + { + var now = current.GroupBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase); + var wrong = new List(); + foreach (var (name, spec) in specs) + { + int.TryParse(spec.GetValueOrDefault("Width"), out var width); + int.TryParse(spec.GetValueOrDefault("Height"), out var height); + if (width <= 0 || height <= 0) continue; // was off + if (!int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) || + !int.TryParse(spec.GetValueOrDefault("PositionY"), out var y)) continue; + if (!now.TryGetValue(name, out var monitor) || !monitor.IsActive || monitor.X != x || monitor.Y != y) + wrong.Add(name); + } + return wrong; + } + /// /// Re-keys layout sections by the name each monitor has now, matched by MonitorID: a /// monitor that was disabled can come back as another \.\DISPLAYn. Sections whose monitor isn't diff --git a/src/ConsoleMode/Services/MonitorService.cs b/src/ConsoleMode/Services/MonitorService.cs index ef9c9f9..84e7020 100644 --- a/src/ConsoleMode/Services/MonitorService.cs +++ b/src/ConsoleMode/Services/MonitorService.cs @@ -113,21 +113,29 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) Thread.Sleep(300); } - var allDeviceNames = backupSpecs.Values - .Select(s => s.GetValueOrDefault("Name") ?? "") - .Where(n => n.Length > 0) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); + // Screens that were on before the session come back; the ones that were off (the TV, usually) stay off. var monitorsToVerify = backupSpecs .Where(kv => IsBackupSpecActive(kv.Value)) .Select(kv => kv.Key) .ToList(); + var monitorsToDisable = backupSpecs + .Where(kv => !IsBackupSpecActive(kv.Value)) + .Select(kv => kv.Key) + .ToList(); + if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) && + !monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase)) + { + monitorsToDisable.Add(ctx.FocusMonitor); + } - if (allDeviceNames.Count > 0) + // 1. Re-attach only the screens that were on. Enabling the ones that were off too (as 1.5's tool did) + // made Windows lay them out and then re-flow everything when they were detached again. + var restored = true; + if (monitorsToVerify.Count > 0) { - _backend.Enable(allDeviceNames); + _backend.Enable(monitorsToVerify); - if (monitorsToVerify.Count > 0 && !WaitMonitorsActive(monitorsToVerify)) + if (!WaitMonitorsActive(monitorsToVerify)) { foreach (var name in monitorsToVerify) _backend.Enable([name]); @@ -136,11 +144,33 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) AppLog.Write($"Restore: /enable não confirmou todos ({string.Join('+', monitorsToVerify)}); usando ExtendAll"); CcdHelper.ExtendAll(); if (!WaitMonitorsActive(monitorsToVerify)) + { + restored = false; result.Issues.Add($"Nem todos os monitores foram reativados: {string.Join(", ", monitorsToVerify)}"); + } } } } + // 2. Detach the screens that were off BEFORE placing the others: while the TV is still part of the + // desktop, Windows keeps the positions around it and moves the other screens when it goes. If the + // originals didn't come back, the TV is the only picture left: leave it on. + if (restored) + { + AppLog.Write($"Restore: telas que voltam a ficar desligadas: {(monitorsToDisable.Count == 0 ? "nenhuma" : string.Join('+', monitorsToDisable))}"); + foreach (var name in monitorsToDisable) + { + if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue; + if (!DisableWithRetry(name)) + result.Issues.Add($"Não foi possível desconectar {name}"); + } + } + else + { + AppLog.Write("Restore: telas originais não voltaram; a tela de jogo fica ligada para não ficar sem imagem"); + } + + // 3. Primary, then every position and mode of the backup in one go, and check that it stuck. if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) { backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); @@ -148,31 +178,51 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) result.Issues.Add($"Não foi possível restaurar o primário para {ctx.OriginalPrimary}"); } - _backend.LoadLayout(AppPaths.BackupMonitorConfig); - Thread.Sleep(800); + const int layoutAttempts = 3; + for (var attempt = 1; attempt <= layoutAttempts; attempt++) + { + _backend.LoadLayout(AppPaths.BackupMonitorConfig); + Thread.Sleep(800); + var wrong = LayoutMismatches(backupSpecs); + if (wrong.Count == 0) break; + AppLog.Write($"Restore: posições fora do backup em {string.Join('+', wrong)} (tentativa {attempt}/{layoutAttempts})"); + } - if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) + // LoadLayout applies the registry's display settings, and a screen that was just detached (the TV) can + // come back with them: detach it again. Doing that can move the others, so the positions are put back + // by CCD, which only touches the screens that are on and never brings a detached one back. + if (restored) { - backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); - if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec)) - result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após LoadConfig"); + for (var round = 1; round <= 2; round++) + { + var back = monitorsToDisable + .Where(n => !string.Equals(n, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase) && IsActiveNow(n)) + .ToList(); + if (back.Count == 0) break; + AppLog.Write($"Restore: {string.Join('+', back)} voltou a ligar ao aplicar o layout; desconectando de novo ({round}/2)"); + foreach (var name in back) + { + if (!DisableWithRetry(name) && round == 2) + result.Issues.Add($"Não foi possível desconectar {name}"); + } + } } - var monitorsToDisable = backupSpecs - .Where(kv => !IsBackupSpecActive(kv.Value)) - .Select(kv => kv.Key) - .ToList(); - if (ctx.FocusWasInactive && !string.IsNullOrWhiteSpace(ctx.FocusMonitor) && - !monitorsToDisable.Contains(ctx.FocusMonitor, StringComparer.OrdinalIgnoreCase)) + var misplaced = LayoutMismatches(backupSpecs); + if (misplaced.Count > 0) { - monitorsToDisable.Add(ctx.FocusMonitor); + var code = _backend.SetPositions(SavedPositions(backupSpecs)); + AppLog.Write($"Restore: posições acertadas por CCD => {code}"); + Thread.Sleep(600); + misplaced = LayoutMismatches(backupSpecs); + if (misplaced.Count > 0) result.Issues.Add($"Posição das telas diferente do backup: {string.Join(", ", misplaced)}"); } - foreach (var name in monitorsToDisable) + if (!string.IsNullOrWhiteSpace(ctx.OriginalPrimary)) { - if (string.Equals(name, ctx.OriginalPrimary, StringComparison.OrdinalIgnoreCase)) continue; - if (!DisableWithRetry(name)) - result.Issues.Add($"Não foi possível desconectar {name}"); + backupSpecs.TryGetValue(ctx.OriginalPrimary, out var primarySpec); + if (!RestorePrimaryWithRetry(ctx.OriginalPrimary, primarySpec)) + result.Issues.Add($"Primário não confirmado em {ctx.OriginalPrimary} após restaurar o layout"); } ClearCache(); @@ -181,6 +231,33 @@ public RestoreResult RestoreBackup(ConsoleRuntimeState state) return result; } + private bool IsActiveNow(string name) => + GetMonitors(true).Any(m => m.IsActive && string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)); + + /// Saved desktop position of every screen that was on. + private static Dictionary SavedPositions(Dictionary> backupSpecs) + { + var positions = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (name, spec) in backupSpecs) + { + if (!IsBackupSpecActive(spec)) continue; + if (int.TryParse(spec.GetValueOrDefault("PositionX"), out var x) && int.TryParse(spec.GetValueOrDefault("PositionY"), out var y)) + positions[name] = (x, y); + } + return positions; + } + + /// Screens that were on in the backup whose position (or state) is not the saved one right now. + private List LayoutMismatches(Dictionary> backupSpecs) + { + var now = GetMonitors(true).Select(m => + { + ParseLeftTop(m.LeftTop, out var x, out var y); + return (m.Name, m.IsActive, x ?? 0, y ?? 0); + }); + return DisplayIdentity.LayoutMismatches(backupSpecs, now); + } + public void SetPrimary(string monitorName) { _backend.SetPrimary(monitorName); diff --git a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs index 0e2ffb6..201ec0a 100644 --- a/tests/ConsoleMode.Tests/DisplayIdentityTests.cs +++ b/tests/ConsoleMode.Tests/DisplayIdentityTests.cs @@ -143,4 +143,50 @@ private static void WriteText(byte[] edid, int offset, byte tag, string text) var bytes = Encoding.ASCII.GetBytes(text + "\n"); for (var i = 0; i < 13; i++) edid[offset + 5 + i] = i < bytes.Length ? bytes[i] : (byte)0x20; } + + // The desk before the session: DISPLAY1 at the origin, DISPLAY2 right below it, the TV off. + private static Dictionary> DeskBackup() => DisplayIdentity.ParseLayout(DisplayIdentity.FormatLayout( + [ + new LayoutEntry(@"\\.\DISPLAY1", "A", "", 32, 2560, 1080, 144, 0, 0), + new LayoutEntry(@"\\.\DISPLAY2", "B", "", 32, 2560, 1440, 144, 0, 1080), + new LayoutEntry(@"\\.\DISPLAY3", "TV", "", 0, 0, 0, 0, 0, 0) + ]).Split("\r\n")); + + [Fact] + public void Layout_check_passes_when_every_screen_that_was_on_is_where_it_was() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), + [ + (@"\\.\DISPLAY1", true, 0, 0), + (@"\\.\DISPLAY2", true, 0, 1080), + (@"\\.\DISPLAY3", false, 0, 0) // the TV was off: its state is not part of the check + ]); + Assert.Empty(wrong); + } + + [Fact] + public void Layout_check_flags_a_screen_that_slid_aside() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), + [ + (@"\\.\DISPLAY1", true, 0, 0), + (@"\\.\DISPLAY2", true, -2560, 1080), + (@"\\.\DISPLAY3", false, 0, 0) + ]); + Assert.Equal(new[] { @"\\.\DISPLAY2" }, wrong); + } + + [Fact] + public void Layout_check_flags_a_screen_that_is_off_or_missing() + { + var wrong = DisplayIdentity.LayoutMismatches(DeskBackup(), [(@"\\.\DISPLAY1", false, 0, 0)]); + Assert.Equal(new[] { @"\\.\DISPLAY1", @"\\.\DISPLAY2" }, wrong.Order()); + } + + [Fact] + public void Layout_check_ignores_backups_without_a_position() + { + var specs = DisplayIdentity.ParseLayout(["[Monitor0]", @"Name=\\.\DISPLAY1", "Width=1920", "Height=1080"]); + Assert.Empty(DisplayIdentity.LayoutMismatches(specs, [(@"\\.\DISPLAY1", true, 500, 500)])); + } }