From 84b8c5cb4fcd70f2bc8044c918426fd3b7059d06 Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 29 Oct 2026 14:33:35 +0900 Subject: [PATCH 1/3] Refuse the Device Simulator in set-game-view-size and add --reset Under the Device Simulator, PlayModeWindow.SetCustomRenderingResolution only logs an error, yet the tool reported Success: true with an unchanged size. The tool also leaves a "uloop" entry in the user-wide GameViewSizes.asset with no way to remove it, and a plain read opened a Game View when no Play Mode view existed. - Move the decision logic into an internal use case behind a port so every view kind and reset branch is covered by EditMode tests with a fake. - Reject a change or reset unless a Game View (or no view) is showing, and report View, SelectedSize, and a Warning that the entry persists. - Add --reset: remove the owned entries from every platform group, then re-select the selection remembered in SessionState (or keep the user's own selection by name, since removal shifts indices). - Add GameViewSizeListBridge to reach the internal size list types through the friend-assembly grant without reflection. --- .../Editor/SetGameViewSizeUseCaseTests.cs | 564 ++++++++++++++++++ .../SetGameViewSizeUseCaseTests.cs.meta | 11 + .../Editor/UnityCLILoop.Tests.Editor.asmdef | 3 +- .../SetGameViewSize/AssemblyInfo.cs | 3 + .../SetGameViewSize/AssemblyInfo.cs.meta | 11 + .../EditorSetGameViewSizePort.cs | 87 +++ .../EditorSetGameViewSizePort.cs.meta | 11 + .../SetGameViewSize/ISetGameViewSizePort.cs | 60 ++ .../ISetGameViewSizePort.cs.meta | 11 + .../SetGameViewSizeResponse.cs | 34 ++ .../SetGameViewSize/SetGameViewSizeSchema.cs | 6 + .../SetGameViewSize/SetGameViewSizeTool.cs | 5 +- .../SetGameViewSize/SetGameViewSizeUseCase.cs | 333 +++++++++-- ...stPartyTools.SetGameViewSize.Editor.asmdef | 3 +- .../GameViewSizeListBridge.cs | 141 +++++ .../GameViewSizeListBridge.cs.meta | 11 + 16 files changed, 1231 insertions(+), 63 deletions(-) create mode 100644 Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs create mode 100644 Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs create mode 100644 Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs.meta create mode 100644 Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs create mode 100644 Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs.meta diff --git a/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs new file mode 100644 index 0000000000..3ae4d161c4 --- /dev/null +++ b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs @@ -0,0 +1,564 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies the set-game-view-size use case against a fake Editor port, without a Game View. + /// + public sealed class SetGameViewSizeUseCaseTests + { + private static SetGameViewSizeSchema Request(int? width, int? height) + { + return new SetGameViewSizeSchema { Width = width, Height = height }; + } + + private static SetGameViewSizeSchema ResetRequest() + { + return new SetGameViewSizeSchema { Reset = true }; + } + + private static List<(string DisplayText, uint Width, uint Height)> SizesWithOwnedThenCustomB() + { + return new List<(string DisplayText, uint Width, uint Height)> + { + ("Free Aspect", 800, 600), + ("Full HD (1920x1080)", 1920, 1080), + ("uloop (1280x720)", 1280, 720), + ("Custom B (640x480)", 640, 480) + }; + } + + private static SetGameViewSizeResponse Execute(FakeSetGameViewSizePort port, SetGameViewSizeSchema schema) + { + return new SetGameViewSizeUseCase(port).Execute(schema); + } + + [Test] + public void Execute_ReadOnGameView_ReportsResolutionAndSelectionWithoutChanging() + { + // Verifies reading on a Game View reports its size and selected entry and touches nothing. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, Request(null, null)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("GameView")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((1920u, 1080u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((1920u, 1080u))); + Assert.That(response.Changed, Is.False); + Assert.That(response.SelectedSize, Is.EqualTo("Full HD (1920x1080)")); + Assert.That(response.Removed, Is.Null); + Assert.That(port.Calls, Is.Empty); + Assert.That(port.Remembered, Is.Null); + } + + [Test] + public void Execute_ReadOnSimulator_ReportsSimulatorResolutionWithoutChanging() + { + // Verifies reading under the Device Simulator reports the Simulator's size as such and touches nothing. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.Simulator }; + + SetGameViewSizeResponse response = Execute(port, Request(null, null)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("Simulator")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((2360u, 1640u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((2360u, 1640u))); + Assert.That(response.SelectedSize, Is.Null); + Assert.That(port.Calls, Is.Empty); + } + + [Test] + public void Execute_ReadOnOtherView_ReportsWithoutChanging() + { + // Verifies reading under a third-party Play Mode view reports it as Other and touches nothing. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.Other }; + + SetGameViewSizeResponse response = Execute(port, Request(null, null)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("Other")); + Assert.That(port.Calls, Is.Empty); + } + + [Test] + public void Execute_ReadWithoutView_ReportsNoneWithoutOpeningAWindow() + { + // Verifies reading with no Play Mode view reports None at 0x0 without asking for the resolution, which would open a window. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.None }; + + SetGameViewSizeResponse response = Execute(port, Request(null, null)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("None")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((0u, 0u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((0u, 0u))); + Assert.That(response.SelectedSize, Is.Null); + Assert.That(port.Calls, Is.Empty); + } + + [Test] + public void Execute_WidthWithoutHeight_IsRejectedWithoutChanging() + { + // Verifies a width without a height is rejected and nothing is applied or remembered. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, Request(1280, null)); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("Width and Height must be provided together.")); + Assert.That(port.Calls, Is.Empty); + Assert.That(port.Remembered, Is.Null); + } + + [Test] + public void Execute_NonPositiveSize_IsRejectedWithoutChanging() + { + // Verifies a non-positive size is rejected and nothing is applied. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, Request(0, 720)); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("Width and Height must be positive integers.")); + Assert.That(port.Calls, Is.Empty); + } + + [Test] + public void Execute_ResetWithSize_IsRejectedWithoutChanging() + { + // Verifies --reset combined with a size is rejected before anything is removed. + FakeSetGameViewSizePort port = new() { OwnedEntriesInOtherGroups = 1 }; + + SetGameViewSizeResponse response = Execute( + port, + new SetGameViewSizeSchema { Reset = true, Width = 1280, Height = 720 }); + + Assert.That(response.Success, Is.False); + Assert.That(response.Message, Is.EqualTo("--reset cannot be combined with --width or --height.")); + Assert.That(port.Calls, Is.Empty); + Assert.That(response.Removed, Is.Null); + } + + [Test] + public void Execute_SetOnGameView_AppliesSelectsOwnedEntryAndRemembersSelection() + { + // Verifies a size change on a Game View selects the owned entry, remembers the user's selection, and warns that the entry persists. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, Request(1280, 720)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("GameView")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((1920u, 1080u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((1280u, 720u))); + Assert.That(response.Changed, Is.True); + Assert.That(response.SelectedSize, Is.EqualTo("uloop (1280x720)")); + Assert.That(port.Remembered, Is.EqualTo("Full HD (1920x1080)")); + Assert.That(port.CountCalls("Apply"), Is.EqualTo(1)); + Assert.That(response.Warning, Does.Contain("--reset")); + Assert.That( + response.Message, + Is.EqualTo("Game View rendering resolution changed from 1920x1080 to 1280x720.")); + } + + [Test] + public void Execute_SetSameSizeOnGameView_ReportsAlreadyButStillSelectsOwnedEntry() + { + // Verifies setting the size already rendered still reports that the owned entry is now selected. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, Request(1920, 1080)); + + Assert.That(response.Success, Is.True); + Assert.That(response.Changed, Is.False); + Assert.That(response.Message, Is.EqualTo("Game View rendering resolution is already 1920x1080.")); + Assert.That(response.SelectedSize, Is.EqualTo("uloop (1920x1080)")); + Assert.That(port.Remembered, Is.EqualTo("Full HD (1920x1080)")); + Assert.That(response.Warning, Is.Not.Empty); + } + + [Test] + public void Execute_SetWhileOwnedEntrySelected_KeepsRememberedSelection() + { + // Verifies a second change while the owned entry is selected does not overwrite the remembered user selection. + FakeSetGameViewSizePort port = new() { Remembered = "Full HD (1920x1080)" }; + port.Sizes.Add(("uloop (1280x720)", 1280, 720)); + port.SelectedIndex = 2; + + SetGameViewSizeResponse response = Execute(port, Request(800, 600)); + + Assert.That(port.Remembered, Is.EqualTo("Full HD (1920x1080)")); + Assert.That(response.SelectedSize, Is.EqualTo("uloop (800x600)")); + Assert.That(response.Changed, Is.True); + } + + [Test] + public void Execute_SetOnSimulator_FailsWithoutChanging() + { + // Verifies a size change under the Device Simulator fails, applies nothing, and points to game-view-mode. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.Simulator }; + + SetGameViewSizeResponse response = Execute(port, Request(1280, 720)); + + Assert.That(response.Success, Is.False); + Assert.That(response.View, Is.EqualTo("Simulator")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((2360u, 1640u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((2360u, 1640u))); + Assert.That(response.Changed, Is.False); + Assert.That(response.Message, Does.Contain("game-view-mode --to GameView")); + Assert.That(response.Message, Does.Contain("--device")); + Assert.That(port.Calls, Is.Empty); + Assert.That(port.Remembered, Is.Null); + } + + [Test] + public void Execute_SetOnOtherView_FailsWithoutChanging() + { + // Verifies a size change under a third-party Play Mode view fails and applies nothing. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.Other }; + + SetGameViewSizeResponse response = Execute(port, Request(1280, 720)); + + Assert.That(response.Success, Is.False); + Assert.That(response.View, Is.EqualTo("Other")); + Assert.That(port.Calls, Is.Empty); + } + + [Test] + public void Execute_SetWithoutView_OpensGameViewAndApplies() + { + // Verifies a size change with no Play Mode view opens a Game View, applies the size, and says a view was opened. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.None }; + + SetGameViewSizeResponse response = Execute(port, Request(1280, 720)); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("GameView")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((0u, 0u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((1280u, 720u))); + Assert.That(response.Changed, Is.True); + Assert.That(response.SelectedSize, Is.EqualTo("uloop (1280x720)")); + Assert.That(port.Remembered, Is.Null); + Assert.That( + response.Message, + Is.EqualTo("No Play Mode view was open, so a Game View was opened with a rendering resolution of 1280x720.")); + Assert.That(response.Warning, Is.Not.Empty); + } + + [Test] + public void Execute_ResetWhileOwnedEntrySelected_RestoresRememberedSelection() + { + // Verifies --reset with the owned entry selected removes every owned entry and re-selects the remembered user selection. + FakeSetGameViewSizePort port = new() + { + Sizes = SizesWithOwnedThenCustomB(), + SelectedIndex = 2, + Remembered = "Full HD (1920x1080)", + OwnedEntriesInOtherGroups = 1 + }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.Success, Is.True); + Assert.That(response.Removed, Is.EqualTo(2)); + Assert.That(response.SelectedSize, Is.EqualTo("Full HD (1920x1080)")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((1280u, 720u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((1920u, 1080u))); + Assert.That(response.Changed, Is.True); + Assert.That(port.CountCalls("SelectFirstSize"), Is.Zero); + Assert.That(port.Remembered, Is.Null); + Assert.That(port.Sizes.Any(size => size.DisplayText.StartsWith("uloop", StringComparison.Ordinal)), Is.False); + Assert.That(response.Message, Does.Contain("Restored the previous selection 'Full HD (1920x1080)'")); + } + + [Test] + public void Execute_ResetWithoutRememberedSelection_SelectsFirstSize() + { + // Verifies --reset with the owned entry selected and no remembered selection falls back to the first entry. + FakeSetGameViewSizePort port = new() { Sizes = SizesWithOwnedThenCustomB(), SelectedIndex = 2 }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.SelectedSize, Is.EqualTo("Free Aspect")); + Assert.That(port.CountCalls("SelectFirstSize"), Is.EqualTo(1)); + Assert.That(response.Message, Does.Contain("unknown")); + } + + [Test] + public void Execute_ResetWhenRememberedSelectionIsGone_SelectsFirstSize() + { + // Verifies --reset falls back to the first entry when the remembered selection no longer exists. + FakeSetGameViewSizePort port = new() + { + Sizes = SizesWithOwnedThenCustomB(), + SelectedIndex = 2, + Remembered = "Custom A (320x240)" + }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.SelectedSize, Is.EqualTo("Free Aspect")); + Assert.That(port.CountCalls("SelectFirstSize"), Is.EqualTo(1)); + Assert.That(response.Message, Does.Contain("no longer exists")); + Assert.That(port.Remembered, Is.Null); + } + + [Test] + public void Execute_ResetWhileUserSizeSelected_RemovesEntriesAndKeepsSelection() + { + // Verifies --reset keeps the user's own selection by re-selecting it by name after the removal shifted its index. + FakeSetGameViewSizePort port = new() + { + Sizes = new List<(string DisplayText, uint Width, uint Height)> + { + ("Free Aspect", 800, 600), + ("Full HD (1920x1080)", 1920, 1080), + ("uloop (1280x720)", 1280, 720), + ("Custom A (640x480)", 640, 480), + ("Custom B (320x240)", 320, 240) + }, + SelectedIndex = 3, + Remembered = "Free Aspect" + }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.Removed, Is.EqualTo(1)); + Assert.That(response.SelectedSize, Is.EqualTo("Custom A (640x480)")); + Assert.That(response.Changed, Is.False); + Assert.That(port.CountCalls("TrySelectSize Custom A"), Is.EqualTo(1)); + Assert.That(port.Remembered, Is.Null); + Assert.That(response.Message, Does.Contain("was kept")); + } + + [Test] + public void Execute_ResetWithoutOwnedEntries_ReportsNothingToRemove() + { + // Verifies --reset with no owned entry removes nothing, leaves the selection alone, and says so. + FakeSetGameViewSizePort port = new(); + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.Success, Is.True); + Assert.That(response.Removed, Is.EqualTo(0)); + Assert.That(response.SelectedSize, Is.EqualTo("Full HD (1920x1080)")); + Assert.That(response.Changed, Is.False); + Assert.That(port.CountCalls("TrySelectSize"), Is.Zero); + Assert.That(response.Message, Does.Contain("No 'uloop' size entry")); + } + + [Test] + public void Execute_ResetOnSimulator_FailsWithoutRemoving() + { + // Verifies --reset under the Device Simulator fails and leaves the entries and the remembered selection untouched. + FakeSetGameViewSizePort port = new() + { + Kind = PlayModeViewKind.Simulator, + OwnedEntriesInOtherGroups = 1, + Remembered = "Free Aspect" + }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.Success, Is.False); + Assert.That(response.View, Is.EqualTo("Simulator")); + Assert.That(response.Removed, Is.Null); + Assert.That(port.CountCalls("RemoveOwnedSizes"), Is.Zero); + Assert.That(port.Remembered, Is.EqualTo("Free Aspect")); + Assert.That(response.Message, Does.Contain("game-view-mode --to GameView")); + } + + [Test] + public void Execute_ResetWithoutView_RemovesEntriesWithoutRestoring() + { + // Verifies --reset with no Play Mode view removes the entries, clears the memory, and opens nothing. + FakeSetGameViewSizePort port = new() + { + Kind = PlayModeViewKind.None, + OwnedEntriesInOtherGroups = 2, + Remembered = "Free Aspect" + }; + + SetGameViewSizeResponse response = Execute(port, ResetRequest()); + + Assert.That(response.Success, Is.True); + Assert.That(response.View, Is.EqualTo("None")); + Assert.That(response.Removed, Is.EqualTo(2)); + Assert.That(response.SelectedSize, Is.Null); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((0u, 0u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((0u, 0u))); + Assert.That(port.Remembered, Is.Null); + Assert.That(response.Message, Does.Contain("no selection to restore")); + } + + [Test] + public void Execute_WidthWithoutHeightWithoutView_IsRejectedWithoutOpeningAWindow() + { + // Verifies a rejected request with no Play Mode view reports 0x0 without asking for the resolution, which would open a window. + FakeSetGameViewSizePort port = new() { Kind = PlayModeViewKind.None }; + + SetGameViewSizeResponse response = Execute(port, Request(1280, null)); + + Assert.That(response.Success, Is.False); + Assert.That(response.View, Is.EqualTo("None")); + Assert.That((response.PreviousWidth, response.PreviousHeight), Is.EqualTo((0u, 0u))); + Assert.That((response.CurrentWidth, response.CurrentHeight), Is.EqualTo((0u, 0u))); + Assert.That(response.Message, Is.EqualTo("Width and Height must be provided together.")); + Assert.That(port.Calls, Is.Empty); + } + + /// + /// Minimal stand-in for the Game View size list of the current platform group. Precondition + /// violations throw so a use case that ignores the view kind fails the test. + /// + private sealed class FakeSetGameViewSizePort : ISetGameViewSizePort + { + public PlayModeViewKind Kind { get; set; } = PlayModeViewKind.GameView; + + // Entries of the current platform group in dropdown order. + public List<(string DisplayText, uint Width, uint Height)> Sizes { get; set; } = new() + { + ("Free Aspect", 800, 600), + ("Full HD (1920x1080)", 1920, 1080) + }; + + public int SelectedIndex { get; set; } = 1; + + // What the Simulator or a third-party view renders at. + public (uint Width, uint Height) NonGameViewResolution { get; set; } = (2360, 1640); + + // Owned entries in the other platform groups, counted by RemoveOwnedSizes. + public int OwnedEntriesInOtherGroups { get; set; } + + public string Remembered { get; set; } + + public List Calls { get; } = new(); + + public int CountCalls(string prefix) + { + return Calls.Count(call => call.StartsWith(prefix, StringComparison.Ordinal)); + } + + public PlayModeViewKind GetMainViewKind() + { + return Kind; + } + + public (uint Width, uint Height) GetRenderingResolution() + { + if (Kind == PlayModeViewKind.None) + { + // Unity opens a Game View here, so a call means the use case would have opened a window. + throw new InvalidOperationException("Reading the resolution without a view opens a window."); + } + + if (Kind != PlayModeViewKind.GameView) + { + return NonGameViewResolution; + } + + return (Sizes[SelectedIndex].Width, Sizes[SelectedIndex].Height); + } + + public void ApplyCustomResolution(uint width, uint height) + { + Calls.Add($"Apply {width}x{height}"); + if (Kind == PlayModeViewKind.Simulator || Kind == PlayModeViewKind.Other) + { + throw new InvalidOperationException("Custom resolution applies only to a Game View."); + } + + Kind = PlayModeViewKind.GameView; + int index = Sizes.FindIndex(size => size.DisplayText.StartsWith("uloop", StringComparison.Ordinal)); + (string DisplayText, uint Width, uint Height) entry = ($"uloop ({width}x{height})", width, height); + if (index < 0) + { + Sizes.Add(entry); + index = Sizes.Count - 1; + } + else + { + Sizes[index] = entry; + } + + SelectedIndex = index; + } + + public string GetSelectedSizeDisplayText() + { + RequireGameView(); + return Sizes[SelectedIndex].DisplayText; + } + + public string GetSelectedSizeBaseText() + { + RequireGameView(); + string displayText = Sizes[SelectedIndex].DisplayText; + return displayText.StartsWith("uloop (", StringComparison.Ordinal) ? "uloop" : displayText; + } + + public int RemoveOwnedSizes() + { + Calls.Add("RemoveOwnedSizes"); + int removed = Sizes.RemoveAll(size => size.DisplayText.StartsWith("uloop (", StringComparison.Ordinal)) + + OwnedEntriesInOtherGroups; + OwnedEntriesInOtherGroups = 0; + // Same clamp as Unity's EnsureSelectedSizeAreValid. + SelectedIndex = Math.Clamp(SelectedIndex, 0, Sizes.Count - 1); + return removed; + } + + public bool TrySelectSize(string displayText) + { + RequireGameView(); + Calls.Add($"TrySelectSize {displayText}"); + int index = Sizes.FindIndex(size => size.DisplayText == displayText); + if (index < 0) + { + return false; + } + + SelectedIndex = index; + return true; + } + + public string SelectFirstSize() + { + RequireGameView(); + Calls.Add("SelectFirstSize"); + SelectedIndex = 0; + return Sizes[0].DisplayText; + } + + public string ReadRememberedSelection() + { + return Remembered; + } + + public void RememberSelection(string displayText) + { + Remembered = displayText; + } + + public void ClearRememberedSelection() + { + Remembered = null; + } + + private void RequireGameView() + { + if (Kind != PlayModeViewKind.GameView) + { + throw new InvalidOperationException("The main Play Mode view is not a Game View."); + } + } + } + } +} diff --git a/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs.meta b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs.meta new file mode 100644 index 0000000000..9bfa8fb033 --- /dev/null +++ b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: dced901ffd4294da79ce9b36cf89ea3f +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef index 9ca9cb9a43..a9e97a7d8a 100644 --- a/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef +++ b/Assets/Tests/Editor/UnityCLILoop.Tests.Editor.asmdef @@ -46,7 +46,8 @@ "GUID:75469ad4d38634e559750d17036d5f7c", "GUID:548ac963c0508475482ff2c4c34f400d", "GUID:f0b6c2c251d6442f0ad1dd53c07e5092", - "GUID:a2da7f440ebf14c8cbea4e74e53fd58f" + "GUID:a2da7f440ebf14c8cbea4e74e53fd58f", + "GUID:11dcde5fce434d9ebb7704b1f3b39bba" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs new file mode 100644 index 0000000000..dfa2c7f965 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("UnityCLILoop.Tests.Editor")] diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs.meta b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs.meta new file mode 100644 index 0000000000..237fe1c6df --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/AssemblyInfo.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 21c8a0323e00040f18b5b240f83e8a59 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs new file mode 100644 index 0000000000..4cba5d8d34 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs @@ -0,0 +1,87 @@ +using UnityEditor; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Live Editor implementation of on top of the internal bridge + /// and public Editor APIs. + /// + internal sealed class EditorSetGameViewSizePort : ISetGameViewSizePort + { + // One key per platform group, because each group keeps its own selected entry. + private const string RememberedSelectionKeyPrefix = + "io.github.hatayama.uloopmcp.setGameViewSize.previousSelection."; + + public PlayModeViewKind GetMainViewKind() + { + return PlayModeViewSwitchBridge.GetMainViewKind(); + } + + public (uint Width, uint Height) GetRenderingResolution() + { + Debug.Assert( + GetMainViewKind() != PlayModeViewKind.None, + "a Play Mode view must exist; reading would open one otherwise"); + PlayModeWindow.GetRenderingResolution(out uint width, out uint height); + return (width, height); + } + + public void ApplyCustomResolution(uint width, uint height) + { + PlayModeViewKind kind = GetMainViewKind(); + Debug.Assert( + kind == PlayModeViewKind.GameView || kind == PlayModeViewKind.None, + "custom resolution applies only to a Game View"); + PlayModeWindow.SetCustomRenderingResolution(width, height, SetGameViewSizeUseCase.OwnedSizeBaseName); + } + + public string GetSelectedSizeDisplayText() + { + return GameViewSizeListBridge.GetSelectedSizeDisplayText(); + } + + public string GetSelectedSizeBaseText() + { + return GameViewSizeListBridge.GetSelectedSizeBaseText(); + } + + public int RemoveOwnedSizes() + { + return GameViewSizeListBridge.RemoveCustomSizes(SetGameViewSizeUseCase.OwnedSizeBaseName); + } + + public bool TrySelectSize(string displayText) + { + return GameViewSizeListBridge.TrySelectSize(displayText); + } + + public string SelectFirstSize() + { + return GameViewSizeListBridge.SelectFirstSize(); + } + + public string ReadRememberedSelection() + { + string remembered = SessionState.GetString(RememberedSelectionKey(), string.Empty); + return string.IsNullOrEmpty(remembered) ? null : remembered; + } + + public void RememberSelection(string displayText) + { + SessionState.SetString(RememberedSelectionKey(), displayText); + } + + public void ClearRememberedSelection() + { + SessionState.SetString(RememberedSelectionKey(), string.Empty); + } + + private static string RememberedSelectionKey() + { + return RememberedSelectionKeyPrefix + GameViewSizeListBridge.GetCurrentGroupName(); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs.meta b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs.meta new file mode 100644 index 0000000000..924bc1cd2b --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9b733e9c5d9824c38b4a84fc636d9802 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs new file mode 100644 index 0000000000..5a1152e668 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs @@ -0,0 +1,60 @@ +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Editor-side operations the set-game-view-size use case needs, kept behind a port so the + /// decision logic can be exercised without a Game View. + /// + internal interface ISetGameViewSizePort + { + PlayModeViewKind GetMainViewKind(); + + /// + /// Rendering resolution of the main view. Precondition: is not + /// None. Never opens a window. + /// + (uint Width, uint Height) GetRenderingResolution(); + + /// + /// Applies the owned size entry. Precondition: the kind is GameView or None; a Game View is + /// opened when none exists. Postcondition: the main view is a Game View whose selected entry + /// is the owned entry at width x height. + /// + void ApplyCustomResolution(uint width, uint height); + + /// Display text of the selected size entry. Precondition: the kind is GameView. + string GetSelectedSizeDisplayText(); + + /// + /// Base text of the selected size entry ("uloop" for the owned entry). Precondition: the kind + /// is GameView. + /// + string GetSelectedSizeBaseText(); + + /// + /// Removes every owned entry (base text "uloop", fixed resolution) from every platform group + /// and saves the list when any was removed. Returns the count. Works with any kind. + /// + int RemoveOwnedSizes(); + + /// + /// Selects the entry whose display text matches and refreshes the rendering size. + /// Precondition: the kind is GameView. Returns false, changing nothing, when no entry matches. + /// + bool TrySelectSize(string displayText); + + /// + /// Selects the first entry of the current group and returns its display text. + /// Precondition: the kind is GameView. + /// + string SelectFirstSize(); + + /// Remembered display text for the current platform group, or null when none. + string ReadRememberedSelection(); + + void RememberSelection(string displayText); + + void ClearRememberedSelection(); + } +} diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs.meta b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs.meta new file mode 100644 index 0000000000..c690221c91 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/ISetGameViewSizePort.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8c9c4e26590364cb3984526b68033b01 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeResponse.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeResponse.cs index 4f80114226..68416cb9b4 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeResponse.cs +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeResponse.cs @@ -7,11 +7,45 @@ namespace io.github.hatayama.UnityCliLoop.FirstPartyTools /// public sealed class SetGameViewSizeResponse : UnityCliLoopToolResponse { + /// + /// Play Mode view after the request: GameView, Simulator, None, or Other. + /// + public string View { get; set; } = ""; + public uint PreviousWidth { get; set; } public uint PreviousHeight { get; set; } public uint CurrentWidth { get; set; } public uint CurrentHeight { get; set; } public bool Changed { get; set; } + + /// + /// Display name of the entry selected in the Game View size dropdown after the request. + /// Present when a Game View is showing. + /// + public string SelectedSize { get; set; } + + public bool ShouldSerializeSelectedSize() + { + return !string.IsNullOrEmpty(SelectedSize); + } + + /// + /// Number of uloop size entries removed. Present with --reset. + /// + public int? Removed { get; set; } + + public bool ShouldSerializeRemoved() + { + return Removed.HasValue; + } + public string Message { get; set; } = ""; + + public string Warning { get; set; } = ""; + + public bool ShouldSerializeWarning() + { + return !string.IsNullOrEmpty(Warning); + } } } diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeSchema.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeSchema.cs index c1a658f604..3c4cea913e 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeSchema.cs +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeSchema.cs @@ -10,5 +10,11 @@ public sealed class SetGameViewSizeSchema : UnityCliLoopToolSchema public int? Width { get; set; } public int? Height { get; set; } + + /// + /// When true, removes the size entries this tool added and restores the previous selection. + /// Cannot be combined with Width / Height. + /// + public bool Reset { get; set; } = false; } } diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeTool.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeTool.cs index 7e28c4a82e..bfe1c4e2ca 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeTool.cs +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeTool.cs @@ -17,8 +17,9 @@ protected override Task ExecuteAsync( SetGameViewSizeSchema parameters, CancellationToken ct) { - SetGameViewSizeUseCase useCase = new(); - return useCase.ExecuteAsync(parameters, ct); + ct.ThrowIfCancellationRequested(); + SetGameViewSizeUseCase useCase = new(new EditorSetGameViewSizePort()); + return Task.FromResult(useCase.Execute(parameters)); } } } diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeUseCase.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeUseCase.cs index 3ea9093d15..b2ad7df8b8 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeUseCase.cs +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/SetGameViewSizeUseCase.cs @@ -1,101 +1,316 @@ -using System.Threading; -using System.Threading.Tasks; -using UnityEditor; +using UnityEngine; + +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; namespace io.github.hatayama.UnityCliLoop.FirstPartyTools { /// - /// Applies and reports the Unity Game View custom rendering resolution. + /// Reads, applies, or resets the Unity Game View custom rendering resolution, refusing views + /// the custom resolution does not apply to. /// - public sealed class SetGameViewSizeUseCase + internal sealed class SetGameViewSizeUseCase { - private const string RenderingResolutionBaseName = "uloop"; + /// Base name of the Game View size entry this tool adds and later removes. + internal const string OwnedSizeBaseName = "uloop"; + + private const string ResetWithSizeMessage = "--reset cannot be combined with --width or --height."; + private const string NotTogetherMessage = "Width and Height must be provided together."; + private const string NotPositiveMessage = "Width and Height must be positive integers."; + private const string ReadNoneMessage = + "No Play Mode view is open, so there is no rendering resolution to read."; + private const string SetSimulatorMessage = + "The Device Simulator is showing, so the Game View rendering resolution was not changed; it does not apply there. " + + "Run `uloop game-view-mode --to GameView` to show the Game View first, or pick a device with " + + "`uloop game-view-mode --device ` to change the simulated screen size."; + private const string SetOtherMessage = + "A Play Mode view of another kind is showing; it does not support a custom resolution, so nothing was changed. " + + "Run `uloop game-view-mode --to GameView` to show the Game View first."; + private const string ResetSimulatorMessage = + "The Device Simulator is showing, so the previous Game View selection cannot be restored; nothing was changed. " + + "Run `uloop game-view-mode --to GameView` first, then run `uloop set-game-view-size --reset`."; + private const string ResetOtherMessage = + "A Play Mode view of another kind is showing, so the previous Game View selection cannot be restored; nothing was changed. " + + "Run `uloop game-view-mode --to GameView` first, then run `uloop set-game-view-size --reset`."; + private const string RemovedNoneMessage = "No 'uloop' size entry was found in the Game View size list."; + private const string ResetNoneTail = "No Play Mode view is open, so there was no selection to restore."; + + private readonly ISetGameViewSizePort _port; + + public SetGameViewSizeUseCase(ISetGameViewSizePort port) + { + Debug.Assert(port != null, "port must not be null"); + _port = port; + } /// - /// Reads the current resolution and optionally applies a paired width and height. + /// Reports the rendering resolution without arguments, applies a paired width and height, or + /// removes the owned entries with Reset. A failed request changes nothing. /// - public Task ExecuteAsync( - SetGameViewSizeSchema parameters, - CancellationToken ct) + public SetGameViewSizeResponse Execute(SetGameViewSizeSchema parameters) { - ct.ThrowIfCancellationRequested(); + Debug.Assert(parameters != null, "parameters must not be null"); - (uint previousWidth, uint previousHeight) = GetRenderingResolution(); bool hasWidth = parameters.Width.HasValue; bool hasHeight = parameters.Height.HasValue; + // Reading the kind has no side effect, so it is read once and every branch agrees on it. + PlayModeViewKind kind = _port.GetMainViewKind(); + + // Checked before the pairing rule so --reset --width 10 names the real mistake. + if (parameters.Reset && (hasWidth || hasHeight)) + { + return Rejected(kind, ResetWithSizeMessage); + } + if (hasWidth != hasHeight) { - return Task.FromResult(CreateResponse( - false, - "Width and Height must be provided together.", - previousWidth, - previousHeight, - previousWidth, - previousHeight)); + return Rejected(kind, NotTogetherMessage); + } + + if (parameters.Reset) + { + return Reset(kind); } if (!hasWidth) { - return Task.FromResult(CreateResponse( - true, - $"Current Game View rendering resolution is {previousWidth}x{previousHeight}.", - previousWidth, - previousHeight, - previousWidth, - previousHeight)); + return Read(kind); } if (parameters.Width.Value <= 0 || parameters.Height.Value <= 0) { - return Task.FromResult(CreateResponse( - false, - "Width and Height must be positive integers.", - previousWidth, - previousHeight, - previousWidth, - previousHeight)); + return Rejected(kind, NotPositiveMessage); } - PlayModeWindow.SetCustomRenderingResolution( - (uint)parameters.Width.Value, - (uint)parameters.Height.Value, - RenderingResolutionBaseName); + return Apply(kind, (uint)parameters.Width.Value, (uint)parameters.Height.Value); + } - (uint currentWidth, uint currentHeight) = GetRenderingResolution(); - return Task.FromResult(CreateResponse( + private (uint Width, uint Height) CurrentSizes(PlayModeViewKind kind) + { + // Reading the resolution with no view open makes Unity open a Game View. + if (kind == PlayModeViewKind.None) + { + return (0, 0); + } + + return _port.GetRenderingResolution(); + } + + private SetGameViewSizeResponse Rejected(PlayModeViewKind kind, string message) + { + (uint Width, uint Height) size = CurrentSizes(kind); + return CreateResponse(false, kind, size, size, message); + } + + private SetGameViewSizeResponse Read(PlayModeViewKind kind) + { + if (kind == PlayModeViewKind.None) + { + return CreateResponse(true, kind, (0, 0), (0, 0), ReadNoneMessage); + } + + (uint Width, uint Height) size = _port.GetRenderingResolution(); + string sizeText = FormatSize(size); + switch (kind) + { + case PlayModeViewKind.GameView: + SetGameViewSizeResponse response = CreateResponse( + true, + kind, + size, + size, + $"Current Game View rendering resolution is {sizeText}."); + response.SelectedSize = _port.GetSelectedSizeDisplayText(); + return response; + case PlayModeViewKind.Simulator: + return CreateResponse( + true, + kind, + size, + size, + $"The Device Simulator is showing and renders at {sizeText}. " + + "The Game View custom resolution does not apply while it is showing."); + default: + return CreateResponse( + true, + kind, + size, + size, + $"A Play Mode view of another kind is showing and renders at {sizeText}. " + + "It does not support a custom resolution."); + } + } + + private SetGameViewSizeResponse Apply(PlayModeViewKind kind, uint width, uint height) + { + if (kind == PlayModeViewKind.Simulator) + { + return Rejected(kind, SetSimulatorMessage); + } + + if (kind == PlayModeViewKind.Other) + { + return Rejected(kind, SetOtherMessage); + } + + (uint Width, uint Height) previous = CurrentSizes(kind); + // Remembered before applying, because afterwards the owned entry is selected. A second change + // while the owned entry is selected must not overwrite the user's selection with our own. + if (kind == PlayModeViewKind.GameView && _port.GetSelectedSizeBaseText() != OwnedSizeBaseName) + { + _port.RememberSelection(_port.GetSelectedSizeDisplayText()); + } + + _port.ApplyCustomResolution(width, height); + (uint Width, uint Height) current = _port.GetRenderingResolution(); + string selected = _port.GetSelectedSizeDisplayText(); + string message = ApplyMessage(kind, previous, current); + SetGameViewSizeResponse response = CreateResponse(true, PlayModeViewKind.GameView, previous, current, message); + response.SelectedSize = selected; + response.Warning = + $"The Game View size list now has the entry '{selected}' selected. Unity saves the list to the user-wide " + + "GameViewSizes.asset, so the entry stays after the Editor closes and appears in every project. " + + "Run `uloop set-game-view-size --reset` to remove it and restore the previous selection."; + return response; + } + + private static string ApplyMessage( + PlayModeViewKind kind, + (uint Width, uint Height) previous, + (uint Width, uint Height) current) + { + if (kind == PlayModeViewKind.None) + { + return $"No Play Mode view was open, so a Game View was opened with a rendering resolution of {FormatSize(current)}."; + } + + if (previous != current) + { + return $"Game View rendering resolution changed from {FormatSize(previous)} to {FormatSize(current)}."; + } + + return $"Game View rendering resolution is already {FormatSize(current)}."; + } + + private SetGameViewSizeResponse Reset(PlayModeViewKind kind) + { + if (kind == PlayModeViewKind.Simulator) + { + return Rejected(kind, ResetSimulatorMessage); + } + + if (kind == PlayModeViewKind.Other) + { + return Rejected(kind, ResetOtherMessage); + } + + if (kind == PlayModeViewKind.None) + { + int removedWithoutView = _port.RemoveOwnedSizes(); + _port.ClearRememberedSelection(); + SetGameViewSizeResponse noneResponse = CreateResponse( + true, + kind, + (0, 0), + (0, 0), + RemovedPart(removedWithoutView) + ResetNoneTail); + noneResponse.Removed = removedWithoutView; + return noneResponse; + } + + return ResetOnGameView(); + } + + private SetGameViewSizeResponse ResetOnGameView() + { + // Read before the removal, because removing entries shifts the selected index. + (uint Width, uint Height) previous = _port.GetRenderingResolution(); + string selectedBefore = _port.GetSelectedSizeDisplayText(); + bool ownedSelected = _port.GetSelectedSizeBaseText() == OwnedSizeBaseName; + int removed = _port.RemoveOwnedSizes(); + + string selected; + string tail; + if (!ownedSelected) + { + // The user's own selection survives the removal, but its index may have shifted: re-select by name. + if (removed > 0) + { + _port.TrySelectSize(selectedBefore); + } + + selected = selectedBefore; + tail = $"The selected size '{selectedBefore}' is not one of uloop's, so it was kept."; + } + else + { + (selected, tail) = RestoreRememberedSelection(); + } + + _port.ClearRememberedSelection(); + (uint Width, uint Height) current = _port.GetRenderingResolution(); + SetGameViewSizeResponse response = CreateResponse( true, - $"Game View rendering resolution changed from {previousWidth}x{previousHeight} to {currentWidth}x{currentHeight}.", - previousWidth, - previousHeight, - currentWidth, - currentHeight)); + PlayModeViewKind.GameView, + previous, + current, + RemovedPart(removed) + tail); + response.Removed = removed; + response.SelectedSize = selected; + return response; + } + + private (string Selected, string Tail) RestoreRememberedSelection() + { + string remembered = _port.ReadRememberedSelection(); + if (remembered != null && _port.TrySelectSize(remembered)) + { + return (remembered, $"Restored the previous selection '{remembered}'."); + } + + string first = _port.SelectFirstSize(); + if (remembered == null) + { + return ( + first, + "The previous selection is unknown (it is remembered only within the Editor session that changed the size), " + + $"so '{first}' was selected."); + } + + return (first, $"The previous selection '{remembered}' no longer exists, so '{first}' was selected."); + } + + private static string RemovedPart(int removed) + { + if (removed > 0) + { + return $"Removed {removed} 'uloop' size entries from the Game View size list (every platform group). "; + } + + return RemovedNoneMessage + " "; } - private static (uint Width, uint Height) GetRenderingResolution() + private static string FormatSize((uint Width, uint Height) size) { - uint width; - uint height; - PlayModeWindow.GetRenderingResolution(out width, out height); - return (width, height); + return $"{size.Width}x{size.Height}"; } private static SetGameViewSizeResponse CreateResponse( bool success, - string message, - uint previousWidth, - uint previousHeight, - uint currentWidth, - uint currentHeight) + PlayModeViewKind view, + (uint Width, uint Height) previous, + (uint Width, uint Height) current, + string message) { return new SetGameViewSizeResponse { Success = success, + View = view.ToString(), Message = message, - PreviousWidth = previousWidth, - PreviousHeight = previousHeight, - CurrentWidth = currentWidth, - CurrentHeight = currentHeight, - Changed = previousWidth != currentWidth || previousHeight != currentHeight + PreviousWidth = previous.Width, + PreviousHeight = previous.Height, + CurrentWidth = current.Width, + CurrentHeight = current.Height, + Changed = previous != current }; } } diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/UnityCLILoop.FirstPartyTools.SetGameViewSize.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/UnityCLILoop.FirstPartyTools.SetGameViewSize.Editor.asmdef index eb7854a617..f536d4e605 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/UnityCLILoop.FirstPartyTools.SetGameViewSize.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/UnityCLILoop.FirstPartyTools.SetGameViewSize.Editor.asmdef @@ -2,7 +2,8 @@ "name": "UnityCLILoop.FirstPartyTools.SetGameViewSize.Editor", "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ - "GUID:fc3fd32eddbee40e39c2d76dc184957b" + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:5079a8d3a72924a81aa1cbc25f65ed1b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs new file mode 100644 index 0000000000..2b8cfb008e --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.InternalAPIBridge +{ + /// + /// Reads and edits the Game View size list and the Game View's selected entry. GameViewSizes, + /// GameViewSizeGroup, and GameView are internal Editor types reached directly through the + /// friend-assembly grant; no reflection. + /// + public static class GameViewSizeListBridge + { + /// Name of the platform group of the active build target, e.g. "Standalone". + public static string GetCurrentGroupName() + { + return GameViewSizes.instance.currentGroupType.ToString(); + } + + /// Display text of the selected entry. Precondition: the main view is a Game View. + public static string GetSelectedSizeDisplayText() + { + return GetSelectedSize(RequireGameView()).displayText; + } + + /// Base text of the selected entry. Precondition: the main view is a Game View. + public static string GetSelectedSizeBaseText() + { + return GetSelectedSize(RequireGameView()).baseText; + } + + /// + /// Removes every fixed-resolution custom entry whose base text equals the argument from every + /// platform group, saves the list when any was removed, and returns the count. + /// + public static int RemoveCustomSizes(string baseText) + { + Debug.Assert(!string.IsNullOrEmpty(baseText), "baseText must not be empty"); + + int removed = 0; + foreach (GameViewSizeGroup group in DistinctGroups()) + { + // Walk backwards so removing an entry does not shift the indices still to be visited. + for (int index = group.GetTotalCount() - 1; index >= group.GetBuiltinCount(); index--) + { + GameViewSize size = group.GetGameViewSize(index); + // Exact match: a prefix match would also remove user entries such as "uloop-phone". + if (size.sizeType != GameViewSizeType.FixedResolution || size.baseText != baseText) + { + continue; + } + + group.RemoveCustomSize(index); + removed++; + } + } + + if (removed > 0) + { + GameViewSizes.instance.SaveToHDD(); + } + + return removed; + } + + /// + /// Selects the entry of the current group whose display text matches. Precondition: the main + /// view is a Game View. Returns false, changing nothing, when no entry matches. + /// + public static bool TrySelectSize(string displayText) + { + GameView gameView = RequireGameView(); + GameViewSizeGroup group = GameViewSizes.instance.currentGroup; + for (int index = 0; index < group.GetTotalCount(); index++) + { + if (group.GetGameViewSize(index).displayText != displayText) + { + continue; + } + + SelectIndex(gameView, index); + return true; + } + + return false; + } + + /// + /// Selects the first entry of the current group and returns its display text. Precondition: + /// the main view is a Game View. + /// + public static string SelectFirstSize() + { + GameView gameView = RequireGameView(); + SelectIndex(gameView, 0); + return GameViewSizes.instance.currentGroup.GetGameViewSize(0).displayText; + } + + private static GameView RequireGameView() + { + GameView gameView = PlayModeView.GetMainPlayModeView() as GameView; + if (gameView == null) + { + throw new InvalidOperationException("The main Play Mode view is not a Game View."); + } + + return gameView; + } + + private static GameViewSize GetSelectedSize(GameView gameView) + { + return GameViewSizes.instance.currentGroup.GetGameViewSize(gameView.selectedSizeIndex); + } + + private static List DistinctGroups() + { + List groups = new(); + foreach (GameViewSizeGroupType type in Enum.GetValues(typeof(GameViewSizeGroupType))) + { + GameViewSizeGroup group = GameViewSizes.instance.GetGroup(type); + // Obsolete members map to the Standalone group, so the same group comes back more than once. + if (!groups.Contains(group)) + { + groups.Add(group); + } + } + + return groups; + } + + private static void SelectIndex(GameView gameView, int index) + { + gameView.SizeSelectionCallback(index, null); + // SizeSelectionCallback returns early when the index is unchanged, which happens after a removal + // shifted the list, so the cached rendering size is refreshed here unconditionally. + gameView.targetSize = gameView.targetRenderSize; + gameView.Repaint(); + } + } +} diff --git a/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs.meta b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs.meta new file mode 100644 index 0000000000..07da471292 --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c173977bc9c3041698a132dccc63a847 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From dcf4e3d0df80490142674a0bf27ca11c708a5bcb Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 29 Oct 2026 14:36:34 +0900 Subject: [PATCH 2/3] Document set-game-view-size --reset and the persisted uloop entry Agents had no way to learn that the size change leaves a "uloop" entry in the user-wide GameViewSizes.asset, or that the size does not apply under the Device Simulator. The skill now documents View, SelectedSize, Removed, and Warning, the --reset contract (selection restored only for the current platform group and Editor session), and the Simulator refusal. The embedded tool catalog gains the Reset property, so the shared release input stamps are refreshed alongside it. --- .../skills/uloop-set-game-view-size/SKILL.md | 33 ++++++++++++------- .../skills/uloop-set-game-view-size/SKILL.md | 33 ++++++++++++------- Packages/src/Documentation~/tools.md | 2 +- Packages/src/Documentation~/tools_ja.md | 2 +- .../SetGameViewSize/Skill/SKILL.md | 33 ++++++++++++------- cli/common/tools/default-tools.json | 6 +++- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 8 files changed, 75 insertions(+), 38 deletions(-) diff --git a/.agents/skills/uloop-set-game-view-size/SKILL.md b/.agents/skills/uloop-set-game-view-size/SKILL.md index 2f37ab076f..97e7358f1b 100644 --- a/.agents/skills/uloop-set-game-view-size/SKILL.md +++ b/.agents/skills/uloop-set-game-view-size/SKILL.md @@ -1,17 +1,17 @@ --- name: uloop-set-game-view-size toolName: set-game-view-size -description: "Set or inspect the Unity Game View custom rendering resolution." +description: "Set, inspect, or reset the Unity Game View custom rendering resolution." --- # uloop set-game-view-size -Set or inspect the Unity Game View custom rendering resolution. +Set, inspect, or reset the Unity Game View custom rendering resolution. ## Usage ```bash -uloop set-game-view-size [--width --height ] +uloop set-game-view-size [--width --height ] [--reset] ``` ## Parameters @@ -20,28 +20,39 @@ uloop set-game-view-size [--width --height ] |-----------|------|---------|-------------| | `--width` | integer | unset | Target Game View rendering width in pixels. Provide with `--height` to change the resolution. | | `--height` | integer | unset | Target Game View rendering height in pixels. Provide with `--width` to change the resolution. | +| `--reset` | flag | - | Removes the `uloop` size entries this tool added from every platform group of the Game View size list, saves the list, and restores the size that was selected before this tool selected its entry. Cannot be combined with `--width` / `--height`. | ## Output Returns JSON containing: -- `Success`: Whether the request completed. -- `PreviousWidth` / `PreviousHeight`: Resolution before the request. -- `CurrentWidth` / `CurrentHeight`: Resolution after the request. -- `Changed`: Whether the resolution changed. +- `Success`: `false` when the Device Simulator (or another non-Game View window) is the Play Mode view and a change or reset was requested, when only one of `--width` / `--height` is given, when a value is not positive, or when `--reset` is combined with a size. Nothing changes in those cases. +- `View`: Play Mode view after the request: `GameView`, `Simulator`, `None` (no Play Mode view open), or `Other` (a third-party Play Mode view). +- `PreviousWidth` / `PreviousHeight`: Rendering resolution before the request (`0` when no view was open). +- `CurrentWidth` / `CurrentHeight`: Rendering resolution after the request. +- `Changed`: Whether the rendering resolution changed. +- `SelectedSize`: Name of the entry selected in the Game View size dropdown after the request, e.g. `uloop (1280x720)` or `Free Aspect`. Present when a Game View is showing. +- `Removed`: Number of `uloop` entries removed. Present with `--reset`. - `Message`: Human-readable status. +- `Warning`: Present after a change; says that the `uloop` entry persists and how to remove it. ## Examples ```bash -# Read the current custom rendering resolution +# Read the current rendering resolution and the selected size entry uloop set-game-view-size -# Set a Full HD custom rendering resolution +# Pin a Full HD rendering resolution uloop set-game-view-size --width 1920 --height 1080 + +# Remove the uloop entry and restore the previous selection +uloop set-game-view-size --reset ``` ## Notes -- Width and height must be provided together when changing the resolution. -- The tool uses Unity's public `UnityEditor.PlayModeWindow` API and does not require reflection. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. +- Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. +- No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. +- Width and height must be provided together and be positive. diff --git a/.claude/skills/uloop-set-game-view-size/SKILL.md b/.claude/skills/uloop-set-game-view-size/SKILL.md index 2f37ab076f..97e7358f1b 100644 --- a/.claude/skills/uloop-set-game-view-size/SKILL.md +++ b/.claude/skills/uloop-set-game-view-size/SKILL.md @@ -1,17 +1,17 @@ --- name: uloop-set-game-view-size toolName: set-game-view-size -description: "Set or inspect the Unity Game View custom rendering resolution." +description: "Set, inspect, or reset the Unity Game View custom rendering resolution." --- # uloop set-game-view-size -Set or inspect the Unity Game View custom rendering resolution. +Set, inspect, or reset the Unity Game View custom rendering resolution. ## Usage ```bash -uloop set-game-view-size [--width --height ] +uloop set-game-view-size [--width --height ] [--reset] ``` ## Parameters @@ -20,28 +20,39 @@ uloop set-game-view-size [--width --height ] |-----------|------|---------|-------------| | `--width` | integer | unset | Target Game View rendering width in pixels. Provide with `--height` to change the resolution. | | `--height` | integer | unset | Target Game View rendering height in pixels. Provide with `--width` to change the resolution. | +| `--reset` | flag | - | Removes the `uloop` size entries this tool added from every platform group of the Game View size list, saves the list, and restores the size that was selected before this tool selected its entry. Cannot be combined with `--width` / `--height`. | ## Output Returns JSON containing: -- `Success`: Whether the request completed. -- `PreviousWidth` / `PreviousHeight`: Resolution before the request. -- `CurrentWidth` / `CurrentHeight`: Resolution after the request. -- `Changed`: Whether the resolution changed. +- `Success`: `false` when the Device Simulator (or another non-Game View window) is the Play Mode view and a change or reset was requested, when only one of `--width` / `--height` is given, when a value is not positive, or when `--reset` is combined with a size. Nothing changes in those cases. +- `View`: Play Mode view after the request: `GameView`, `Simulator`, `None` (no Play Mode view open), or `Other` (a third-party Play Mode view). +- `PreviousWidth` / `PreviousHeight`: Rendering resolution before the request (`0` when no view was open). +- `CurrentWidth` / `CurrentHeight`: Rendering resolution after the request. +- `Changed`: Whether the rendering resolution changed. +- `SelectedSize`: Name of the entry selected in the Game View size dropdown after the request, e.g. `uloop (1280x720)` or `Free Aspect`. Present when a Game View is showing. +- `Removed`: Number of `uloop` entries removed. Present with `--reset`. - `Message`: Human-readable status. +- `Warning`: Present after a change; says that the `uloop` entry persists and how to remove it. ## Examples ```bash -# Read the current custom rendering resolution +# Read the current rendering resolution and the selected size entry uloop set-game-view-size -# Set a Full HD custom rendering resolution +# Pin a Full HD rendering resolution uloop set-game-view-size --width 1920 --height 1080 + +# Remove the uloop entry and restore the previous selection +uloop set-game-view-size --reset ``` ## Notes -- Width and height must be provided together when changing the resolution. -- The tool uses Unity's public `UnityEditor.PlayModeWindow` API and does not require reflection. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. +- Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. +- No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. +- Width and height must be provided together and be positive. diff --git a/Packages/src/Documentation~/tools.md b/Packages/src/Documentation~/tools.md index 793e56b442..bec2fe075d 100644 --- a/Packages/src/Documentation~/tools.md +++ b/Packages/src/Documentation~/tools.md @@ -115,7 +115,7 @@ Supports three matching modes: `exact` (default), `prefix`, and `contains` - all Use `CaptureMode: rendering` to capture the Game View's rendering output directly instead of the EditorWindow's appearance. This is for grabbing the in-game picture during PlayMode without the Editor's window chrome or scaling getting in the way. Add `AnnotateRaycastGrid: true` to overlay a coordinate grid on the captured image, which makes it easier for an AI looking at the image to pick coordinates to pass to `simulate-mouse-input`. -Use `uloop set-game-view-size --width 1920 --height 1080` to pin a custom Game View resolution. Do this when you want the coordinate space of `CaptureMode: rendering` to stay stable across runs (run it without arguments to read the current resolution). +Use `uloop set-game-view-size --width 1920 --height 1080` to pin a custom Game View resolution. Do this when you want the coordinate space of `CaptureMode: rendering` to stay stable across runs (run it without arguments to read the current resolution). The pinned size is kept as a `uloop` entry in the Game View size dropdown and saved in Unity's user-wide preferences; run `uloop set-game-view-size --reset` to remove it and restore the previous selection. Under the Device Simulator the size does not apply; switch with `uloop game-view-mode --to GameView` first. ```text → screenshot (WindowName: "Console") → Save Console window state as PNG diff --git a/Packages/src/Documentation~/tools_ja.md b/Packages/src/Documentation~/tools_ja.md index 9e492c1c4c..b104e6493f 100644 --- a/Packages/src/Documentation~/tools_ja.md +++ b/Packages/src/Documentation~/tools_ja.md @@ -115,7 +115,7 @@ macOS / Windows Editor上で、Unity Editor ウィンドウを最前面に表示 `CaptureMode: rendering` を指定すると、EditorWindowの見た目ではなくGame Viewのレンダリング結果を直接キャプチャします。PlayMode中のゲーム画面を、Editorのウィンドウ枠やスケーリングの影響を受けずに取得したい場合に使います。 `AnnotateRaycastGrid: true` を併用すると、キャプチャ画像に座標グリッドが重ねて描画されます。画像を見たAIが `simulate-mouse-input` に渡す座標を決めやすくなります。 -`uloop set-game-view-size --width 1920 --height 1080` でGame Viewのカスタム解像度を固定できます。`CaptureMode: rendering` の座標系を実行ごとに安定させたいときに使ってください(引数なしで実行すると現在の解像度を取得できます)。 +`uloop set-game-view-size --width 1920 --height 1080` でGame Viewのカスタム解像度を固定できます。`CaptureMode: rendering` の座標系を実行ごとに安定させたいときに使ってください(引数なしで実行すると現在の解像度を取得できます)。固定したサイズは Game View のサイズ一覧に `uloop` という項目として追加され、Unity のユーザー共通の設定に保存されます。`uloop set-game-view-size --reset` でその項目を消して元の選択に戻せます。Device Simulator 表示中はこのサイズは効かないため、先に `uloop game-view-mode --to GameView` で切り替えてください。 ```text → screenshot (WindowName: "Console") → Console画面の状態をPNGで保存 diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md index 2f37ab076f..97e7358f1b 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md @@ -1,17 +1,17 @@ --- name: uloop-set-game-view-size toolName: set-game-view-size -description: "Set or inspect the Unity Game View custom rendering resolution." +description: "Set, inspect, or reset the Unity Game View custom rendering resolution." --- # uloop set-game-view-size -Set or inspect the Unity Game View custom rendering resolution. +Set, inspect, or reset the Unity Game View custom rendering resolution. ## Usage ```bash -uloop set-game-view-size [--width --height ] +uloop set-game-view-size [--width --height ] [--reset] ``` ## Parameters @@ -20,28 +20,39 @@ uloop set-game-view-size [--width --height ] |-----------|------|---------|-------------| | `--width` | integer | unset | Target Game View rendering width in pixels. Provide with `--height` to change the resolution. | | `--height` | integer | unset | Target Game View rendering height in pixels. Provide with `--width` to change the resolution. | +| `--reset` | flag | - | Removes the `uloop` size entries this tool added from every platform group of the Game View size list, saves the list, and restores the size that was selected before this tool selected its entry. Cannot be combined with `--width` / `--height`. | ## Output Returns JSON containing: -- `Success`: Whether the request completed. -- `PreviousWidth` / `PreviousHeight`: Resolution before the request. -- `CurrentWidth` / `CurrentHeight`: Resolution after the request. -- `Changed`: Whether the resolution changed. +- `Success`: `false` when the Device Simulator (or another non-Game View window) is the Play Mode view and a change or reset was requested, when only one of `--width` / `--height` is given, when a value is not positive, or when `--reset` is combined with a size. Nothing changes in those cases. +- `View`: Play Mode view after the request: `GameView`, `Simulator`, `None` (no Play Mode view open), or `Other` (a third-party Play Mode view). +- `PreviousWidth` / `PreviousHeight`: Rendering resolution before the request (`0` when no view was open). +- `CurrentWidth` / `CurrentHeight`: Rendering resolution after the request. +- `Changed`: Whether the rendering resolution changed. +- `SelectedSize`: Name of the entry selected in the Game View size dropdown after the request, e.g. `uloop (1280x720)` or `Free Aspect`. Present when a Game View is showing. +- `Removed`: Number of `uloop` entries removed. Present with `--reset`. - `Message`: Human-readable status. +- `Warning`: Present after a change; says that the `uloop` entry persists and how to remove it. ## Examples ```bash -# Read the current custom rendering resolution +# Read the current rendering resolution and the selected size entry uloop set-game-view-size -# Set a Full HD custom rendering resolution +# Pin a Full HD rendering resolution uloop set-game-view-size --width 1920 --height 1080 + +# Remove the uloop entry and restore the previous selection +uloop set-game-view-size --reset ``` ## Notes -- Width and height must be provided together when changing the resolution. -- The tool uses Unity's public `UnityEditor.PlayModeWindow` API and does not require reflection. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. +- Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. +- No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. +- Width and height must be provided together and be positive. diff --git a/cli/common/tools/default-tools.json b/cli/common/tools/default-tools.json index 840e31f318..c0277abf4d 100644 --- a/cli/common/tools/default-tools.json +++ b/cli/common/tools/default-tools.json @@ -752,7 +752,7 @@ }, { "name": "set-game-view-size", - "description": "Set or inspect the Unity Game View custom rendering resolution.", + "description": "Set, inspect, or reset the Unity Game View custom rendering resolution.", "inputSchema": { "type": "object", "properties": { @@ -763,6 +763,10 @@ "Height": { "type": "integer", "description": "Target Game View rendering height in pixels. Provide with --width to change the resolution." + }, + "Reset": { + "type": "boolean", + "description": "Removes the uloop size entries this tool added from every platform group of the Game View size list, saves the list, and restores the size that was selected before this tool selected its entry. Cannot be combined with --width / --height." } } } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 95e7e6a6fc..ef93adeb92 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "faa1b83079893ecb47a5cf672efb5b07bc16c600" + "sharedInputsHash": "23acd185b352d8863851088bbfb9623193a604c0" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index b57025b498..325526c139 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "2543dd10ed36b76084828884dd356c38fcc769fe" + "sharedInputsHash": "fb98448944d852bca377103bb80ee814c12e49d1" } From e168a744c1adbe4a774731a5f3e2348bf283f26e Mon Sep 17 00:00:00 2001 From: hatayama Date: Thu, 29 Oct 2026 15:06:06 +0900 Subject: [PATCH 3/3] Match the owned Game View size entry exactly when applying a size PlayModeWindow.SetCustomRenderingResolution reuses the first entry whose display text starts with "uloop", so a user entry such as "uloop-phone" was overwritten, then kept by --reset (which matches the base text exactly), and a second change remembered it in place of the real previous selection. - Apply the size through the bridge with the same steps as Unity's SetCustomResolution, but find the entry with the exact-match rule. - Extract that rule into IsOwnedSize, shared by apply and removal, and pin it with tests, since it is the only guard before entries are deleted from the user-wide size list. - Add a use case test with a "uloop-phone" entry and note in the skill that such entries are never touched. --- .../skills/uloop-set-game-view-size/SKILL.md | 2 +- .../skills/uloop-set-game-view-size/SKILL.md | 2 +- .../Editor/GameViewSizeListBridgeTests.cs | 34 +++++++++++ .../GameViewSizeListBridgeTests.cs.meta | 11 ++++ .../Editor/SetGameViewSizeUseCaseTests.cs | 32 +++++++++- .../EditorSetGameViewSizePort.cs | 2 +- .../SetGameViewSize/Skill/SKILL.md | 2 +- .../GameViewSizeListBridge.cs | 59 ++++++++++++++++++- 8 files changed, 137 insertions(+), 7 deletions(-) create mode 100644 Assets/Tests/Editor/GameViewSizeListBridgeTests.cs create mode 100644 Assets/Tests/Editor/GameViewSizeListBridgeTests.cs.meta diff --git a/.agents/skills/uloop-set-game-view-size/SKILL.md b/.agents/skills/uloop-set-game-view-size/SKILL.md index 97e7358f1b..63125b2075 100644 --- a/.agents/skills/uloop-set-game-view-size/SKILL.md +++ b/.agents/skills/uloop-set-game-view-size/SKILL.md @@ -51,7 +51,7 @@ uloop set-game-view-size --reset ## Notes -- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry, and entries whose name only starts with `uloop` (such as `uloop-phone`) are never touched. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. - `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. - Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. - No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. diff --git a/.claude/skills/uloop-set-game-view-size/SKILL.md b/.claude/skills/uloop-set-game-view-size/SKILL.md index 97e7358f1b..63125b2075 100644 --- a/.claude/skills/uloop-set-game-view-size/SKILL.md +++ b/.claude/skills/uloop-set-game-view-size/SKILL.md @@ -51,7 +51,7 @@ uloop set-game-view-size --reset ## Notes -- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry, and entries whose name only starts with `uloop` (such as `uloop-phone`) are never touched. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. - `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. - Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. - No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. diff --git a/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs b/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs new file mode 100644 index 0000000000..9c48f099d0 --- /dev/null +++ b/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs @@ -0,0 +1,34 @@ +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Verifies which Game View size entries the size list bridge treats as owned, the only guard in + /// front of removing entries from the user-wide size list. + /// + public sealed class GameViewSizeListBridgeTests + { + [Test] + public void IsOwnedSize_FixedResolutionWithExactBaseText_IsOwned() + { + // Verifies a fixed-resolution entry named exactly like the owned base text is claimed. + Assert.That(GameViewSizeListBridge.IsOwnedSize("uloop", true, "uloop"), Is.True); + } + + [Test] + public void IsOwnedSize_BaseTextOnlyStartingWithOwnedName_IsNotOwned() + { + // Verifies a user entry whose name merely starts with the owned base text is left alone. + Assert.That(GameViewSizeListBridge.IsOwnedSize("uloop-phone", true, "uloop"), Is.False); + } + + [Test] + public void IsOwnedSize_AspectRatioWithExactBaseText_IsNotOwned() + { + // Verifies an aspect-ratio entry is never claimed, because the tool only adds fixed resolutions. + Assert.That(GameViewSizeListBridge.IsOwnedSize("uloop", false, "uloop"), Is.False); + } + } +} diff --git a/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs.meta b/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs.meta new file mode 100644 index 0000000000..4a0aa90cc9 --- /dev/null +++ b/Assets/Tests/Editor/GameViewSizeListBridgeTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 6a65ba57500d149ab8d2bba2f038b05b +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs index 3ae4d161c4..57c4d8aadd 100644 --- a/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs +++ b/Assets/Tests/Editor/SetGameViewSizeUseCaseTests.cs @@ -199,6 +199,35 @@ public void Execute_SetWhileOwnedEntrySelected_KeepsRememberedSelection() Assert.That(response.Changed, Is.True); } + [Test] + public void Execute_SetWithUserEntryNamedLikeOurs_LeavesItAloneAndRemembersIt() + { + // Verifies a user entry whose name only starts with "uloop" is neither overwritten by a change nor removed by --reset. + FakeSetGameViewSizePort port = new() + { + Sizes = new List<(string DisplayText, uint Width, uint Height)> + { + ("Free Aspect", 800, 600), + ("uloop-phone (390x844)", 390, 844), + ("Full HD (1920x1080)", 1920, 1080) + }, + SelectedIndex = 1 + }; + + SetGameViewSizeResponse setResponse = Execute(port, Request(1280, 720)); + + Assert.That(port.Sizes[1], Is.EqualTo(("uloop-phone (390x844)", 390u, 844u))); + Assert.That(port.Sizes[^1].DisplayText, Is.EqualTo("uloop (1280x720)")); + Assert.That(setResponse.SelectedSize, Is.EqualTo("uloop (1280x720)")); + Assert.That(port.Remembered, Is.EqualTo("uloop-phone (390x844)")); + + SetGameViewSizeResponse resetResponse = Execute(port, ResetRequest()); + + Assert.That(resetResponse.Removed, Is.EqualTo(1)); + Assert.That(port.Sizes.Any(size => size.DisplayText == "uloop-phone (390x844)"), Is.True); + Assert.That(resetResponse.SelectedSize, Is.EqualTo("uloop-phone (390x844)")); + } + [Test] public void Execute_SetOnSimulator_FailsWithoutChanging() { @@ -476,7 +505,8 @@ public void ApplyCustomResolution(uint width, uint height) } Kind = PlayModeViewKind.GameView; - int index = Sizes.FindIndex(size => size.DisplayText.StartsWith("uloop", StringComparison.Ordinal)); + // "uloop (" is the display form of the owned entry: exact base text and a fixed resolution. + int index = Sizes.FindIndex(size => size.DisplayText.StartsWith("uloop (", StringComparison.Ordinal)); (string DisplayText, uint Width, uint Height) entry = ($"uloop ({width}x{height})", width, height); if (index < 0) { diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs index 4cba5d8d34..f7f4bc29e2 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/EditorSetGameViewSizePort.cs @@ -35,7 +35,7 @@ public void ApplyCustomResolution(uint width, uint height) Debug.Assert( kind == PlayModeViewKind.GameView || kind == PlayModeViewKind.None, "custom resolution applies only to a Game View"); - PlayModeWindow.SetCustomRenderingResolution(width, height, SetGameViewSizeUseCase.OwnedSizeBaseName); + GameViewSizeListBridge.ApplyCustomSize(width, height, SetGameViewSizeUseCase.OwnedSizeBaseName); } public string GetSelectedSizeDisplayText() diff --git a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md index 97e7358f1b..63125b2075 100644 --- a/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md +++ b/Packages/src/Editor/FirstPartyTools/SetGameViewSize/Skill/SKILL.md @@ -51,7 +51,7 @@ uloop set-game-view-size --reset ## Notes -- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. +- Changing the size adds an entry named `uloop (x)` to the Game View size dropdown and selects it; later calls update that one entry, and entries whose name only starts with `uloop` (such as `uloop-phone`) are never touched. Unity saves the list to the user-wide `GameViewSizes.asset` in the Editor preferences folder (macOS: `~/Library/Preferences/Unity/Editor-5.x/`, Windows: `%APPDATA%\Unity\Editor-5.x\Preferences\`), so the entry outlives the Editor session and appears in every project. One entry exists per platform group (Standalone, iOS, Android, ...), so it can sit in more than one group after switching the build target. - `--reset` removes those entries from every group and selects the size that was selected before the first change in the current Editor session. That earlier selection is remembered only within the Editor session (it survives a domain reload, not an Editor restart); when it is unknown or no longer exists, the first entry of the group (`Free Aspect`) is selected and `Message` says so. A selection you made yourself in the meantime is kept. The selection is restored only for the current platform group (the one of the active build target); in other groups the entry is removed and Unity keeps or clamps the selection on its own. - Under the Device Simulator the Game View size does not apply: a change or `--reset` returns `Success: false` and changes nothing. Run `uloop game-view-mode --to GameView` first, or pick a device with `uloop game-view-mode --device ` to change the simulated screen size. Reading without arguments still works and reports the Simulator's rendering size with `View: Simulator`. - No Play Mode view open: reading reports `View: None` with `0x0` and opens nothing; a change opens a Game View with the requested size; `--reset` removes the entries and has no selection to restore. diff --git a/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs index 2b8cfb008e..7527673af5 100644 --- a/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs +++ b/Packages/src/Editor/InternalAPIBridge/GameViewSizeListBridge.cs @@ -30,6 +30,48 @@ public static string GetSelectedSizeBaseText() return GetSelectedSize(RequireGameView()).baseText; } + /// + /// Whether a size entry is one the tool owns: a fixed resolution whose base text equals the owned + /// base text exactly. A prefix match would also claim user entries such as "uloop-phone". + /// + public static bool IsOwnedSize(string candidateBaseText, bool isFixedResolution, string ownedBaseText) + { + return isFixedResolution && string.Equals(candidateBaseText, ownedBaseText, StringComparison.Ordinal); + } + + /// + /// Sets the owned entry of the current group to width x height, adding it when missing, saves the + /// list, and selects the entry, opening a Game View when no Play Mode view exists. + /// Precondition: the main view is a Game View or none exists. + /// + public static void ApplyCustomSize(uint width, uint height, string baseText) + { + Debug.Assert(!string.IsNullOrEmpty(baseText), "baseText must not be empty"); + PlayModeView mainView = PlayModeView.GetMainPlayModeView(); + Debug.Assert(mainView == null || mainView is GameView, "the main Play Mode view must be a Game View or absent"); + // Same fallback as PlayModeWindow.SetCustomRenderingResolution when no Play Mode view exists. + GameView gameView = mainView as GameView ?? EditorWindow.CreateWindow(); + + // Mirrors GameView.SetCustomResolution, except that Unity matches the first entry whose display + // text starts with the base name and would overwrite a user entry such as "uloop-phone". + GameViewSizeGroup group = GameViewSizes.instance.currentGroup; + int index = FindOwnedCustomSize(group, baseText); + if (index >= 0) + { + GameViewSize size = group.GetGameViewSize(index); + size.width = (int)width; + size.height = (int)height; + } + else + { + group.AddCustomSize(new GameViewSize(GameViewSizeType.FixedResolution, (int)width, (int)height, baseText)); + index = group.GetTotalCount() - 1; + } + + GameViewSizes.instance.SaveToHDD(); + SelectIndex(gameView, index); + } + /// /// Removes every fixed-resolution custom entry whose base text equals the argument from every /// platform group, saves the list when any was removed, and returns the count. @@ -45,8 +87,7 @@ public static int RemoveCustomSizes(string baseText) for (int index = group.GetTotalCount() - 1; index >= group.GetBuiltinCount(); index--) { GameViewSize size = group.GetGameViewSize(index); - // Exact match: a prefix match would also remove user entries such as "uloop-phone". - if (size.sizeType != GameViewSizeType.FixedResolution || size.baseText != baseText) + if (!IsOwnedSize(size.baseText, size.sizeType == GameViewSizeType.FixedResolution, baseText)) { continue; } @@ -97,6 +138,20 @@ public static string SelectFirstSize() return GameViewSizes.instance.currentGroup.GetGameViewSize(0).displayText; } + private static int FindOwnedCustomSize(GameViewSizeGroup group, string baseText) + { + for (int index = group.GetBuiltinCount(); index < group.GetTotalCount(); index++) + { + GameViewSize size = group.GetGameViewSize(index); + if (IsOwnedSize(size.baseText, size.sizeType == GameViewSizeType.FixedResolution, baseText)) + { + return index; + } + } + + return -1; + } + private static GameView RequireGameView() { GameView gameView = PlayModeView.GetMainPlayModeView() as GameView;