mirror of
https://github.com/Ellpeck/MLEM.git
synced 2024-11-22 04:53:29 +01:00
Multiple improvements to InputHandler key/button repeats:
- Trigger InputHandler key and gamepad repeats for the most recently pressed input - Added InputHandler.TryGetDownTime and store the down times of inputs - Removed InputHandler.StoreAllActiveInputs and always store all active inputs
This commit is contained in:
parent
bb7192b3cc
commit
c6fe72bdc9
5 changed files with 83 additions and 87 deletions
|
@ -16,6 +16,7 @@ Additions
|
||||||
- Added TextureRegion.OffsetCopy
|
- Added TextureRegion.OffsetCopy
|
||||||
- Added RectangleF.DistanceSquared and RectangleF.Distance
|
- Added RectangleF.DistanceSquared and RectangleF.Distance
|
||||||
- Added GamepadExtensions.GetAnalogValue to get the analog value of any gamepad button
|
- Added GamepadExtensions.GetAnalogValue to get the analog value of any gamepad button
|
||||||
|
- Added InputHandler.TryGetDownTime
|
||||||
|
|
||||||
Improvements
|
Improvements
|
||||||
- Generify GenericFont's string drawing
|
- Generify GenericFont's string drawing
|
||||||
|
@ -24,6 +25,7 @@ Improvements
|
||||||
- Allow LinkCode to specify a color to draw with
|
- Allow LinkCode to specify a color to draw with
|
||||||
- Allow better control over the order and layout of a Keybind's combinations
|
- Allow better control over the order and layout of a Keybind's combinations
|
||||||
- Allow setting a gamepad button deadzone in InputHandler
|
- Allow setting a gamepad button deadzone in InputHandler
|
||||||
|
- Trigger InputHandler key and gamepad repeats for the most recently pressed input
|
||||||
|
|
||||||
Fixes
|
Fixes
|
||||||
- **Fixed a formatting Code only knowing about the last Token that it is applied in**
|
- **Fixed a formatting Code only knowing about the last Token that it is applied in**
|
||||||
|
@ -32,6 +34,7 @@ Fixes
|
||||||
- Fixed InputHandler.InputsPressed ignoring repeat events for keyboards and gamepads
|
- Fixed InputHandler.InputsPressed ignoring repeat events for keyboards and gamepads
|
||||||
|
|
||||||
Removals
|
Removals
|
||||||
|
- **Removed InputHandler.StoreAllActiveInputs and always store all active inputs**
|
||||||
- Renamed GenericFont.OneEmSpace to Emsp (and marked OneEmSpace as obsolete)
|
- Renamed GenericFont.OneEmSpace to Emsp (and marked OneEmSpace as obsolete)
|
||||||
|
|
||||||
### MLEM.Ui
|
### MLEM.Ui
|
||||||
|
|
|
@ -4,7 +4,6 @@ using System.Linq;
|
||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
using Microsoft.Xna.Framework.Input;
|
using Microsoft.Xna.Framework.Input;
|
||||||
using Microsoft.Xna.Framework.Input.Touch;
|
using Microsoft.Xna.Framework.Input.Touch;
|
||||||
using MLEM.Extensions;
|
|
||||||
using MLEM.Input;
|
using MLEM.Input;
|
||||||
using MLEM.Misc;
|
using MLEM.Misc;
|
||||||
using MLEM.Ui.Elements;
|
using MLEM.Ui.Elements;
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
using System;
|
|
||||||
using Microsoft.Xna.Framework;
|
using Microsoft.Xna.Framework;
|
||||||
using Microsoft.Xna.Framework.Input;
|
using Microsoft.Xna.Framework.Input;
|
||||||
|
|
||||||
|
|
|
@ -56,10 +56,6 @@ namespace MLEM.Input {
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool HandleGamepadRepeats = true;
|
public bool HandleGamepadRepeats = true;
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Set this field to false to enable <see cref="InputsDown"/> and <see cref="InputsPressed"/> being calculated.
|
|
||||||
/// </summary>
|
|
||||||
public bool StoreAllActiveInputs;
|
|
||||||
/// <summary>
|
|
||||||
/// This field represents the deadzone that gamepad <see cref="Buttons"/> have when input is queried for them using this input handler.
|
/// This field represents the deadzone that gamepad <see cref="Buttons"/> have when input is queried for them using this input handler.
|
||||||
/// A deadzone is the percentage (between 0 and 1) that an analog value has to exceed for it to be considered down (<see cref="IsGamepadButtonDown"/>) or pressed (<see cref="IsGamepadButtonPressed"/>).
|
/// A deadzone is the percentage (between 0 and 1) that an analog value has to exceed for it to be considered down (<see cref="IsGamepadButtonDown"/>) or pressed (<see cref="IsGamepadButtonPressed"/>).
|
||||||
/// Querying of analog values is done using <see cref="GamepadExtensions.GetAnalogValue"/>.
|
/// Querying of analog values is done using <see cref="GamepadExtensions.GetAnalogValue"/>.
|
||||||
|
@ -68,13 +64,12 @@ namespace MLEM.Input {
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> values that are currently down.
|
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> values that are currently down.
|
||||||
/// Note that this value only gets set if <see cref="StoreAllActiveInputs"/> is true.
|
/// Additionally, <see cref="TryGetDownTime"/> or <see cref="GetDownTime"/> can be used to determine the amount of time that a given input has been down for.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GenericInput[] InputsDown { get; private set; } = Array.Empty<GenericInput>();
|
public GenericInput[] InputsDown { get; private set; } = Array.Empty<GenericInput>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> that are currently considered pressed.
|
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> that are currently considered pressed.
|
||||||
/// An input is considered pressed if it was up in the last update, and is up in the current one.
|
/// An input is considered pressed if it was up in the last update, and is up in the current one.
|
||||||
/// Note that this value only gets set if <see cref="StoreAllActiveInputs"/> is true.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public GenericInput[] InputsPressed { get; private set; } = Array.Empty<GenericInput>();
|
public GenericInput[] InputsPressed { get; private set; } = Array.Empty<GenericInput>();
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
@ -141,15 +136,14 @@ namespace MLEM.Input {
|
||||||
|
|
||||||
private readonly GamePadState[] lastGamepads = new GamePadState[GamePad.MaximumGamePadCount];
|
private readonly GamePadState[] lastGamepads = new GamePadState[GamePad.MaximumGamePadCount];
|
||||||
private readonly GamePadState[] gamepads = new GamePadState[GamePad.MaximumGamePadCount];
|
private readonly GamePadState[] gamepads = new GamePadState[GamePad.MaximumGamePadCount];
|
||||||
private readonly DateTime[] heldGamepadButtonStarts = new DateTime[GamePad.MaximumGamePadCount];
|
|
||||||
private readonly DateTime[] lastGamepadButtonRepeats = new DateTime[GamePad.MaximumGamePadCount];
|
private readonly DateTime[] lastGamepadButtonRepeats = new DateTime[GamePad.MaximumGamePadCount];
|
||||||
private readonly bool[] triggerGamepadButtonRepeat = new bool[GamePad.MaximumGamePadCount];
|
private readonly bool[] triggerGamepadButtonRepeat = new bool[GamePad.MaximumGamePadCount];
|
||||||
private readonly Buttons?[] heldGamepadButtons = new Buttons?[GamePad.MaximumGamePadCount];
|
private readonly Buttons?[] heldGamepadButtons = new Buttons?[GamePad.MaximumGamePadCount];
|
||||||
private readonly List<GenericInput> inputsDownAccum = new List<GenericInput>();
|
|
||||||
private readonly List<GestureSample> gestures = new List<GestureSample>();
|
private readonly List<GestureSample> gestures = new List<GestureSample>();
|
||||||
|
|
||||||
private Point ViewportOffset => new Point(-this.Game.GraphicsDevice.Viewport.X, -this.Game.GraphicsDevice.Viewport.Y);
|
private Point ViewportOffset => new Point(-this.Game.GraphicsDevice.Viewport.X, -this.Game.GraphicsDevice.Viewport.Y);
|
||||||
private DateTime heldKeyStart;
|
private Dictionary<(GenericInput, int), DateTime> inputsDownAccum = new Dictionary<(GenericInput, int), DateTime>();
|
||||||
|
private Dictionary<(GenericInput, int), DateTime> inputsDown = new Dictionary<(GenericInput, int), DateTime>();
|
||||||
private DateTime lastKeyRepeat;
|
private DateTime lastKeyRepeat;
|
||||||
private bool triggerKeyRepeat;
|
private bool triggerKeyRepeat;
|
||||||
private Keys heldKey;
|
private Keys heldKey;
|
||||||
|
@ -162,13 +156,11 @@ namespace MLEM.Input {
|
||||||
/// <param name="handleMouse">If mouse input should be handled</param>
|
/// <param name="handleMouse">If mouse input should be handled</param>
|
||||||
/// <param name="handleGamepads">If gamepad input should be handled</param>
|
/// <param name="handleGamepads">If gamepad input should be handled</param>
|
||||||
/// <param name="handleTouch">If touch input should be handled</param>
|
/// <param name="handleTouch">If touch input should be handled</param>
|
||||||
/// <param name="storeAllActiveInputs">Whether all inputs that are currently down and pressed should be calculated each update</param>
|
public InputHandler(Game game, bool handleKeyboard = true, bool handleMouse = true, bool handleGamepads = true, bool handleTouch = true) : base(game) {
|
||||||
public InputHandler(Game game, bool handleKeyboard = true, bool handleMouse = true, bool handleGamepads = true, bool handleTouch = true, bool storeAllActiveInputs = true) : base(game) {
|
|
||||||
this.HandleKeyboard = handleKeyboard;
|
this.HandleKeyboard = handleKeyboard;
|
||||||
this.HandleMouse = handleMouse;
|
this.HandleMouse = handleMouse;
|
||||||
this.HandleGamepads = handleGamepads;
|
this.HandleGamepads = handleGamepads;
|
||||||
this.HandleTouch = handleTouch;
|
this.HandleTouch = handleTouch;
|
||||||
this.StoreAllActiveInputs = storeAllActiveInputs;
|
|
||||||
this.Gestures = this.gestures.AsReadOnly();
|
this.Gestures = this.gestures.AsReadOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -177,42 +169,28 @@ namespace MLEM.Input {
|
||||||
/// Call this in your <see cref="Game.Update"/> method.
|
/// Call this in your <see cref="Game.Update"/> method.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Update() {
|
public void Update() {
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
var active = this.Game.IsActive;
|
var active = this.Game.IsActive;
|
||||||
if (this.HandleKeyboard) {
|
if (this.HandleKeyboard) {
|
||||||
this.LastKeyboardState = this.KeyboardState;
|
this.LastKeyboardState = this.KeyboardState;
|
||||||
this.KeyboardState = active ? Keyboard.GetState() : default;
|
this.KeyboardState = active ? Keyboard.GetState() : default;
|
||||||
var pressedKeys = this.KeyboardState.GetPressedKeys();
|
var pressedKeys = this.KeyboardState.GetPressedKeys();
|
||||||
if (this.StoreAllActiveInputs) {
|
foreach (var pressed in pressedKeys)
|
||||||
foreach (var pressed in pressedKeys)
|
this.AccumulateDown(pressed, -1);
|
||||||
this.inputsDownAccum.Add(pressed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.HandleKeyboardRepeats) {
|
if (this.HandleKeyboardRepeats) {
|
||||||
this.triggerKeyRepeat = false;
|
this.triggerKeyRepeat = false;
|
||||||
if (this.heldKey == Keys.None) {
|
// the key that started being held most recently should be the one being repeated
|
||||||
// if we're not repeating a key, set the first key being held to the repeat key
|
this.heldKey = pressedKeys.OrderBy(k => this.GetDownTime(k)).FirstOrDefault();
|
||||||
// note that modifier keys don't count as that wouldn't really make sense
|
if (this.TryGetDownTime(this.heldKey, out var heldTime)) {
|
||||||
var key = pressedKeys.FirstOrDefault(k => !k.IsModifier());
|
// if we've been holding the key longer than the initial delay...
|
||||||
if (key != Keys.None) {
|
if (heldTime >= this.KeyRepeatDelay) {
|
||||||
this.heldKey = key;
|
var diff = now - this.lastKeyRepeat;
|
||||||
this.heldKeyStart = DateTime.UtcNow;
|
// and we've been holding it for longer than a repeat...
|
||||||
}
|
if (diff >= this.KeyRepeatRate) {
|
||||||
} else {
|
this.lastKeyRepeat = now;
|
||||||
// if the repeating key isn't being held anymore, reset
|
// then trigger a repeat, causing IsKeyPressed to be true once
|
||||||
if (!this.IsKeyDown(this.heldKey)) {
|
this.triggerKeyRepeat = true;
|
||||||
this.heldKey = Keys.None;
|
|
||||||
} else {
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var holdTime = now - this.heldKeyStart;
|
|
||||||
// if we've been holding the key longer than the initial delay...
|
|
||||||
if (holdTime >= this.KeyRepeatDelay) {
|
|
||||||
var diff = now - this.lastKeyRepeat;
|
|
||||||
// and we've been holding it for longer than a repeat...
|
|
||||||
if (diff >= this.KeyRepeatRate) {
|
|
||||||
this.lastKeyRepeat = now;
|
|
||||||
// then trigger a repeat, causing IsKeyPressed to be true once
|
|
||||||
this.triggerKeyRepeat = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -224,11 +202,9 @@ namespace MLEM.Input {
|
||||||
var state = Mouse.GetState();
|
var state = Mouse.GetState();
|
||||||
if (active && this.Game.GraphicsDevice.Viewport.Bounds.Contains(state.Position)) {
|
if (active && this.Game.GraphicsDevice.Viewport.Bounds.Contains(state.Position)) {
|
||||||
this.MouseState = state;
|
this.MouseState = state;
|
||||||
if (this.StoreAllActiveInputs) {
|
foreach (var button in MouseExtensions.MouseButtons) {
|
||||||
foreach (var button in MouseExtensions.MouseButtons) {
|
if (state.GetState(button) == ButtonState.Pressed)
|
||||||
if (state.GetState(button) == ButtonState.Pressed)
|
this.AccumulateDown(button, -1);
|
||||||
this.inputsDownAccum.Add(button);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// mouse position and scroll wheel value should be preserved when the mouse is out of bounds
|
// mouse position and scroll wheel value should be preserved when the mouse is out of bounds
|
||||||
|
@ -244,43 +220,29 @@ namespace MLEM.Input {
|
||||||
if (GamePad.GetCapabilities(i).IsConnected) {
|
if (GamePad.GetCapabilities(i).IsConnected) {
|
||||||
if (active) {
|
if (active) {
|
||||||
this.gamepads[i] = GamePad.GetState(i);
|
this.gamepads[i] = GamePad.GetState(i);
|
||||||
if (this.StoreAllActiveInputs) {
|
foreach (var button in EnumHelper.Buttons) {
|
||||||
foreach (var button in EnumHelper.Buttons) {
|
if (this.IsGamepadButtonDown(button, i))
|
||||||
if (this.IsGamepadButtonDown(button, i))
|
this.AccumulateDown(button, i);
|
||||||
this.inputsDownAccum.Add(button);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else if (this.ConnectedGamepads > i) {
|
||||||
if (this.ConnectedGamepads > i)
|
this.ConnectedGamepads = i;
|
||||||
this.ConnectedGamepads = i;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.HandleGamepadRepeats) {
|
if (this.HandleGamepadRepeats) {
|
||||||
for (var i = 0; i < this.ConnectedGamepads; i++) {
|
for (var i = 0; i < this.ConnectedGamepads; i++) {
|
||||||
this.triggerGamepadButtonRepeat[i] = false;
|
this.triggerGamepadButtonRepeat[i] = false;
|
||||||
|
this.heldGamepadButtons[i] = EnumHelper.Buttons
|
||||||
if (!this.heldGamepadButtons[i].HasValue) {
|
.Where(b => this.IsGamepadButtonDown(b, i))
|
||||||
foreach (var b in EnumHelper.Buttons) {
|
.OrderBy(b => this.GetDownTime(b, i))
|
||||||
if (this.IsGamepadButtonDown(b, i)) {
|
.Cast<Buttons?>().FirstOrDefault();
|
||||||
this.heldGamepadButtons[i] = b;
|
if (this.heldGamepadButtons[i].HasValue && this.TryGetDownTime(this.heldGamepadButtons[i].Value, out var heldTime, i)) {
|
||||||
this.heldGamepadButtonStarts[i] = DateTime.UtcNow;
|
if (heldTime >= this.KeyRepeatDelay) {
|
||||||
break;
|
var diff = now - this.lastGamepadButtonRepeats[i];
|
||||||
}
|
if (diff >= this.KeyRepeatRate) {
|
||||||
}
|
this.lastGamepadButtonRepeats[i] = now;
|
||||||
} else {
|
this.triggerGamepadButtonRepeat[i] = true;
|
||||||
if (!this.IsGamepadButtonDown(this.heldGamepadButtons[i].Value, i)) {
|
|
||||||
this.heldGamepadButtons[i] = null;
|
|
||||||
} else {
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var holdTime = now - this.heldGamepadButtonStarts[i];
|
|
||||||
if (holdTime >= this.KeyRepeatDelay) {
|
|
||||||
var diff = now - this.lastGamepadButtonRepeats[i];
|
|
||||||
if (diff >= this.KeyRepeatRate) {
|
|
||||||
this.lastGamepadButtonRepeats[i] = now;
|
|
||||||
this.triggerGamepadButtonRepeat[i] = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -308,15 +270,16 @@ namespace MLEM.Input {
|
||||||
this.gestures.Add(TouchPanel.ReadGesture());
|
this.gestures.Add(TouchPanel.ReadGesture());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.StoreAllActiveInputs) {
|
if (this.inputsDownAccum.Count <= 0) {
|
||||||
if (this.inputsDownAccum.Count <= 0) {
|
this.InputsPressed = Array.Empty<GenericInput>();
|
||||||
this.InputsPressed = Array.Empty<GenericInput>();
|
this.InputsDown = Array.Empty<GenericInput>();
|
||||||
this.InputsDown = Array.Empty<GenericInput>();
|
this.inputsDown.Clear();
|
||||||
} else {
|
} else {
|
||||||
this.InputsPressed = this.inputsDownAccum.Where(this.IsPressed).ToArray();
|
this.InputsPressed = this.inputsDownAccum.Keys.Where(kv => this.IsPressed(kv.Item1, kv.Item2)).Select(kv => kv.Item1).ToArray();
|
||||||
this.InputsDown = this.inputsDownAccum.ToArray();
|
this.InputsDown = this.inputsDownAccum.Keys.Select(kv => kv.Item1).ToArray();
|
||||||
this.inputsDownAccum.Clear();
|
// swapping these collections means that we don't have to keep moving entries between them
|
||||||
}
|
(this.inputsDown, this.inputsDownAccum) = (this.inputsDownAccum, this.inputsDown);
|
||||||
|
this.inputsDownAccum.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -652,6 +615,38 @@ namespace MLEM.Input {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries to retrieve the amount of time that a given <see cref="GenericInput"/> has been held down for.
|
||||||
|
/// If the input is currently down, this method returns true and the amount of time that it has been down for is stored in <paramref name="downTime"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The input whose down time to query.</param>
|
||||||
|
/// <param name="downTime">The resulting down time, or <see cref="TimeSpan.Zero"/> if the input is not being held.</param>
|
||||||
|
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
|
||||||
|
/// <returns>Whether the input is currently being held.</returns>
|
||||||
|
public bool TryGetDownTime(GenericInput input, out TimeSpan downTime, int index = -1) {
|
||||||
|
if (this.inputsDown.TryGetValue((input, index), out var start)) {
|
||||||
|
downTime = DateTime.UtcNow - start;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the amount of time that a given <see cref="GenericInput"/> has been held down for.
|
||||||
|
/// If this input isn't currently own, this method returns <see cref="TimeSpan.Zero"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="input">The input whose down time to query.</param>
|
||||||
|
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
|
||||||
|
/// <returns>The resulting down time, or <see cref="TimeSpan.Zero"/> if the input is not being held.</returns>
|
||||||
|
public TimeSpan GetDownTime(GenericInput input, int index = -1) {
|
||||||
|
this.TryGetDownTime(input, out var time, index);
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AccumulateDown(GenericInput input, int index) {
|
||||||
|
this.inputsDownAccum.Add((input, index), this.inputsDown.TryGetValue((input, index), out var start) ? start : DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Helper function to enable gestures for a <see cref="TouchPanel"/> easily.
|
/// Helper function to enable gestures for a <see cref="TouchPanel"/> easily.
|
||||||
/// Note that, if other gestures were previously enabled, they will not get overridden.
|
/// Note that, if other gestures were previously enabled, they will not get overridden.
|
||||||
|
|
|
@ -327,9 +327,9 @@ namespace Sandbox {
|
||||||
}
|
}
|
||||||
|
|
||||||
/*if (Input.InputsDown.Length > 0)
|
/*if (Input.InputsDown.Length > 0)
|
||||||
Console.WriteLine("Down: " + string.Join(", ", Input.InputsDown));
|
Console.WriteLine("Down: " + string.Join(", ", Input.InputsDown));*/
|
||||||
if (Input.InputsPressed.Length > 0)
|
if (Input.InputsPressed.Length > 0)
|
||||||
Console.WriteLine("Pressed: " + string.Join(", ", Input.InputsPressed));*/
|
Console.WriteLine("Pressed: " + string.Join(", ", Input.InputsPressed));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void DoDraw(GameTime gameTime) {
|
protected override void DoDraw(GameTime gameTime) {
|
||||||
|
|
Loading…
Reference in a new issue