Split keyboard and mouse policy handling
This commit is contained in:
parent
d174341b6d
commit
87f4e90ca2
7 changed files with 75 additions and 43 deletions
|
|
@ -11,7 +11,7 @@
|
||||||
static bool isKeyboardMouseInputAllowed()
|
static bool isKeyboardMouseInputAllowed()
|
||||||
{
|
{
|
||||||
auto session = Session::get();
|
auto session = Session::get();
|
||||||
return session == nullptr || session->isKeyboardMouseInputAllowed();
|
return session == nullptr || session->isMouseInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long the fingers must be stationary to start a right click
|
// How long the fingers must be stationary to start a right click
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ static bool isGamepadInputAllowed()
|
||||||
return session == nullptr || session->isGamepadInputAllowed();
|
return session == nullptr || session->isGamepadInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
static bool isKeyboardMouseInputAllowed()
|
static bool isMouseInputAllowed()
|
||||||
{
|
{
|
||||||
auto session = Session::get();
|
auto session = Session::get();
|
||||||
return session == nullptr || session->isKeyboardMouseInputAllowed();
|
return session == nullptr || session->isMouseInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long the Start button must be pressed to toggle mouse emulation
|
// How long the Start button must be pressed to toggle mouse emulation
|
||||||
|
|
@ -387,7 +387,7 @@ Uint32 SdlInputHandler::mouseEmulationTimerCallback(Uint32 interval, void *param
|
||||||
deltaX = qAbs(deltaX) > MOUSE_EMULATION_DEADZONE ? deltaX - MOUSE_EMULATION_DEADZONE : 0;
|
deltaX = qAbs(deltaX) > MOUSE_EMULATION_DEADZONE ? deltaX - MOUSE_EMULATION_DEADZONE : 0;
|
||||||
deltaY = qAbs(deltaY) > MOUSE_EMULATION_DEADZONE ? deltaY - MOUSE_EMULATION_DEADZONE : 0;
|
deltaY = qAbs(deltaY) > MOUSE_EMULATION_DEADZONE ? deltaY - MOUSE_EMULATION_DEADZONE : 0;
|
||||||
|
|
||||||
if ((deltaX != 0 || deltaY != 0) && isKeyboardMouseInputAllowed()) {
|
if ((deltaX != 0 || deltaY != 0) && isMouseInputAllowed()) {
|
||||||
LiSendMouseMoveEvent((short)deltaX, (short)deltaY);
|
LiSendMouseMoveEvent((short)deltaX, (short)deltaY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -497,47 +497,47 @@ void SdlInputHandler::handleControllerButtonEvent(SDL_ControllerButtonEvent* eve
|
||||||
}
|
}
|
||||||
else if (state->mouseEmulationTimer != 0) {
|
else if (state->mouseEmulationTimer != 0) {
|
||||||
if (event->button == SDL_CONTROLLER_BUTTON_A) {
|
if (event->button == SDL_CONTROLLER_BUTTON_A) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
|
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_B) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_B) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
|
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_RIGHT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_X) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_X) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_MIDDLE);
|
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_MIDDLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_X1);
|
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_X1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_X2);
|
LiSendMouseButtonEvent(BUTTON_ACTION_PRESS, BUTTON_X2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_UP) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_UP) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendScrollEvent(1);
|
LiSendScrollEvent(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_DOWN) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_DOWN) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendScrollEvent(-1);
|
LiSendScrollEvent(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_RIGHT) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_RIGHT) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendHScrollEvent(1);
|
LiSendHScrollEvent(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_LEFT) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_DPAD_LEFT) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendHScrollEvent(-1);
|
LiSendHScrollEvent(-1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -570,27 +570,27 @@ void SdlInputHandler::handleControllerButtonEvent(SDL_ControllerButtonEvent* eve
|
||||||
}
|
}
|
||||||
else if (state->mouseEmulationTimer != 0) {
|
else if (state->mouseEmulationTimer != 0) {
|
||||||
if (event->button == SDL_CONTROLLER_BUTTON_A) {
|
if (event->button == SDL_CONTROLLER_BUTTON_A) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
|
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_B) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_B) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
|
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_RIGHT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_X) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_X) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_MIDDLE);
|
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_MIDDLE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_X1);
|
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_X1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (event->button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) {
|
else if (event->button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) {
|
||||||
if (isKeyboardMouseInputAllowed()) {
|
if (isMouseInputAllowed()) {
|
||||||
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_X2);
|
LiSendMouseButtonEvent(BUTTON_ACTION_RELEASE, BUTTON_X2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
static bool isKeyboardMouseInputAllowed()
|
static bool isKeyboardMouseInputAllowed()
|
||||||
{
|
{
|
||||||
auto session = Session::get();
|
auto session = Session::get();
|
||||||
return session == nullptr || session->isKeyboardMouseInputAllowed();
|
return session == nullptr || session->isKeyboardInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SdlInputHandler::performSpecialKeyCombo(KeyCombo combo)
|
void SdlInputHandler::performSpecialKeyCombo(KeyCombo combo)
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
static bool isKeyboardMouseInputAllowed()
|
static bool isKeyboardMouseInputAllowed()
|
||||||
{
|
{
|
||||||
auto session = Session::get();
|
auto session = Session::get();
|
||||||
return session == nullptr || session->isKeyboardMouseInputAllowed();
|
return session == nullptr || session->isMouseInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
void SdlInputHandler::handleMouseButtonEvent(SDL_MouseButtonEvent* event)
|
void SdlInputHandler::handleMouseButtonEvent(SDL_MouseButtonEvent* event)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
static bool isKeyboardMouseInputAllowed()
|
static bool isKeyboardMouseInputAllowed()
|
||||||
{
|
{
|
||||||
auto session = Session::get();
|
auto session = Session::get();
|
||||||
return session == nullptr || session->isKeyboardMouseInputAllowed();
|
return session == nullptr || session->isMouseInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
// How long the mouse button will be pressed for a tap to click gesture
|
// How long the mouse button will be pressed for a tap to click gesture
|
||||||
|
|
|
||||||
|
|
@ -557,7 +557,8 @@ Session::Session(NvComputer* computer, NvApp& app, StreamingPreferences *prefere
|
||||||
m_ManualAudioMuted(false),
|
m_ManualAudioMuted(false),
|
||||||
m_AudioVolumeScalar(1.0f),
|
m_AudioVolumeScalar(1.0f),
|
||||||
m_AllowGamepadInput(true),
|
m_AllowGamepadInput(true),
|
||||||
m_AllowKeyboardMouseInput(false),
|
m_AllowKeyboardInput(false),
|
||||||
|
m_AllowMouseInput(false),
|
||||||
m_ControlPanelVisible(true),
|
m_ControlPanelVisible(true),
|
||||||
m_ConnectionStatus(CONN_STATUS_OKAY),
|
m_ConnectionStatus(CONN_STATUS_OKAY),
|
||||||
m_StatusOverlayGeneration(0),
|
m_StatusOverlayGeneration(0),
|
||||||
|
|
@ -627,9 +628,13 @@ void Session::notifyAudioVolumeState()
|
||||||
|
|
||||||
void Session::setKeyboardMouseInputAllowed(bool allowed)
|
void Session::setKeyboardMouseInputAllowed(bool allowed)
|
||||||
{
|
{
|
||||||
bool previous = m_AllowKeyboardMouseInput.exchange(allowed, std::memory_order_relaxed);
|
const bool previousKeyboard = m_AllowKeyboardInput.exchange(allowed, std::memory_order_relaxed);
|
||||||
if (previous && !allowed && m_InputHandler != nullptr) {
|
if (previousKeyboard && !allowed && m_InputHandler != nullptr) {
|
||||||
m_InputHandler->raiseAllKeys();
|
m_InputHandler->raiseAllKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool previousMouse = m_AllowMouseInput.exchange(allowed, std::memory_order_relaxed);
|
||||||
|
if (previousMouse && !allowed && m_InputHandler != nullptr) {
|
||||||
m_InputHandler->raiseAllMouseButtons();
|
m_InputHandler->raiseAllMouseButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -656,11 +661,12 @@ void Session::toggleControlPanelVisibility()
|
||||||
|
|
||||||
void Session::notifyInputPermissionState()
|
void Session::notifyInputPermissionState()
|
||||||
{
|
{
|
||||||
char buffer[96];
|
char buffer[160];
|
||||||
SDL_snprintf(buffer,
|
SDL_snprintf(buffer,
|
||||||
sizeof(buffer),
|
sizeof(buffer),
|
||||||
"Keyboard/Mouse: %s\nGamepad: %s",
|
"Input policy: host controlled\nKeyboard: %s\nMouse: %s\nGamepad: %s",
|
||||||
isKeyboardMouseInputAllowed() ? "ON" : "OFF",
|
isKeyboardInputAllowed() ? "ON" : "OFF",
|
||||||
|
isMouseInputAllowed() ? "ON" : "OFF",
|
||||||
isGamepadInputAllowed() ? "ON" : "OFF");
|
isGamepadInputAllowed() ? "ON" : "OFF");
|
||||||
|
|
||||||
showTemporaryStatusOverlay(buffer);
|
showTemporaryStatusOverlay(buffer);
|
||||||
|
|
@ -668,10 +674,11 @@ void Session::notifyInputPermissionState()
|
||||||
|
|
||||||
void Session::sendInputPermissionStateToHost(uint8_t reason)
|
void Session::sendInputPermissionStateToHost(uint8_t reason)
|
||||||
{
|
{
|
||||||
const bool allowKeyboardMouse = isKeyboardMouseInputAllowed();
|
const bool allowKeyboard = isKeyboardInputAllowed();
|
||||||
|
const bool allowMouse = isMouseInputAllowed();
|
||||||
const bool allowGamepad = isGamepadInputAllowed();
|
const bool allowGamepad = isGamepadInputAllowed();
|
||||||
const int err = LiSendSessionInputPolicy(allowKeyboardMouse,
|
const int err = LiSendSessionInputPolicy(allowKeyboard,
|
||||||
allowKeyboardMouse,
|
allowMouse,
|
||||||
allowGamepad,
|
allowGamepad,
|
||||||
reason);
|
reason);
|
||||||
|
|
||||||
|
|
@ -719,11 +726,14 @@ void Session::refreshControlPanelOverlay()
|
||||||
SDL_snprintf(panelText,
|
SDL_snprintf(panelText,
|
||||||
sizeof(panelText),
|
sizeof(panelText),
|
||||||
"Stream Controls\n"
|
"Stream Controls\n"
|
||||||
"KB/M (Ctrl+Alt+Shift+K): %s\n"
|
"Keyboard: %s\n"
|
||||||
"Pad (Ctrl+Alt+Shift+G): %s\n"
|
"Mouse: %s\n"
|
||||||
|
"Pad: %s\n"
|
||||||
"Vol (U/J, mute N): %s\n"
|
"Vol (U/J, mute N): %s\n"
|
||||||
"UI (Ctrl+Alt+Shift+P / Select+L1+R1+B): ON",
|
"UI (Ctrl+Alt+Shift+P / Select+L1+R1+B): ON\n"
|
||||||
isKeyboardMouseInputAllowed() ? "ON" : "OFF",
|
"Input policy: host controlled",
|
||||||
|
isKeyboardInputAllowed() ? "ON" : "OFF",
|
||||||
|
isMouseInputAllowed() ? "ON" : "OFF",
|
||||||
isGamepadInputAllowed() ? "ON" : "OFF",
|
isGamepadInputAllowed() ? "ON" : "OFF",
|
||||||
volumeState);
|
volumeState);
|
||||||
|
|
||||||
|
|
@ -774,10 +784,13 @@ void Session::showTemporaryStatusOverlay(const char* text, Uint32 timeoutMs)
|
||||||
|
|
||||||
void Session::applyHostInputPolicy(bool allowKeyboard, bool allowMouse, bool allowGamepad, uint8_t reason)
|
void Session::applyHostInputPolicy(bool allowKeyboard, bool allowMouse, bool allowGamepad, uint8_t reason)
|
||||||
{
|
{
|
||||||
const bool allowKeyboardMouse = allowKeyboard && allowMouse;
|
const bool previousKeyboard = m_AllowKeyboardInput.exchange(allowKeyboard, std::memory_order_relaxed);
|
||||||
const bool previousKeyboardMouse = m_AllowKeyboardMouseInput.exchange(allowKeyboardMouse, std::memory_order_relaxed);
|
if (previousKeyboard && !allowKeyboard && m_InputHandler != nullptr) {
|
||||||
if (previousKeyboardMouse && !allowKeyboardMouse && m_InputHandler != nullptr) {
|
|
||||||
m_InputHandler->raiseAllKeys();
|
m_InputHandler->raiseAllKeys();
|
||||||
|
}
|
||||||
|
|
||||||
|
const bool previousMouse = m_AllowMouseInput.exchange(allowMouse, std::memory_order_relaxed);
|
||||||
|
if (previousMouse && !allowMouse && m_InputHandler != nullptr) {
|
||||||
m_InputHandler->raiseAllMouseButtons();
|
m_InputHandler->raiseAllMouseButtons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -786,11 +799,18 @@ void Session::applyHostInputPolicy(bool allowKeyboard, bool allowMouse, bool all
|
||||||
m_InputHandler->raiseAllGamepadInputs();
|
m_InputHandler->raiseAllGamepadInputs();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (reason != LI_SESSION_INPUT_POLICY_REASON_STREAM_START) {
|
const bool policyChanged =
|
||||||
notifyInputPermissionState();
|
previousKeyboard != allowKeyboard ||
|
||||||
|
previousMouse != allowMouse ||
|
||||||
|
previousGamepad != allowGamepad;
|
||||||
|
if (reason == LI_SESSION_INPUT_POLICY_REASON_STREAM_START ||
|
||||||
|
reason == LI_SESSION_INPUT_POLICY_REASON_HOST_ACK ||
|
||||||
|
reason == LI_SESSION_INPUT_POLICY_REASON_HOST_OVERRIDE ||
|
||||||
|
!policyChanged) {
|
||||||
|
refreshControlPanelOverlay();
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
refreshControlPanelOverlay();
|
notifyInputPermissionState();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1975,7 +1995,8 @@ void Session::start()
|
||||||
s_ActiveSession = this;
|
s_ActiveSession = this;
|
||||||
|
|
||||||
m_AllowGamepadInput.store(true, std::memory_order_relaxed);
|
m_AllowGamepadInput.store(true, std::memory_order_relaxed);
|
||||||
m_AllowKeyboardMouseInput.store(false, std::memory_order_relaxed);
|
m_AllowKeyboardInput.store(false, std::memory_order_relaxed);
|
||||||
|
m_AllowMouseInput.store(false, std::memory_order_relaxed);
|
||||||
m_ManualAudioMuted.store(false, std::memory_order_relaxed);
|
m_ManualAudioMuted.store(false, std::memory_order_relaxed);
|
||||||
m_AudioMuted.store(false, std::memory_order_relaxed);
|
m_AudioMuted.store(false, std::memory_order_relaxed);
|
||||||
m_AudioVolumeScalar.store(1.0f, std::memory_order_relaxed);
|
m_AudioVolumeScalar.store(1.0f, std::memory_order_relaxed);
|
||||||
|
|
|
||||||
|
|
@ -131,9 +131,19 @@ public:
|
||||||
return m_AllowGamepadInput.load(std::memory_order_relaxed);
|
return m_AllowGamepadInput.load(std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool isKeyboardInputAllowed() const
|
||||||
|
{
|
||||||
|
return m_AllowKeyboardInput.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isMouseInputAllowed() const
|
||||||
|
{
|
||||||
|
return m_AllowMouseInput.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
bool isKeyboardMouseInputAllowed() const
|
bool isKeyboardMouseInputAllowed() const
|
||||||
{
|
{
|
||||||
return m_AllowKeyboardMouseInput.load(std::memory_order_relaxed);
|
return isKeyboardInputAllowed() && isMouseInputAllowed();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setGamepadInputAllowed(bool allowed);
|
void setGamepadInputAllowed(bool allowed);
|
||||||
|
|
@ -298,7 +308,8 @@ private:
|
||||||
std::atomic<bool> m_ManualAudioMuted;
|
std::atomic<bool> m_ManualAudioMuted;
|
||||||
std::atomic<float> m_AudioVolumeScalar;
|
std::atomic<float> m_AudioVolumeScalar;
|
||||||
std::atomic<bool> m_AllowGamepadInput;
|
std::atomic<bool> m_AllowGamepadInput;
|
||||||
std::atomic<bool> m_AllowKeyboardMouseInput;
|
std::atomic<bool> m_AllowKeyboardInput;
|
||||||
|
std::atomic<bool> m_AllowMouseInput;
|
||||||
std::atomic<bool> m_ControlPanelVisible;
|
std::atomic<bool> m_ControlPanelVisible;
|
||||||
std::atomic<int> m_ConnectionStatus;
|
std::atomic<int> m_ConnectionStatus;
|
||||||
std::atomic<uint32_t> m_StatusOverlayGeneration;
|
std::atomic<uint32_t> m_StatusOverlayGeneration;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue