using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace DanroJump.UI.Modals
{
///
/// Управляет стеком UI Toolkit-модалок и удерживает фокус внутри последнего открытого окна.
///
public sealed class ModalStackController
{
private readonly VisualElement layer;
private readonly VisualElement backdrop;
private readonly List stack = new();
private VisualElement focusBeforeFirstModal;
private VisualElement eventRoot;
private bool isRegistered;
///
/// Создает контроллер для слоя модальных окон и фоновой затемняющей подложки.
///
public ModalStackController(VisualElement layer, VisualElement backdrop)
{
this.layer = layer;
this.backdrop = backdrop;
HideLayer();
}
///
/// Показывает, есть ли открытые модальные окна.
///
public bool HasOpenModals => stack.Count > 0;
///
/// Последняя открытая модалка, которая сейчас удерживает фокус.
///
public ModalWindowBase TopModal => stack.Count > 0 ? stack[^1] : null;
///
/// Открывает модалку поверх текущего стека и переводит в нее фокус.
///
public void Open(ModalWindowBase modal)
{
if (modal == null || modal.Root == null)
{
return;
}
if (stack.Contains(modal))
{
Close(modal);
}
if (stack.Count == 0)
{
focusBeforeFirstModal = GetFocusedVisualElement();
ShowLayer();
RegisterFocusGuards();
}
stack.Add(modal);
modal.CloseRequested += Close;
modal.Open();
modal.Root.BringToFront();
modal.FocusDefault();
}
///
/// Закрывает указанную модалку и все окна, которые были открыты поверх нее.
///
public void Close(ModalWindowBase modal)
{
if (modal == null)
{
return;
}
var index = stack.LastIndexOf(modal);
if (index < 0)
{
return;
}
for (var i = stack.Count - 1; i >= index; i--)
{
CloseAt(i);
}
if (stack.Count > 0)
{
TopModal.FocusDefault();
return;
}
UnregisterFocusGuards();
HideLayer();
focusBeforeFirstModal?.Focus();
focusBeforeFirstModal = null;
}
///
/// Закрывает верхнюю модалку в стеке.
///
public void CloseTop()
{
Close(TopModal);
}
///
/// Закрывает все модалки и снимает глобальные обработчики фокуса.
///
public void Dispose()
{
while (stack.Count > 0)
{
CloseAt(stack.Count - 1);
}
UnregisterFocusGuards();
HideLayer();
focusBeforeFirstModal = null;
}
///
/// Закрывает модалку по индексу стека без восстановления фокуса наружу.
///
private void CloseAt(int index)
{
var modal = stack[index];
modal.CloseRequested -= Close;
modal.Close();
stack.RemoveAt(index);
}
///
/// Показывает общий слой модалок и backdrop.
///
private void ShowLayer()
{
if (layer != null)
{
layer.style.display = DisplayStyle.Flex;
layer.SetEnabled(true);
layer.BringToFront();
}
if (backdrop != null)
{
backdrop.style.display = DisplayStyle.Flex;
backdrop.SetEnabled(true);
}
}
///
/// Скрывает общий слой модалок и backdrop.
///
private void HideLayer()
{
if (backdrop != null)
{
backdrop.style.display = DisplayStyle.None;
backdrop.SetEnabled(false);
}
if (layer != null)
{
layer.style.display = DisplayStyle.None;
layer.SetEnabled(false);
}
}
///
/// Подключает обработчики к корню панели, чтобы фокус нельзя было увести за верхнюю модалку.
///
private void RegisterFocusGuards()
{
if (layer == null || isRegistered)
{
return;
}
eventRoot = layer.panel?.visualTree ?? layer;
eventRoot.RegisterCallback(HandleFocusIn, TrickleDown.TrickleDown);
eventRoot.RegisterCallback(HandleFocusOut, TrickleDown.TrickleDown);
eventRoot.RegisterCallback(HandleNavigationMove, TrickleDown.TrickleDown);
eventRoot.RegisterCallback(HandleKeyDown, TrickleDown.TrickleDown);
isRegistered = true;
}
///
/// Снимает обработчики, удерживающие фокус внутри модального стека.
///
private void UnregisterFocusGuards()
{
if (eventRoot == null || !isRegistered)
{
return;
}
eventRoot.UnregisterCallback(HandleFocusIn, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback(HandleFocusOut, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback(HandleNavigationMove, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback(HandleKeyDown, TrickleDown.TrickleDown);
eventRoot = null;
isRegistered = false;
}
///
/// Возвращает фокус назад в верхнюю модалку, если он попал во внешний UI.
///
private void HandleFocusIn(FocusInEvent evt)
{
var top = TopModal;
if (top == null || evt.target is not VisualElement element)
{
return;
}
if (!top.Contains(element))
{
top.FocusDefault();
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
top.RememberFocusedElement(element);
}
///
/// Планирует проверку фокуса после того, как UI Toolkit завершит FocusOut.
///
private void HandleFocusOut(FocusOutEvent evt)
{
if (TopModal == null)
{
return;
}
layer?.schedule.Execute(KeepFocusInsideTopModal);
}
///
/// Преобразует UI Toolkit navigation-событие в линейное движение по элементам модалки.
///
private void HandleNavigationMove(NavigationMoveEvent evt)
{
var top = TopModal;
if (top == null || evt.target is not VisualElement current)
{
return;
}
if (IsInsideTextField(current))
{
return;
}
if (top.HandleNavigationMove(current, evt.direction))
{
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
var direction = GetNavigationOffset(evt.direction);
if (direction == 0)
{
return;
}
top.FocusRelativeTo(current, direction);
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
}
///
/// Обрабатывает клавиатурную навигацию и закрытие верхнего окна по back-кнопкам.
///
private void HandleKeyDown(KeyDownEvent evt)
{
var top = TopModal;
if (top == null)
{
return;
}
var current = evt.target as VisualElement;
if (IsInsideTextField(current) && IsTextFieldKey(evt.keyCode))
{
return;
}
if (IsBackKey(evt.keyCode))
{
if (top.CanCloseByBack)
{
CloseTop();
}
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
if (top.HandleNavigationKey(current, evt.keyCode))
{
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
var direction = GetKeyNavigationOffset(evt);
if (direction == 0)
{
return;
}
top.FocusRelativeTo(current, direction);
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
}
///
/// Отложенно восстанавливает фокус после FocusOut, когда UI Toolkit уже обновил focusController.
///
private void KeepFocusInsideTopModal()
{
var top = TopModal;
if (top == null)
{
return;
}
var focused = GetFocusedVisualElement();
if (focused == null || !top.Contains(focused))
{
top.FocusDefault();
}
}
///
/// Возвращает текущий сфокусированный элемент панели.
///
private VisualElement GetFocusedVisualElement()
{
return layer?.panel?.focusController?.focusedElement as VisualElement;
}
///
/// Переводит направление NavigationMoveEvent в сдвиг по линейному списку фокуса.
///
private static int GetNavigationOffset(NavigationMoveEvent.Direction direction)
{
return direction switch
{
NavigationMoveEvent.Direction.Up => -1,
NavigationMoveEvent.Direction.Down => 1,
_ => 0,
};
}
///
/// Переводит клавиши навигации и WASD в сдвиг по линейному списку фокуса.
///
private static int GetKeyNavigationOffset(KeyDownEvent evt)
{
return evt.keyCode switch
{
KeyCode.Tab => evt.shiftKey ? -1 : 1,
KeyCode.UpArrow => -1,
KeyCode.W => -1,
KeyCode.DownArrow => 1,
KeyCode.S => 1,
_ => 0,
};
}
///
/// Проверяет клавиши, которые должны закрывать верхнюю модалку.
///
private static bool IsBackKey(KeyCode keyCode)
{
return keyCode == KeyCode.Escape ||
keyCode == KeyCode.Backspace ||
keyCode == KeyCode.JoystickButton1;
}
///
/// Проверяет, находится ли событие внутри TextField или его внутреннего текстового элемента.
///
private static bool IsInsideTextField(VisualElement element)
{
for (var current = element; current != null; current = current.parent)
{
if (current is TextField)
{
return true;
}
}
return false;
}
///
/// Клавиши, которые TextField должен получать без модальной навигации и закрытия.
///
private static bool IsTextFieldKey(KeyCode keyCode)
{
return keyCode == KeyCode.Backspace ||
keyCode == KeyCode.Space ||
keyCode == KeyCode.Return ||
keyCode == KeyCode.KeypadEnter ||
keyCode == KeyCode.UpArrow ||
keyCode == KeyCode.DownArrow ||
keyCode == KeyCode.LeftArrow ||
keyCode == KeyCode.RightArrow ||
keyCode == KeyCode.W ||
keyCode == KeyCode.A ||
keyCode == KeyCode.S ||
keyCode == KeyCode.D;
}
///
/// Запрещает встроенной UI Toolkit-навигации повторно двигать фокус после кастомной обработки.
///
private static void IgnoreFocusControllerEvent(EventBase evt)
{
(evt?.target as VisualElement)?.panel?.focusController?.IgnoreEvent(evt);
}
}
}