mirror of
https://git.ryujinx.app/ryubing/ryujinx.git
synced 2026-06-26 22:29:04 +00:00
This PR refactors keyboard handling to use physical key mappings for all gameplay input, ensuring controls remain consistent across different OS keyboard layouts. I'd like to give out an ENORMOUS thank you to @Neo for his very generous help on getting MacOS caps lock behaviour working, but as well for taking the time for extensive testing, planning and discussions, and finally for writing this PR message :) Keep being awsome pal 👊 ### New Features : * **New**: Gameplay input now uses physical key positions instead of OS layouts, ensuring the same physical key triggers the same action across keyboard layouts. * Key rebinding stores physical keys and config compatibility is preserved, with physical keys now the primary gameplay‑binding format. * Physical‑key model is now consistent across platforms, including updated SDL/headless behavior. * **Added**: New Input setting "Reset keybinds to default", with a new confirmation dialog appears when changes are being overwritten. * **Fractured**: Keyboard‑related locales to the newly created `KeyboardLayout.json`. * New input device settings/actions use clearer labels and tooltips. * UI Key Labels (such as Left Shift and Right Shift) are more accurate and standardized, with clearer symbols, consistent naming, dynamic learning of printable labels from real key events, and persistence across restarts. ### Improvements : * **Reduced**: Incorrect key labels by using observed host symbols instead of language assumptions. * **Reduced**: Stuck/stale keys by using binary pressed‑key tracking, fixing rebinding/gameplay paths, better held‑key recovery after focus changes, and clearing keyboard state when Ryujinx/settings windows lose focus. * **Improved**: Device handling → refreshing no longer clears the selector, disconnect fallback is consistent, reconnect restores controllers automatically, and the UI avoids invalid/empty device states. * **Improved**: Async input‑assignment callbacks are now guarded when switching views/devices, preventing stale callbacks from hitting detached views. * **Adjusted**: Input visualiser to be more robust when switching sources or handling controller disconnect/reconnect, without needing to reopen settings. * **Improved**: Modification (changes to input controls) tracking * Rebinding to the same value, reverting to original config, restoring defaults without differences, or reloading equivalent profiles no longer leaves Player marked as modified. * **Reduced**: Keyboard LED noise in logs and added optional UI keyboard‑state/rebinding diagnostics. ### Fixes : * **Special Keys**: * AltGr and other special keys behave correctly, including proper Ctrl+Alt → AltRight handling and more consistent normalization of special/synthetic keys. * Caps Lock is now reliably bindable on all platforms (Windows/Linux register every press; macOS every other). * **Fixed**: Certain cases where keyboard input broke after pointer interactions ### Current Limitations These are planned on being fixed/improved upon in future PRs: * Hotkeys still use semantic (Key) mappings. * Software keyboard / text input still uses the semantic path * Printable key labels may fall back to defaults until observed from host input. * Full semantic/physical split currently implemented only in the Avalonia driver. Co-authored-by: _Neo_ <ursamajorjanus2819@gmail.com> Reviewed-on: https://git.ryujinx.app/projects/Ryubing/pulls/13
235 lines
7.6 KiB
C#
235 lines
7.6 KiB
C#
using Avalonia.Input;
|
|
using Ryujinx.Ava.Common.Locale;
|
|
using Ryujinx.Ava.Input;
|
|
using Ryujinx.Common.Configuration;
|
|
using Ryujinx.Common.Logging;
|
|
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Text.Json;
|
|
using AvaPhysicalKey = Avalonia.Input.PhysicalKey;
|
|
using ConfigPhysicalKey = Ryujinx.Common.Configuration.Hid.PhysicalKey;
|
|
using InputKey = Ryujinx.Input.Key;
|
|
|
|
namespace Ryujinx.Ava.UI.Helpers
|
|
{
|
|
internal static class PhysicalKeyLabelHelper
|
|
{
|
|
private const string ObservedLabelsFileName = "keyboard_layout_labels.json";
|
|
private static readonly ConcurrentDictionary<ConfigPhysicalKey, string> _observedLayoutLabels = new();
|
|
private static readonly object _observedLayoutLabelsLock = new();
|
|
private static readonly JsonSerializerOptions _serializerOptions = new()
|
|
{
|
|
WriteIndented = true,
|
|
AllowTrailingCommas = true,
|
|
ReadCommentHandling = JsonCommentHandling.Skip
|
|
};
|
|
private static bool _observedLayoutLabelsLoaded;
|
|
public static event Action LabelsChanged;
|
|
|
|
public static string GetDisplayString(ConfigPhysicalKey key)
|
|
{
|
|
EnsureObservedLayoutLabelsLoaded();
|
|
|
|
if (KeyboardLayoutLocaleHelper.TryGetPhysicalLabel(key, out string localizedLabel))
|
|
{
|
|
return localizedLabel;
|
|
}
|
|
|
|
if (_observedLayoutLabels.TryGetValue(key, out string observedLabel))
|
|
{
|
|
return observedLabel;
|
|
}
|
|
|
|
if (TryGetFallbackPrintableKeyLabel(key, out string label))
|
|
{
|
|
return label;
|
|
}
|
|
|
|
return key.ToString();
|
|
}
|
|
|
|
public static void ObserveKeyPress(object sender, KeyEventArgs args)
|
|
{
|
|
EnsureObservedLayoutLabelsLoaded();
|
|
|
|
if (args.KeyModifiers != KeyModifiers.None)
|
|
{
|
|
return;
|
|
}
|
|
|
|
InputKey inputKey = AvaloniaKeyboardMappingHelper.ToInputKey(args.PhysicalKey);
|
|
if (!TryConvertToConfigPhysicalKey(inputKey, out ConfigPhysicalKey physicalKey) ||
|
|
KeyboardLayoutLocaleHelper.TryGetPhysicalLocaleKey(physicalKey, out _))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (TryNormalizeObservedPrintableLabel(args.KeySymbol, out string label))
|
|
{
|
|
if (IsCapsLockOn() && !char.IsLetter(label[0]))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (_observedLayoutLabels.TryGetValue(physicalKey, out string existingLabel) && existingLabel == label)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_observedLayoutLabels[physicalKey] = label;
|
|
SaveObservedLayoutLabels();
|
|
LabelsChanged?.Invoke();
|
|
}
|
|
}
|
|
|
|
private static void EnsureObservedLayoutLabelsLoaded()
|
|
{
|
|
if (_observedLayoutLabelsLoaded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lock (_observedLayoutLabelsLock)
|
|
{
|
|
if (_observedLayoutLabelsLoaded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
string labelsPath = GetObservedLabelsPath();
|
|
if (!File.Exists(labelsPath))
|
|
{
|
|
_observedLayoutLabelsLoaded = true;
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
string labelsJson = File.ReadAllText(labelsPath);
|
|
Dictionary<string, string>? labels = JsonSerializer.Deserialize<Dictionary<string, string>>(labelsJson, _serializerOptions);
|
|
|
|
if (labels != null)
|
|
{
|
|
foreach ((string key, string value) in labels)
|
|
{
|
|
if (Enum.TryParse(key, out ConfigPhysicalKey physicalKey) &&
|
|
!string.IsNullOrEmpty(value))
|
|
{
|
|
_observedLayoutLabels[physicalKey] = value;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning?.Print(LogClass.UI, $"Unable to load observed keyboard layout labels from '{labelsPath}': {ex.Message}");
|
|
}
|
|
finally
|
|
{
|
|
_observedLayoutLabelsLoaded = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void SaveObservedLayoutLabels()
|
|
{
|
|
lock (_observedLayoutLabelsLock)
|
|
{
|
|
try
|
|
{
|
|
Dictionary<string, string> labels = new();
|
|
|
|
foreach ((ConfigPhysicalKey key, string value) in _observedLayoutLabels)
|
|
{
|
|
labels[key.ToString()] = value;
|
|
}
|
|
|
|
File.WriteAllText(GetObservedLabelsPath(), JsonSerializer.Serialize(labels, _serializerOptions));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Warning?.Print(LogClass.UI, $"Unable to save observed keyboard layout labels: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static string GetObservedLabelsPath()
|
|
{
|
|
return Path.Combine(AppDataManager.BaseDirPath, ObservedLabelsFileName);
|
|
}
|
|
|
|
private static bool TryGetFallbackPrintableKeyLabel(ConfigPhysicalKey key, out string label)
|
|
{
|
|
// The legacy enum name for the ISO extra key is misleading, so give it a distinct physical label.
|
|
if (key == ConfigPhysicalKey.Grave)
|
|
{
|
|
label = "<>";
|
|
return true;
|
|
}
|
|
|
|
if (!AvaloniaKeyboardMappingHelper.TryGetAvaPhysicalKey((InputKey)(int)key, out AvaPhysicalKey avaPhysicalKey))
|
|
{
|
|
label = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
label = PhysicalKeyExtensions.ToQwertyKeySymbol(avaPhysicalKey, false);
|
|
|
|
if (string.IsNullOrEmpty(label) || label.Length != 1 || char.IsControl(label[0]))
|
|
{
|
|
label = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
if (char.IsLetter(label[0]))
|
|
{
|
|
label = char.ToUpperInvariant(label[0]).ToString();
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool IsCapsLockOn()
|
|
{
|
|
try
|
|
{
|
|
return OperatingSystem.IsWindows() && Console.CapsLock;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.Debug?.Print(LogClass.UI, $"CapsLock state query failed: {ex.Message}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private static bool TryNormalizeObservedPrintableLabel(string keySymbol, out string label)
|
|
{
|
|
if (string.IsNullOrEmpty(keySymbol) || keySymbol.Length != 1 || char.IsControl(keySymbol[0]))
|
|
{
|
|
label = string.Empty;
|
|
return false;
|
|
}
|
|
|
|
label = char.IsLetter(keySymbol[0])
|
|
? char.ToUpperInvariant(keySymbol[0]).ToString()
|
|
: keySymbol;
|
|
|
|
return true;
|
|
}
|
|
|
|
private static bool TryConvertToConfigPhysicalKey(InputKey key, out ConfigPhysicalKey physicalKey)
|
|
{
|
|
if (key is >= InputKey.Unknown and < InputKey.Count)
|
|
{
|
|
physicalKey = (ConfigPhysicalKey)(int)key;
|
|
return true;
|
|
}
|
|
|
|
physicalKey = ConfigPhysicalKey.Unknown;
|
|
return false;
|
|
}
|
|
}
|
|
}
|