ScenarioPicker: Reveal moves the content the right way; Launch is one-shot; the scrollbar takes no focus; the picker holds focus itself so the scene's background-click deselect is back on
ScenarioPickerVerify builds the real scenario list after a layout pass and arrows to every row, up and down, requiring each wholly visible; Launch by click, second click and Submit runs once. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
169679dbc4
commit
ca24a32028
3 changed files with 105 additions and 7 deletions
|
|
@ -525,7 +525,7 @@ MonoBehaviour:
|
|||
type: 3}
|
||||
m_TrackedDeviceOrientationAction: {fileID: 1025543830046995696, guid: ca9f5fa95ffab41fb9a615ab714db018,
|
||||
type: 3}
|
||||
m_DeselectOnBackgroundClick: 0
|
||||
m_DeselectOnBackgroundClick: 1
|
||||
m_PointerBehavior: 0
|
||||
--- !u!114 &553528108
|
||||
MonoBehaviour:
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ public static class ScenarioPickerVerify
|
|||
CancelAndBackAreOnePath();
|
||||
DetailPaneFollowsSelection();
|
||||
OneGroupHasNoSectionLabel();
|
||||
LaunchesOnceHoweverItIsReached();
|
||||
ArrowingThroughTheRealListKeepsTheSelectionVisible();
|
||||
Debug.Log("[ScenarioPickerVerify] ok");
|
||||
EditorApplication.Exit(0);
|
||||
}
|
||||
|
|
@ -66,7 +68,7 @@ public static class ScenarioPickerVerify
|
|||
if (!condition) throw new InvalidOperationException(what);
|
||||
}
|
||||
|
||||
private static Screen Build(bool oneGroup = false)
|
||||
private static Screen Build(bool oneGroup = false, bool real = false)
|
||||
{
|
||||
var (menu, list, detail) = ScenarioPickerCapture.Build();
|
||||
// Build opens the scene afresh, which destroys the previous EventSystem.
|
||||
|
|
@ -80,7 +82,7 @@ public static class ScenarioPickerVerify
|
|||
B = new Scenario[] { new Fake("Gamma", ambient: false) },
|
||||
C = new Scenario[] { new Fake("Delta"), new Fake("Echo") }
|
||||
};
|
||||
var groups = oneGroup
|
||||
var groups = real ? ScenarioPickerCapture.AllGroups() : oneGroup
|
||||
? new List<(string label, Scenario[] scenarios)> { ("modes", screen.A) }
|
||||
: new List<(string label, Scenario[] scenarios)> { ("modes", screen.A), ("development", screen.B), ("test arenas", screen.C) };
|
||||
screen.Picker = ScenarioPicker.Show(list, detail, groups, screen.Launched.Add, () => screen.Backs++, menu.Dialog.Confirm);
|
||||
|
|
@ -88,9 +90,43 @@ public static class ScenarioPickerVerify
|
|||
screen.Rows = buttons.Take(buttons.Length - 1).ToArray();
|
||||
screen.Back = buttons.Last();
|
||||
screen.Launch = detail.GetComponentsInChildren<Button>(false).Single();
|
||||
if (real) Layout(menu, list, detail);
|
||||
return screen;
|
||||
}
|
||||
|
||||
// A live 1920x1080 layout, as the game has one a frame after Show; edit mode has no frames, so it is forced here.
|
||||
private static void Layout(MainMenu menu, PropertiesPanel list, PropertiesPanel detail)
|
||||
{
|
||||
menu.Dialog.gameObject.SetActive(false);
|
||||
var canvas = menu.GetComponentInParent<Canvas>(true).rootCanvas;
|
||||
var camera = new GameObject("verify camera").AddComponent<Camera>();
|
||||
camera.targetTexture = new RenderTexture(1920, 1080, 24);
|
||||
canvas.renderMode = RenderMode.ScreenSpaceCamera;
|
||||
canvas.worldCamera = camera;
|
||||
canvas.planeDistance = 10;
|
||||
canvas.gameObject.SetActive(true);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
LayoutRebuilder.ForceRebuildLayoutImmediate((RectTransform) canvas.transform);
|
||||
foreach (var text in list.GetComponentsInChildren<TMP_Text>()) text.ForceMeshUpdate();
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Scroll(list).onValueChanged.Invoke(Vector2.zero);
|
||||
}
|
||||
|
||||
private static ScrollRect Scroll(PropertiesPanel list) => list.Content.GetComponentInParent<ScrollRect>();
|
||||
|
||||
// Wholly inside the viewport, in world space, and not hidden by FitViewport's CanvasGroup.
|
||||
private static bool Shown(Button row, ScrollRect scroll)
|
||||
{
|
||||
var view = new Vector3[4];
|
||||
((RectTransform) scroll.viewport).GetWorldCorners(view);
|
||||
var rect = new Vector3[4];
|
||||
((RectTransform) row.transform).GetWorldCorners(rect);
|
||||
var alpha = 1f;
|
||||
for (var t = row.transform; t != null && t != scroll.content; t = t.parent)
|
||||
if (t.TryGetComponent<CanvasGroup>(out var group)) alpha = Mathf.Min(alpha, group.alpha);
|
||||
return rect[0].y >= view[0].y - .01f && rect[1].y <= view[1].y + .01f && alpha == 1;
|
||||
}
|
||||
|
||||
private static void Move(Screen screen, MoveDirection direction) =>
|
||||
ExecuteEvents.Execute(screen.Launch.gameObject, new AxisEventData(EventSystem.current) { moveDir = direction }, ExecuteEvents.moveHandler);
|
||||
|
||||
|
|
@ -190,4 +226,38 @@ public static class ScenarioPickerVerify
|
|||
Require(many.List.Children == 5 + 3 + 2 + 1, $"Three groups should list 5 rows, 3 section labels, 2 spacers and Back, not {many.List.Children} children.");
|
||||
Require(one.List.Children == 2 + 1, $"One group should list its 2 rows and Back, not {one.List.Children} children.");
|
||||
}
|
||||
|
||||
private static void LaunchesOnceHoweverItIsReached()
|
||||
{
|
||||
var screen = Build();
|
||||
screen.Launch.onClick.Invoke();
|
||||
screen.Launch.onClick.Invoke();
|
||||
ExecuteEvents.Execute(screen.Launch.gameObject, new BaseEventData(EventSystem.current), ExecuteEvents.submitHandler);
|
||||
Require(screen.Launched.Count == 1, $"Launch ran {screen.Launched.Count} times after a click, a second click and Submit.");
|
||||
Require(screen.Launch.navigation.mode == Navigation.Mode.None && Scroll(screen.List).verticalScrollbar.navigation.mode == Navigation.Mode.None,
|
||||
"The list scrollbar or Launch takes Unity navigation.");
|
||||
}
|
||||
|
||||
// The real scenario groups overflow the list's viewport; every row the arrows reach must end wholly visible.
|
||||
private static void ArrowingThroughTheRealListKeepsTheSelectionVisible()
|
||||
{
|
||||
var screen = Build(real: true);
|
||||
var scroll = Scroll(screen.List);
|
||||
var content = (RectTransform) scroll.content;
|
||||
var viewport = (RectTransform) scroll.viewport;
|
||||
Require(content.rect.height > viewport.rect.height + 1, $"The real list ({content.rect.height}) does not overflow its viewport ({viewport.rect.height}); the scroll rules are unmeasured.");
|
||||
Require(Shown(screen.Rows[0], scroll), "The first row is not shown on Show.");
|
||||
for (var i = 1; i < screen.Rows.Length; i++)
|
||||
{
|
||||
Move(screen, MoveDirection.Down);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Require(Shown(screen.Rows[i], scroll), $"Down to row {i} ({Selected(screen)}) left it hidden or cut off.");
|
||||
}
|
||||
for (var i = screen.Rows.Length - 2; i >= 0; i--)
|
||||
{
|
||||
Move(screen, MoveDirection.Up);
|
||||
Canvas.ForceUpdateCanvases();
|
||||
Require(Shown(screen.Rows[i], scroll), $"Up to row {i} ({Selected(screen)}) left it hidden or cut off.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using TMPro;
|
|||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem.UI;
|
||||
using UnityEngine.UI;
|
||||
|
||||
// The New Game screen: scenario names on the left pane by group, the selected scenario's brief, seed and ambient on a
|
||||
|
|
@ -28,6 +29,8 @@ public sealed class ScenarioPicker
|
|||
private UnityAction<Vector2> _fit;
|
||||
private static readonly Color Accent = new Color(1, .4458f, .05f);
|
||||
private int _selected;
|
||||
private bool _launched;
|
||||
private bool _restoreDeselect;
|
||||
|
||||
private ScenarioPicker(PropertiesPanel list, PropertiesPanel detail, Action<Scenario> launch, Action back)
|
||||
{
|
||||
|
|
@ -112,6 +115,7 @@ public sealed class ScenarioPicker
|
|||
picker._launchButton.OnCancelAsObservable().Subscribe(_ => picker.Back());
|
||||
|
||||
picker.StyleScrollbars();
|
||||
picker.HoldFocus();
|
||||
picker.Select(0);
|
||||
// Layout is not live until the frame after Show; edit mode has no frames.
|
||||
if (Application.isPlaying) Observable.NextFrame().Subscribe(_ => picker.FitViewport());
|
||||
|
|
@ -165,6 +169,8 @@ public sealed class ScenarioPicker
|
|||
{
|
||||
if (!_scroll.verticalScrollbar.TryGetComponent<Image>(out var track)) track = _scroll.verticalScrollbar.gameObject.AddComponent<Image>();
|
||||
track.color = new Color(Accent.r, Accent.g, Accent.b, .2f);
|
||||
// A click on the bar selects it and takes focus off Launch, which would silence the arrows.
|
||||
_scroll.verticalScrollbar.navigation = new Navigation { mode = Navigation.Mode.None };
|
||||
}
|
||||
_scroll.onValueChanged.RemoveAllListeners();
|
||||
_fit = _ => FitViewport();
|
||||
|
|
@ -233,16 +239,21 @@ public sealed class ScenarioPicker
|
|||
if (scroll == null || scroll.content == null) return;
|
||||
var (low, high) = ViewSpan(scroll);
|
||||
var rowBounds = RectTransformUtility.CalculateRelativeRectTransformBounds(scroll.content, row);
|
||||
var shift = rowBounds.max.y > high ? high - rowBounds.max.y
|
||||
: rowBounds.min.y < low ? low - rowBounds.min.y : 0;
|
||||
scroll.content.anchoredPosition -= new Vector2(0, shift);
|
||||
var shift = rowBounds.max.y > high ? rowBounds.max.y - high
|
||||
: rowBounds.min.y < low ? rowBounds.min.y - low : 0;
|
||||
// Content moves against the row: a row above the viewport needs the content to move down, and the reverse.
|
||||
scroll.content.anchoredPosition += new Vector2(0, shift);
|
||||
if (shift != 0) FitViewport();
|
||||
}
|
||||
|
||||
// Launches the selection; focus is cleared first so a repeated Enter or a second click cannot launch twice.
|
||||
// Launches the selection, once: Enter, Submit and a mouse click all arrive here, and nothing launches again until
|
||||
// the picker is shown afresh, however long the menu takes to fade out.
|
||||
private void Activate()
|
||||
{
|
||||
if (_launched) return;
|
||||
_launched = true;
|
||||
Focus(null);
|
||||
ReleaseFocus();
|
||||
_launch(Selected);
|
||||
}
|
||||
|
||||
|
|
@ -250,9 +261,26 @@ public sealed class ScenarioPicker
|
|||
private void Back()
|
||||
{
|
||||
Focus(null);
|
||||
ReleaseFocus();
|
||||
_back();
|
||||
}
|
||||
|
||||
// While the picker is up, a click on empty space must not drop focus from Launch, or the arrows and Enter go dead.
|
||||
// The input module's flag is scene-wide, so the picker takes it for its own lifetime and gives it back on leaving.
|
||||
private void HoldFocus()
|
||||
{
|
||||
if (EventSystem.current == null || !(EventSystem.current.currentInputModule is InputSystemUIInputModule module) || !module.deselectOnBackgroundClick) return;
|
||||
module.deselectOnBackgroundClick = false;
|
||||
_restoreDeselect = true;
|
||||
}
|
||||
|
||||
private void ReleaseFocus()
|
||||
{
|
||||
if (!_restoreDeselect) return;
|
||||
_restoreDeselect = false;
|
||||
if (EventSystem.current != null && EventSystem.current.currentInputModule is InputSystemUIInputModule module) module.deselectOnBackgroundClick = true;
|
||||
}
|
||||
|
||||
private static void Focus(GameObject target)
|
||||
{
|
||||
if (EventSystem.current != null) EventSystem.current.SetSelectedGameObject(target);
|
||||
|
|
|
|||
Loading…
Reference in a new issue