using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Input.Touch;
using MLEM.Input;
using MLEM.Misc;
using MLEM.Ui.Elements;
using MLEM.Ui.Style;
namespace MLEM.Ui {
///
/// UiControls holds and manages all of the controls for a .
/// UiControls supports keyboard, mouse, gamepad and touch input using an underlying .
///
public class UiControls {
///
/// The input handler that is used for querying input
///
public readonly InputHandler Input;
///
/// A list of , and/or that act as the buttons on the keyboard which perform the action.
/// If the is held, these buttons perform .
///
public readonly Keybind KeyboardButtons = new Keybind().Add(Keys.Space).Add(Keys.Enter);
///
/// AA that acts as the buttons on a gamepad that perform the action.
///
public readonly Keybind GamepadButtons = new Keybind(Buttons.A);
///
/// A that acts as the buttons on a gamepad that perform the action.
///
public readonly Keybind SecondaryGamepadButtons = new Keybind(Buttons.X);
///
/// A that acts as the buttons that select a that is above the currently selected element.
///
public readonly Keybind UpButtons = new Keybind().Add(Buttons.DPadUp).Add(Buttons.LeftThumbstickUp);
///
/// A that acts as the buttons that select a that is below the currently selected element.
///
public readonly Keybind DownButtons = new Keybind().Add(Buttons.DPadDown).Add(Buttons.LeftThumbstickDown);
///
/// A that acts as the buttons that select a that is to the left of the currently selected element.
///
public readonly Keybind LeftButtons = new Keybind().Add(Buttons.DPadLeft).Add(Buttons.LeftThumbstickLeft);
///
/// A that acts as the buttons that select a that is to the right of the currently selected element.
///
public readonly Keybind RightButtons = new Keybind().Add(Buttons.DPadRight).Add(Buttons.LeftThumbstickRight);
///
/// All instances used by these ui controls.
/// This can be used to easily serialize and deserialize all ui keybinds.
///
public readonly Keybind[] Keybinds;
///
/// The that is currently active.
/// The active root element is the one with the highest that whose property is true.
///
public RootElement ActiveRoot { get; protected set; }
///
/// The that the mouse is currently over.
///
public Element MousedElement { get; protected set; }
///
/// The that is currently touched.
///
public Element TouchedElement { get; protected set; }
///
/// The element that is currently selected.
/// This is the of the .
///
public Element SelectedElement => this.GetSelectedElement(this.ActiveRoot);
///
/// The zero-based index of the used for gamepad input.
/// If this index is lower than 0, every connected gamepad will trigger input.
///
public int GamepadIndex = -1;
///
/// Set this to false to disable mouse input for these ui controls.
/// Note that this does not disable mouse input for the underlying .
///
public bool HandleMouse = true;
///
/// Set this to false to disable keyboard input for these ui controls.
/// Note that this does not disable keyboard input for the underlying .
///
public bool HandleKeyboard = true;
///
/// Set this to false to disable touch input for these ui controls.
/// Note that this does not disable touch input for the underlying .
///
public bool HandleTouch = true;
///
/// Set this to false to disable gamepad input for these ui controls.
/// Note that this does not disable gamepad input for the underlying .
///
public bool HandleGamepad = true;
///
/// If this value is true, the ui controls are in automatic navigation mode.
/// This means that the will be drawn around the .
///
public bool IsAutoNavMode {
get => this.isAutoNavMode;
set {
if (this.isAutoNavMode != value) {
this.isAutoNavMode = value;
this.AutoNavModeChanged?.Invoke(value);
}
}
}
///
/// An event that is raised when is changed.
/// This can be used for custom actions like hiding the mouse cursor when automatic navigation is enabled.
///
public event Action AutoNavModeChanged;
///
/// This value ist true if the was created by this ui controls instance, or if it was passed in.
/// If the input handler was created by this instance, its method should be called by us.
///
protected readonly bool IsInputOurs;
///
/// The that this ui controls instance is controlling
///
protected readonly UiSystem System;
private readonly Dictionary selectedElements = new Dictionary();
private bool isAutoNavMode;
///
/// Creates a new instance of the ui controls.
/// You should rarely have to invoke this manually, since the handles it.
///
/// The ui system to control with these controls
/// The input handler to use for controlling, or null to create a new one.
public UiControls(UiSystem system, InputHandler inputHandler = null) {
this.System = system;
this.Input = inputHandler ?? new InputHandler(system.Game);
this.IsInputOurs = inputHandler == null;
this.Keybinds = typeof(UiControls).GetFields()
.Where(f => f.FieldType == typeof(Keybind))
.Select(f => (Keybind) f.GetValue(this)).ToArray();
// enable all required gestures
InputHandler.EnableGestures(GestureType.Tap, GestureType.Hold);
}
///
/// Update this ui controls instance, causing the underlying to be updated, as well as ui input to be queried.
///
public virtual void Update() {
if (this.IsInputOurs)
this.Input.Update();
this.ActiveRoot = this.System.GetRootElements().FirstOrDefault(root => !root.Element.IsHidden && root.CanSelectContent);
// MOUSE INPUT
if (this.HandleMouse) {
var mousedNow = this.GetElementUnderPos(this.Input.ViewportMousePosition.ToVector2());
this.SetMousedElement(mousedNow);
if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Left)) {
this.IsAutoNavMode = false;
var selectedNow = mousedNow != null && mousedNow.CanBeSelected ? mousedNow : null;
this.SelectElement(this.ActiveRoot, selectedNow);
if (mousedNow != null && mousedNow.CanBePressed) {
this.System.InvokeOnElementPressed(mousedNow);
this.Input.TryConsumeMouseButtonPressed(MouseButton.Left);
}
} else if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Right)) {
this.IsAutoNavMode = false;
if (mousedNow != null && mousedNow.CanBePressed) {
this.System.InvokeOnElementSecondaryPressed(mousedNow);
this.Input.TryConsumeMouseButtonPressed(MouseButton.Right);
}
}
}
// KEYBOARD INPUT
if (this.HandleKeyboard) {
if (this.KeyboardButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) {
if (this.Input.IsModifierKeyDown(ModifierKey.Shift)) {
// secondary action on element using space or enter
this.System.InvokeOnElementSecondaryPressed(this.SelectedElement);
} else {
// first action on element using space or enter
this.System.InvokeOnElementPressed(this.SelectedElement);
}
this.KeyboardButtons.TryConsumePressed(this.Input, this.GamepadIndex);
}
} else if (this.Input.IsKeyPressedAvailable(Keys.Tab)) {
this.IsAutoNavMode = true;
// tab or shift-tab to next or previous element
var backward = this.Input.IsModifierKeyDown(ModifierKey.Shift);
var next = this.GetTabNextElement(backward);
if (this.SelectedElement?.Root != null)
next = this.SelectedElement.GetTabNextElement(backward, next);
if (next != this.SelectedElement) {
this.SelectElement(this.ActiveRoot, next);
this.Input.TryConsumeKeyPressed(Keys.Tab);
}
}
}
// TOUCH INPUT
if (this.HandleTouch) {
if (this.Input.GetViewportGesture(GestureType.Tap, out var tap)) {
this.IsAutoNavMode = false;
var tapped = this.GetElementUnderPos(tap.Position);
this.SelectElement(this.ActiveRoot, tapped);
if (tapped != null && tapped.CanBePressed)
this.System.InvokeOnElementPressed(tapped);
} else if (this.Input.GetViewportGesture(GestureType.Hold, out var hold)) {
this.IsAutoNavMode = false;
var held = this.GetElementUnderPos(hold.Position);
this.SelectElement(this.ActiveRoot, held);
if (held != null && held.CanBePressed)
this.System.InvokeOnElementSecondaryPressed(held);
} else if (this.Input.ViewportTouchState.Count <= 0) {
this.SetTouchedElement(null);
} else {
foreach (var location in this.Input.ViewportTouchState) {
var element = this.GetElementUnderPos(location.Position);
if (location.State == TouchLocationState.Pressed) {
// start touching an element if we just touched down on it
this.SetTouchedElement(element);
} else if (element != this.TouchedElement) {
// if we moved off of the touched element, we stop touching
this.SetTouchedElement(null);
}
}
}
}
// GAMEPAD INPUT
if (this.HandleGamepad) {
if (this.GamepadButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) {
this.System.InvokeOnElementPressed(this.SelectedElement);
this.GamepadButtons.TryConsumePressed(this.Input, this.GamepadIndex);
}
} else if (this.SecondaryGamepadButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) {
this.System.InvokeOnElementSecondaryPressed(this.SelectedElement);
this.SecondaryGamepadButtons.TryConsumePressed(this.Input, this.GamepadIndex);
}
} else if (this.DownButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.HandleGamepadNextElement(Direction2.Down))
this.DownButtons.TryConsumePressed(this.Input, this.GamepadIndex);
} else if (this.LeftButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.HandleGamepadNextElement(Direction2.Left))
this.LeftButtons.TryConsumePressed(this.Input, this.GamepadIndex);
} else if (this.RightButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.HandleGamepadNextElement(Direction2.Right))
this.RightButtons.TryConsumePressed(this.Input, this.GamepadIndex);
} else if (this.UpButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.HandleGamepadNextElement(Direction2.Up))
this.UpButtons.TryConsumePressed(this.Input, this.GamepadIndex);
}
}
}
///
/// Returns the in the underlying that is currently below the given position.
/// Throughout the ui system, this is used for mouse input querying.
///
/// The position to query
/// The element under the position, or null if there isn't one
public virtual Element GetElementUnderPos(Vector2 position) {
foreach (var root in this.System.GetRootElements()) {
var pos = Vector2.Transform(position, root.InvTransform);
var moused = root.Element.GetElementUnderPos(pos);
if (moused != null)
return moused;
}
return null;
}
///
/// Selects the given element that is a child of the given root element.
/// Optionally, automatic navigation can be forced on, causing the to be drawn around the element.
/// A simpler version of this method is .
///
/// The root element of the
/// The element to select, or null to deselect the selected element.
/// Whether automatic navigation should be forced on
public void SelectElement(RootElement root, Element element, bool? autoNav = null) {
if (root == null)
return;
if (element != null && !element.CanBeSelected)
return;
var selected = this.GetSelectedElement(root);
if (selected == element)
return;
if (selected != null)
this.System.InvokeOnElementDeselected(selected);
if (element != null) {
this.System.InvokeOnElementSelected(element);
this.selectedElements[root.Name] = element;
} else {
this.selectedElements.Remove(root.Name);
}
this.System.InvokeOnSelectedElementChanged(element);
if (autoNav != null)
this.IsAutoNavMode = autoNav.Value;
}
///
/// Sets the to the given value, calling the appropriate events.
///
/// The element to set as moused
public void SetMousedElement(Element element) {
if (element != null && !element.CanBeMoused)
return;
if (element != this.MousedElement) {
if (this.MousedElement != null)
this.System.InvokeOnElementMouseExit(this.MousedElement);
if (element != null)
this.System.InvokeOnElementMouseEnter(element);
this.MousedElement = element;
this.System.InvokeOnMousedElementChanged(element);
}
}
///
/// Sets the to the given value, calling the appropriate events.
///
/// The element to set as touched
public void SetTouchedElement(Element element) {
if (element != null && !element.CanBeMoused)
return;
if (element != this.TouchedElement) {
if (this.TouchedElement != null)
this.System.InvokeOnElementTouchExit(this.TouchedElement);
if (element != null)
this.System.InvokeOnElementTouchEnter(element);
this.TouchedElement = element;
this.System.InvokeOnTouchedElementChanged(element);
}
}
///
/// Returns the selected element for the given root element.
/// A property equivalent to this method is .
///
/// The root element whose selected element to return
/// The given root's selected element, or null if the root doesn't exist, or if there is no selected element for that root.
public Element GetSelectedElement(RootElement root) {
if (root == null)
return null;
this.selectedElements.TryGetValue(root.Name, out var element);
return element;
}
///
/// Returns the next element to select when pressing the key during keyboard navigation.
/// If the backward boolean is true, the previous element should be returned instead.
///
/// If we're going backwards (if is held)
/// The next or previous element to select
protected virtual Element GetTabNextElement(bool backward) {
if (this.ActiveRoot == null)
return null;
var children = this.ActiveRoot.Element.GetChildren(c => !c.IsHidden, true, true).Append(this.ActiveRoot.Element)
// we can't add these checks to GetChildren because it ignores false grandchildren
.Where(c => c.CanBeSelected && c.AutoNavGroup == this.SelectedElement?.AutoNavGroup);
if (this.SelectedElement?.Root != this.ActiveRoot) {
// if we don't have an element selected in this root, navigate to the first one without a group
var allowed = children.Where(c => c.AutoNavGroup == null);
return backward ? allowed.LastOrDefault() : allowed.FirstOrDefault();
} else {
var foundCurr = false;
Element lastFound = null;
foreach (var child in children) {
if (child == this.SelectedElement) {
// when going backwards, return the last element found before the current one
if (backward)
return lastFound;
foundCurr = true;
} else {
// when going forwards, return the element after the current one
if (!backward && foundCurr)
return child;
}
lastFound = child;
}
return null;
}
}
///
/// Returns the next element that should be selected during gamepad navigation, based on the that we're looking for elements in.
///
/// The direction that we're looking for next elements in
/// The first element found in that area
protected virtual Element GetGamepadNextElement(Direction2 direction) {
if (this.ActiveRoot == null)
return null;
var children = this.ActiveRoot.Element.GetChildren(c => !c.IsHidden, true, true).Append(this.ActiveRoot.Element)
// we can't add these checks to GetChildren because it ignores false grandchildren
.Where(c => c.CanBeSelected && c.AutoNavGroup == this.SelectedElement?.AutoNavGroup);
if (this.SelectedElement?.Root != this.ActiveRoot) {
// if we don't have an element selected in this root, navigate to the first one without a group
return children.FirstOrDefault(c => c.AutoNavGroup == null);
} else {
Element closest = null;
float closestPriority = 0;
foreach (var child in children) {
if (child == this.SelectedElement)
continue;
var (xOffset, yOffset) = child.Area.Center - this.SelectedElement.Area.Center;
var angle = Math.Abs(MathHelper.WrapAngle(direction.Angle() - (float) Math.Atan2(yOffset, xOffset)));
if (angle >= MathHelper.PiOver2 - Element.Epsilon)
continue;
var distSq = child.Area.DistanceSquared(this.SelectedElement.Area);
// both distance and angle play a role in a destination button's priority, so we combine them
var priority = (distSq + 1) * (angle / MathHelper.PiOver2 + 1);
if (closest == null || priority < closestPriority) {
closest = child;
closestPriority = priority;
}
}
return closest;
}
}
private bool HandleGamepadNextElement(Direction2 dir) {
this.IsAutoNavMode = true;
var next = this.GetGamepadNextElement(dir);
if (this.SelectedElement != null)
next = this.SelectedElement.GetGamepadNextElement(dir, next);
if (next != null) {
this.SelectElement(this.ActiveRoot, next);
return true;
}
return false;
}
}
}