diff --git a/Ryujinx.HLE/HLEConfiguration.cs b/Ryujinx.HLE/HLEConfiguration.cs index 16c19ba48..6dfa6a268 100644 --- a/Ryujinx.HLE/HLEConfiguration.cs +++ b/Ryujinx.HLE/HLEConfiguration.cs @@ -7,6 +7,7 @@ using Ryujinx.HLE.FileSystem.Content; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Account.Acc; using Ryujinx.HLE.HOS.SystemState; +using Ryujinx.HLE.Ui; using System; namespace Ryujinx.HLE diff --git a/Ryujinx.HLE/HOS/Applets/IApplet.cs b/Ryujinx.HLE/HOS/Applets/IApplet.cs index a29eeb843..224d67874 100644 --- a/Ryujinx.HLE/HOS/Applets/IApplet.cs +++ b/Ryujinx.HLE/HOS/Applets/IApplet.cs @@ -1,4 +1,6 @@ using Ryujinx.HLE.HOS.Services.Am.AppletAE; +using Ryujinx.HLE.Ui; +using Ryujinx.Memory; using System; using System.Runtime.InteropServices; @@ -13,6 +15,11 @@ namespace Ryujinx.HLE.HOS.Applets ResultCode GetResult(); + bool DrawTo(RenderingSurfaceInfo surfaceInfo, IVirtualMemoryManager destination, ulong position) + { + return false; + } + static T ReadStruct(ReadOnlySpan data) where T : unmanaged { return MemoryMarshal.Cast(data)[0]; diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineKeyboardState.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineKeyboardState.cs index 024ff2cf4..47e1a7746 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineKeyboardState.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineKeyboardState.cs @@ -11,23 +11,23 @@ Uninitialized = 0x0, /// - /// A Calc was previously received and fulfilled, so the software keyboard is initialized, but is not processing input. + /// The software keyboard is initialized, but it is not visible and not processing input. /// Initialized = 0x1, /// - /// A Calc was received and the software keyboard is processing input. + /// The software keyboard is transitioning to a visible state. /// - Ready = 0x2, + Appearing = 0x2, /// - /// New text data or cursor position of the software keyboard are available. + /// The software keyboard is visible and receiving processing input. /// - DataAvailable = 0x3, + Shown = 0x3, /// - /// The Calc request was fulfilled with either a text input or a cancel. + /// software keyboard is transitioning to a hidden state because the user pressed either OK or Cancel. /// - Complete = 0x4 + Disappearing = 0x4 } } diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineResponses.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineResponses.cs index 50e77b742..d48227a0f 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineResponses.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InlineResponses.cs @@ -38,9 +38,20 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard return (uint)text.Length; // Return the cursor position at the end of the text } - private static void WriteStringWithCursor(string text, BinaryWriter writer, uint maxSize, Encoding encoding) + private static void WriteStringWithCursor(string text, uint cursor, BinaryWriter writer, uint maxSize, Encoding encoding, bool padMiddle) { - uint cursor = WriteString(text, writer, maxSize, encoding); + uint length = WriteString(text, writer, maxSize, encoding); + + if (cursor > length) + { + cursor = length; + } + + if (padMiddle) + { + writer.Write((int)-1); // ? + writer.Write((int)-1); // ? + } writer.Write(cursor); // Cursor position } @@ -72,7 +83,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard } } - public static byte[] ChangedString(string text, InlineKeyboardState state) + public static byte[] ChangedString(string text, uint cursor, InlineKeyboardState state) { uint resSize = 6 * sizeof(uint) + MaxStrLenUTF16; @@ -80,15 +91,13 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.ChangedString, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF16, Encoding.Unicode); - writer.Write((int)0); // ? - writer.Write((int)0); // ? + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF16, Encoding.Unicode, true); return stream.ToArray(); } } - public static byte[] MovedCursor(string text, InlineKeyboardState state) + public static byte[] MovedCursor(string text, uint cursor, InlineKeyboardState state) { uint resSize = 4 * sizeof(uint) + MaxStrLenUTF16; @@ -96,13 +105,13 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.MovedCursor, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF16, Encoding.Unicode); + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF16, Encoding.Unicode, false); return stream.ToArray(); } } - public static byte[] MovedTab(string text, InlineKeyboardState state) + public static byte[] MovedTab(string text, uint cursor, InlineKeyboardState state) { // Should be the same as MovedCursor. @@ -112,7 +121,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.MovedTab, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF16, Encoding.Unicode); + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF16, Encoding.Unicode, false); return stream.ToArray(); } @@ -145,7 +154,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard } } - public static byte[] ChangedStringUtf8(string text, InlineKeyboardState state) + public static byte[] ChangedStringUtf8(string text, uint cursor, InlineKeyboardState state) { uint resSize = 6 * sizeof(uint) + MaxStrLenUTF8; @@ -153,15 +162,13 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.ChangedStringUtf8, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF8, Encoding.UTF8); - writer.Write((int)0); // ? - writer.Write((int)0); // ? + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF8, Encoding.UTF8, true); return stream.ToArray(); } } - public static byte[] MovedCursorUtf8(string text, InlineKeyboardState state) + public static byte[] MovedCursorUtf8(string text, uint cursor, InlineKeyboardState state) { uint resSize = 4 * sizeof(uint) + MaxStrLenUTF8; @@ -169,7 +176,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.MovedCursorUtf8, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF8, Encoding.UTF8); + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF8, Encoding.UTF8, false); return stream.ToArray(); } @@ -228,7 +235,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard } } - public static byte[] ChangedStringV2(string text, InlineKeyboardState state) + public static byte[] ChangedStringV2(string text, uint cursor, InlineKeyboardState state) { uint resSize = 6 * sizeof(uint) + MaxStrLenUTF16 + 0x1; @@ -236,16 +243,14 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.ChangedStringV2, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF16, Encoding.Unicode); - writer.Write((int)0); // ? - writer.Write((int)0); // ? + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF16, Encoding.Unicode, true); writer.Write((byte)0); // Flag == 0 return stream.ToArray(); } } - public static byte[] MovedCursorV2(string text, InlineKeyboardState state) + public static byte[] MovedCursorV2(string text, uint cursor, InlineKeyboardState state) { uint resSize = 4 * sizeof(uint) + MaxStrLenUTF16 + 0x1; @@ -253,14 +258,14 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.MovedCursorV2, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF16, Encoding.Unicode); + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF16, Encoding.Unicode, false); writer.Write((byte)0); // Flag == 0 return stream.ToArray(); } } - public static byte[] ChangedStringUtf8V2(string text, InlineKeyboardState state) + public static byte[] ChangedStringUtf8V2(string text, uint cursor, InlineKeyboardState state) { uint resSize = 6 * sizeof(uint) + MaxStrLenUTF8 + 0x1; @@ -268,16 +273,14 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.ChangedStringUtf8V2, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF8, Encoding.UTF8); - writer.Write((int)0); // ? - writer.Write((int)0); // ? + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF8, Encoding.UTF8, true); writer.Write((byte)0); // Flag == 0 return stream.ToArray(); } } - public static byte[] MovedCursorUtf8V2(string text, InlineKeyboardState state) + public static byte[] MovedCursorUtf8V2(string text, uint cursor, InlineKeyboardState state) { uint resSize = 4 * sizeof(uint) + MaxStrLenUTF8 + 0x1; @@ -285,7 +288,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard using (BinaryWriter writer = new BinaryWriter(stream)) { BeginResponse(state, InlineKeyboardResponse.MovedCursorUtf8V2, writer); - WriteStringWithCursor(text, writer, MaxStrLenUTF8, Encoding.UTF8); + WriteStringWithCursor(text, cursor, writer, MaxStrLenUTF8, Encoding.UTF8, false); writer.Write((byte)0); // Flag == 0 return stream.ToArray(); diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InvalidButtonFlags.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InvalidButtonFlags.cs new file mode 100644 index 000000000..1166e81dd --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/InvalidButtonFlags.cs @@ -0,0 +1,17 @@ +using System; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// Identifies prohibited buttons. + /// + [Flags] + enum InvalidButtonFlags : uint + { + None = 0, + AnalogStickL = 1 << 1, + AnalogStickR = 1 << 2, + ZL = 1 << 3, + ZR = 1 << 4, + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardCalcFlags.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardCalcFlags.cs new file mode 100644 index 000000000..0b0f138b6 --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardCalcFlags.cs @@ -0,0 +1,26 @@ +using System; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// Bitmask of commands encoded in the Flags field of the Calc structs. + /// + [Flags] + enum KeyboardCalcFlags : ulong + { + Initialize = 0x1, + SetVolume = 0x2, + Appear = 0x4, + SetInputText = 0x8, + SetCursorPos = 0x10, + SetUtf8Mode = 0x20, + SetKeyboardBackground = 0x100, + SetKeyboardOptions1 = 0x200, + SetKeyboardOptions2 = 0x800, + EnableSeGroup = 0x2000, + DisableSeGroup = 0x4000, + SetBackspaceEnabled = 0x8000, + AppearTrigger = 0x10000, + MustShow = Appear | SetInputText | AppearTrigger + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardInputMode.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardInputMode.cs new file mode 100644 index 000000000..681b9208a --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardInputMode.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// Active input options set by the keyboard applet. These options allow keyboard + /// players to input text without conflicting with the controller mappings. + /// + enum KeyboardInputMode : uint + { + ControllerAndKeyboard, + KeyboardOnly, + ControllerOnly, + Count, + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMiniaturizationMode.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMiniaturizationMode.cs new file mode 100644 index 000000000..5184118cd --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMiniaturizationMode.cs @@ -0,0 +1,12 @@ +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// The miniaturization mode used by the keyboard in inline mode. + /// + enum KeyboardMiniaturizationMode : byte + { + None = 0, + Auto = 1, + Forced = 2 + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMode.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMode.cs index e5418a6f3..f512050e1 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMode.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardMode.cs @@ -8,21 +8,24 @@ /// /// A full alpha-numeric keyboard. /// - Default, + Default = 0, /// /// Number pad. /// - NumbersOnly, + NumbersOnly = 1, /// - /// QWERTY (and variants) keyboard only. + /// ASCII characters keyboard. /// - LettersOnly, + ASCII = 2, - /// - /// Unknown keyboard variant. - /// - Unknown + FullLatin = 3, + Alphabet = 4, + SimplifiedChinese = 5, + TraditionalChinese = 6, + Korean = 7, + LanguageSet2 = 8, + LanguageSet2Latin = 9, } } diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardResult.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardResult.cs new file mode 100644 index 000000000..de112f548 --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/KeyboardResult.cs @@ -0,0 +1,14 @@ +using System; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// The intention of the user when they finish the interaction with the keyboard. + /// + enum KeyboardResult + { + NotSet = 0, + Accept = 1, + Cancel = 2, + } +} \ No newline at end of file diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.png b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.png new file mode 100644 index 000000000..a8ee784dd Binary files /dev/null and b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.png differ diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.svg b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.svg new file mode 100644 index 000000000..6257fd12f --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnA.svg @@ -0,0 +1,80 @@ + + + + + + + + image/svg+xml + + + + + + + + A + + diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.png b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.png new file mode 100644 index 000000000..e1fa3454a Binary files /dev/null and b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.png differ diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.svg b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.svg new file mode 100644 index 000000000..ea6bb9bdb --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_BtnB.svg @@ -0,0 +1,93 @@ + + + + + + + + image/svg+xml + + + + + + + + B + B + + diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.png b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.png new file mode 100644 index 000000000..d6dbdc1a3 Binary files /dev/null and b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.png differ diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.svg b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.svg new file mode 100644 index 000000000..2256ebeb9 --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/Resources/Icon_KeyF6.svg @@ -0,0 +1,108 @@ + + + + + + + + image/svg+xml + + + + + + + + F6 + + + + + + + diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppear.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppear.cs index 262dd4df8..e1ee0507d 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppear.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppear.cs @@ -5,16 +5,12 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// A structure with appearance configurations for the software keyboard when running in inline mode. /// - [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + [StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)] struct SoftwareKeyboardAppear { - private const int OkTextLength = 8; + public const int OkTextLength = SoftwareKeyboardAppearEx.OkTextLength; - /// - /// Some games send a Calc without intention of showing the keyboard, a - /// common trend observed is that this field will be != 0 in such cases. - /// - public uint ShouldBeHidden; + public KeyboardMode KeyboardMode; /// /// The string displayed in the Submit button. @@ -38,15 +34,26 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard [MarshalAs(UnmanagedType.I1)] public bool PredictionEnabled; - public byte Empty; + /// + /// When set, there is only the option to accept the input. + /// + [MarshalAs(UnmanagedType.I1)] + public bool CancelButtonDisabled; /// /// Specifies prohibited characters that cannot be input into the text entry area. /// - public InvalidCharFlags InvalidCharFlag; + public InvalidCharFlags InvalidChars; - public int Padding1; - public int Padding2; + /// + /// Maximum text length allowed. + /// + public int TextMaxLength; + + /// + /// Minimum text length allowed. + /// + public int TextMinLength; /// /// Indicates the return button is enabled in the keyboard. This allows for input with multiple lines. @@ -57,21 +64,56 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// [10.0.0+] If value is 1 or 2, then keytopAsFloating=0 and footerScalable=1 in Calc. /// - public byte Unknown1; + public KeyboardMiniaturizationMode MiniaturizationMode; - public byte Padding4; - public byte Padding5; + public byte Reserved1; + public byte Reserved2; /// - /// Bitmask 0x1000 of the Calc and DirectionalButtonAssignEnabled in bitmask 0x10000000. + /// Bit field with invalid buttons for the keyboard. /// - public uint CalcFlags; + public InvalidButtonFlags InvalidButtons; - public uint Padding6; - public uint Padding7; - public uint Padding8; - public uint Padding9; - public uint Padding10; - public uint Padding11; + [MarshalAs(UnmanagedType.I1)] + public bool UseSaveData; + + public uint Reserved3; + public ushort Reserved4; + public byte Reserved5; + public ulong Reserved6; + public ulong Reserved7; + + public SoftwareKeyboardAppearEx ToExtended() + { + SoftwareKeyboardAppearEx appear = new SoftwareKeyboardAppearEx(); + + appear.KeyboardMode = KeyboardMode; + appear.OkText = OkText; + appear.LeftOptionalSymbolKey = LeftOptionalSymbolKey; + appear.RightOptionalSymbolKey = RightOptionalSymbolKey; + appear.PredictionEnabled = PredictionEnabled; + appear.CancelButtonDisabled = CancelButtonDisabled; + appear.InvalidChars = InvalidChars; + appear.TextMaxLength = TextMaxLength; + appear.TextMinLength = TextMinLength; + appear.UseNewLine = UseNewLine; + appear.MiniaturizationMode = MiniaturizationMode; + appear.Reserved1 = Reserved1; + appear.Reserved2 = Reserved2; + appear.InvalidButtons = InvalidButtons; + appear.UseSaveData = UseSaveData; + appear.Reserved3 = Reserved3; + appear.Reserved4 = Reserved4; + appear.Reserved5 = Reserved5; + appear.Uid0 = Reserved6; + appear.Uid1 = Reserved7; + appear.SamplingNumber = 0; + appear.Reserved6 = 0; + appear.Reserved7 = 0; + appear.Reserved8 = 0; + appear.Reserved9 = 0; + + return appear; + } } } diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppearEx.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppearEx.cs new file mode 100644 index 000000000..d1756b07a --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardAppearEx.cs @@ -0,0 +1,100 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// A structure with appearance configurations for the software keyboard when running in inline mode. + /// + [StructLayout(LayoutKind.Sequential, Pack = 1, CharSet = CharSet.Unicode)] + struct SoftwareKeyboardAppearEx + { + public const int OkTextLength = 8; + + public KeyboardMode KeyboardMode; + + /// + /// The string displayed in the Submit button. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = OkTextLength + 1)] + public string OkText; + + /// + /// The character displayed in the left button of the numeric keyboard. + /// + public char LeftOptionalSymbolKey; + + /// + /// The character displayed in the right button of the numeric keyboard. + /// + public char RightOptionalSymbolKey; + + /// + /// When set, predictive typing is enabled making use of the system dictionary, and any custom user dictionary. + /// + [MarshalAs(UnmanagedType.I1)] + public bool PredictionEnabled; + + /// + /// When set, there is only the option to accept the input. + /// + [MarshalAs(UnmanagedType.I1)] + public bool CancelButtonDisabled; + + /// + /// Specifies prohibited characters that cannot be input into the text entry area. + /// + public InvalidCharFlags InvalidChars; + + /// + /// Maximum text length allowed. + /// + public int TextMaxLength; + + /// + /// Minimum text length allowed. + /// + public int TextMinLength; + + /// + /// Indicates the return button is enabled in the keyboard. This allows for input with multiple lines. + /// + [MarshalAs(UnmanagedType.I1)] + public bool UseNewLine; + + /// + /// [10.0.0+] If value is 1 or 2, then keytopAsFloating=0 and footerScalable=1 in Calc. + /// + public KeyboardMiniaturizationMode MiniaturizationMode; + + public byte Reserved1; + public byte Reserved2; + + /// + /// Bit field with invalid buttons for the keyboard. + /// + public InvalidButtonFlags InvalidButtons; + + [MarshalAs(UnmanagedType.I1)] + public bool UseSaveData; + + public uint Reserved3; + public ushort Reserved4; + public byte Reserved5; + + /// + /// The id of the user associated with the appear request. + /// + public ulong Uid0; + public ulong Uid1; + + /// + /// The sampling number for the keyboard appearance. + /// + public ulong SamplingNumber; + + public ulong Reserved6; + public ulong Reserved7; + public ulong Reserved8; + public ulong Reserved9; + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardApplet.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardApplet.cs index af01bbc07..771ab8816 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardApplet.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardApplet.cs @@ -1,35 +1,35 @@ -using Ryujinx.Common; +using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Logging; using Ryujinx.HLE.HOS.Applets.SoftwareKeyboard; using Ryujinx.HLE.HOS.Services.Am.AppletAE; +using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Npad; +using Ryujinx.HLE.Ui; +using Ryujinx.HLE.Ui.Input; +using Ryujinx.Memory; using System; +using System.Diagnostics; using System.IO; using System.Runtime.InteropServices; using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace Ryujinx.HLE.HOS.Applets { internal class SoftwareKeyboardApplet : IApplet { - private const string DefaultText = "Ryujinx"; + private const string DefaultInputText = "Ryujinx"; - private const long DebounceTimeMillis = 200; - private const int ResetDelayMillis = 500; + private const int StandardBufferSize = 0x7D8; + private const int MaxUserWords = 0x1388; + private const int MaxUiTextSize = 100; + + private const Key CycleInputModesKey = Key.F6; private readonly Switch _device; - private const int StandardBufferSize = 0x7D8; - private const int InteractiveBufferSize = 0x7D4; - private const int MaxUserWords = 0x1388; - - private SoftwareKeyboardState _foregroundState = SoftwareKeyboardState.Uninitialized; + private SoftwareKeyboardState _foregroundState = SoftwareKeyboardState.Uninitialized; private volatile InlineKeyboardState _backgroundState = InlineKeyboardState.Uninitialized; private bool _isBackground = false; - private bool _alreadyShown = false; - private volatile bool _useChangedStringV2 = false; private AppletSession _normalSession; private AppletSession _interactiveSession; @@ -39,18 +39,24 @@ namespace Ryujinx.HLE.HOS.Applets // Configuration for background (inline) mode. private SoftwareKeyboardInitialize _keyboardBackgroundInitialize; - private SoftwareKeyboardCalc _keyboardBackgroundCalc; private SoftwareKeyboardCustomizeDic _keyboardBackgroundDic; private SoftwareKeyboardDictSet _keyboardBackgroundDictSet; private SoftwareKeyboardUserWord[] _keyboardBackgroundUserWords; private byte[] _transferMemory; - private string _textValue = ""; - private bool _okPressed = false; - private Encoding _encoding = Encoding.Unicode; - private long _lastTextSetMillis = 0; - private bool _lastWasHidden = false; + private string _textValue = ""; + private int _cursorBegin = 0; + private Encoding _encoding = Encoding.Unicode; + private KeyboardResult _lastResult = KeyboardResult.NotSet; + + private IDynamicTextInputHandler _dynamicTextInputHandler = null; + private SoftwareKeyboardRenderer _keyboardRenderer = null; + private NpadReader _npads = null; + private bool _canAcceptController = false; + private KeyboardInputMode _inputMode = KeyboardInputMode.ControllerAndKeyboard; + + private object _lock = new object(); public event EventHandler AppletStateChanged; @@ -62,58 +68,74 @@ namespace Ryujinx.HLE.HOS.Applets public ResultCode Start(AppletSession normalSession, AppletSession interactiveSession) { - _normalSession = normalSession; - _interactiveSession = interactiveSession; - - _interactiveSession.DataAvailable += OnInteractiveData; - - _alreadyShown = false; - _useChangedStringV2 = false; - - var launchParams = _normalSession.Pop(); - var keyboardConfig = _normalSession.Pop(); - - if (keyboardConfig.Length == Marshal.SizeOf()) + lock (_lock) { - // Initialize the keyboard applet in background mode. + _normalSession = normalSession; + _interactiveSession = interactiveSession; - _isBackground = true; + _interactiveSession.DataAvailable += OnInteractiveData; - _keyboardBackgroundInitialize = ReadStruct(keyboardConfig); - _backgroundState = InlineKeyboardState.Uninitialized; + var launchParams = _normalSession.Pop(); + var keyboardConfig = _normalSession.Pop(); - return ResultCode.Success; - } - else - { - // Initialize the keyboard applet in foreground mode. + _isBackground = keyboardConfig.Length == Marshal.SizeOf(); - _isBackground = false; - - if (keyboardConfig.Length < Marshal.SizeOf()) + if (_isBackground) { - Logger.Error?.Print(LogClass.ServiceAm, $"SoftwareKeyboardConfig size mismatch. Expected {Marshal.SizeOf():x}. Got {keyboardConfig.Length:x}"); + // Initialize the keyboard applet in background mode. + + _keyboardBackgroundInitialize = ReadStruct(keyboardConfig); + _backgroundState = InlineKeyboardState.Uninitialized; + + if (_device.UiHandler == null) + { + Logger.Error?.Print(LogClass.ServiceAm, "GUI Handler is not set, software keyboard applet will not work properly"); + } + else + { + // Create a text handler that converts keyboard strokes to strings. + _dynamicTextInputHandler = _device.UiHandler.CreateDynamicTextInputHandler(); + _dynamicTextInputHandler.TextChangedEvent += HandleTextChangedEvent; + _dynamicTextInputHandler.KeyPressedEvent += HandleKeyPressedEvent; + + _npads = new NpadReader(_device); + _npads.NpadButtonDownEvent += HandleNpadButtonDownEvent; + _npads.NpadButtonUpEvent += HandleNpadButtonUpEvent; + + _keyboardRenderer = new SoftwareKeyboardRenderer(_device.UiHandler.HostUiTheme); + } + + return ResultCode.Success; } else { - _keyboardForegroundConfig = ReadStruct(keyboardConfig); + // Initialize the keyboard applet in foreground mode. + + if (keyboardConfig.Length < Marshal.SizeOf()) + { + Logger.Error?.Print(LogClass.ServiceAm, $"SoftwareKeyboardConfig size mismatch. Expected {Marshal.SizeOf():x}. Got {keyboardConfig.Length:x}"); + } + else + { + _keyboardForegroundConfig = ReadStruct(keyboardConfig); + } + + if (!_normalSession.TryPop(out _transferMemory)) + { + Logger.Error?.Print(LogClass.ServiceAm, "SwKbd Transfer Memory is null"); + } + + if (_keyboardForegroundConfig.UseUtf8) + { + _encoding = Encoding.UTF8; + } + + _foregroundState = SoftwareKeyboardState.Ready; + + ExecuteForegroundKeyboard(); + + return ResultCode.Success; } - - if (!_normalSession.TryPop(out _transferMemory)) - { - Logger.Error?.Print(LogClass.ServiceAm, "SwKbd Transfer Memory is null"); - } - - if (_keyboardForegroundConfig.UseUtf8) - { - _encoding = Encoding.UTF8; - } - - _foregroundState = SoftwareKeyboardState.Ready; - - ExecuteForegroundKeyboard(); - - return ResultCode.Success; } } @@ -122,14 +144,33 @@ namespace Ryujinx.HLE.HOS.Applets return ResultCode.Success; } - private InlineKeyboardState GetInlineState() + private bool IsKeyboardActive() { - return _backgroundState; + return _backgroundState >= InlineKeyboardState.Appearing && _backgroundState < InlineKeyboardState.Disappearing; } - private void SetInlineState(InlineKeyboardState state) + private bool InputModeControllerEnabled() { - _backgroundState = state; + return _inputMode == KeyboardInputMode.ControllerAndKeyboard || + _inputMode == KeyboardInputMode.ControllerOnly; + } + + private bool InputModeTypingEnabled() + { + return _inputMode == KeyboardInputMode.ControllerAndKeyboard || + _inputMode == KeyboardInputMode.KeyboardOnly; + } + + private void AdvanceInputMode() + { + _inputMode = (KeyboardInputMode)((int)(_inputMode + 1) % (int)KeyboardInputMode.Count); + } + + public bool DrawTo(RenderingSurfaceInfo surfaceInfo, IVirtualMemoryManager destination, ulong position) + { + _npads?.Update(); + + return _keyboardRenderer?.DrawTo(surfaceInfo, destination, position) ?? false; } private void ExecuteForegroundKeyboard() @@ -151,30 +192,32 @@ namespace Ryujinx.HLE.HOS.Applets _keyboardForegroundConfig.StringLengthMax = 100; } - var args = new SoftwareKeyboardUiArgs - { - HeaderText = _keyboardForegroundConfig.HeaderText, - SubtitleText = _keyboardForegroundConfig.SubtitleText, - GuideText = _keyboardForegroundConfig.GuideText, - SubmitText = (!string.IsNullOrWhiteSpace(_keyboardForegroundConfig.SubmitText) ? - _keyboardForegroundConfig.SubmitText : "OK"), - StringLengthMin = _keyboardForegroundConfig.StringLengthMin, - StringLengthMax = _keyboardForegroundConfig.StringLengthMax, - InitialText = initialText - }; - - // Call the configured GUI handler to get user's input if (_device.UiHandler == null) { Logger.Warning?.Print(LogClass.Application, "GUI Handler is not set. Falling back to default"); - _okPressed = true; + + _textValue = DefaultInputText; + _lastResult = KeyboardResult.Accept; } else { - _okPressed = _device.UiHandler.DisplayInputDialog(args, out _textValue); - } + // Call the configured GUI handler to get user's input. - _textValue ??= initialText ?? DefaultText; + var args = new SoftwareKeyboardUiArgs + { + HeaderText = _keyboardForegroundConfig.HeaderText, + SubtitleText = _keyboardForegroundConfig.SubtitleText, + GuideText = _keyboardForegroundConfig.GuideText, + SubmitText = (!string.IsNullOrWhiteSpace(_keyboardForegroundConfig.SubmitText) ? + _keyboardForegroundConfig.SubmitText : "OK"), + StringLengthMin = _keyboardForegroundConfig.StringLengthMin, + StringLengthMax = _keyboardForegroundConfig.StringLengthMax, + InitialText = initialText + }; + + _lastResult = _device.UiHandler.DisplayInputDialog(args, out _textValue) ? KeyboardResult.Accept : KeyboardResult.Cancel; + _textValue ??= initialText ?? DefaultInputText; + } // If the game requests a string with a minimum length less // than our default text, repeat our default text until we meet @@ -189,7 +232,7 @@ namespace Ryujinx.HLE.HOS.Applets // we truncate it. if (_textValue.Length > _keyboardForegroundConfig.StringLengthMax) { - _textValue = _textValue.Substring(0, (int)_keyboardForegroundConfig.StringLengthMax); + _textValue = _textValue.Substring(0, _keyboardForegroundConfig.StringLengthMax); } // Does the application want to validate the text itself? @@ -201,7 +244,7 @@ namespace Ryujinx.HLE.HOS.Applets // back a validation status, which is handled in OnInteractiveDataPushIn. _foregroundState = SoftwareKeyboardState.ValidationPending; - _interactiveSession.Push(BuildResponse(_textValue, true)); + _interactiveSession.Push(BuildForegroundResponse()); } else { @@ -210,7 +253,7 @@ namespace Ryujinx.HLE.HOS.Applets // and poll it for completion. _foregroundState = SoftwareKeyboardState.Complete; - _normalSession.Push(BuildResponse(_textValue, false)); + _normalSession.Push(BuildForegroundResponse()); AppletStateChanged?.Invoke(this, null); } @@ -223,7 +266,10 @@ namespace Ryujinx.HLE.HOS.Applets if (_isBackground) { - OnBackgroundInteractiveData(data); + lock (_lock) + { + OnBackgroundInteractiveData(data); + } } else { @@ -241,7 +287,7 @@ namespace Ryujinx.HLE.HOS.Applets // For now we assume success, so we push the final result // to the standard output buffer and carry on our merry way. - _normalSession.Push(BuildResponse(_textValue, false)); + _normalSession.Push(BuildForegroundResponse()); AppletStateChanged?.Invoke(this, null); @@ -251,7 +297,7 @@ namespace Ryujinx.HLE.HOS.Applets { // If we have already completed, we push the result text // back on the output buffer and poll the application. - _normalSession.Push(BuildResponse(_textValue, false)); + _normalSession.Push(BuildForegroundResponse()); AppletStateChanged?.Invoke(this, null); } @@ -271,19 +317,19 @@ namespace Ryujinx.HLE.HOS.Applets using (MemoryStream stream = new MemoryStream(data)) using (BinaryReader reader = new BinaryReader(stream)) { - InlineKeyboardRequest request = (InlineKeyboardRequest)reader.ReadUInt32(); - InlineKeyboardState state = GetInlineState(); + var request = (InlineKeyboardRequest)reader.ReadUInt32(); + long remaining; - Logger.Debug?.Print(LogClass.ServiceAm, $"Keyboard received command {request} in state {state}"); + Logger.Debug?.Print(LogClass.ServiceAm, $"Keyboard received command {request} in state {_backgroundState}"); switch (request) { case InlineKeyboardRequest.UseChangedStringV2: - _useChangedStringV2 = true; + Logger.Stub?.Print(LogClass.ServiceAm, "Inline keyboard request UseChangedStringV2"); break; case InlineKeyboardRequest.UseMovedCursorV2: - // Not used because we only reply with the final string. + Logger.Stub?.Print(LogClass.ServiceAm, "Inline keyboard request UseMovedCursorV2"); break; case InlineKeyboardRequest.SetUserWordInfo: // Read the user word info data. @@ -317,7 +363,7 @@ namespace Ryujinx.HLE.HOS.Applets } } } - _interactiveSession.Push(InlineResponses.ReleasedUserWordInfo(state)); + _interactiveSession.Push(InlineResponses.ReleasedUserWordInfo(_backgroundState)); break; case InlineKeyboardRequest.SetCustomizeDic: // Read the custom dic data. @@ -331,7 +377,6 @@ namespace Ryujinx.HLE.HOS.Applets var keyboardDicData = reader.ReadBytes((int)remaining); _keyboardBackgroundDic = ReadStruct(keyboardDicData); } - _interactiveSession.Push(InlineResponses.UnsetCustomizeDic(state)); break; case InlineKeyboardRequest.SetCustomizedDictionaries: // Read the custom dictionaries data. @@ -345,52 +390,89 @@ namespace Ryujinx.HLE.HOS.Applets var keyboardDictData = reader.ReadBytes((int)remaining); _keyboardBackgroundDictSet = ReadStruct(keyboardDictData); } - _interactiveSession.Push(InlineResponses.UnsetCustomizedDictionaries(state)); break; case InlineKeyboardRequest.Calc: - // The Calc request tells the Applet to enter the main input handling loop, which will end - // with either a text being submitted or a cancel request from the user. - - // NOTE: Some Calc requests happen early in the application and are not meant to be shown. This possibly - // happens because the game has complete control over when the inline keyboard is drawn, but here it - // would cause a dialog to pop in the emulator, which is inconvenient. An algorithm is applied to - // decide whether it is a dummy Calc or not, but regardless of the result, the dummy Calc appears to - // never happen twice, so the keyboard will always show if it has already been shown before. - bool shouldShowKeyboard = _alreadyShown; - _alreadyShown = true; + // The Calc request is used to communicate configuration changes and commands to the keyboard. + // Fields in the Calc struct and operations are masked by the Flags field. // Read the Calc data. + SoftwareKeyboardCalcEx newCalc; remaining = stream.Length - stream.Position; - if (remaining != Marshal.SizeOf()) + if (remaining == Marshal.SizeOf()) { - Logger.Error?.Print(LogClass.ServiceAm, $"Received invalid Software Keyboard Calc of {remaining} bytes"); + var keyboardCalcData = reader.ReadBytes((int)remaining); + var keyboardCalc = ReadStruct(keyboardCalcData); + + newCalc = keyboardCalc.ToExtended(); + } + else if (remaining == Marshal.SizeOf() || remaining == SoftwareKeyboardCalcEx.AlternativeSize) + { + var keyboardCalcData = reader.ReadBytes((int)remaining); + + newCalc = ReadStruct(keyboardCalcData); } else { - var keyboardCalcData = reader.ReadBytes((int)remaining); - _keyboardBackgroundCalc = ReadStruct(keyboardCalcData); + Logger.Error?.Print(LogClass.ServiceAm, $"Received invalid Software Keyboard Calc of {remaining} bytes"); - // Check if the application expects UTF8 encoding instead of UTF16. - if (_keyboardBackgroundCalc.UseUtf8) - { - _encoding = Encoding.UTF8; - } - - // Force showing the keyboard regardless of the state, an unwanted - // input dialog may show, but it is better than a soft lock. - if (_keyboardBackgroundCalc.Appear.ShouldBeHidden == 0) - { - shouldShowKeyboard = true; - } + newCalc = new SoftwareKeyboardCalcEx(); } - // Send an initialization finished signal. - state = InlineKeyboardState.Ready; - SetInlineState(state); - _interactiveSession.Push(InlineResponses.FinishedInitialize(state)); - // Start a task with the GUI handler to get user's input. - new Task(() => { GetInputTextAndSend(shouldShowKeyboard, state); }).Start(); + + // Process each individual operation specified in the flags. + + bool updateText = false; + + if ((newCalc.Flags & KeyboardCalcFlags.Initialize) != 0) + { + _interactiveSession.Push(InlineResponses.FinishedInitialize(_backgroundState)); + + _backgroundState = InlineKeyboardState.Initialized; + } + + if ((newCalc.Flags & KeyboardCalcFlags.SetCursorPos) != 0) + { + _cursorBegin = newCalc.CursorPos; + updateText = true; + + Logger.Debug?.Print(LogClass.ServiceAm, $"Cursor position set to {_cursorBegin}"); + } + + if ((newCalc.Flags & KeyboardCalcFlags.SetInputText) != 0) + { + _textValue = newCalc.InputText; + updateText = true; + + Logger.Debug?.Print(LogClass.ServiceAm, $"Input text set to {_textValue}"); + } + + if ((newCalc.Flags & KeyboardCalcFlags.SetUtf8Mode) != 0) + { + _encoding = newCalc.UseUtf8 ? Encoding.UTF8 : Encoding.Default; + + Logger.Debug?.Print(LogClass.ServiceAm, $"Encoding set to {_encoding}"); + } + + if (updateText) + { + _dynamicTextInputHandler.SetText(_textValue, _cursorBegin); + _keyboardRenderer.UpdateTextState(_textValue, _cursorBegin, _cursorBegin, null, null); + } + + if ((newCalc.Flags & KeyboardCalcFlags.MustShow) != 0) + { + ActivateFrontend(); + + _backgroundState = InlineKeyboardState.Shown; + + PushChangedString(_textValue, (uint)_cursorBegin, _backgroundState); + } + + // Send the response to the Calc + _interactiveSession.Push(InlineResponses.Default(_backgroundState)); break; case InlineKeyboardRequest.Finalize: + // Destroy the frontend. + DestroyFrontend(); // The calling application wants to close the keyboard applet and will wait for a state change. _backgroundState = InlineKeyboardState.Uninitialized; AppletStateChanged?.Invoke(this, null); @@ -398,137 +480,234 @@ namespace Ryujinx.HLE.HOS.Applets default: // We shouldn't be able to get here through standard swkbd execution. Logger.Warning?.Print(LogClass.ServiceAm, $"Invalid Software Keyboard request {request} during state {_backgroundState}"); - _interactiveSession.Push(InlineResponses.Default(state)); + _interactiveSession.Push(InlineResponses.Default(_backgroundState)); break; } } } - private void GetInputTextAndSend(bool shouldShowKeyboard, InlineKeyboardState oldState) + private void ActivateFrontend() { - bool submit = true; + Logger.Debug?.Print(LogClass.ServiceAm, $"Activating software keyboard frontend"); - // Use the text specified by the Calc if it is available, otherwise use the default one. - string inputText = (!string.IsNullOrWhiteSpace(_keyboardBackgroundCalc.InputText) ? - _keyboardBackgroundCalc.InputText : DefaultText); + _inputMode = KeyboardInputMode.ControllerAndKeyboard; - // Compute the elapsed time for the debouncing algorithm. - long currentMillis = PerformanceCounter.ElapsedMilliseconds; - long inputElapsedMillis = currentMillis - _lastTextSetMillis; + _npads.Update(true); - // Reset the input text before submitting the final result, that's because some games do not expect - // consecutive submissions to abruptly shrink and they will crash if it happens. Changing the string - // before the final submission prevents that. - InlineKeyboardState newState = InlineKeyboardState.DataAvailable; - SetInlineState(newState); - ChangedString("", newState); + NpadButton buttons = _npads.GetCurrentButtonsOfAllNpads(); - if (!_lastWasHidden && (inputElapsedMillis < DebounceTimeMillis)) - { - // A repeated Calc request has been received without player interaction, after the input has been - // sent. This behavior happens in some games, so instead of showing another dialog, just apply a - // time-based debouncing algorithm and repeat the last submission, either a value or a cancel. - // It is also possible that the first Calc request was hidden by accident, in this case use the - // debouncing as an oportunity to properly ask for input. - inputText = _textValue; - submit = _textValue != null; - _lastWasHidden = false; + // Block the input if the current accept key is pressed so the applet won't be instantly closed. + _canAcceptController = (buttons & NpadButton.A) == 0; - Logger.Warning?.Print(LogClass.Application, "Debouncing repeated keyboard request"); - } - else if (!shouldShowKeyboard) - { - // Submit the default text to avoid soft locking if the keyboard was ignored by - // accident. It's better to change the name than being locked out of the game. - inputText = DefaultText; - _lastWasHidden = true; + _dynamicTextInputHandler.TextProcessingEnabled = true; - Logger.Debug?.Print(LogClass.Application, "Received a dummy Calc, keyboard will not be shown"); - } - else if (_device.UiHandler == null) - { - Logger.Warning?.Print(LogClass.Application, "GUI Handler is not set. Falling back to default"); - _lastWasHidden = false; - } - else - { - // Call the configured GUI handler to get user's input. - var args = new SoftwareKeyboardUiArgs - { - HeaderText = "", // The inline keyboard lacks these texts - SubtitleText = "", - GuideText = "", - SubmitText = (!string.IsNullOrWhiteSpace(_keyboardBackgroundCalc.Appear.OkText) ? - _keyboardBackgroundCalc.Appear.OkText : "OK"), - StringLengthMin = 0, - StringLengthMax = 100, - InitialText = inputText - }; - - submit = _device.UiHandler.DisplayInputDialog(args, out inputText); - inputText = submit ? inputText : null; - _lastWasHidden = false; - } - - // The 'Complete' state indicates the Calc request has been fulfilled by the applet. - newState = InlineKeyboardState.Complete; - - if (submit) - { - Logger.Debug?.Print(LogClass.ServiceAm, "Sending keyboard OK"); - DecidedEnter(inputText, newState); - } - else - { - Logger.Debug?.Print(LogClass.ServiceAm, "Sending keyboard Cancel"); - DecidedCancel(newState); - } - - _interactiveSession.Push(InlineResponses.Default(newState)); - - // The constant calls to PopInteractiveData suggest that the keyboard applet continuously reports - // data back to the application and this can also be time-sensitive. Pushing a state reset right - // after the data has been sent does not work properly and the application will soft-lock. This - // delay gives time for the application to catch up with the data and properly process the state - // reset. - Thread.Sleep(ResetDelayMillis); - - // 'Initialized' is the only known state so far that does not soft-lock the keyboard after use. - newState = InlineKeyboardState.Initialized; - - Logger.Debug?.Print(LogClass.ServiceAm, $"Resetting state of the keyboard to {newState}"); - - SetInlineState(newState); - _interactiveSession.Push(InlineResponses.Default(newState)); - - // Keep the text and the timestamp of the input for the debouncing algorithm. - _textValue = inputText; - _lastTextSetMillis = PerformanceCounter.ElapsedMilliseconds; + _keyboardRenderer.UpdateCommandState(null, null, true); + _keyboardRenderer.UpdateTextState(null, null, null, null, true); } - private void ChangedString(string text, InlineKeyboardState state) + private void DeactivateFrontend() { - if (_encoding == Encoding.UTF8) + Logger.Debug?.Print(LogClass.ServiceAm, $"Deactivating software keyboard frontend"); + + _inputMode = KeyboardInputMode.ControllerAndKeyboard; + _canAcceptController = false; + + _dynamicTextInputHandler.TextProcessingEnabled = false; + _dynamicTextInputHandler.SetText(_textValue, _cursorBegin); + } + + private void DestroyFrontend() + { + Logger.Debug?.Print(LogClass.ServiceAm, $"Destroying software keyboard frontend"); + + _keyboardRenderer?.Dispose(); + _keyboardRenderer = null; + + if (_dynamicTextInputHandler != null) { - if (_useChangedStringV2) + _dynamicTextInputHandler.TextChangedEvent -= HandleTextChangedEvent; + _dynamicTextInputHandler.KeyPressedEvent -= HandleKeyPressedEvent; + _dynamicTextInputHandler.Dispose(); + _dynamicTextInputHandler = null; + } + + if (_npads != null) + { + _npads.NpadButtonDownEvent -= HandleNpadButtonDownEvent; + _npads.NpadButtonUpEvent -= HandleNpadButtonUpEvent; + _npads = null; + } + } + + private bool HandleKeyPressedEvent(Key key) + { + if (key == CycleInputModesKey) + { + lock (_lock) { - _interactiveSession.Push(InlineResponses.ChangedStringUtf8V2(text, state)); + if (IsKeyboardActive()) + { + AdvanceInputMode(); + + bool typingEnabled = InputModeTypingEnabled(); + bool controllerEnabled = InputModeControllerEnabled(); + + _dynamicTextInputHandler.TextProcessingEnabled = typingEnabled; + + _keyboardRenderer.UpdateTextState(null, null, null, null, typingEnabled); + _keyboardRenderer.UpdateCommandState(null, null, controllerEnabled); + } } - else + } + + return true; + } + + private void HandleTextChangedEvent(string text, int cursorBegin, int cursorEnd, bool overwriteMode) + { + lock (_lock) + { + // Text processing should not run with typing disabled. + Debug.Assert(InputModeTypingEnabled()); + + if (text.Length > MaxUiTextSize) { - _interactiveSession.Push(InlineResponses.ChangedStringUtf8(text, state)); + // Limit the text size and change it back. + text = text.Substring(0, MaxUiTextSize); + cursorBegin = Math.Min(cursorBegin, MaxUiTextSize); + cursorEnd = Math.Min(cursorEnd, MaxUiTextSize); + + _dynamicTextInputHandler.SetText(text, cursorBegin, cursorEnd); } + + _textValue = text; + _cursorBegin = cursorBegin; + _keyboardRenderer.UpdateTextState(text, cursorBegin, cursorEnd, overwriteMode, null); + + PushUpdatedState(text, cursorBegin, KeyboardResult.NotSet); + } + } + + private void HandleNpadButtonDownEvent(int npadIndex, NpadButton button) + { + lock (_lock) + { + if (!IsKeyboardActive()) + { + return; + } + + switch (button) + { + case NpadButton.A: + _keyboardRenderer.UpdateCommandState(_canAcceptController, null, null); + break; + case NpadButton.B: + _keyboardRenderer.UpdateCommandState(null, _canAcceptController, null); + break; + } + } + } + + private void HandleNpadButtonUpEvent(int npadIndex, NpadButton button) + { + lock (_lock) + { + KeyboardResult result = KeyboardResult.NotSet; + + switch (button) + { + case NpadButton.A: + result = KeyboardResult.Accept; + _keyboardRenderer.UpdateCommandState(false, null, null); + break; + case NpadButton.B: + result = KeyboardResult.Cancel; + _keyboardRenderer.UpdateCommandState(null, false, null); + break; + } + + if (IsKeyboardActive()) + { + if (!_canAcceptController) + { + _canAcceptController = true; + } + else if (InputModeControllerEnabled()) + { + PushUpdatedState(_textValue, _cursorBegin, result); + } + } + } + } + + private void PushUpdatedState(string text, int cursorBegin, KeyboardResult result) + { + _lastResult = result; + _textValue = text; + + bool cancel = result == KeyboardResult.Cancel; + bool accept = result == KeyboardResult.Accept; + + if (!IsKeyboardActive()) + { + // Keyboard is not active. + + return; + } + + if (accept == false && cancel == false) + { + Logger.Debug?.Print(LogClass.ServiceAm, $"Updating keyboard text to {text} and cursor position to {cursorBegin}"); + + PushChangedString(text, (uint)cursorBegin, _backgroundState); } else { - if (_useChangedStringV2) + // Disable the frontend. + DeactivateFrontend(); + + // The 'Complete' state indicates the Calc request has been fulfilled by the applet. + _backgroundState = InlineKeyboardState.Disappearing; + + if (accept) { - _interactiveSession.Push(InlineResponses.ChangedStringV2(text, state)); + Logger.Debug?.Print(LogClass.ServiceAm, $"Sending keyboard OK with text {text}"); + + DecidedEnter(text, _backgroundState); } - else + else if (cancel) { - _interactiveSession.Push(InlineResponses.ChangedString(text, state)); + Logger.Debug?.Print(LogClass.ServiceAm, "Sending keyboard Cancel"); + + DecidedCancel(_backgroundState); } + + _interactiveSession.Push(InlineResponses.Default(_backgroundState)); + + Logger.Debug?.Print(LogClass.ServiceAm, $"Resetting state of the keyboard to {_backgroundState}"); + + // Set the state of the applet to 'Initialized' as it is the only known state so far + // that does not soft-lock the keyboard after use. + + _backgroundState = InlineKeyboardState.Initialized; + + _interactiveSession.Push(InlineResponses.Default(_backgroundState)); + } + } + + private void PushChangedString(string text, uint cursor, InlineKeyboardState state) + { + // TODO (Caian): The *V2 methods are not supported because the applications that request + // them do not seem to accept them. The regular methods seem to work just fine in all cases. + + if (_encoding == Encoding.UTF8) + { + _interactiveSession.Push(InlineResponses.ChangedStringUtf8(text, cursor, state)); + } + else + { + _interactiveSession.Push(InlineResponses.ChangedString(text, cursor, state)); } } @@ -549,27 +728,17 @@ namespace Ryujinx.HLE.HOS.Applets _interactiveSession.Push(InlineResponses.DecidedCancel(state)); } - private byte[] BuildResponse(string text, bool interactive) + private byte[] BuildForegroundResponse() { - int bufferSize = interactive ? InteractiveBufferSize : StandardBufferSize; + int bufferSize = StandardBufferSize; using (MemoryStream stream = new MemoryStream(new byte[bufferSize])) using (BinaryWriter writer = new BinaryWriter(stream)) { - byte[] output = _encoding.GetBytes(text); - - if (!interactive) - { - // Result Code - writer.Write(_okPressed ? 0U : 1U); - } - else - { - // In interactive mode, we write the length of the text as a long, rather than - // a result code. This field is inclusive of the 64-bit size. - writer.Write((long)output.Length + 8); - } + byte[] output = _encoding.GetBytes(_textValue); + // Result Code. + writer.Write(_lastResult == KeyboardResult.Accept ? 0U : 1U); writer.Write(output); return stream.ToArray(); diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalc.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalc.cs index a80690c3d..d6b2d05f3 100644 --- a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalc.cs +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalc.cs @@ -8,7 +8,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard [StructLayout(LayoutKind.Sequential, Pack=1, CharSet = CharSet.Unicode)] struct SoftwareKeyboardCalc { - private const int InputTextLength = 505; + public const int InputTextLength = SoftwareKeyboardCalcEx.InputTextLength; public uint Unknown; @@ -21,22 +21,26 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard public byte Unknown2; /// - /// Configuration flags. Their purpose is currently unknown. + /// Configuration flags. Each bit in the bitfield enabled a different operation of the keyboard + /// using the data provided with the Calc structure. /// - public ulong Flags; + public KeyboardCalcFlags Flags; /// /// The original parameters used when initializing the keyboard applet. + /// Flag: 0x1 /// public SoftwareKeyboardInitialize Initialize; /// /// The audio volume used by the sound effects of the keyboard. + /// Flag: 0x2 /// public float Volume; /// /// The initial position of the text cursor (caret) in the provided input text. + /// Flag: 0x10 /// public int CursorPos; @@ -47,12 +51,14 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// The initial input text to be used by the software keyboard. + /// Flag: 0x8 /// [MarshalAs(UnmanagedType.ByValTStr, SizeConst = InputTextLength + 1)] public string InputText; /// /// When set, the strings communicated by software keyboard will be encoded as UTF-8 instead of UTF-16. + /// Flag: 0x20 /// [MarshalAs(UnmanagedType.I1)] public bool UseUtf8; @@ -61,6 +67,7 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// [5.0.0+] Enable the backspace key in the software keyboard. + /// Flag: 0x8000 /// [MarshalAs(UnmanagedType.I1)] public bool BackspaceEnabled; @@ -68,25 +75,39 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard public short Unknown4; public byte Unknown5; + /// + /// Flag: 0x200 + /// [MarshalAs(UnmanagedType.I1)] public bool KeytopAsFloating; + /// + /// Flag: 0x100 + /// [MarshalAs(UnmanagedType.I1)] public bool FooterScalable; + /// + /// Flag: 0x100 + /// [MarshalAs(UnmanagedType.I1)] public bool AlphaEnabledInInputMode; + /// + /// Flag: 0x100 + /// public byte InputModeFadeType; /// /// When set, the software keyboard ignores touch input. + /// Flag: 0x200 /// [MarshalAs(UnmanagedType.I1)] public bool TouchDisabled; /// /// When set, the software keyboard ignores hardware keyboard commands. + /// Flag: 0x800 /// [MarshalAs(UnmanagedType.I1)] public bool HardwareKeyboardDisabled; @@ -96,11 +117,13 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// Default value is 1.0. + /// Flag: 0x200 /// public float KeytopScale0; /// /// Default value is 1.0. + /// Flag: 0x200 /// public float KeytopScale1; @@ -109,16 +132,19 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// Default value is 1.0. + /// Flag: 0x100 /// public float KeytopBgAlpha; /// /// Default value is 1.0. + /// Flag: 0x100 /// public float FooterBgAlpha; /// /// Default value is 1.0. + /// Flag: 0x200 /// public float BalloonScale; @@ -129,6 +155,8 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard /// /// [5.0.0+] Enable sound effect. + /// Flag: Enable: 0x2000 + /// Disable: 0x4000 /// public byte SeGroup; @@ -143,5 +171,50 @@ namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard public byte Trigger; public byte Padding; + + public SoftwareKeyboardCalcEx ToExtended() + { + SoftwareKeyboardCalcEx calc = new SoftwareKeyboardCalcEx(); + + calc.Unknown = Unknown; + calc.Size = Size; + calc.Unknown1 = Unknown1; + calc.Unknown2 = Unknown2; + calc.Flags = Flags; + calc.Initialize = Initialize; + calc.Volume = Volume; + calc.CursorPos = CursorPos; + calc.Appear = Appear.ToExtended(); + calc.InputText = InputText; + calc.UseUtf8 = UseUtf8; + calc.Unknown3 = Unknown3; + calc.BackspaceEnabled = BackspaceEnabled; + calc.Unknown4 = Unknown4; + calc.Unknown5 = Unknown5; + calc.KeytopAsFloating = KeytopAsFloating; + calc.FooterScalable = FooterScalable; + calc.AlphaEnabledInInputMode = AlphaEnabledInInputMode; + calc.InputModeFadeType = InputModeFadeType; + calc.TouchDisabled = TouchDisabled; + calc.HardwareKeyboardDisabled = HardwareKeyboardDisabled; + calc.Unknown6 = Unknown6; + calc.Unknown7 = Unknown7; + calc.KeytopScale0 = KeytopScale0; + calc.KeytopScale1 = KeytopScale1; + calc.KeytopTranslate0 = KeytopTranslate0; + calc.KeytopTranslate1 = KeytopTranslate1; + calc.KeytopBgAlpha = KeytopBgAlpha; + calc.FooterBgAlpha = FooterBgAlpha; + calc.BalloonScale = BalloonScale; + calc.Unknown8 = Unknown8; + calc.Unknown9 = Unknown9; + calc.Unknown10 = Unknown10; + calc.Unknown11 = Unknown11; + calc.SeGroup = SeGroup; + calc.TriggerFlag = TriggerFlag; + calc.Trigger = Trigger; + + return calc; + } } } diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalcEx.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalcEx.cs new file mode 100644 index 000000000..fded8975f --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardCalcEx.cs @@ -0,0 +1,182 @@ +using System.Runtime.InteropServices; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// A structure with configuration options of the software keyboard when starting a new input request in inline mode. + /// This is the extended version of the structure with extended appear options. + /// + [StructLayout(LayoutKind.Sequential, Pack=1, CharSet = CharSet.Unicode)] + struct SoftwareKeyboardCalcEx + { + /// + /// This struct was built following Switchbrew's specs, but this size (larger) is also found in real games. + /// It's assumed that this is padding at the end of this struct, because all members seem OK. + /// + public const int AlternativeSize = 1256; + + public const int InputTextLength = 505; + + public uint Unknown; + + /// + /// The size of the Calc struct, as reported by the process communicating with the applet. + /// + public ushort Size; + + public byte Unknown1; + public byte Unknown2; + + /// + /// Configuration flags. Each bit in the bitfield enabled a different operation of the keyboard + /// using the data provided with the Calc structure. + /// + public KeyboardCalcFlags Flags; + + /// + /// The original parameters used when initializing the keyboard applet. + /// Flag: 0x1 + /// + public SoftwareKeyboardInitialize Initialize; + + /// + /// The audio volume used by the sound effects of the keyboard. + /// Flag: 0x2 + /// + public float Volume; + + /// + /// The initial position of the text cursor (caret) in the provided input text. + /// Flag: 0x10 + /// + public int CursorPos; + + /// + /// Appearance configurations for the on-screen keyboard. + /// + public SoftwareKeyboardAppearEx Appear; + + /// + /// The initial input text to be used by the software keyboard. + /// Flag: 0x8 + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = InputTextLength + 1)] + public string InputText; + + /// + /// When set, the strings communicated by software keyboard will be encoded as UTF-8 instead of UTF-16. + /// Flag: 0x20 + /// + [MarshalAs(UnmanagedType.I1)] + public bool UseUtf8; + + public byte Unknown3; + + /// + /// [5.0.0+] Enable the backspace key in the software keyboard. + /// Flag: 0x8000 + /// + [MarshalAs(UnmanagedType.I1)] + public bool BackspaceEnabled; + + public short Unknown4; + public byte Unknown5; + + /// + /// Flag: 0x200 + /// + [MarshalAs(UnmanagedType.I1)] + public bool KeytopAsFloating; + + /// + /// Flag: 0x100 + /// + [MarshalAs(UnmanagedType.I1)] + public bool FooterScalable; + + /// + /// Flag: 0x100 + /// + [MarshalAs(UnmanagedType.I1)] + public bool AlphaEnabledInInputMode; + + /// + /// Flag: 0x100 + /// + public byte InputModeFadeType; + + /// + /// When set, the software keyboard ignores touch input. + /// Flag: 0x200 + /// + [MarshalAs(UnmanagedType.I1)] + public bool TouchDisabled; + + /// + /// When set, the software keyboard ignores hardware keyboard commands. + /// Flag: 0x800 + /// + [MarshalAs(UnmanagedType.I1)] + public bool HardwareKeyboardDisabled; + + public uint Unknown6; + public uint Unknown7; + + /// + /// Default value is 1.0. + /// Flag: 0x200 + /// + public float KeytopScale0; + + /// + /// Default value is 1.0. + /// Flag: 0x200 + /// + public float KeytopScale1; + + public float KeytopTranslate0; + public float KeytopTranslate1; + + /// + /// Default value is 1.0. + /// Flag: 0x100 + /// + public float KeytopBgAlpha; + + /// + /// Default value is 1.0. + /// Flag: 0x100 + /// + public float FooterBgAlpha; + + /// + /// Default value is 1.0. + /// Flag: 0x200 + /// + public float BalloonScale; + + public float Unknown8; + public uint Unknown9; + public uint Unknown10; + public uint Unknown11; + + /// + /// [5.0.0+] Enable sound effect. + /// Flag: Enable: 0x2000 + /// Disable: 0x4000 + /// + public byte SeGroup; + + /// + /// [6.0.0+] Enables the Trigger field when Trigger is non-zero. + /// + public byte TriggerFlag; + + /// + /// [6.0.0+] Always set to zero. + /// + public byte Trigger; + + public byte Padding; + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardRenderer.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardRenderer.cs new file mode 100644 index 000000000..c16b861ea --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/SoftwareKeyboardRenderer.cs @@ -0,0 +1,717 @@ +using Ryujinx.HLE.Ui; +using Ryujinx.Memory; +using System; +using System.Diagnostics; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Drawing.Imaging; +using System.Drawing.Text; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// Class that generates the graphics for the software keyboard applet during inline mode. + /// + internal class SoftwareKeyboardRenderer : IDisposable + { + const int TextBoxBlinkThreshold = 8; + const int TextBoxBlinkSleepMilliseconds = 100; + const int TextBoxBlinkJoinWaitMilliseconds = 1000; + + const string MessageText = "Please use the keyboard to input text"; + const string AcceptText = "Accept"; + const string CancelText = "Cancel"; + const string ControllerToggleText = "Toggle input"; + + private RenderingSurfaceInfo _surfaceInfo; + private Bitmap _surface = null; + private object _renderLock = new object(); + + private string _inputText = ""; + private int _cursorStart = 0; + private int _cursorEnd = 0; + private bool _acceptPressed = false; + private bool _cancelPressed = false; + private bool _overwriteMode = false; + private bool _typingEnabled = true; + private bool _controllerEnabled = true; + + private Image _ryujinxLogo = null; + private Image _padAcceptIcon = null; + private Image _padCancelIcon = null; + private Image _keyModeIcon = null; + + private float _textBoxOutlineWidth; + private float _padPressedPenWidth; + + private Brush _panelBrush; + private Brush _disabledBrush; + private Brush _textNormalBrush; + private Brush _textSelectedBrush; + private Brush _textOverCursorBrush; + private Brush _cursorBrush; + private Brush _selectionBoxBrush; + private Brush _keyCapBrush; + private Brush _keyProgressBrush; + + private Pen _gridSeparatorPen; + private Pen _textBoxOutlinePen; + private Pen _cursorPen; + private Pen _selectionBoxPen; + private Pen _padPressedPen; + + private int _inputTextFontSize; + private int _padButtonFontSize; + private Font _messageFont; + private Font _inputTextFont; + private Font _labelsTextFont; + private Font _padSymbolFont; + private Font _keyCapFont; + + private float _inputTextCalibrationHeight; + private float _panelPositionY; + private RectangleF _panelRectangle; + private PointF _logoPosition; + private float _messagePositionY; + + private TRef _textBoxBlinkCounter = new TRef(0); + private TimedAction _textBoxBlinkTimedAction = new TimedAction(); + + public SoftwareKeyboardRenderer(IHostUiTheme uiTheme) + { + _surfaceInfo = new RenderingSurfaceInfo(0, 0, 0, 0, 0); + + string ryujinxLogoPath = "Ryujinx.Ui.Resources.Logo_Ryujinx.png"; + int ryujinxLogoSize = 32; + + _ryujinxLogo = LoadResource(Assembly.GetEntryAssembly(), ryujinxLogoPath, ryujinxLogoSize, ryujinxLogoSize); + + string padAcceptIconPath = "Ryujinx.HLE.HOS.Applets.SoftwareKeyboard.Resources.Icon_BtnA.png"; + string padCancelIconPath = "Ryujinx.HLE.HOS.Applets.SoftwareKeyboard.Resources.Icon_BtnB.png"; + string keyModeIconPath = "Ryujinx.HLE.HOS.Applets.SoftwareKeyboard.Resources.Icon_KeyF6.png"; + + _padAcceptIcon = LoadResource(Assembly.GetExecutingAssembly(), padAcceptIconPath , 0, 0); + _padCancelIcon = LoadResource(Assembly.GetExecutingAssembly(), padCancelIconPath , 0, 0); + _keyModeIcon = LoadResource(Assembly.GetExecutingAssembly(), keyModeIconPath , 0, 0); + + Color panelColor = ToColor(uiTheme.DefaultBackgroundColor, 255); + Color panelTransparentColor = ToColor(uiTheme.DefaultBackgroundColor, 150); + Color normalTextColor = ToColor(uiTheme.DefaultForegroundColor); + Color invertedTextColor = ToColor(uiTheme.DefaultForegroundColor, null, true); + Color selectedTextColor = ToColor(uiTheme.SelectionForegroundColor); + Color borderColor = ToColor(uiTheme.DefaultBorderColor); + Color selectionBackgroundColor = ToColor(uiTheme.SelectionBackgroundColor); + Color gridSeparatorColor = Color.FromArgb(180, 255, 255, 255); + + float cursorWidth = 2; + + _textBoxOutlineWidth = 2; + _padPressedPenWidth = 2; + + _panelBrush = new SolidBrush(panelColor); + _disabledBrush = new SolidBrush(panelTransparentColor); + _textNormalBrush = new SolidBrush(normalTextColor); + _textSelectedBrush = new SolidBrush(selectedTextColor); + _textOverCursorBrush = new SolidBrush(invertedTextColor); + _cursorBrush = new SolidBrush(normalTextColor); + _selectionBoxBrush = new SolidBrush(selectionBackgroundColor); + _keyCapBrush = Brushes.White; + _keyProgressBrush = new SolidBrush(borderColor); + + _gridSeparatorPen = new Pen(gridSeparatorColor, 2); + _textBoxOutlinePen = new Pen(borderColor, _textBoxOutlineWidth); + _cursorPen = new Pen(normalTextColor, cursorWidth); + _selectionBoxPen = new Pen(selectionBackgroundColor, cursorWidth); + _padPressedPen = new Pen(borderColor, _padPressedPenWidth); + + _inputTextFontSize = 20; + _padButtonFontSize = 24; + + string font = uiTheme.FontFamily; + + _messageFont = new Font(font, 26, FontStyle.Regular, GraphicsUnit.Pixel); + _inputTextFont = new Font(font, _inputTextFontSize, FontStyle.Regular, GraphicsUnit.Pixel); + _labelsTextFont = new Font(font, 24, FontStyle.Regular, GraphicsUnit.Pixel); + _padSymbolFont = new Font(font, _padButtonFontSize, FontStyle.Regular, GraphicsUnit.Pixel); + _keyCapFont = new Font(font, 15, FontStyle.Regular, GraphicsUnit.Pixel); + + // System.Drawing has serious problems measuring strings, so it requires a per-pixel calibration + // to ensure we are rendering text inside the proper region + _inputTextCalibrationHeight = CalibrateTextHeight(_inputTextFont); + + StartTextBoxBlinker(_textBoxBlinkTimedAction, _textBoxBlinkCounter); + } + + private static void StartTextBoxBlinker(TimedAction timedAction, TRef blinkerCounter) + { + timedAction.Reset(() => + { + // The blinker is on falf of the time and events such as input + // changes can reset the blinker. + var value = Volatile.Read(ref blinkerCounter.Value); + value = (value + 1) % (2 * TextBoxBlinkThreshold); + Volatile.Write(ref blinkerCounter.Value, value); + + }, TextBoxBlinkSleepMilliseconds); + } + + private Color ToColor(ThemeColor color, byte? overrideAlpha = null, bool flipRgb = false) + { + var a = (byte)(color.A * 255); + var r = (byte)(color.R * 255); + var g = (byte)(color.G * 255); + var b = (byte)(color.B * 255); + + if (flipRgb) + { + r = (byte)(255 - r); + g = (byte)(255 - g); + b = (byte)(255 - b); + } + + return Color.FromArgb(overrideAlpha.GetValueOrDefault(a), r, g, b); + } + + private Image LoadResource(Assembly assembly, string resourcePath, int newWidth, int newHeight) + { + Stream resourceStream = assembly.GetManifestResourceStream(resourcePath); + + Debug.Assert(resourceStream != null); + + var originalImage = Image.FromStream(resourceStream); + + if (newHeight == 0 || newWidth == 0) + { + return originalImage; + } + + var newSize = new Rectangle(0, 0, newWidth, newHeight); + var newImage = new Bitmap(newWidth, newHeight); + + using (var graphics = System.Drawing.Graphics.FromImage(newImage)) + using (var wrapMode = new ImageAttributes()) + { + graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + graphics.CompositingQuality = CompositingQuality.HighQuality; + graphics.CompositingMode = CompositingMode.SourceCopy; + graphics.PixelOffsetMode = PixelOffsetMode.HighQuality; + graphics.SmoothingMode = SmoothingMode.HighQuality; + + wrapMode.SetWrapMode(WrapMode.TileFlipXY); + graphics.DrawImage(originalImage, newSize, 0, 0, originalImage.Width, originalImage.Height, GraphicsUnit.Pixel, wrapMode); + } + + return newImage; + } + +#pragma warning disable CS8632 + public void UpdateTextState(string? inputText, int? cursorStart, int? cursorEnd, bool? overwriteMode, bool? typingEnabled) +#pragma warning restore CS8632 + { + lock (_renderLock) + { + // Update the parameters that were provided. + _inputText = inputText != null ? inputText : _inputText; + _cursorStart = cursorStart.GetValueOrDefault(_cursorStart); + _cursorEnd = cursorEnd.GetValueOrDefault(_cursorEnd); + _overwriteMode = overwriteMode.GetValueOrDefault(_overwriteMode); + _typingEnabled = typingEnabled.GetValueOrDefault(_typingEnabled); + + // Reset the cursor blink. + Volatile.Write(ref _textBoxBlinkCounter.Value, 0); + } + } + + public void UpdateCommandState(bool? acceptPressed, bool? cancelPressed, bool? controllerEnabled) + { + lock (_renderLock) + { + // Update the parameters that were provided. + _acceptPressed = acceptPressed.GetValueOrDefault(_acceptPressed); + _cancelPressed = cancelPressed.GetValueOrDefault(_cancelPressed); + _controllerEnabled = controllerEnabled.GetValueOrDefault(_controllerEnabled); + } + } + + private void Redraw() + { + if (_surface == null) + { + return; + } + + using (var graphics = CreateGraphics()) + { + var messageRectangle = MeasureString(graphics, MessageText, _messageFont); + float messagePositionX = (_panelRectangle.Width - messageRectangle.Width) / 2 - messageRectangle.X; + float messagePositionY = _messagePositionY - messageRectangle.Y; + PointF messagePosition = new PointF(messagePositionX, messagePositionY); + + graphics.Clear(Color.Transparent); + graphics.TranslateTransform(0, _panelPositionY); + graphics.FillRectangle(_panelBrush, _panelRectangle); + graphics.DrawImage(_ryujinxLogo, _logoPosition); + + DrawString(graphics, MessageText, _messageFont, _textNormalBrush, messagePosition); + + if (!_typingEnabled) + { + // Just draw a semi-transparent rectangle on top to fade the component with the background. + // TODO (caian): This will not work if one decides to add make background semi-transparent as well. + graphics.FillRectangle(_disabledBrush, messagePositionX, messagePositionY, messageRectangle.Width, messageRectangle.Height); + } + + DrawTextBox(graphics); + + float halfWidth = _panelRectangle.Width / 2; + + PointF acceptButtonPosition = new PointF(halfWidth - 180, 185); + PointF cancelButtonPosition = new PointF(halfWidth , 185); + PointF disableButtonPosition = new PointF(halfWidth + 180, 185); + + DrawPadButton (graphics, acceptButtonPosition , _padAcceptIcon, AcceptText, _acceptPressed, _controllerEnabled); + DrawPadButton (graphics, cancelButtonPosition , _padCancelIcon, CancelText, _cancelPressed, _controllerEnabled); + DrawControllerToggle(graphics, disableButtonPosition, _controllerEnabled); + } + } + + private void RecreateSurface() + { + Debug.Assert(_surfaceInfo.ColorFormat == Services.SurfaceFlinger.ColorFormat.A8B8G8R8); + + // Use the whole area of the image to draw, even the alignment, otherwise it may shear the final + // image if the pitch is different. + uint totalWidth = _surfaceInfo.Pitch / 4; + uint totalHeight = _surfaceInfo.Size / _surfaceInfo.Pitch; + + Debug.Assert(_surfaceInfo.Width <= totalWidth); + Debug.Assert(_surfaceInfo.Height <= totalHeight); + Debug.Assert(_surfaceInfo.Pitch * _surfaceInfo.Height <= _surfaceInfo.Size); + + _surface = new Bitmap((int)totalWidth, (int)totalHeight, PixelFormat.Format32bppArgb); + } + + private void RecomputeConstants() + { + float totalWidth = _surfaceInfo.Width; + float totalHeight = _surfaceInfo.Height; + + float panelHeight = 240; + + _panelPositionY = totalHeight - panelHeight; + _panelRectangle = new RectangleF(0, 0, totalWidth, panelHeight); + + _messagePositionY = 60; + + float logoPositionX = (totalWidth - _ryujinxLogo.Width) / 2; + float logoPositionY = 18; + + _logoPosition = new PointF(logoPositionX, logoPositionY); + } + + private StringFormat CreateStringFormat(string text) + { + StringFormat format = new StringFormat(StringFormat.GenericTypographic); + format.FormatFlags |= StringFormatFlags.MeasureTrailingSpaces; + format.SetMeasurableCharacterRanges(new CharacterRange[] { new CharacterRange(0, text.Length) }); + + return format; + } + + private RectangleF MeasureString(System.Drawing.Graphics graphics, string text, System.Drawing.Font font) + { + bool isEmpty = false; + + if (string.IsNullOrEmpty(text)) + { + isEmpty = true; + text = " "; + } + + var format = CreateStringFormat(text); + var rectangle = new RectangleF(0, 0, float.PositiveInfinity, float.PositiveInfinity); + var regions = graphics.MeasureCharacterRanges(text, font, rectangle, format); + + Debug.Assert(regions.Length == 1); + + rectangle = regions[0].GetBounds(graphics); + + if (isEmpty) + { + rectangle.Width = 0; + } + else + { + rectangle.Width += 1.0f; + } + + return rectangle; + } + + private float CalibrateTextHeight(Font font) + { + // This is a pixel-wise calibration that tests the offset of a reference character because Windows text measurement + // is horrible when compared to other frameworks like Cairo and diverge across systems and fonts. + + Debug.Assert(font.Unit == GraphicsUnit.Pixel); + + var surfaceSize = (int)Math.Ceiling(2 * font.Size); + + string calibrationText = "|"; + + using (var surface = new Bitmap(surfaceSize, surfaceSize, PixelFormat.Format32bppArgb)) + using (var graphics = CreateGraphics(surface)) + { + var measuredRectangle = MeasureString(graphics, calibrationText, font); + + Debug.Assert(measuredRectangle.Right <= surfaceSize); + Debug.Assert(measuredRectangle.Bottom <= surfaceSize); + + var textPosition = new PointF(0, 0); + + graphics.Clear(Color.Transparent); + DrawString(graphics, calibrationText, font, Brushes.White, textPosition); + + var lockRectangle = new Rectangle(0, 0, surface.Width, surface.Height); + var surfaceData = surface.LockBits(lockRectangle, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); + var surfaceBytes = new byte[surfaceData.Stride * surfaceData.Height]; + + Marshal.Copy(surfaceData.Scan0, surfaceBytes, 0, surfaceBytes.Length); + + Point topLeft = new Point(); + Point bottomLeft = new Point(); + + bool foundTopLeft = false; + + for (int y = 0; y < surfaceData.Height; y++) + { + for (int x = 0; x < surfaceData.Stride; x += 4) + { + int position = y * surfaceData.Stride + x; + + if (surfaceBytes[position] != 0) + { + if (!foundTopLeft) + { + topLeft.X = x; + topLeft.Y = y; + foundTopLeft = true; + + break; + } + else + { + bottomLeft.X = x; + bottomLeft.Y = y; + + break; + } + } + } + } + + return bottomLeft.Y - topLeft.Y; + } + } + + private void DrawString(System.Drawing.Graphics graphics, string text, Font font, Brush brush, PointF point) + { + var format = CreateStringFormat(text); + graphics.DrawString(text, font, brush, point, format); + } + + private System.Drawing.Graphics CreateGraphics() + { + return CreateGraphics(_surface); + } + + private System.Drawing.Graphics CreateGraphics(Image surface) + { + var graphics = System.Drawing.Graphics.FromImage(surface); + + graphics.TextRenderingHint = TextRenderingHint.ClearTypeGridFit; + graphics.InterpolationMode = InterpolationMode.NearestNeighbor; + graphics.CompositingQuality = CompositingQuality.HighSpeed; + graphics.CompositingMode = CompositingMode.SourceOver; + graphics.PixelOffsetMode = PixelOffsetMode.HighSpeed; + graphics.SmoothingMode = SmoothingMode.HighSpeed; + + return graphics; + } + + private void DrawTextBox(System.Drawing.Graphics graphics) + { + var inputTextRectangle = MeasureString(graphics, _inputText, _inputTextFont); + + float boxWidth = (int)(Math.Max(300, inputTextRectangle.Width + inputTextRectangle.X + 8)); + float boxHeight = 32; + float boxY = 110; + float boxX = (int)((_panelRectangle.Width - boxWidth) / 2); + + graphics.DrawRectangle(_textBoxOutlinePen, boxX, boxY, boxWidth, boxHeight); + + float inputTextX = (_panelRectangle.Width - inputTextRectangle.Width) / 2 - inputTextRectangle.X; + float inputTextY = boxY + boxHeight - inputTextRectangle.Bottom - 5; + + var inputTextPosition = new PointF(inputTextX, inputTextY); + + DrawString(graphics, _inputText, _inputTextFont, _textNormalBrush, inputTextPosition); + + // Draw the cursor on top of the text and redraw the text with a different color if necessary. + + Brush cursorTextBrush; + Brush cursorBrush; + Pen cursorPen; + + float cursorPositionYBottom = inputTextY + inputTextRectangle.Bottom; + float cursorPositionYTop = cursorPositionYBottom - _inputTextCalibrationHeight - 2; + float cursorPositionXLeft; + float cursorPositionXRight; + + bool cursorVisible = false; + + if (_cursorStart != _cursorEnd) + { + cursorTextBrush = _textSelectedBrush; + cursorBrush = _selectionBoxBrush; + cursorPen = _selectionBoxPen; + + string textUntilBegin = _inputText.Substring(0, _cursorStart); + string textUntilEnd = _inputText.Substring(0, _cursorEnd); + + RectangleF selectionBeginRectangle = MeasureString(graphics, textUntilBegin, _inputTextFont); + RectangleF selectionEndRectangle = MeasureString(graphics, textUntilEnd , _inputTextFont); + + cursorVisible = true; + cursorPositionXLeft = inputTextX + selectionBeginRectangle.Width + selectionBeginRectangle.X; + cursorPositionXRight = inputTextX + selectionEndRectangle.Width + selectionEndRectangle.X; + } + else + { + cursorTextBrush = _textOverCursorBrush; + cursorBrush = _cursorBrush; + cursorPen = _cursorPen; + + if (Volatile.Read(ref _textBoxBlinkCounter.Value) < TextBoxBlinkThreshold) + { + // Show the blinking cursor. + + int cursorStart = Math.Min(_inputText.Length, _cursorStart); + string textUntilCursor = _inputText.Substring(0, cursorStart); + RectangleF cursorTextRectangle = MeasureString(graphics, textUntilCursor, _inputTextFont); + + cursorVisible = true; + cursorPositionXLeft = inputTextX + cursorTextRectangle.Width + cursorTextRectangle.X; + + if (_overwriteMode) + { + // The blinking cursor is in overwrite mode so it takes the size of a character. + + if (_cursorStart < _inputText.Length) + { + textUntilCursor = _inputText.Substring(0, cursorStart + 1); + cursorTextRectangle = MeasureString(graphics, textUntilCursor, _inputTextFont); + cursorPositionXRight = inputTextX + cursorTextRectangle.Width + cursorTextRectangle.X; + } + else + { + cursorPositionXRight = cursorPositionXLeft + _inputTextFontSize / 2; + } + } + else + { + // The blinking cursor is in insert mode so it is only a line. + cursorPositionXRight = cursorPositionXLeft; + } + } + else + { + cursorPositionXLeft = inputTextX; + cursorPositionXRight = inputTextX; + } + } + + if (_typingEnabled && cursorVisible) + { + float cursorWidth = cursorPositionXRight - cursorPositionXLeft; + float cursorHeight = cursorPositionYBottom - cursorPositionYTop; + + if (cursorWidth == 0) + { + graphics.DrawLine(cursorPen, cursorPositionXLeft, cursorPositionYTop, cursorPositionXLeft, cursorPositionYBottom); + } + else + { + graphics.DrawRectangle(cursorPen, cursorPositionXLeft, cursorPositionYTop, cursorWidth, cursorHeight); + graphics.FillRectangle(cursorBrush, cursorPositionXLeft, cursorPositionYTop, cursorWidth, cursorHeight); + + var cursorRectangle = new RectangleF(cursorPositionXLeft, cursorPositionYTop, cursorWidth, cursorHeight); + + var oldClip = graphics.Clip; + graphics.Clip = new Region(cursorRectangle); + + DrawString(graphics, _inputText, _inputTextFont, cursorTextBrush, inputTextPosition); + + graphics.Clip = oldClip; + } + } + else if (!_typingEnabled) + { + // Just draw a semi-transparent rectangle on top to fade the component with the background. + // TODO (caian): This will not work if one decides to add make background semi-transparent as well. + graphics.FillRectangle(_disabledBrush, boxX - _textBoxOutlineWidth, boxY - _textBoxOutlineWidth, + boxWidth + 2* _textBoxOutlineWidth, boxHeight + 2* _textBoxOutlineWidth); + } + } + + private void DrawPadButton(System.Drawing.Graphics graphics, PointF point, Image icon, string label, bool pressed, bool enabled) + { + // Use relative positions so we can center the the entire drawing later. + + float iconX = 0; + float iconY = 0; + float iconWidth = icon.Width; + float iconHeight = icon.Height; + + var labelRectangle = MeasureString(graphics, label, _labelsTextFont); + + float labelPositionX = iconWidth + 8 - labelRectangle.X; + float labelPositionY = (iconHeight - labelRectangle.Height) / 2 - labelRectangle.Y - 1; + + float fullWidth = labelPositionX + labelRectangle.Width + labelRectangle.X; + float fullHeight = iconHeight; + + // Convert all relative positions into absolute. + + float originX = (int)(point.X - fullWidth / 2); + float originY = (int)(point.Y - fullHeight / 2); + + iconX += originX; + iconY += originY; + + var labelPosition = new PointF(labelPositionX + originX, labelPositionY + originY); + + graphics.DrawImageUnscaled(icon, (int)iconX, (int)iconY); + + DrawString(graphics, label, _labelsTextFont, _textNormalBrush, labelPosition); + + GraphicsPath frame = new GraphicsPath(); + frame.AddRectangle(new RectangleF(originX - 2 * _padPressedPenWidth, originY - 2 * _padPressedPenWidth, + fullWidth + 4 * _padPressedPenWidth, fullHeight + 4 * _padPressedPenWidth)); + + if (enabled) + { + if (pressed) + { + graphics.DrawPath(_padPressedPen, frame); + } + } + else + { + // Just draw a semi-transparent rectangle on top to fade the component with the background. + // TODO (caian): This will not work if one decides to add make background semi-transparent as well. + graphics.FillPath(_disabledBrush, frame); + } + } + + private void DrawControllerToggle(System.Drawing.Graphics graphics, PointF point, bool enabled) + { + var labelRectangle = MeasureString(graphics, ControllerToggleText, _labelsTextFont); + + // Use relative positions so we can center the the entire drawing later. + + float keyWidth = _keyModeIcon.Width; + float keyHeight = _keyModeIcon.Height; + + float labelPositionX = keyWidth + 8 - labelRectangle.X; + float labelPositionY = -labelRectangle.Y - 1; + + float keyX = 0; + float keyY = (int)((labelPositionY + labelRectangle.Height - keyHeight) / 2); + + float fullWidth = labelPositionX + labelRectangle.Width; + float fullHeight = Math.Max(labelPositionY + labelRectangle.Height, keyHeight); + + // Convert all relative positions into absolute. + + float originX = (int)(point.X - fullWidth / 2); + float originY = (int)(point.Y - fullHeight / 2); + + keyX += originX; + keyY += originY; + + var labelPosition = new PointF(labelPositionX + originX, labelPositionY + originY); + var overlayPosition = new Point((int)keyX, (int)keyY); + + graphics.DrawImageUnscaled(_keyModeIcon, overlayPosition); + + DrawString(graphics, ControllerToggleText, _labelsTextFont, _textNormalBrush, labelPosition); + } + + private unsafe bool TryCopyTo(IVirtualMemoryManager destination, ulong position) + { + if (_surface == null) + { + return false; + } + + Rectangle lockRectangle = new Rectangle(0, 0, _surface.Width, _surface.Height); + BitmapData surfaceData = _surface.LockBits(lockRectangle, ImageLockMode.ReadWrite, PixelFormat.Format32bppArgb); + + Debug.Assert(surfaceData.Stride == _surfaceInfo.Pitch); + Debug.Assert(surfaceData.Stride * surfaceData.Height == _surfaceInfo.Size); + + // Convert the pixel format used in System.Drawing to the one required by a Switch Surface. + int dataLength = surfaceData.Stride * surfaceData.Height; + byte* dataPointer = (byte*)surfaceData.Scan0; + byte* dataEnd = dataPointer + dataLength; + + for (; dataPointer < dataEnd; dataPointer += 4) + { + *(uint*)dataPointer = (uint)( + (*(dataPointer + 0) << 16) | + (*(dataPointer + 1) << 8 ) | + (*(dataPointer + 2) << 0 ) | + (*(dataPointer + 3) << 24)); + } + + try + { + Span dataSpan = new Span((void*)surfaceData.Scan0, dataLength); + destination.Write(position, dataSpan); + } + finally + { + _surface.UnlockBits(surfaceData); + } + + return true; + } + + internal bool DrawTo(RenderingSurfaceInfo surfaceInfo, IVirtualMemoryManager destination, ulong position) + { + lock (_renderLock) + { + if (!_surfaceInfo.Equals(surfaceInfo)) + { + _surfaceInfo = surfaceInfo; + RecreateSurface(); + RecomputeConstants(); + } + + Redraw(); + + return TryCopyTo(destination, position); + } + } + + public void Dispose() + { + _textBoxBlinkTimedAction.RequestCancel(); + } + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TRef.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TRef.cs new file mode 100644 index 000000000..53746e745 --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TRef.cs @@ -0,0 +1,19 @@ +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// Wraps a type in a class so it gets stored in the GC managed heap. This is used as communication mechanism + /// between classed that need to be disposed and, thus, can't share their references. + /// + /// The internal type. + class TRef + { + public T Value; + + public TRef() { } + + public TRef(T value) + { + Value = value; + } + } +} diff --git a/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TimedAction.cs b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TimedAction.cs new file mode 100644 index 000000000..8884bdcf4 --- /dev/null +++ b/Ryujinx.HLE/HOS/Applets/SoftwareKeyboard/TimedAction.cs @@ -0,0 +1,172 @@ +using System; +using System.Threading; + +namespace Ryujinx.HLE.HOS.Applets.SoftwareKeyboard +{ + /// + /// A threaded executor of periodic actions that can be cancelled. The total execution time is optional + /// and, in this case, a progress is reported back to the action. + /// + class TimedAction + { + public const int MaxThreadSleep = 100; + + private class SleepSubstepData + { + public readonly int SleepMilliseconds; + public readonly int SleepCount; + public readonly int SleepRemainderMilliseconds; + + public SleepSubstepData(int sleepMilliseconds) + { + SleepMilliseconds = Math.Min(sleepMilliseconds, MaxThreadSleep); + SleepCount = sleepMilliseconds / SleepMilliseconds; + SleepRemainderMilliseconds = sleepMilliseconds - SleepCount * SleepMilliseconds; + } + } + + private TRef _cancelled = null; + private Thread _thread = null; + private object _lock = new object(); + + public bool IsRunning + { + get + { + lock (_lock) + { + if (_thread == null) + { + return false; + } + + return _thread.IsAlive; + } + } + } + + public void RequestCancel() + { + lock (_lock) + { + if (_cancelled != null) + { + Volatile.Write(ref _cancelled.Value, true); + } + } + } + + public TimedAction() { } + + private void Reset(Thread thread, TRef cancelled) + { + lock (_lock) + { + // Cancel the current task. + if (_cancelled != null) + { + Volatile.Write(ref _cancelled.Value, true); + } + + _cancelled = cancelled; + + _thread = thread; + _thread.IsBackground = true; + _thread.Start(); + } + } + + public void Reset(Action action, int totalMilliseconds, int sleepMilliseconds) + { + // Create a dedicated cancel token for each task. + var cancelled = new TRef(false); + + Reset(new Thread(() => + { + var substepData = new SleepSubstepData(sleepMilliseconds); + + int totalCount = totalMilliseconds / sleepMilliseconds; + int totalRemainder = totalMilliseconds - totalCount * sleepMilliseconds; + + if (Volatile.Read(ref cancelled.Value)) + { + action(-1); + + return; + } + + action(0); + + for (int i = 1; i <= totalCount; i++) + { + if (SleepWithSubstep(substepData, cancelled)) + { + action(-1); + + return; + } + + action((float)(i * sleepMilliseconds) / totalMilliseconds); + } + + if (totalRemainder > 0) + { + if (SleepWithSubstep(substepData, cancelled)) + { + action(-1); + + return; + } + + action(1); + } + }), cancelled); + } + + public void Reset(Action action, int sleepMilliseconds) + { + // Create a dedicated cancel token for each task. + var cancelled = new TRef(false); + + Reset(new Thread(() => + { + var substepData = new SleepSubstepData(sleepMilliseconds); + + while (!Volatile.Read(ref cancelled.Value)) + { + action(); + + if (SleepWithSubstep(substepData, cancelled)) + { + return; + } + } + }), cancelled); + } + + private static bool SleepWithSubstep(SleepSubstepData substepData, TRef cancelled) + { + for (int i = 0; i < substepData.SleepCount; i++) + { + if (Volatile.Read(ref cancelled.Value)) + { + return true; + } + + Thread.Sleep(substepData.SleepMilliseconds); + } + + if (substepData.SleepRemainderMilliseconds > 0) + { + if (Volatile.Read(ref cancelled.Value)) + { + return true; + } + + Thread.Sleep(substepData.SleepRemainderMilliseconds); + } + + return Volatile.Read(ref cancelled.Value); + } + } +} diff --git a/Ryujinx.HLE/HOS/Services/Am/AppletAE/AllSystemAppletProxiesService/LibraryAppletCreator/ILibraryAppletAccessor.cs b/Ryujinx.HLE/HOS/Services/Am/AppletAE/AllSystemAppletProxiesService/LibraryAppletCreator/ILibraryAppletAccessor.cs index 2deb830ec..405806c42 100644 --- a/Ryujinx.HLE/HOS/Services/Am/AppletAE/AllSystemAppletProxiesService/LibraryAppletCreator/ILibraryAppletAccessor.cs +++ b/Ryujinx.HLE/HOS/Services/Am/AppletAE/AllSystemAppletProxiesService/LibraryAppletCreator/ILibraryAppletAccessor.cs @@ -25,6 +25,8 @@ namespace Ryujinx.HLE.HOS.Services.Am.AppletAE.AllSystemAppletProxiesService.Lib private int _normalOutDataEventHandle; private int _interactiveOutDataEventHandle; + private int _indirectLayerHandle; + public ILibraryAppletAccessor(AppletId appletId, Horizon system) { _kernelContext = system.KernelContext; @@ -222,21 +224,11 @@ namespace Ryujinx.HLE.HOS.Services.Am.AppletAE.AllSystemAppletProxiesService.Lib // GetIndirectLayerConsumerHandle() -> u64 indirect_layer_consumer_handle public ResultCode GetIndirectLayerConsumerHandle(ServiceCtx context) { - /* - if (indirectLayerConsumer == null) - { - return ResultCode.ObjectInvalid; - } - */ + Horizon horizon = _kernelContext.Device.System; - // TODO: Official sw uses this during LibraryApplet creation when LibraryAppletMode is 0x3. - // Since we don't support IndirectLayer and the handle couldn't be 0, it's fine to return 1. + _indirectLayerHandle = horizon.AppletState.IndirectLayerHandles.Add(_applet); - ulong indirectLayerConsumerHandle = 1; - - context.ResponseData.Write(indirectLayerConsumerHandle); - - Logger.Stub?.PrintStub(LogClass.ServiceAm, new { indirectLayerConsumerHandle }); + context.ResponseData.Write((ulong)_indirectLayerHandle); return ResultCode.Success; } @@ -260,6 +252,10 @@ namespace Ryujinx.HLE.HOS.Services.Am.AppletAE.AllSystemAppletProxiesService.Lib _kernelContext.Syscall.CloseHandle(_interactiveOutDataEventHandle); } } + + Horizon horizon = _kernelContext.Device.System; + + horizon.AppletState.IndirectLayerHandles.Delete(_indirectLayerHandle); } } } diff --git a/Ryujinx.HLE/HOS/Services/Vi/RootService/IApplicationDisplayService.cs b/Ryujinx.HLE/HOS/Services/Vi/RootService/IApplicationDisplayService.cs index 9272fd80b..267548ddf 100644 --- a/Ryujinx.HLE/HOS/Services/Vi/RootService/IApplicationDisplayService.cs +++ b/Ryujinx.HLE/HOS/Services/Vi/RootService/IApplicationDisplayService.cs @@ -2,13 +2,16 @@ using Ryujinx.Common; using Ryujinx.Common.Logging; using Ryujinx.Common.Memory; using Ryujinx.Cpu; +using Ryujinx.HLE.HOS.Applets; using Ryujinx.HLE.HOS.Ipc; using Ryujinx.HLE.HOS.Kernel.Common; using Ryujinx.HLE.HOS.Services.SurfaceFlinger; using Ryujinx.HLE.HOS.Services.Vi.RootService.ApplicationDisplayService; +using Ryujinx.HLE.Ui; using Ryujinx.HLE.HOS.Services.Vi.RootService.ApplicationDisplayService.Types; using Ryujinx.HLE.HOS.Services.Vi.Types; using System; +using System.Diagnostics; using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Text; @@ -343,6 +346,20 @@ namespace Ryujinx.HLE.HOS.Services.Vi.RootService return ResultCode.Success; } + private ulong GetA8B8G8R8LayerSize(int width, int height, out int pitch, out int alignment) + { + const int defaultAlignment = 0x1000; + const ulong defaultSize = 0x20000; + + alignment = defaultAlignment; + pitch = BitUtils.AlignUp(BitUtils.DivRoundUp(width * 32, 8), 64); + + int memorySize = pitch * BitUtils.AlignUp(height, 64); + ulong requiredMemorySize = (ulong)BitUtils.AlignUp(memorySize, alignment); + + return (requiredMemorySize + defaultSize - 1) / defaultSize * defaultSize; + } + [CommandHipc(2450)] // GetIndirectLayerImageMap(s64 width, s64 height, u64 handle, nn::applet::AppletResourceUserId, pid) -> (s64, s64, buffer) public ResultCode GetIndirectLayerImageMap(ServiceCtx context) @@ -350,13 +367,42 @@ namespace Ryujinx.HLE.HOS.Services.Vi.RootService // The size of the layer buffer should be an aligned multiple of width * height // because it was created using GetIndirectLayerImageRequiredMemoryInfo as a guide. + long layerWidth = context.RequestData.ReadInt64(); + long layerHeight = context.RequestData.ReadInt64(); + long layerHandle = context.RequestData.ReadInt64(); ulong layerBuffPosition = context.Request.ReceiveBuff[0].Position; ulong layerBuffSize = context.Request.ReceiveBuff[0].Size; - // Fill the layer with zeros. - context.Memory.Fill(layerBuffPosition, layerBuffSize, 0x00); + // Get the pitch of the layer that is necessary to render correctly. + ulong size = GetA8B8G8R8LayerSize((int)layerWidth, (int)layerHeight, out int pitch, out _); - Logger.Stub?.PrintStub(LogClass.ServiceVi); + Debug.Assert(layerBuffSize == size); + + RenderingSurfaceInfo surfaceInfo = new RenderingSurfaceInfo(ColorFormat.A8B8G8R8, (uint)layerWidth, (uint)layerHeight, (uint)pitch, (uint)layerBuffSize); + + // Get the applet associated with the handle. + object appletObject = context.Device.System.AppletState.IndirectLayerHandles.GetData((int)layerHandle); + + if (appletObject == null) + { + Logger.Error?.Print(LogClass.ServiceVi, $"Indirect layer handle {layerHandle} does not match any applet"); + + return ResultCode.Success; + } + + Debug.Assert(appletObject is IApplet); + + IApplet applet = appletObject as IApplet; + + if (!applet.DrawTo(surfaceInfo, context.Memory, layerBuffPosition)) + { + Logger.Error?.Print(LogClass.ServiceVi, $"Applet did not draw on indirect layer handle {layerHandle}"); + + return ResultCode.Success; + } + + context.ResponseData.Write(layerWidth); + context.ResponseData.Write(layerHeight); return ResultCode.Success; } @@ -390,19 +436,13 @@ namespace Ryujinx.HLE.HOS.Services.Vi.RootService } */ - const ulong defaultAlignment = 0x1000; - const ulong defaultSize = 0x20000; - // NOTE: The official service setup a A8B8G8R8 texture with a linear layout and then query its size. // As we don't need this texture on the emulator, we can just simplify this logic and directly // do a linear layout size calculation. (stride * height * bytePerPixel) - int pitch = BitUtils.AlignUp(BitUtils.DivRoundUp(width * 32, 8), 64); - int memorySize = pitch * BitUtils.AlignUp(height, 64); - ulong requiredMemorySize = (ulong)BitUtils.AlignUp(memorySize, (int)defaultAlignment); - ulong size = (requiredMemorySize + defaultSize - 1) / defaultSize * defaultSize; + ulong size = GetA8B8G8R8LayerSize(width, height, out int pitch, out int alignment); context.ResponseData.Write(size); - context.ResponseData.Write(defaultAlignment); + context.ResponseData.Write(alignment); } return ResultCode.Success; diff --git a/Ryujinx.HLE/HOS/SystemState/AppletStateMgr.cs b/Ryujinx.HLE/HOS/SystemState/AppletStateMgr.cs index 9e18b9edc..5704ef4b0 100644 --- a/Ryujinx.HLE/HOS/SystemState/AppletStateMgr.cs +++ b/Ryujinx.HLE/HOS/SystemState/AppletStateMgr.cs @@ -14,12 +14,15 @@ namespace Ryujinx.HLE.HOS.SystemState public IdDictionary AppletResourceUserIds { get; } + public IdDictionary IndirectLayerHandles { get; } + public AppletStateMgr(Horizon system) { Messages = new ConcurrentQueue(); MessageEvent = new KEvent(system.KernelContext); AppletResourceUserIds = new IdDictionary(); + IndirectLayerHandles = new IdDictionary(); } public void SetFocus(bool isFocused) diff --git a/Ryujinx.HLE/Ryujinx.HLE.csproj b/Ryujinx.HLE/Ryujinx.HLE.csproj index 225f622c9..89189d7a2 100644 --- a/Ryujinx.HLE/Ryujinx.HLE.csproj +++ b/Ryujinx.HLE/Ryujinx.HLE.csproj @@ -32,11 +32,17 @@ + + + + + + diff --git a/Ryujinx.HLE/Switch.cs b/Ryujinx.HLE/Switch.cs index 096963e3d..eeb0e7b9d 100644 --- a/Ryujinx.HLE/Switch.cs +++ b/Ryujinx.HLE/Switch.cs @@ -5,6 +5,7 @@ using Ryujinx.HLE.FileSystem; using Ryujinx.HLE.HOS; using Ryujinx.HLE.HOS.Services.Apm; using Ryujinx.HLE.HOS.Services.Hid; +using Ryujinx.HLE.Ui; using Ryujinx.Memory; using System; diff --git a/Ryujinx.HLE/Ui/DynamicTextChangedHandler.cs b/Ryujinx.HLE/Ui/DynamicTextChangedHandler.cs new file mode 100644 index 000000000..c571fb683 --- /dev/null +++ b/Ryujinx.HLE/Ui/DynamicTextChangedHandler.cs @@ -0,0 +1,4 @@ +namespace Ryujinx.HLE.Ui +{ + public delegate void DynamicTextChangedHandler(string text, int cursorBegin, int cursorEnd, bool overwriteMode); +} \ No newline at end of file diff --git a/Ryujinx.HLE/Ui/IDynamicTextInputHandler.cs b/Ryujinx.HLE/Ui/IDynamicTextInputHandler.cs new file mode 100644 index 000000000..6e7b4c495 --- /dev/null +++ b/Ryujinx.HLE/Ui/IDynamicTextInputHandler.cs @@ -0,0 +1,16 @@ +using System; + +namespace Ryujinx.HLE.Ui +{ + public interface IDynamicTextInputHandler : IDisposable + { + event DynamicTextChangedHandler TextChangedEvent; + event KeyPressedHandler KeyPressedEvent; + event KeyReleasedHandler KeyReleasedEvent; + + bool TextProcessingEnabled { get; set; } + + void SetText(string text, int cursorBegin); + void SetText(string text, int cursorBegin, int cursorEnd); + } +} \ No newline at end of file diff --git a/Ryujinx.HLE/IHostUiHandler.cs b/Ryujinx.HLE/Ui/IHostUiHandler.cs similarity index 81% rename from Ryujinx.HLE/IHostUiHandler.cs rename to Ryujinx.HLE/Ui/IHostUiHandler.cs index b85fc356a..91d8be857 100644 --- a/Ryujinx.HLE/IHostUiHandler.cs +++ b/Ryujinx.HLE/Ui/IHostUiHandler.cs @@ -1,7 +1,7 @@ using Ryujinx.HLE.HOS.Applets; using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types; -namespace Ryujinx.HLE +namespace Ryujinx.HLE.Ui { public interface IHostUiHandler { @@ -36,5 +36,16 @@ namespace Ryujinx.HLE /// /// False when OK is pressed, True when another button (Details) is pressed. bool DisplayErrorAppletDialog(string title, string message, string[] buttonsText); + + /// + /// Creates a handler to process keyboard inputs into text strings. + /// + /// An instance of the text handler. + IDynamicTextInputHandler CreateDynamicTextInputHandler(); + + /// + /// Gets fonts and colors used by the host. + /// + IHostUiTheme HostUiTheme { get; } } } \ No newline at end of file diff --git a/Ryujinx.HLE/Ui/IHostUiTheme.cs b/Ryujinx.HLE/Ui/IHostUiTheme.cs new file mode 100644 index 000000000..6404c80c9 --- /dev/null +++ b/Ryujinx.HLE/Ui/IHostUiTheme.cs @@ -0,0 +1,13 @@ +namespace Ryujinx.HLE.Ui +{ + public interface IHostUiTheme + { + string FontFamily { get; } + + ThemeColor DefaultBackgroundColor { get; } + ThemeColor DefaultForegroundColor { get; } + ThemeColor DefaultBorderColor { get; } + ThemeColor SelectionBackgroundColor { get; } + ThemeColor SelectionForegroundColor { get; } + } +} diff --git a/Ryujinx.HLE/Ui/Input/NpadButtonHandler.cs b/Ryujinx.HLE/Ui/Input/NpadButtonHandler.cs new file mode 100644 index 000000000..cd41f5c8b --- /dev/null +++ b/Ryujinx.HLE/Ui/Input/NpadButtonHandler.cs @@ -0,0 +1,6 @@ +using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Npad; + +namespace Ryujinx.HLE.Ui.Input +{ + delegate void NpadButtonHandler(int npadIndex, NpadButton button); +} diff --git a/Ryujinx.HLE/Ui/Input/NpadReader.cs b/Ryujinx.HLE/Ui/Input/NpadReader.cs new file mode 100644 index 000000000..bc3fb396c --- /dev/null +++ b/Ryujinx.HLE/Ui/Input/NpadReader.cs @@ -0,0 +1,137 @@ +using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Common; +using Ryujinx.HLE.HOS.Services.Hid.Types.SharedMemory.Npad; +using System; + +namespace Ryujinx.HLE.Ui.Input +{ + /// + /// Class that converts Hid entries for the Npad into pressed / released events. + /// + class NpadReader + { + private readonly Switch _device; + private NpadCommonState[] _lastStates; + + public event NpadButtonHandler NpadButtonUpEvent; + public event NpadButtonHandler NpadButtonDownEvent; + + public NpadReader(Switch device) + { + _device = device; + _lastStates = new NpadCommonState[_device.Hid.SharedMemory.Npads.Length]; + } + + public NpadButton GetCurrentButtonsOfNpad(int npadIndex) + { + return _lastStates[npadIndex].Buttons; + } + + public NpadButton GetCurrentButtonsOfAllNpads() + { + NpadButton buttons = 0; + + foreach (var state in _lastStates) + { + buttons |= state.Buttons; + } + + return buttons; + } + + private ref RingLifo GetCommonStateLifo(ref NpadInternalState npad) + { + switch (npad.StyleSet) + { + case NpadStyleTag.FullKey: + return ref npad.FullKey; + case NpadStyleTag.Handheld: + return ref npad.Handheld; + case NpadStyleTag.JoyDual: + return ref npad.JoyDual; + case NpadStyleTag.JoyLeft: + return ref npad.JoyLeft; + case NpadStyleTag.JoyRight: + return ref npad.JoyRight; + case NpadStyleTag.Palma: + return ref npad.Palma; + default: + return ref npad.SystemExt; + } + } + + public void Update(bool supressEvents=false) + { + ref var npads = ref _device.Hid.SharedMemory.Npads; + + // Process each input individually. + for (int npadIndex = 0; npadIndex < npads.Length; npadIndex++) + { + UpdateNpad(npadIndex, supressEvents); + } + } + + private void UpdateNpad(int npadIndex, bool supressEvents) + { + const int MaxEntries = 1024; + + ref var npadState = ref _device.Hid.SharedMemory.Npads[npadIndex]; + ref var lastEntry = ref _lastStates[npadIndex]; + + var fullKeyEntries = GetCommonStateLifo(ref npadState.InternalState).ReadEntries(MaxEntries); + + int firstEntryNum; + + // Scan the LIFO for the first entry that is newer that what's already processed. + for (firstEntryNum = fullKeyEntries.Length - 1; firstEntryNum >= 0 && fullKeyEntries[firstEntryNum].Object.SamplingNumber <= lastEntry.SamplingNumber; firstEntryNum--) ; + + if (firstEntryNum == -1) + { + return; + } + + for (; firstEntryNum >= 0; firstEntryNum--) + { + var entry = fullKeyEntries[firstEntryNum]; + + // The interval of valid entries should be contiguous. + if (entry.SamplingNumber < lastEntry.SamplingNumber) + { + break; + } + + if (!supressEvents) + { + ProcessNpadButtons(npadIndex, entry.Object.Buttons); + } + + lastEntry = entry.Object; + } + } + + private void ProcessNpadButtons(int npadIndex, NpadButton buttons) + { + NpadButton lastButtons = _lastStates[npadIndex].Buttons; + + for (ulong buttonMask = 1; buttonMask != 0; buttonMask <<= 1) + { + NpadButton currentButton = (NpadButton)buttonMask & buttons; + NpadButton lastButton = (NpadButton)buttonMask & lastButtons; + + if (lastButton != 0) + { + if (currentButton == 0) + { + NpadButtonUpEvent?.Invoke(npadIndex, lastButton); + } + } + else + { + if (currentButton != 0) + { + NpadButtonDownEvent?.Invoke(npadIndex, currentButton); + } + } + } + } + } +} diff --git a/Ryujinx.HLE/Ui/KeyPressedHandler.cs b/Ryujinx.HLE/Ui/KeyPressedHandler.cs new file mode 100644 index 000000000..096bf7314 --- /dev/null +++ b/Ryujinx.HLE/Ui/KeyPressedHandler.cs @@ -0,0 +1,6 @@ +using Ryujinx.Common.Configuration.Hid; + +namespace Ryujinx.HLE.Ui +{ + public delegate bool KeyPressedHandler(Key key); +} \ No newline at end of file diff --git a/Ryujinx.HLE/Ui/KeyReleasedHandler.cs b/Ryujinx.HLE/Ui/KeyReleasedHandler.cs new file mode 100644 index 000000000..4faaa529c --- /dev/null +++ b/Ryujinx.HLE/Ui/KeyReleasedHandler.cs @@ -0,0 +1,6 @@ +using Ryujinx.Common.Configuration.Hid; + +namespace Ryujinx.HLE.Ui +{ + public delegate bool KeyReleasedHandler(Key key); +} \ No newline at end of file diff --git a/Ryujinx.HLE/Ui/RenderingSurfaceInfo.cs b/Ryujinx.HLE/Ui/RenderingSurfaceInfo.cs new file mode 100644 index 000000000..0903ffdd4 --- /dev/null +++ b/Ryujinx.HLE/Ui/RenderingSurfaceInfo.cs @@ -0,0 +1,34 @@ +using Ryujinx.HLE.HOS.Services.SurfaceFlinger; + +namespace Ryujinx.HLE.Ui +{ + /// + /// Information about the indirect layer that is being drawn to. + /// + class RenderingSurfaceInfo + { + public ColorFormat ColorFormat { get; } + public uint Width { get; } + public uint Height { get; } + public uint Pitch { get; } + public uint Size { get; } + + public RenderingSurfaceInfo(ColorFormat colorFormat, uint width, uint height, uint pitch, uint size) + { + ColorFormat = colorFormat; + Width = width; + Height = height; + Pitch = pitch; + Size = size; + } + + public bool Equals(RenderingSurfaceInfo other) + { + return ColorFormat == other.ColorFormat && + Width == other.Width && + Height == other.Height && + Pitch == other.Pitch && + Size == other.Size; + } + } +} diff --git a/Ryujinx.HLE/Ui/ThemeColor.cs b/Ryujinx.HLE/Ui/ThemeColor.cs new file mode 100644 index 000000000..1a42b1673 --- /dev/null +++ b/Ryujinx.HLE/Ui/ThemeColor.cs @@ -0,0 +1,18 @@ +namespace Ryujinx.HLE.Ui +{ + public struct ThemeColor + { + public float A { get; } + public float R { get; } + public float G { get; } + public float B { get; } + + public ThemeColor(float a, float r, float g, float b) + { + A = a; + R = r; + G = g; + B = b; + } + } +} diff --git a/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs b/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs new file mode 100644 index 000000000..7e624152f --- /dev/null +++ b/Ryujinx.Headless.SDL2/HeadlessDynamicTextInputHandler.cs @@ -0,0 +1,51 @@ +using Ryujinx.HLE.Ui; +using System.Threading; +using System.Threading.Tasks; + +namespace Ryujinx.Headless.SDL2 +{ + /// + /// Headless text processing class, right now there is no way to forward the input to it. + /// + internal class HeadlessDynamicTextInputHandler : IDynamicTextInputHandler + { + private bool _canProcessInput; + + public event DynamicTextChangedHandler TextChangedEvent; + public event KeyPressedHandler KeyPressedEvent { add { } remove { } } + public event KeyReleasedHandler KeyReleasedEvent { add { } remove { } } + + public bool TextProcessingEnabled + { + get + { + return Volatile.Read(ref _canProcessInput); + } + + set + { + Volatile.Write(ref _canProcessInput, value); + + // Launch a task to update the text. + Task.Run(() => + { + Thread.Sleep(100); + TextChangedEvent?.Invoke("Ryujinx", 7, 7, false); + }); + } + } + + public HeadlessDynamicTextInputHandler() + { + // Start with input processing turned off so the text box won't accumulate text + // if the user is playing on the keyboard. + _canProcessInput = false; + } + + public void SetText(string text, int cursorBegin) { } + + public void SetText(string text, int cursorBegin, int cursorEnd) { } + + public void Dispose() { } + } +} \ No newline at end of file diff --git a/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs b/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs new file mode 100644 index 000000000..4ef00b3c4 --- /dev/null +++ b/Ryujinx.Headless.SDL2/HeadlessHostUiTheme.cs @@ -0,0 +1,17 @@ +using Ryujinx.HLE.Ui; + +namespace Ryujinx.Headless.SDL2 +{ + internal class HeadlessHostUiTheme : IHostUiTheme + { + public string FontFamily => "sans-serif"; + + public ThemeColor DefaultBackgroundColor => new ThemeColor(1, 0, 0, 0); + public ThemeColor DefaultForegroundColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor DefaultBorderColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor SelectionBackgroundColor => new ThemeColor(1, 1, 1, 1); + public ThemeColor SelectionForegroundColor => new ThemeColor(1, 0, 0, 0); + + public HeadlessHostUiTheme() { } + } +} \ No newline at end of file diff --git a/Ryujinx.Headless.SDL2/WindowBase.cs b/Ryujinx.Headless.SDL2/WindowBase.cs index 858b08010..3fbd9bc3d 100644 --- a/Ryujinx.Headless.SDL2/WindowBase.cs +++ b/Ryujinx.Headless.SDL2/WindowBase.cs @@ -4,10 +4,9 @@ using Ryujinx.Common.Configuration.Hid; using Ryujinx.Common.Logging; using Ryujinx.Graphics.GAL; using Ryujinx.Graphics.GAL.Multithreading; -using Ryujinx.HLE; using Ryujinx.HLE.HOS.Applets; using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types; -using Ryujinx.HLE.HOS.Services.Hid; +using Ryujinx.HLE.Ui; using Ryujinx.Input; using Ryujinx.Input.HLE; using Ryujinx.SDL2.Common; @@ -35,6 +34,9 @@ namespace Ryujinx.Headless.SDL2 public event EventHandler StatusUpdatedEvent; protected IntPtr WindowHandle { get; set; } + + public IHostUiTheme HostUiTheme { get; } + protected SDL2MouseDriver MouseDriver; private InputManager _inputManager; private IKeyboard _keyboardInterface; @@ -67,6 +69,7 @@ namespace Ryujinx.Headless.SDL2 _exitEvent = new ManualResetEvent(false); _aspectRatio = aspectRatio; _enableMouse = enableMouse; + HostUiTheme = new HeadlessHostUiTheme(); SDL2Driver.Instance.Initialize(); } @@ -353,6 +356,11 @@ namespace Ryujinx.Headless.SDL2 return DisplayMessageDialog("Controller Applet", message); } + public IDynamicTextInputHandler CreateDynamicTextInputHandler() + { + return new HeadlessDynamicTextInputHandler(); + } + public void ExecuteProgram(Switch device, ProgramSpecifyKind kind, ulong value) { device.Configuration.UserChannelPersistence.ExecuteProgram(kind, value); diff --git a/Ryujinx/Input/GTK3/GTK3MappingHelper.cs b/Ryujinx/Input/GTK3/GTK3MappingHelper.cs index 8bab0dc06..49ea0d15c 100644 --- a/Ryujinx/Input/GTK3/GTK3MappingHelper.cs +++ b/Ryujinx/Input/GTK3/GTK3MappingHelper.cs @@ -1,4 +1,6 @@ -using System.Runtime.CompilerServices; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; using GtkKey = Gdk.Key; namespace Ryujinx.Input.GTK3 @@ -144,11 +146,39 @@ namespace Ryujinx.Input.GTK3 GtkKey.blank, }; + private static readonly Dictionary _gtkKeyMapping; + + static GTK3MappingHelper() + { + var inputKeys = Enum.GetValues(typeof(Key)); + + // GtkKey is not contiguous and quite large, so use a dictionary instead of an array. + _gtkKeyMapping = new Dictionary(); + + foreach (var key in inputKeys) + { + try + { + var index = ToGtkKey((Key)key); + _gtkKeyMapping[index] = (Key)key; + } + catch + { + // Skip invalid mappings. + } + } + } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static GtkKey ToGtkKey(Key key) { return _keyMapping[(int)key]; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static Key ToInputKey(GtkKey key) + { + return _gtkKeyMapping.GetValueOrDefault(key, Key.Unknown); + } } } diff --git a/Ryujinx/Ui/Applet/GtkDynamicTextInputHandler.cs b/Ryujinx/Ui/Applet/GtkDynamicTextInputHandler.cs new file mode 100644 index 000000000..92e993854 --- /dev/null +++ b/Ryujinx/Ui/Applet/GtkDynamicTextInputHandler.cs @@ -0,0 +1,108 @@ +using Gtk; +using Ryujinx.HLE.Ui; +using Ryujinx.Input.GTK3; +using Ryujinx.Ui.Widgets; +using System.Threading; + +namespace Ryujinx.Ui.Applet +{ + /// + /// Class that forwards key events to a GTK Entry so they can be processed into text. + /// + internal class GtkDynamicTextInputHandler : IDynamicTextInputHandler + { + private readonly Window _parent; + private readonly OffscreenWindow _inputToTextWindow = new OffscreenWindow(); + private readonly RawInputToTextEntry _inputToTextEntry = new RawInputToTextEntry(); + + private bool _canProcessInput; + + public event DynamicTextChangedHandler TextChangedEvent; + public event KeyPressedHandler KeyPressedEvent; + public event KeyReleasedHandler KeyReleasedEvent; + + public bool TextProcessingEnabled + { + get + { + return Volatile.Read(ref _canProcessInput); + } + + set + { + Volatile.Write(ref _canProcessInput, value); + } + } + + public GtkDynamicTextInputHandler(Window parent) + { + _parent = parent; + _parent.KeyPressEvent += HandleKeyPressEvent; + _parent.KeyReleaseEvent += HandleKeyReleaseEvent; + + _inputToTextWindow.Add(_inputToTextEntry); + + _inputToTextEntry.TruncateMultiline = true; + + // Start with input processing turned off so the text box won't accumulate text + // if the user is playing on the keyboard. + _canProcessInput = false; + } + + [GLib.ConnectBefore()] + private void HandleKeyPressEvent(object o, KeyPressEventArgs args) + { + var key = (Common.Configuration.Hid.Key)GTK3MappingHelper.ToInputKey(args.Event.Key); + + if (!(KeyPressedEvent?.Invoke(key)).GetValueOrDefault(true)) + { + return; + } + + if (_canProcessInput) + { + _inputToTextEntry.SendKeyPressEvent(o, args); + _inputToTextEntry.GetSelectionBounds(out int selectionStart, out int selectionEnd); + TextChangedEvent?.Invoke(_inputToTextEntry.Text, selectionStart, selectionEnd, _inputToTextEntry.OverwriteMode); + } + } + + [GLib.ConnectBefore()] + private void HandleKeyReleaseEvent(object o, KeyReleaseEventArgs args) + { + var key = (Common.Configuration.Hid.Key)GTK3MappingHelper.ToInputKey(args.Event.Key); + + if (!(KeyReleasedEvent?.Invoke(key)).GetValueOrDefault(true)) + { + return; + } + + if (_canProcessInput) + { + // TODO (caian): This solution may have problems if the pause is sent after a key press + // and before a key release. But for now GTK Entry does not seem to use release events. + _inputToTextEntry.SendKeyReleaseEvent(o, args); + _inputToTextEntry.GetSelectionBounds(out int selectionStart, out int selectionEnd); + TextChangedEvent?.Invoke(_inputToTextEntry.Text, selectionStart, selectionEnd, _inputToTextEntry.OverwriteMode); + } + } + + public void SetText(string text, int cursorBegin) + { + _inputToTextEntry.Text = text; + _inputToTextEntry.Position = cursorBegin; + } + + public void SetText(string text, int cursorBegin, int cursorEnd) + { + _inputToTextEntry.Text = text; + _inputToTextEntry.SelectRegion(cursorBegin, cursorEnd); + } + + public void Dispose() + { + _parent.KeyPressEvent -= HandleKeyPressEvent; + _parent.KeyReleaseEvent -= HandleKeyReleaseEvent; + } + } +} \ No newline at end of file diff --git a/Ryujinx/Ui/Applet/GtkHostUiHandler.cs b/Ryujinx/Ui/Applet/GtkHostUiHandler.cs index c227ebd38..d81cbe3c5 100644 --- a/Ryujinx/Ui/Applet/GtkHostUiHandler.cs +++ b/Ryujinx/Ui/Applet/GtkHostUiHandler.cs @@ -1,10 +1,11 @@ using Gtk; -using Ryujinx.HLE; using Ryujinx.HLE.HOS.Applets; using Ryujinx.HLE.HOS.Services.Am.AppletOE.ApplicationProxyService.ApplicationProxy.Types; +using Ryujinx.HLE.Ui; using Ryujinx.Ui.Widgets; using System; using System.Threading; +using Action = System.Action; namespace Ryujinx.Ui.Applet { @@ -12,9 +13,13 @@ namespace Ryujinx.Ui.Applet { private readonly Window _parent; + public IHostUiTheme HostUiTheme { get; } + public GtkHostUiHandler(Window parent) { _parent = parent; + + HostUiTheme = new GtkHostUiTheme(parent); } public bool DisplayMessageDialog(ControllerAppletUiArgs args) @@ -186,5 +191,23 @@ namespace Ryujinx.Ui.Applet return showDetails; } + + private void SynchronousGtkInvoke(Action action) + { + var waitHandle = new ManualResetEventSlim(); + + Application.Invoke(delegate + { + action(); + waitHandle.Set(); + }); + + waitHandle.Wait(); + } + + public IDynamicTextInputHandler CreateDynamicTextInputHandler() + { + return new GtkDynamicTextInputHandler(_parent); + } } } \ No newline at end of file diff --git a/Ryujinx/Ui/Applet/GtkHostUiTheme.cs b/Ryujinx/Ui/Applet/GtkHostUiTheme.cs new file mode 100644 index 000000000..f25da47c4 --- /dev/null +++ b/Ryujinx/Ui/Applet/GtkHostUiTheme.cs @@ -0,0 +1,90 @@ +using Gtk; +using Ryujinx.HLE.Ui; +using System.Diagnostics; + +namespace Ryujinx.Ui.Applet +{ + internal class GtkHostUiTheme : IHostUiTheme + { + private const int RenderSurfaceWidth = 32; + private const int RenderSurfaceHeight = 32; + + public string FontFamily { get; private set; } + + public ThemeColor DefaultBackgroundColor { get; } + public ThemeColor DefaultForegroundColor { get; } + public ThemeColor DefaultBorderColor { get; } + public ThemeColor SelectionBackgroundColor { get; } + public ThemeColor SelectionForegroundColor { get; } + + public GtkHostUiTheme(Window parent) + { + Entry entry = new Entry(); + entry.SetStateFlags(StateFlags.Selected, true); + + // Get the font and some colors directly from GTK. + FontFamily = entry.PangoContext.FontDescription.Family; + + // Get foreground colors from the style context. + + var defaultForegroundColor = entry.StyleContext.GetColor(StateFlags.Normal); + var selectedForegroundColor = entry.StyleContext.GetColor(StateFlags.Selected); + + DefaultForegroundColor = new ThemeColor((float) defaultForegroundColor.Alpha, (float) defaultForegroundColor.Red, (float) defaultForegroundColor.Green, (float) defaultForegroundColor.Blue); + SelectionForegroundColor = new ThemeColor((float)selectedForegroundColor.Alpha, (float)selectedForegroundColor.Red, (float)selectedForegroundColor.Green, (float)selectedForegroundColor.Blue); + + ListBoxRow row = new ListBoxRow(); + row.SetStateFlags(StateFlags.Selected, true); + + // Request the main thread to render some UI elements to an image to get an approximation for the color. + // NOTE (caian): This will only take the color of the top-left corner of the background, which may be incorrect + // if someone provides a custom style with a gradient or image. + + using (var surface = new Cairo.ImageSurface(Cairo.Format.Argb32, RenderSurfaceWidth, RenderSurfaceHeight)) + using (var context = new Cairo.Context(surface)) + { + context.SetSourceRGBA(1, 1, 1, 1); + context.Rectangle(0, 0, RenderSurfaceWidth, RenderSurfaceHeight); + context.Fill(); + + // The background color must be from the main Window because entry uses a different color. + parent.StyleContext.RenderBackground(context, 0, 0, RenderSurfaceWidth, RenderSurfaceHeight); + + DefaultBackgroundColor = ToThemeColor(surface.Data); + + context.SetSourceRGBA(1, 1, 1, 1); + context.Rectangle(0, 0, RenderSurfaceWidth, RenderSurfaceHeight); + context.Fill(); + + // Use the background color of the list box row when selected as the text box frame color because they are the + // same in the default theme. + row.StyleContext.RenderBackground(context, 0, 0, RenderSurfaceWidth, RenderSurfaceHeight); + + DefaultBorderColor = ToThemeColor(surface.Data); + } + + // Use the border color as the text selection color. + SelectionBackgroundColor = DefaultBorderColor; + } + + private ThemeColor ToThemeColor(byte[] data) + { + Debug.Assert(data.Length == 4 * RenderSurfaceWidth * RenderSurfaceHeight); + + // Take the center-bottom pixel of the surface. + int position = 4 * (RenderSurfaceWidth * (RenderSurfaceHeight - 1) + RenderSurfaceWidth / 2); + + if (position + 4 > data.Length) + { + return new ThemeColor(1, 0, 0, 0); + } + + float a = data[position + 3] / 255.0f; + float r = data[position + 2] / 255.0f; + float g = data[position + 1] / 255.0f; + float b = data[position + 0] / 255.0f; + + return new ThemeColor(a, r, g, b); + } + } +} \ No newline at end of file diff --git a/Ryujinx/Ui/Widgets/RawInputToTextEntry.cs b/Ryujinx/Ui/Widgets/RawInputToTextEntry.cs new file mode 100644 index 000000000..a0092f29b --- /dev/null +++ b/Ryujinx/Ui/Widgets/RawInputToTextEntry.cs @@ -0,0 +1,27 @@ +using Gtk; + +namespace Ryujinx.Ui.Widgets +{ + public class RawInputToTextEntry : Entry + { + public void SendKeyPressEvent(object o, KeyPressEventArgs args) + { + base.OnKeyPressEvent(args.Event); + } + + public void SendKeyReleaseEvent(object o, KeyReleaseEventArgs args) + { + base.OnKeyReleaseEvent(args.Event); + } + + public void SendButtonPressEvent(object o, ButtonPressEventArgs args) + { + base.OnButtonPressEvent(args.Event); + } + + public void SendButtonReleaseEvent(object o, ButtonReleaseEventArgs args) + { + base.OnButtonReleaseEvent(args.Event); + } + } +}